From 52d1e8bde911f437bb056a25994e96b5c6daad6c Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 10:34:23 -0400 Subject: [PATCH 01/24] feat(capacity): publish the dashboard as a persistent tailnet-only command service Add bin/fm-dash-serve.mjs, a loopback service published tailnet-only through tailscale serve (never Funnel) that wears the producer dashboard unchanged and injects an interactive layer for the captain-authenticated tailnet identity: one-click CAP action approval, decision option approval, idea verdicts over data/ideas/, server-side and interval capacity refresh, and de-anonymized clickable work-item and decision detail views assembled from briefs, recorded metadata, the backlog, status tails, and scout reports. Clicked commands never execute in the web process: each becomes one durable fm-dash-command.v1 record in state/dash-inbox/, surfaced to the running firstmate through the registered fm-dash watcher check and claimed exactly once with bin/fm-dash-inbox.sh under the capacity skill's authority limits. bin/fm-dash-install.sh owns launchd persistence (RunAtLoad + KeepAlive + kickstart), the never-Funnel serve mapping with post-install verification, config/dash.json, and a --read-only install shape for serving ahead of command wiring. fm-capacity.mjs gains the opt-in --refs identity sidecar it owns so the authenticated service can enrich the page while the on-disk dashboard stays identity-opaque, plus a fix for the rollcall empty-state wrapping. AGENTS.md, docs/configuration.md, docs/dashboard-service.md, docs/scripts.md, and the capacity skill carry the contract; tests/fm-dash.test.sh covers identity fail-closed behavior, dispatch validation, detail assembly, idea verdicts, read-only mode, inbox claim, the watcher check, and the installer's funnel-free structure. --- .agents/skills/capacity/SKILL.md | 18 +- .gitignore | 1 + AGENTS.md | 7 +- bin/fm-capacity.mjs | 40 +- bin/fm-dash-inbox.sh | 121 ++++ bin/fm-dash-install.sh | 275 ++++++++ bin/fm-dash-serve.mjs | 1089 ++++++++++++++++++++++++++++++ docs/configuration.md | 7 + docs/dashboard-service.md | 85 +++ docs/scripts.md | 3 + tests/fm-dash.test.sh | 397 +++++++++++ 11 files changed, 2035 insertions(+), 8 deletions(-) create mode 100755 bin/fm-dash-inbox.sh create mode 100755 bin/fm-dash-install.sh create mode 100755 bin/fm-dash-serve.mjs create mode 100644 docs/dashboard-service.md create mode 100644 tests/fm-dash.test.sh diff --git a/.agents/skills/capacity/SKILL.md b/.agents/skills/capacity/SKILL.md index 3e67fbf0a86..704dbb78ef4 100644 --- a/.agents/skills/capacity/SKILL.md +++ b/.agents/skills/capacity/SKILL.md @@ -28,7 +28,7 @@ Structured captain holds and the keyed open-decision fold are the only decision The generated dashboard is a polished, responsive, accessible, self-contained HTML file that works directly from disk. Do not invoke, depend on, open, poll, share, or embed Lavish for `/capacity`. -Do not expose the dashboard through a local, LAN, Tailscale, public, or third-party service. +Do not expose the dashboard through any local, LAN, public, or third-party service; the sole sanctioned exposure is the tailnet-only dashboard service in section 6, and even that surface is never Funnel and never public. The normal invocation may replace only the generated private dashboard and must not write a cache unless the producing script's help explicitly adds and owns one in the future. Never put secrets, credentials, PHI, production data, or report bodies into the dashboard. @@ -84,3 +84,19 @@ Do not compare against, incrementally patch, or rely on the prior dashboard as c The normal `/capacity` invocation is read-mostly and must not dispatch, merge, tear down, mutate task state, edit the backlog, register decisions, or create speculative work as a side effect. If the fresh result reveals an action, report its stable ID and wait for or discuss the captain's ordinary chat direction. Continue the already-required live supervision cycle after presenting the result whenever fleet work or X mode is under way. + +## 6. Dashboard command service + +The optional persistent dashboard service (`bin/fm-dash-serve.mjs`, installed by `bin/fm-dash-install.sh`, designed in `docs/dashboard-service.md`) publishes the generated dashboard tailnet-only, never Funnel, and lets the captain click a current `CAP-NN` action or a server-side refresh. +The service never executes fleet commands: a click only writes a durable command record into `state/dash-inbox/`, and the registered `fm-dash` watcher check wakes Firstmate while records are pending. +Its refresh button reruns the producer server-side and is equivalent to a fresh normal invocation, so it needs no Firstmate action. + +On a `check:` wake naming `fm-dash.check.sh`, run `bin/fm-dash-inbox.sh claim` and handle each claimed record by its kind: + +- A `CAP-NN` record is the captain's ordinary chat approval of that action ID under section 4, including its full re-resolution and authority limits. +- A `decision` record is the captain's answer for the named decision key with the recorded option text; route it through `decision-hold-lifecycle` exactly as a chat answer, and re-confirm in chat before acting when the chosen option has a destructive or irreversible consequence. +- An `idea` record is the captain's verdict on the named `data/ideas/` idea: on approve, create the follow-up work item(s) through the normal backlog lifecycle; on deny, record the outcome against the idea; on suggest, treat the suggestion text as captain input on that idea. + +A claimed record never authorizes a PR merge, `local-only` landing, destructive action, irreversible action, security-sensitive action, or discard of unlanded work; when a claimed action leads to such a choice, escalate it to captain chat exactly as section 4 requires. +Report the outcome of handled commands to the captain through normal escalation etiquette rather than assuming the dashboard told them. +While the service is installed and registered, treat pending dashboard commands like X-mode mentions for supervision: keep the live supervision cycle running even with no other fleet work so a click can wake Firstmate. diff --git a/.gitignore b/.gitignore index 5ed2da0c32a..4f6425a4d57 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ config/backend config/x-mode.env config/cmux-socket-password config/wedge-alarm +config/dash.json diff --git a/AGENTS.md b/AGENTS.md index e0bb07a8f1f..8ac15b6ac0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,7 @@ config/backend runtime session-provider backend override for new tasks; LOCAL, config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present +config/dash.json optional capacity dashboard service settings (loopback port, authorized captain tailnet logins); LOCAL, gitignored; written by bin/fm-dash-install.sh (docs/dashboard-service.md) data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update @@ -99,6 +100,9 @@ state/ volatile runtime signals; gitignored x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) x-poll.error generated X-mode relay diagnostic dedupe marker + fm-dash.check.sh registered dashboard-service command poll; wakes firstmate while captain dashboard commands are pending (section 7; docs/dashboard-service.md) + dash-inbox/ durable captain commands clicked on the served capacity dashboard; claimed exactly once with bin/fm-dash-inbox.sh under the capacity skill + dash-refs.json producer-owned private mapping from opaque dashboard references to real identities, written by fm-capacity.mjs --refs for the authenticated dashboard service .wake-queue durable queued wakes: epochseqkindkeypayload .wake-queue.seq monotonic wake sequence used to distinguish a normal watcher wake handoff from a silent arm-cycle death .watcher-arm-dead durable alarm from an arm cycle that ended without a wake handoff or healthy successor while tasks remain; cleared by a confirmed healthy arm or normal handoff @@ -313,6 +317,7 @@ When the captain invokes `/user-journey-audit` or explicitly asks for a user-jou That invocation narrowly authorizes confirmed ordinary reversible bug implementation, never feature implementation or merge, and the skill owns the conditional procedure. When the captain invokes `/capacity` or asks about capacity, bottlenecks, pipeline utilization, work supply, idle lanes, or maximizing fleet throughput, load `capacity`. +Also load `capacity` on a `check:` wake naming `fm-dash.check.sh`: it delivers captain-clicked dashboard commands, and the skill owns their claim and handling. That read-mostly skill owns the conditional procedure and must never invent work, dispatch for utilization, or weaken lifecycle safety. ## 8. Supervision protocol @@ -320,7 +325,7 @@ That read-mostly skill owns the conditional procedure and must never invent work Fleet supervision is an always-loaded operational contract; `docs/architecture.md`, `docs/turnend-guard.md`, the emitted session-start block, and script help own mechanisms and harness-specific recipes. Whenever work is under way, keep exactly one live supervision cycle using the emitted protocol for this primary harness. -X mode may require that same live cycle with no fleet work. +X mode or an installed dashboard command service may require that same live cycle with no fleet work. Do not substitute another harness's wait shape, use shell `&`, or create a second cycle when a healthy one already exists. After every actionable wake, resume the emitted protocol as the final action before ending the turn. No turn ends blind while work is under way, including turns described as holding or waiting. diff --git a/bin/fm-capacity.mjs b/bin/fm-capacity.mjs index a9cefa29e4a..7da76654d69 100755 --- a/bin/fm-capacity.mjs +++ b/bin/fm-capacity.mjs @@ -21,9 +21,10 @@ * Run --help for the exact inherited snapshot bounds, environment-probe budget, * bottleneck order, CAP-01 through CAP-10 meanings, and output replacement rules. * - * The producer is read-mostly. It writes only the selected dashboard path and - * never dispatches, merges, tears down, changes backlog/task state, or opens a - * service. Inline dashboard JavaScript copies prompts only and cannot run actions. + * The producer is read-mostly. It writes only the selected dashboard path, + * plus the opt-in --refs identity sidecar it owns for the authenticated + * dashboard service, and never dispatches, merges, tears down, changes + * backlog/task state, or opens a service. Inline dashboard JavaScript copies prompts only and cannot run actions. * Live environment probes share one 30-second fleet-wide deadline and preserve * unavailable evidence for homes that cannot be inspected within that bound. */ @@ -60,7 +61,7 @@ const STAGE_LABELS = { function usage(exitCode = 0) { const out = exitCode === 0 ? process.stdout : process.stderr; - out.write(`usage: fm-capacity.mjs [--json] [--output ] [--snapshot ] [--environment ] + out.write(`usage: fm-capacity.mjs [--json] [--output ] [--snapshot ] [--environment ] [--refs ] Gather a fresh bounded fleet snapshot, classify meaningful capacity, and atomically replace a self-contained offline dashboard. The default destination is @@ -71,6 +72,13 @@ must not traverse a symlink below FM_HOME or replace a symlink leaf, and is mode --environment are deterministic fixture inputs for tests/offline review and must not be used for a normal /capacity run. +--refs additionally writes the fm-capacity-refs.v1 sidecar this producer owns: the +private mode-0600 mapping from every opaque dashboard reference (item-NN, project-NN, +home-NN) to its real identity. The dashboard itself stays identity-opaque; the sidecar +exists so the captain-authenticated tailnet dashboard service (bin/fm-dash-serve.mjs) +can enrich the page and serve rich detail views without weakening the offline file. +The sidecar path must stay inside the effective state or data directory. + MODEL fm-capacity.v1 generated, dashboard_path, provenance, measures, primary_bottleneck, pipeline, lanes, readiness, aging, recommendations, omissions. Pipeline owns queued, ready, @@ -105,7 +113,7 @@ BOTTLENECK ORDER AND STABLE ACTIONS } function parseArgs(argv) { - const opts = { json: false, output: null, snapshot: null, environment: null }; + const opts = { json: false, output: null, snapshot: null, environment: null, refs: null }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--json") opts.json = true; @@ -115,9 +123,11 @@ function parseArgs(argv) { else if (arg.startsWith("--snapshot=")) opts.snapshot = arg.slice(11); else if (arg === "--environment") opts.environment = argv[++i]; else if (arg.startsWith("--environment=")) opts.environment = arg.slice(14); + else if (arg === "--refs") opts.refs = argv[++i]; + else if (arg.startsWith("--refs=")) opts.refs = arg.slice(7); else if (arg === "-h" || arg === "--help") usage(0); else usage(2); - if ((arg === "--output" || arg === "--snapshot" || arg === "--environment") && !argv[i]) usage(2); + if ((arg === "--output" || arg === "--snapshot" || arg === "--environment" || arg === "--refs") && !argv[i]) usage(2); } return opts; } @@ -1343,6 +1353,7 @@ function renderHtml(model, captainActions) { .needs-you h2{color:var(--serious)}.blocked-items h2{color:var(--crit)} .rollcall ul{margin-top:.6rem} .rollcall li{display:grid;grid-template-columns:5.2rem minmax(0,.45fr) minmax(0,1fr);gap:.4rem 1.1rem;align-items:baseline;border-top:1px solid color-mix(in srgb,var(--sev) 30%,var(--hair));padding:.55rem 0;font-size:1.02rem;min-width:0} + .rollcall li.empty{display:block} .verb{font-weight:800;text-transform:uppercase;letter-spacing:.08em;font-size:.72rem} .verb-approve{color:var(--blue)}.verb-decide{color:var(--serious)}.verb-blocked{color:var(--crit)} .who{font-weight:650;min-width:0}.who .item-id{margin-right:.35rem} @@ -1474,6 +1485,23 @@ function main() { } const { model, captainActions } = classify(snapshot, environment); writePrivateAtomic(output, renderHtml(model, captainActions), snapshot.fm_home || path.dirname(allowedData)); + if (opts.refs) { + const allowedState = path.resolve(snapshot.roots?.state || path.join(snapshot.fm_home || ROOT, "state")); + const refsPath = path.resolve(opts.refs); + const insideOf = (root) => { + const relative = path.relative(root, refsPath); + return relative && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); + }; + if (!insideOf(allowedState) && !insideOf(allowedData)) { + throw new Error("refs sidecar path must stay inside the effective state or data directory"); + } + const refs = {}; + for (const [key, ref] of opaqueRefs.entries()) { + const separator = key.indexOf("\0"); + refs[ref] = { kind: key.slice(0, separator), value: key.slice(separator + 1) }; + } + writePrivateAtomic(refsPath, `${JSON.stringify({ schema: "fm-capacity-refs.v1", generated: model.generated, refs }, null, 2)}\n`, snapshot.fm_home || path.dirname(allowedData)); + } if (opts.json) { process.stdout.write(`${JSON.stringify(model, null, 2)}\n`); } else { diff --git a/bin/fm-dash-inbox.sh b/bin/fm-dash-inbox.sh new file mode 100755 index 00000000000..d0b9d4a4b5f --- /dev/null +++ b/bin/fm-dash-inbox.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# fm-dash-inbox.sh - firstmate-side consumer for captain dashboard commands. +# +# Single owner of state/dash-inbox/ consumption: listing pending +# fm-dash-command.v1 records written by bin/fm-dash-serve.mjs and claiming them +# durably. "claim" atomically archives each record under +# state/dash-inbox/archive/ (newest 50 kept) and prints it, so a command is +# surfaced exactly once even across interrupted turns; a claim that printed is a +# claim that archived. Consumption semantics are owned by the capacity skill: +# each claimed prompt is the captain's approval of that CAP action ID with all +# of that skill's authority limits, never destructive or merge authority. +# +# Usage: fm-dash-inbox.sh [list|claim|pending-count] +# list print pending commands without consuming them +# claim archive and print pending commands for handling +# pending-count print the number of pending commands +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +INBOX="$STATE/dash-inbox" +ARCHIVE="$INBOX/archive" +ARCHIVE_KEEP=50 + +usage() { + sed -n 's/^# \{0,1\}//p' "${BASH_SOURCE[0]}" | sed -n '2,15p' + exit "${1:-0}" +} + +pending_files() { + [ -d "$INBOX" ] || return 0 + find "$INBOX" -maxdepth 1 -name '*.json' -type f 2>/dev/null | LC_ALL=C sort +} + +print_record() { + local file=$1 + # shellcheck disable=SC2016 # the $-expressions below are JavaScript template literals, not shell + node -e ' + const fs = require("node:fs"); + try { + const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (r.schema !== "fm-dash-command.v1" || typeof r.id !== "string" || typeof r.prompt !== "string") { + console.log(`- unreadable record ${process.argv[1]} (unexpected schema); inspect it by hand`); + process.exit(0); + } + console.log(`- ${r.id} requested by ${r.requested_by || "unknown"} at ${r.requested_at || "unknown"} (dashboard generated ${r.dashboard_generated || "unknown"})`); + console.log(` prompt: ${r.prompt.replace(/\s+/g, " ")}`); + } catch { + console.log(`- unreadable record ${process.argv[1]} (invalid JSON); inspect it by hand`); + } + ' "$file" +} + +prune_archive() { + local extra + extra=$(find "$ARCHIVE" -maxdepth 1 -name '*.json' -type f 2>/dev/null | LC_ALL=C sort -r | tail -n +$((ARCHIVE_KEEP + 1))) + [ -n "$extra" ] || return 0 + printf '%s\n' "$extra" | while IFS= read -r old; do + rm -f -- "$old" + done +} + +command -v node >/dev/null 2>&1 || { echo "error: node is required to read dashboard command records" >&2; exit 1; } + +case "${1:-list}" in + -h|--help) + usage 0 + ;; + pending-count) + pending_files | grep -c . || true + ;; + list) + files=$(pending_files) + if [ -z "$files" ]; then + echo "no pending dashboard commands" + exit 0 + fi + printf 'pending: %s captain dashboard command(s)\n' "$(printf '%s\n' "$files" | grep -c .)" + printf '%s\n' "$files" | while IFS= read -r f; do + print_record "$f" + done + ;; + claim) + files=$(pending_files) + if [ -z "$files" ]; then + echo "no pending dashboard commands" + exit 0 + fi + mkdir -p "$ARCHIVE" + chmod 700 "$ARCHIVE" 2>/dev/null || true + count=0 + claimed="" + while IFS= read -r f; do + dest="$ARCHIVE/$(basename "$f")" + # rename-based claim: a record either stays pending or is archived; a + # concurrent claimer loses the rename and skips the record. + if mv -n -- "$f" "$dest" 2>/dev/null && [ ! -e "$f" ] && [ -e "$dest" ]; then + count=$((count + 1)) + claimed="$claimed$dest"$'\n' + fi + done <&2 + ;; +esac diff --git a/bin/fm-dash-install.sh b/bin/fm-dash-install.sh new file mode 100755 index 00000000000..6b1ddf294c3 --- /dev/null +++ b/bin/fm-dash-install.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# fm-dash-install.sh - persistent tailnet-only publication of the capacity dashboard. +# +# Single owner of dashboard-service persistence: the launchd agent that keeps +# bin/fm-dash-serve.mjs running across reboots, the tailscale serve proxy that +# exposes it tailnet-only at one stable HTTPS URL, config/dash.json, and the +# registered fm-dash watcher check that lets clicked commands wake firstmate. +# It never enables Funnel: the serve mapping is tailnet-only by construction and +# install verifies Funnel is off for the served port, tearing the mapping back +# down and refusing if any Funnel exposure is detected. +# +# Usage: fm-dash-install.sh [options] +# install write config, launchd agent, tailscale serve mapping, and the +# fm-dash watcher check; idempotent, prints the stable URL +# uninstall remove the serve mapping and launchd agent; keeps config and +# any pending commands in state/dash-inbox/ +# status report agent, serve mapping, and pending-command state +# print-plist print the launchd plist to stdout without installing +# write-check write and register only the fm-dash watcher check +# Options: +# --port local loopback port for the service (default 8847) +# --serve-port tailnet HTTPS port for tailscale serve (default 8443) +# --captain authorized tailnet login; repeatable; defaults to the +# tailnet self login reported by tailscale status +# --read-only serve the dashboard without command dispatch and skip the +# watcher check; for running the service ahead of command wiring +# FM_HOME selects the home; scripts and the service always run from this +# checkout. docs/dashboard-service.md owns the architecture and evidence. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +CONFIG_DIR="$FM_HOME/config" +CONFIG="$CONFIG_DIR/dash.json" +CHECK="$STATE/fm-dash.check.sh" +DEFAULT_PORT=8847 +DEFAULT_SERVE_PORT=8443 + +usage() { + sed -n 's/^# \{0,1\}//p' "${BASH_SOURCE[0]}" | sed -n '2,29p' + exit "${1:-0}" +} + +err() { + printf 'error: %s\n' "$1" >&2 + exit 1 +} + +home_label() { + local hash + hash=$(printf '%s' "$FM_HOME" | { shasum -a 256 2>/dev/null || sha256sum; } | awk '{print substr($1,1,8)}') + printf 'io.firstmate.dashboard.%s' "$hash" +} + +node_bin() { + command -v node || err "node is required" +} + +tailscale_bin() { + command -v tailscale || err "the tailscale CLI is required" +} + +tailscale_self_json() { + "$(tailscale_bin)" status --json 2>/dev/null +} + +tailscale_self_login() { + tailscale_self_json | node -e ' + let raw = ""; + process.stdin.on("data", (c) => { raw += c; }); + process.stdin.on("end", () => { + try { + const s = JSON.parse(raw); + const user = s.User && s.Self ? s.User[s.Self.UserID] : null; + if (user && user.LoginName) { console.log(user.LoginName); return; } + } catch {} + process.exit(1); + }); + ' +} + +tailscale_self_dnsname() { + tailscale_self_json | node -e ' + let raw = ""; + process.stdin.on("data", (c) => { raw += c; }); + process.stdin.on("end", () => { + try { + const s = JSON.parse(raw); + if (s.Self && s.Self.DNSName) { console.log(s.Self.DNSName.replace(/\.$/, "")); return; } + } catch {} + process.exit(1); + }); + ' +} + +# Refuse loudly if any Funnel exposure exists for the served port. Funnel is +# never acceptable for this service. +assert_no_funnel() { + local serve_port=$1 + "$(tailscale_bin)" serve status --json 2>/dev/null | node -e ' + let raw = ""; + process.stdin.on("data", (c) => { raw += c; }); + process.stdin.on("end", () => { + try { + const s = JSON.parse(raw || "{}"); + const allow = s.AllowFunnel || {}; + for (const [hostport, enabled] of Object.entries(allow)) { + if (enabled && hostport.endsWith(":" + process.argv[1])) { + console.error("funnel is enabled for " + hostport); + process.exit(1); + } + } + } catch {} + }); + ' "$serve_port" +} + +write_config() { + local port=$1 read_only=$2 + shift 2 + mkdir -p "$CONFIG_DIR" + node -e ' + const fs = require("node:fs"); + const [config, port, readOnly, ...logins] = process.argv.slice(1); + const payload = { port: Number(port), captain_logins: logins, read_only: readOnly === "true" }; + fs.writeFileSync(config, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 }); + ' "$CONFIG" "$port" "$read_only" "$@" || err "could not write $CONFIG" + chmod 600 "$CONFIG" 2>/dev/null || true +} + +render_plist() { + local label=$1 node_path=$2 log_dir=$3 + cat < + + + + Label$label + ProgramArguments + + $node_path + $FM_ROOT/bin/fm-dash-serve.mjs + + EnvironmentVariables + + FM_HOME$FM_HOME + + RunAtLoad + KeepAlive + StandardOutPath$log_dir/dash-serve.log + StandardErrorPath$log_dir/dash-serve.log + + +PLIST +} + +write_check() { + mkdir -p "$STATE" + cat > "$CHECK" <<'SHIM' +#!/bin/sh +# fm-dash watcher check - wakes firstmate when captain dashboard commands are +# pending in state/dash-inbox/. Written and registered by bin/fm-dash-install.sh. +state_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +inbox="$state_dir/dash-inbox" +[ -d "$inbox" ] || exit 0 +count=0 +for f in "$inbox"/*.json; do + [ -e "$f" ] || continue + count=$((count + 1)) +done +[ "$count" -gt 0 ] || exit 0 +printf 'dashboard: %s captain command(s) pending - run bin/fm-dash-inbox.sh claim and handle them under the capacity skill\n' "$count" +SHIM + chmod 700 "$CHECK" + "$SCRIPT_DIR/fm-check-register.sh" fm-dash || err "could not register the fm-dash watcher check" +} + +cmd_install() { + local port=$DEFAULT_PORT serve_port=$DEFAULT_SERVE_PORT read_only=false captains=() label plist_path node_path dnsname + while [ $# -gt 0 ]; do + case "$1" in + --port) port=${2:?--port needs a value}; shift 2 ;; + --serve-port) serve_port=${2:?--serve-port needs a value}; shift 2 ;; + --captain) captains+=("${2:?--captain needs a value}"); shift 2 ;; + --read-only) read_only=true; shift ;; + *) err "unknown install option: $1" ;; + esac + done + [ "$(uname)" = Darwin ] || err "install requires macOS launchd; on another OS run bin/fm-dash-serve.mjs under your init system and proxy it with tailscale serve (never funnel)" + command -v launchctl >/dev/null 2>&1 || err "launchctl is required" + node_path=$(node_bin) + tailscale_bin >/dev/null + + if [ "${#captains[@]}" -eq 0 ]; then + local self_login + self_login=$(tailscale_self_login) || err "could not resolve the tailnet self login; pass --captain " + captains=("$self_login") + fi + + write_config "$port" "$read_only" "${captains[@]}" + # A read-only install serves the dashboard without command dispatch, so the + # watcher check that surfaces clicked commands is not registered. + [ "$read_only" = true ] || write_check + + label=$(home_label) + plist_path="$HOME/Library/LaunchAgents/$label.plist" + mkdir -p "$HOME/Library/LaunchAgents" "$STATE" + render_plist "$label" "$node_path" "$STATE" > "$plist_path" + launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$plist_path" || err "launchctl bootstrap failed for $plist_path" + # RunAtLoad does not reliably start a re-bootstrapped agent on every macOS; + # kickstart makes install-serves-now deterministic. + launchctl kickstart "gui/$(id -u)/$label" 2>/dev/null || true + + # Tailnet-only HTTPS proxy. tailscale serve without funnel is tailnet-only by + # construction; the assertion below still verifies no Funnel exposure exists + # for this port and tears the mapping down if one is found. + "$(tailscale_bin)" serve --bg --https="$serve_port" "http://127.0.0.1:$port" >/dev/null \ + || err "tailscale serve refused the mapping; is tailscale up?" + if ! assert_no_funnel "$serve_port"; then + "$(tailscale_bin)" serve --https="$serve_port" off >/dev/null 2>&1 || true + err "funnel exposure detected for port $serve_port; the mapping was removed - this service must stay tailnet-only" + fi + + dnsname=$(tailscale_self_dnsname) || err "could not resolve this machine's tailnet name" + printf 'installed: launchd agent %s\n' "$label" + printf 'captains: %s\n' "${captains[*]}" + printf 'dashboard: https://%s:%s/\n' "$dnsname" "$serve_port" +} + +cmd_uninstall() { + local serve_port=$DEFAULT_SERVE_PORT label plist_path + while [ $# -gt 0 ]; do + case "$1" in + --serve-port) serve_port=${2:?--serve-port needs a value}; shift 2 ;; + *) err "unknown uninstall option: $1" ;; + esac + done + [ "$(uname)" = Darwin ] || err "uninstall requires macOS launchd" + label=$(home_label) + plist_path="$HOME/Library/LaunchAgents/$label.plist" + if command -v tailscale >/dev/null 2>&1; then + tailscale serve --https="$serve_port" off >/dev/null 2>&1 || true + fi + launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true + rm -f "$plist_path" + printf 'uninstalled: %s (config and any pending commands were kept)\n' "$label" +} + +cmd_status() { + local label pending + label=$(home_label) + if launchctl print "gui/$(id -u)/$label" >/dev/null 2>&1; then + printf 'agent: %s loaded\n' "$label" + else + printf 'agent: %s not loaded\n' "$label" + fi + if command -v tailscale >/dev/null 2>&1; then + tailscale serve status 2>/dev/null | sed 's/^/serve: /' || true + fi + pending=$("$SCRIPT_DIR/fm-dash-inbox.sh" pending-count 2>/dev/null || echo unknown) + printf 'pending commands: %s\n' "$pending" +} + +case "${1:-}" in + -h|--help|'') usage 0 ;; + install) shift; cmd_install "$@" ;; + uninstall) shift; cmd_uninstall "$@" ;; + status) shift; cmd_status ;; + print-plist) shift; render_plist "$(home_label)" "$(node_bin)" "$STATE" ;; + write-check) shift; write_check ;; + *) usage 2 >&2 ;; +esac diff --git a/bin/fm-dash-serve.mjs b/bin/fm-dash-serve.mjs new file mode 100755 index 00000000000..71b55af38b8 --- /dev/null +++ b/bin/fm-dash-serve.mjs @@ -0,0 +1,1089 @@ +#!/usr/bin/env node +/** + * fm-dash-serve.mjs - persistent tailnet-only capacity dashboard service. + * + * This file is the single owner of the dashboard service's HTTP surface, + * captain-identity enforcement, interactive layer injection, refresh + * serialization, and durable command-inbox write mechanics. docs/dashboard-service.md + * owns the architecture narrative and setup evidence; bin/fm-dash-install.sh owns + * launchd persistence and tailscale serve wiring; bin/fm-dash-inbox.sh owns + * firstmate-side consumption of the records this service writes. + * + * The service never executes fleet commands, never calls firstmate tooling other + * than the read-mostly bin/fm-capacity.mjs producer, and never mutates any state + * outside state/dash-inbox/ and the producer-owned dashboard file. A clicked + * CAP action becomes one durable fm-dash-command.v1 record in state/dash-inbox/; + * the running firstmate consumes it through its registered fm-dash watcher check + * (bin/fm-dash-inbox.sh claim). Delivery therefore rides the sanctioned wake + * path and inherits its cadence rather than any direct control channel. + * + * Identity fails closed: every route except /healthz requires the + * Tailscale-User-Login header injected by tailscale serve to match a login in + * config/dash.json. Requests without a matching identity get 403 and cause no + * writes. Dispatch accepts only known CAP-NN identifiers that are present in + * the currently served dashboard AND in the fixed one-click allowlist below; + * free-text commands are structurally impossible and unknown or future action + * IDs are refused (route those through captain chat). The server binds + * 127.0.0.1 only, so the only remote path in is the tailnet proxy. + * + * Environment: FM_HOME selects the home (defaults to this checkout); + * FM_DASH_CAPACITY_ARGS appends producer fixture args for tests ONLY and must + * stay unset in real deployments. Run --help for routes and config schema. + */ + +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import process from "node:process"; +import { randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(SCRIPT_DIR, ".."); +const FM_HOME = path.resolve(process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || ROOT); +const STATE = process.env.FM_STATE_OVERRIDE || path.join(FM_HOME, "state"); +const DATA = path.join(FM_HOME, "data"); +const CONFIG_PATH = path.join(FM_HOME, "config", "dash.json"); +const DASHBOARD = path.join(DATA, "capacity-dashboard.html"); +const INBOX = path.join(STATE, "dash-inbox"); +const CAPACITY = path.join(ROOT, "bin", "fm-capacity.mjs"); +const REFS = path.join(STATE, "dash-refs.json"); +const BACKLOG = path.join(DATA, "backlog.md"); +const IDEAS = path.join(DATA, "ideas", "idea-backlog.md"); +const PITCHES = path.join(DATA, "ideas", "pitches"); +const REFRESH_TIMEOUT_MS = 180000; +const MAX_BODY_BYTES = 4096; + +// One-click eligible action IDs. Every current CAP action only requests +// lifecycle-safe guidance or work that re-enters normal authority checks +// (capacity skill section 4); none authorizes a merge, discard, or other +// destructive or irreversible act. A future action ID absent from this list is +// refused with guidance to raise it in captain chat, so new actions default to +// NOT one-click until deliberately reviewed and added here. +const ONE_CLICK_ACTIONS = new Set([ + "CAP-01", "CAP-02", "CAP-03", "CAP-04", "CAP-05", + "CAP-06", "CAP-07", "CAP-08", "CAP-09", "CAP-10", +]); + +function usage(exitCode = 0) { + const out = exitCode === 0 ? process.stdout : process.stderr; + out.write(`usage: fm-dash-serve.mjs [--port ] + +Serve the FM_HOME capacity dashboard on 127.0.0.1 for a tailnet-only +tailscale serve proxy. Config lives in config/dash.json: + {"port": 8847, "captain_logins": ["captain@example.com"], + "read_only": false, "auto_refresh_seconds": 900} +--port overrides the configured port. read_only=true refuses dispatch and +serves the page without send buttons, for running the service before command +consumption is wired up. auto_refresh_seconds reruns the producer on that +interval (and at startup when the dashboard is missing or stale); 0 disables +auto-render. Routes: + GET /healthz liveness, no identity required + GET / dashboard with the interactive layer injected + GET /api/pending pending command count + POST /api/refresh rerun bin/fm-capacity.mjs server-side (serialized) + POST /api/dispatch {"id":"CAP-NN"} -> durable record in state/dash-inbox/ +All routes except /healthz require a Tailscale-User-Login header matching a +configured captain login and fail closed otherwise. Dispatch refuses IDs not in +both the served dashboard and the fixed one-click allowlist. +`); + process.exit(exitCode); +} + +function log(line) { + process.stdout.write(`${new Date().toISOString()} ${line}\n`); +} + +function readConfig() { + try { + const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + const logins = Array.isArray(parsed.captain_logins) + ? parsed.captain_logins.filter((login) => typeof login === "string" && login.trim() !== "") + : []; + const port = Number.isInteger(parsed.port) && parsed.port > 0 && parsed.port < 65536 ? parsed.port : null; + const readOnly = parsed.read_only === true; + const autoRefreshSeconds = Number.isInteger(parsed.auto_refresh_seconds) && parsed.auto_refresh_seconds >= 0 + ? parsed.auto_refresh_seconds + : 900; + return { port, logins, readOnly, autoRefreshSeconds }; + } catch { + return { port: null, logins: [], readOnly: false, autoRefreshSeconds: 900 }; + } +} + +function unescapeHtml(text) { + return text + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +// The producer-rendered dashboard is the single source of current actions: an +// ID is dispatchable only while the served page actually recommends it. +function readDashboard() { + let html; + try { + html = fs.readFileSync(DASHBOARD, "utf8"); + } catch { + return null; + } + const actions = new Map(); + for (const match of html.matchAll(/data-copy="([^"]*)"/g)) { + const prompt = unescapeHtml(match[1]); + const id = (prompt.match(/CAP-\d{2}/) || [])[0]; + if (id && !actions.has(id)) actions.set(id, prompt); + } + const generated = (html.match(/generated ([^<]+))\]]+\.ts\.net[^\s"'`<>)\]]*/g)) links.add(match[0]); + } + return [...links]; +} + +// Bulleted or numbered body lines of a captain-hold item are its options; each +// option's impact is the text of that line plus any continuation up to the next +// option marker. +function decisionOptions(body) { + const options = []; + let current = null; + for (const line of body) { + const marker = line.match(/^(?:[-*]|\d+[.)])\s+(.*)$/); + if (marker) { current = { text: marker[1].trim(), impact: [] }; options.push(current); continue; } + if (current && line !== "") current.impact.push(line); + } + return options.map((option) => ({ text: option.text, impact: option.impact.join(" ").slice(0, 800) })); +} + +function refDisplayMap(refsFile) { + const display = {}; + for (const [ref, entry] of Object.entries(refsFile.refs)) { + if (entry.kind === "project") display[ref] = { t: "project", label: entry.value }; + else if (entry.kind === "home") display[ref] = { t: "home", label: entry.value }; + else if (entry.kind === "item") { + const separator = entry.value.indexOf("/"); + const owner = entry.value.slice(0, separator); + const id = entry.value.slice(separator + 1); + display[ref] = owner === "decision" + ? { t: "decision", label: id } + : { t: "work", label: id, owner }; + } + } + return display; +} + +function assembleDetail(ref) { + const refsFile = readRefs(); + const entry = refsFile?.refs?.[ref]; + if (!entry || entry.kind !== "item") return null; + const separator = entry.value.indexOf("/"); + const owner = entry.value.slice(0, separator); + const id = entry.value.slice(separator + 1); + const backlogItem = parseBacklog().find((item) => item.id === id) || null; + + if (owner === "decision") { + const body = backlogItem ? backlogItem.body : []; + return { + type: "decision", + ref, + id, + title: backlogItem ? backlogItem.title : id, + description: body.filter((line) => !/^(?:[-*]|\d+[.)])\s+/.test(line)).join("\n").trim().slice(0, 2000) || null, + options: decisionOptions(body), + recent: statusTail(id), + note: backlogItem ? null : "No structured record was found for this decision key; answer it in captain chat.", + }; + } + if (owner !== "main") { + return { + type: "work", + ref, + id, + owner, + title: backlogItem ? backlogItem.title : id, + note: "This work lives with a domain supervisor; its instructions and records are in that home.", + }; + } + const brief = briefSections(id); + const meta = readMeta(id); + const report = readText(path.join(DATA, id, "report.md"), 8192); + const recent = statusTail(id); + const backlogBody = backlogItem ? backlogItem.body.join("\n") : ""; + const testPlan = brief.testPlan + || (backlogBody.match(/acceptance criteria[:\s]*([\s\S]{0,600})/i) || [])[1]?.trim() + || null; + return { + type: "work", + ref, + id, + owner: "main", + title: backlogItem ? backlogItem.title : id, + description: brief.description || backlogBody.slice(0, 2000) || null, + test_plan: testPlan, + pr: meta.pr || null, + project: meta.project ? path.basename(meta.project) : null, + delivery_mode: meta.mode || null, + previews: previewLinks(brief.raw, report, recent.join("\n")), + report_excerpt: report ? report.trim().slice(0, 1200) : null, + recent, + }; +} + +// Parse data/ideas/idea-backlog.md generously: any heading or list line +// carrying an IDEA-XX token starts an idea; following lines up to the next +// idea are its concept summary. +function parseIdeas() { + const text = readText(IDEAS, 262144); + if (!text) return []; + const ideas = []; + let current = null; + for (const line of text.split("\n")) { + const marker = line.match(/^\s*(?:#{1,4}\s*|[-*]\s+|\d+[.)]\s+)?.*?\b(IDEA-\d+)\b[:\s-]*(.*)$/); + if (marker && !ideas.some((idea) => idea.id === marker[1])) { + current = { id: marker[1], title: marker[2].trim() || marker[1], summary: [] }; + ideas.push(current); + continue; + } + if (current && !/\bIDEA-\d+\b/.test(line)) current.summary.push(line); + } + return ideas.map((idea) => ({ id: idea.id, title: idea.title, summary: idea.summary.join("\n").trim().slice(0, 3000) })); +} + +function ideaDetail(id) { + const idea = parseIdeas().find((entry) => entry.id === id); + if (!idea) return null; + const pitch = /^IDEA-\d+$/.test(id) ? readText(path.join(PITCHES, `${id}.md`), 131072) : null; + return { + type: "idea", + id: idea.id, + title: idea.title, + pitch: pitch ? pitch.trim().slice(0, 12000) : null, + description: pitch ? null : idea.summary || null, + }; +} + +let refreshing = null; +function runRefresh() { + if (refreshing) return refreshing; + const extraArgs = (process.env.FM_DASH_CAPACITY_ARGS || "").split(" ").filter(Boolean); + refreshing = new Promise((resolve) => { + const child = spawn(process.execPath, [CAPACITY, "--refs", REFS, ...extraArgs], { + cwd: ROOT, + env: { ...process.env, FM_HOME }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const timer = setTimeout(() => child.kill("SIGKILL"), REFRESH_TIMEOUT_MS); + child.on("close", (code) => { + clearTimeout(timer); + refreshing = null; + if (code === 0) resolve({ ok: true }); + else resolve({ ok: false, error: stderr.trim().slice(0, 500) || `capacity producer exited ${code}` }); + }); + child.on("error", (error) => { + clearTimeout(timer); + refreshing = null; + resolve({ ok: false, error: error.message }); + }); + }); + return refreshing; +} + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(body) }); + res.end(body); +} + +function sendHtml(res, status, html) { + res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + res.end(html); +} + +function requesterLogin(req) { + const value = req.headers["tailscale-user-login"]; + return typeof value === "string" ? value.trim() : ""; +} + +function authorized(req, config) { + const login = requesterLogin(req); + return login !== "" && config.logins.includes(login); +} + +// Injected interactive layer. It only talks to this service's own API; the +// underlying producer file stays untouched on disk and keeps working offline. +// When the producer's refs sidecar is present the layer also de-anonymizes the +// page for the authenticated captain: opaque item/project/home references get +// their real names, and work items and decisions become clickable detail views. +function interactiveLayer(dispatchable, pending, generated, readOnly, extras) { + const config = JSON.stringify({ + dispatchable, + pending, + generated, + readOnly: readOnly === true, + refs: extras?.refs || {}, + ideas: extras?.ideas || [], + }); + return ` + `; +} + +function setupPage(message) { + return ` + +Firstmate capacity dashboard + +

Capacity dashboard

${message}

`; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { reject(new Error("body too large")); req.destroy(); return; } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +async function handle(req, res) { + const url = new URL(req.url, "http://localhost"); + if (req.method === "GET" && url.pathname === "/healthz") { + sendJson(res, 200, { status: "ok" }); + return; + } + const config = readConfig(); + if (config.logins.length === 0) { + sendHtml(res, 403, setupPage("No captain login is configured yet. Run bin/fm-dash-install.sh on the firstmate machine to finish setup.")); + return; + } + if (!authorized(req, config)) { + log(`refused ${req.method} ${url.pathname} identity=${JSON.stringify(requesterLogin(req)) || "none"}`); + sendJson(res, 403, { status: "forbidden", error: "tailnet identity is not an authorized captain login" }); + return; + } + if (req.method === "GET" && url.pathname === "/") { + const dashboard = readDashboard(); + if (!dashboard) { + sendHtml(res, 200, setupPage("No dashboard has been generated yet. Use the Refresh capacity action once firstmate has generated a first snapshot, or run /capacity from firstmate.") + .replace("", `${interactiveLayer([], pendingRecords().length, "never", config.readOnly)}`)); + return; + } + const dispatchable = config.readOnly ? [] : [...dashboard.actions.keys()].filter((id) => ONE_CLICK_ACTIONS.has(id)); + const refsFile = readRefs(); + const layer = interactiveLayer(dispatchable, pendingRecords().length, dashboard.generated, config.readOnly, { + refs: refsFile ? refDisplayMap(refsFile) : {}, + ideas: parseIdeas().map((idea) => ({ id: idea.id, title: idea.title })), + }); + sendHtml(res, 200, dashboard.html.replace("", `${layer}`)); + return; + } + if (req.method === "GET" && url.pathname === "/api/detail") { + const ref = url.searchParams.get("ref"); + const idea = url.searchParams.get("idea"); + let detail = null; + if (idea && /^IDEA-\d+$/.test(idea)) detail = ideaDetail(idea); + else if (ref && /^(?:item|project|home)-\d{2,}$/.test(ref)) detail = assembleDetail(ref); + if (!detail) { + sendJson(res, 404, { status: "not-found" }); + return; + } + sendJson(res, 200, detail); + return; + } + if (req.method === "GET" && url.pathname === "/api/pending") { + sendJson(res, 200, { status: "ok", pending: pendingRecords().length }); + return; + } + if (req.method === "POST" && url.pathname === "/api/refresh") { + if (refreshing) { + sendJson(res, 409, { status: "busy" }); + return; + } + log(`refresh requested by ${requesterLogin(req)}`); + const result = await runRefresh(); + if (result.ok) sendJson(res, 200, { status: "refreshed" }); + else sendJson(res, 502, { status: "failed", error: result.error }); + return; + } + if (req.method === "POST" && url.pathname === "/api/dispatch") { + if (config.readOnly) { + sendJson(res, 403, { status: "refused", error: "this dashboard is read-only; command dispatch is not enabled yet" }); + return; + } + let body; + try { + body = JSON.parse(await readBody(req) || "{}"); + } catch { + sendJson(res, 400, { status: "refused", error: "invalid request body" }); + return; + } + + // Decision approval: the chosen option must be one the server itself just + // read from the decision's structured record. + if (typeof body.ref === "string") { + const detail = /^(?:item|project|home)-\d{2,}$/.test(body.ref) ? assembleDetail(body.ref) : null; + if (!detail || detail.type !== "decision") { + sendJson(res, 400, { status: "refused", error: "approval accepts a currently listed decision only" }); + return; + } + const option = detail.options?.[body.option]; + if (!option) { + sendJson(res, 400, { status: "refused", error: "the chosen option is not on the decision's record" }); + return; + } + const pending = pendingRecords(); + if (pending.some((record) => record.kind === "decision" && record.decision_key === detail.id)) { + sendJson(res, 200, { status: "already-queued", pending: pending.length }); + return; + } + const record = { + schema: "fm-dash-command.v1", + kind: "decision", + id: body.ref, + decision_key: detail.id, + option_text: option.text, + requested_by: requesterLogin(req), + requested_at: new Date().toISOString(), + prompt: `Captain approved decision ${detail.id}: choose "${option.text}". Route it through the normal decision lifecycle; a destructive or irreversible consequence still needs chat confirmation.`, + }; + const name = enqueueCommand(record); + log(`queued decision ${detail.id} as ${name} for ${record.requested_by}`); + sendJson(res, 200, { status: "queued", pending: pending.length + 1 }); + return; + } + + // Idea verdicts: approve, deny, or captain suggestions for a listed idea. + // The suggestion text is captain-authored data for firstmate, never a + // command the service interprets or executes. + if (typeof body.idea === "string") { + const verdict = body.verdict; + if (!/^IDEA-\d+$/.test(body.idea) || !["approve", "deny", "suggest"].includes(verdict)) { + sendJson(res, 400, { status: "refused", error: "idea dispatch needs a listed idea and an approve, deny, or suggest verdict" }); + return; + } + const idea = parseIdeas().find((entry) => entry.id === body.idea); + if (!idea) { + sendJson(res, 404, { status: "refused", error: `${body.idea} is not in the idea backlog` }); + return; + } + const suggestion = verdict === "suggest" ? String(body.suggestion || "").trim().slice(0, 2000) : null; + if (verdict === "suggest" && !suggestion) { + sendJson(res, 400, { status: "refused", error: "suggestions need text" }); + return; + } + const pending = pendingRecords(); + if (verdict !== "suggest" && pending.some((record) => record.kind === "idea" && record.idea === idea.id && record.verdict === verdict)) { + sendJson(res, 200, { status: "already-queued", pending: pending.length }); + return; + } + const verbs = { approve: "approved", deny: "denied", suggest: "added suggestions to" }; + const record = { + schema: "fm-dash-command.v1", + kind: "idea", + id: idea.id, + idea: idea.id, + verdict, + suggestion, + requested_by: requesterLogin(req), + requested_at: new Date().toISOString(), + prompt: `Captain ${verbs[verdict]} idea ${idea.id} (${idea.title}).${suggestion ? ` Captain suggestion text: ${suggestion}` : ""}${verdict === "approve" ? " Create the follow-up work item(s) through the normal backlog lifecycle." : ""}`, + }; + const name = enqueueCommand(record); + log(`queued idea ${idea.id} ${verdict} as ${name} for ${record.requested_by}`); + sendJson(res, 200, { status: "queued", pending: pending.length + 1 }); + return; + } + + const id = body.id; + if (typeof id !== "string" || !/^CAP-\d{2}$/.test(id)) { + sendJson(res, 400, { status: "refused", error: "dispatch accepts a known CAP-NN action id only" }); + return; + } + if (!ONE_CLICK_ACTIONS.has(id)) { + sendJson(res, 403, { status: "refused", error: `${id} is not one-click eligible; raise it in captain chat` }); + return; + } + const dashboard = readDashboard(); + const prompt = dashboard?.actions.get(id); + if (!prompt) { + sendJson(res, 409, { status: "refused", error: `${id} is not recommended by the current dashboard; refresh first` }); + return; + } + const pending = pendingRecords(); + if (pending.some((record) => record.id === id)) { + sendJson(res, 200, { status: "already-queued", pending: pending.length }); + return; + } + const record = { + schema: "fm-dash-command.v1", + id, + prompt, + requested_by: requesterLogin(req), + requested_at: new Date().toISOString(), + dashboard_generated: dashboard.generated, + }; + const name = enqueueCommand(record); + log(`queued ${id} as ${name} for ${record.requested_by}`); + sendJson(res, 200, { status: "queued", pending: pending.length + 1 }); + return; + } + sendJson(res, 404, { status: "not-found" }); +} + +function main() { + const args = process.argv.slice(2); + let portOverride = null; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === "--help" || args[i] === "-h") usage(0); + else if (args[i] === "--port" && args[i + 1]) { portOverride = Number(args[i + 1]); i += 1; } + else usage(2); + } + const config = readConfig(); + const port = portOverride || config.port || 8847; + const server = http.createServer((req, res) => { + handle(req, res).catch((error) => { + log(`error handling ${req.method} ${req.url}: ${error.message}`); + if (!res.headersSent) sendJson(res, 500, { status: "error" }); + else res.end(); + }); + }); + server.listen(port, "127.0.0.1", () => { + log(`fm-dash-serve listening on 127.0.0.1:${port} for FM_HOME=${FM_HOME}${config.readOnly ? " (read-only)" : ""}`); + }); + if (config.autoRefreshSeconds > 0) { + const autoRender = async () => { + const result = await runRefresh(); + log(result.ok ? "auto-render replaced the dashboard" : `auto-render failed: ${result.error}`); + }; + let stale = true; + try { + stale = Date.now() - fs.statSync(DASHBOARD).mtimeMs > config.autoRefreshSeconds * 1000; + } catch { /* missing dashboard is stale */ } + if (stale) autoRender(); + setInterval(autoRender, config.autoRefreshSeconds * 1000).unref(); + } + const stop = () => server.close(() => process.exit(0)); + process.on("SIGTERM", stop); + process.on("SIGINT", stop); +} + +main(); diff --git a/docs/configuration.md b/docs/configuration.md index 17f1e16091c..ad9fd95a340 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -102,6 +102,13 @@ An absent file means `auto`, i.e. default-on on macOS: the alarm exists precisel A missing or failing channel logs and falls through to the next, never crashing the daemon. See [`wedge-alarm.md`](wedge-alarm.md) for the channel reference and macOS verification evidence, and [`examples/wedge-alarm`](examples/wedge-alarm) for a copyable config. +## Capacity dashboard service (config/dash.json) + +The optional persistent dashboard service publishes `data/capacity-dashboard.html` tailnet-only at one stable HTTPS URL, adds one-click `CAP-NN` dispatch, clickable de-anonymized work-item and decision detail views, idea verdicts over `data/ideas/`, and a server-side refresh, and delivers clicked commands to the running Firstmate through durable `state/dash-inbox/` records surfaced by the registered `fm-dash` watcher check. +`config/dash.json` (local, gitignored, written by `bin/fm-dash-install.sh`) holds `port` (loopback service port, default 8847), `captain_logins` (the tailnet logins allowed to reach the service), `read_only` (serve and auto-render without any mutation route), and `auto_refresh_seconds` (producer rerun interval, default 900, 0 disables); the service fails closed without a matching `Tailscale-User-Login` identity. +The service reads the producer's opt-in `state/dash-refs.json` identity sidecar (`fm-capacity.mjs --refs`) to enrich the served page; the on-disk dashboard itself stays identity-opaque. +`bin/fm-dash-serve.mjs --help` owns routes and the one-click allowlist, `bin/fm-dash-install.sh --help` owns launchd persistence and the never-Funnel tailscale serve wiring, `bin/fm-dash-inbox.sh --help` owns command consumption, and [`dashboard-service.md`](dashboard-service.md) owns the architecture and trust design. + ## Gate defaults (.no-mistakes.yaml) The tracked `.no-mistakes.yaml` keeps test evidence outside the repo and defines `commands.test` so no-mistakes runs firstmate's bash behavior suite directly. diff --git a/docs/dashboard-service.md b/docs/dashboard-service.md new file mode 100644 index 00000000000..a2584727693 --- /dev/null +++ b/docs/dashboard-service.md @@ -0,0 +1,85 @@ +# Persistent tailnet-only capacity dashboard service + +This document owns the architecture narrative, trust design, and verification evidence for the always-on capacity dashboard. +Mechanics live with their owners: `bin/fm-dash-serve.mjs --help` (routes, config schema, one-click allowlist), `bin/fm-dash-install.sh --help` (persistence and tailscale wiring), `bin/fm-dash-inbox.sh --help` (command consumption), and the capacity skill (handling semantics for delivered commands). + +## What it is + +The service publishes the producer-generated `data/capacity-dashboard.html` at one stable tailnet HTTPS URL that survives reboots, and layers interactive abilities onto it: + +- Every current one-click-eligible `CAP-NN` action gets an "Approve & send" button. +- A "Refresh capacity" button reruns `bin/fm-capacity.mjs` server-side and reloads the page; the producer also reruns automatically on the configured interval so the page never goes stale. +- The page is de-anonymized for the authenticated captain: opaque `item-NN`/`project-NN`/`home-NN` references become real names, and work items and decisions are clickable rich detail views (description, test plan, PR link, tailnet preview links, report excerpt, recent activity) assembled from task briefs, recorded metadata, the backlog, and scout reports. +- Open decisions show each recorded option with its impact, and every option carries an Approve button. +- An Ideas section renders `data/ideas/idea-backlog.md`; each idea opens its pitch (`data/ideas/pitches/IDEA-XX.md` when present, else the concept summary) with Approve, Deny, and Add-suggestions controls. +- A service bar shows how many captain commands are queued for firstmate. + +`bin/fm-capacity.mjs` remains the single owner of the dashboard's content and look; the service injects its interactive layer at serve time and never modifies the file on disk, so the file keeps working offline exactly as before. +The on-disk dashboard stays identity-opaque: the producer's opt-in `--refs` sidecar (`state/dash-refs.json`, `fm-capacity-refs.v1`, mode 0600) carries the opaque-to-real mapping, and only the captain-authenticated service reads it to enrich the served page. + +## Components + +- `bin/fm-dash-serve.mjs` - the HTTP service, bound to 127.0.0.1 only. +- `bin/fm-dash-install.sh` - launchd agent (`RunAtLoad` + `KeepAlive`, so it survives reboots and crashes), `tailscale serve` mapping, `config/dash.json`, and the registered `fm-dash` watcher check. +- `state/dash-inbox/` - durable captain command records (`fm-dash-command.v1`), one file per clicked action. +- `state/fm-dash.check.sh` - watcher check registered through `bin/fm-check-register.sh`; prints one line while commands are pending so the watcher wakes firstmate. +- `bin/fm-dash-inbox.sh` - firstmate's list/claim helper; claim archives each record under `state/dash-inbox/archive/` so a command is surfaced exactly once. + +## Inbound command channel + +Button clicks never execute anything. +The design keeps the web process outside every fleet-mutation path: + +1. The captain clicks "Send to firstmate" on a `CAP-NN` action. +2. The service validates the request (see trust design) and writes one durable `fm-dash-command.v1` record into `state/dash-inbox/` with an atomic temp-file rename, mode 0600. +3. The registered `fm-dash` watcher check notices the pending record on its normal cadence (`FM_CHECK_INTERVAL`, default 300 seconds) and wakes the running firstmate through the standard durable wake queue. +4. Firstmate claims the records with `bin/fm-dash-inbox.sh claim` and handles each prompt as the captain's approval of that action ID under the capacity skill's section 4 semantics. + +Consequences of that shape: + +- The service holds no session with firstmate, no terminal access, and no merge, dispatch, or teardown capability; compromise of the web process yields at most bogus `CAP-NN` approval records, which firstmate still re-resolves through every normal lifecycle authority check. +- Delivery is durable: a click made while firstmate is down waits in the inbox and is delivered on the next watcher cycle or session start sweep of pending checks. +- Delivery latency is the watcher check cadence, not instantaneous; the page says "queued for firstmate" honestly rather than pretending immediacy. +- Commands survive service restarts, firstmate restarts, and reboots because the inbox is plain durable state. + +## Trust design + +Identity is enforced at every layer that can fail: + +- `tailscale serve` terminates HTTPS on the tailnet and injects the `Tailscale-User-Login` header for the authenticated tailnet peer; Funnel traffic would carry no such identity. +- The service refuses every route except `/healthz` unless that header matches a login in `config/dash.json` (`captain_logins`, recorded from the tailnet self login at install time or passed with `--captain`). +- With no configured captain login the service serves only a setup notice and refuses everything else. +- The service binds 127.0.0.1, so the only remote path in is the tailscale proxy; a local process on the captain's machine is already inside the trust boundary because it could write `FM_HOME` state directly. + +Dispatch is validated against records the server itself reads: + +- A `CAP-NN` request must currently be recommended by the served dashboard itself AND sit in the service's fixed one-click allowlist of reviewed lifecycle-safe actions; unknown or future IDs are refused with guidance to raise them in captain chat, so new actions default to chat-only. +- A decision approval must name a decision in the producer's refs sidecar, and the chosen option must be one the server just parsed from that decision's structured backlog record; an off-record option is refused. +- An idea verdict must name an idea currently listed in `data/ideas/idea-backlog.md` and one of the approve, deny, or suggest verbs; on approval, firstmate creates the work item(s) through the normal backlog lifecycle - the service itself never creates work. +- The only free text accepted anywhere is the bounded add-suggestions note on an idea, which is captain-authored data for firstmate (the tailnet identity check makes it genuinely the captain), never a command the service interprets or executes. +- Destructive, irreversible, and security-sensitive choices stay in captain chat structurally: no current `CAP-NN` prompt grants such authority, the capacity skill forbids treating a dashboard approval as merge or discard authority, decision records carry an explicit re-confirm-in-chat boundary for destructive consequences, and firstmate re-resolves every claimed command through the normal lifecycle before acting. + +Funnel is never acceptable for this surface. +The installer only ever creates a plain `tailscale serve` mapping, verifies after configuring that no Funnel exposure exists for the served port, and tears the mapping back down and refuses if one is found. + +## Setup and removal + +``` +bin/fm-dash-install.sh install # defaults: port 8847, serve port 8443, captain = tailnet self login +bin/fm-dash-install.sh install --read-only # serve and auto-render only; no dispatch, no watcher check +bin/fm-dash-install.sh status +bin/fm-dash-install.sh uninstall +``` + +A read-only install is the right shape for running the service ahead of command-consumption wiring: the page, detail views, refresh, and auto-render all work, while every mutation route refuses. + +Install is idempotent and prints the stable URL, `https://..ts.net:8443/`. +Uninstall removes the serve mapping and launchd agent but keeps `config/dash.json` and any pending commands. +The launchd agent logs to `state/dash-serve.log`. +On a non-macOS host the installer refuses and the service can be run under the local init system with the same tailscale serve mapping. + +## Verification evidence + +2026-07-28, macOS 15.6, node v26.5.0, tailscale CLI present at /opt/homebrew/bin/tailscale. +`bash tests/fm-dash.test.sh` passed end to end against a live local service instance: identity-less and wrong-identity requests got 403 with no inbox write; an authorized dispatch wrote exactly one mode-0600 `fm-dash-command.v1` record and a duplicate click coalesced; free-text, non-allowlisted (`CAP-99`), and not-currently-recommended IDs were refused; `/api/refresh` regenerated the dashboard through the real producer; claim archived and printed each record exactly once; the registered check shim printed one line only while commands were pending; and the rendered plist carried `RunAtLoad`, `KeepAlive`, the pinned `FM_HOME`, and no Funnel reference. +The launchd bootstrap and live `tailscale serve` mapping mutate the host machine and were not exercised from the isolated task worktree; run `bin/fm-dash-install.sh install` once on the target machine and confirm `status` shows the agent loaded, the serve mapping present, and `tailscale serve status` showing no Funnel line for the port. diff --git a/docs/scripts.md b/docs/scripts.md index 999a4a7a598..b5ad932dc11 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -14,6 +14,9 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | | `fm-capacity.mjs` | Classify meaningful fleet capacity and replace the private offline pipeline dashboard | +| `fm-dash-serve.mjs` | Serve the capacity dashboard tailnet-only with one-click CAP dispatch and server-side refresh (docs/dashboard-service.md) | +| `fm-dash-install.sh` | Install the persistent dashboard service: launchd agent, never-Funnel tailscale serve mapping, and registered fm-dash check | +| `fm-dash-inbox.sh` | List and claim durable captain dashboard commands from `state/dash-inbox/` | | `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin | | `fm-task-add.sh` | Create backlog items with creation-time task-ID validation and safe mint fitting | | `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home | diff --git a/tests/fm-dash.test.sh b/tests/fm-dash.test.sh new file mode 100644 index 00000000000..0f1f0927cba --- /dev/null +++ b/tests/fm-dash.test.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +# Behavior and contract tests for the persistent tailnet-only dashboard service: +# bin/fm-dash-serve.mjs, bin/fm-dash-inbox.sh, and bin/fm-dash-install.sh. +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SERVE="$ROOT/bin/fm-dash-serve.mjs" +INBOX_SH="$ROOT/bin/fm-dash-inbox.sh" +INSTALL_SH="$ROOT/bin/fm-dash-install.sh" +CAPACITY="$ROOT/bin/fm-capacity.mjs" +TMP_ROOT=$(fm_test_tmproot fm-dash) + +command -v node >/dev/null 2>&1 || { echo "skip: node not found"; exit 0; } +command -v curl >/dev/null 2>&1 || { echo "skip: curl not found"; exit 0; } + +CAPTAIN="captain@example.com" +SERVER_PID="" + +cleanup() { + [ -z "$SERVER_PID" ] || kill "$SERVER_PID" 2>/dev/null || true + fm_test_cleanup +} +trap cleanup EXIT + +make_fixture() { + local home=$1 snapshot=$2 environment=$3 + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + cat > "$snapshot" < "$environment" <<'EOF' +{ + "backend": {"name":"tmux","available":true,"evidence":"required runtime tools present","owner":"fixture"}, + "github_auth": {"status":"available","evidence":"authenticated","owner":"fixture"}, + "dispatch": {"config_present":true,"valid":true,"reason":null,"lanes":[ + {"harness":"codex","model":"gpt-test","effort":"high","when":"default","available":true,"availability_evidence":"executable present","quota":"not observed - capacity never guesses quota"} + ]}, + "secondmates": {} +} +EOF + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued +- [ ] ready-safe - Ship the gamma feature (repo: gamma) (kind: ship) + Acceptance criteria: bounded regression tests pass. +- [ ] captain-choice - Choose the rollout policy (repo: alpha) (kind: captain) + Pick the alpha rollout pace before dependent work starts. + - Conservative rollout: slower, safest for existing users + - Fast rollout: reaches everyone this week, higher regression risk + +## Done +EOF + mkdir -p "$home/data/ready-safe" "$home/data/ideas/pitches" + cat > "$home/data/ready-safe/brief.md" <<'EOF' +# Task +Ship the gamma feature so gamma users get streaming exports. + +Acceptance criteria: +bounded regression tests pass and the export path stays backward compatible. + +# Setup +Standard worktree setup. +EOF + printf 'pr=https://github.com/purple-phoenix/firstmate/pull/999\nproject=%s/projects/gamma\nmode=no-mistakes\n' "$home" > "$home/state/ready-safe.meta" + printf 'working: preview at https://demo.tailebcf61.ts.net:5300/\n' > "$home/state/ready-safe.status" + cat > "$home/data/ideas/idea-backlog.md" <<'EOF' +# Idea backlog + +## IDEA-01 - Faster onboarding +New crew homes should self-provision in one command. + +## IDEA-02 - Nightly digest +Send the captain a nightly fleet digest. +EOF + printf '# Faster onboarding pitch\n\nOne command provisions a ready home.\n' > "$home/data/ideas/pitches/IDEA-01.md" +} + +write_config() { + local home=$1 port=$2 + cat > "$home/config/dash.json" <{console.log(s.address().port);s.close();});' +} + +start_server() { + local home=$1 port=$2 fixture_args=${3:-} + FM_HOME="$home" FM_DASH_CAPACITY_ARGS="$fixture_args" node "$SERVE" --port "$port" > "$TMP_ROOT/serve.log" 2>&1 & + SERVER_PID=$! + local tries=0 + while ! curl -sf "http://127.0.0.1:$port/healthz" >/dev/null 2>&1; do + tries=$((tries + 1)) + [ "$tries" -lt 50 ] || fail "dashboard service did not start (see $TMP_ROOT/serve.log)" + sleep 0.1 + done +} + +stop_server() { + [ -z "$SERVER_PID" ] || kill "$SERVER_PID" 2>/dev/null || true + SERVER_PID="" +} + +REQ_STATUS="" +RESP="" +req() { + # req [login] [body] -> sets REQ_STATUS and RESP + local method=$1 url=$2 login=${3:-} body=${4:-} + local args=(-s -o "$TMP_ROOT/resp.body" -w '%{http_code}' -X "$method") + [ -z "$login" ] || args+=(-H "Tailscale-User-Login: $login") + [ -z "$body" ] || args+=(-H "content-type: application/json" -d "$body") + REQ_STATUS=$(curl "${args[@]}" "$url") + RESP=$(cat "$TMP_ROOT/resp.body") +} + +HOME_DIR="$TMP_ROOT/home" +SNAPSHOT="$TMP_ROOT/snapshot.json" +ENVIRONMENT="$TMP_ROOT/environment.json" +make_fixture "$HOME_DIR" "$SNAPSHOT" "$ENVIRONMENT" +FM_HOME="$HOME_DIR" "$CAPACITY" --snapshot "$SNAPSHOT" --environment "$ENVIRONMENT" \ + --output "$HOME_DIR/data/capacity-dashboard.html" --refs "$HOME_DIR/state/dash-refs.json" >/dev/null \ + || fail "could not render the fixture dashboard with its refs sidecar" +PORT=$(pick_port) +write_config "$HOME_DIR" "$PORT" + +test_identity_fails_closed() { + local body + start_server "$HOME_DIR" "$PORT" + req GET "http://127.0.0.1:$PORT/healthz" + [ "$REQ_STATUS" = 200 ] || fail "healthz should not require identity (got $REQ_STATUS)" + req GET "http://127.0.0.1:$PORT/" + [ "$REQ_STATUS" = 403 ] || fail "identity-less page read was not refused (got $REQ_STATUS)" + req GET "http://127.0.0.1:$PORT/" "mallory@example.com" + [ "$REQ_STATUS" = 403 ] || fail "unauthorized tailnet identity was not refused (got $REQ_STATUS)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "mallory@example.com" '{"id":"CAP-06"}' + [ "$REQ_STATUS" = 403 ] || fail "unauthorized dispatch was not refused (got $REQ_STATUS)" + [ -z "$(find "$HOME_DIR/state/dash-inbox" -name '*.json' 2>/dev/null)" ] \ + || fail "a refused dispatch still wrote an inbox record" + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + [ "$REQ_STATUS" = 200 ] || fail "authorized captain read failed (got $REQ_STATUS)" + pass "every route except healthz requires the configured captain identity" +} + +test_served_page_wears_dashboard_with_interactive_layer() { + local body + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + assert_contains "$RESP" 'Firstmate capacity dashboard' "served page is not the producer dashboard" + assert_contains "$RESP" 'fmdash-bar' "served page lacks the injected service bar" + assert_contains "$RESP" 'Refresh capacity' "served page lacks the refresh control" + assert_contains "$RESP" 'Approve & send' "served page lacks the dispatch control script" + assert_contains "$RESP" 'data-copy' "producer copy layer was lost in serving" + assert_contains "$RESP" '"ready-safe"' "served page config lacks the de-anonymized work item id" + assert_contains "$RESP" 'IDEA-01' "served page config lacks the idea backlog" + pass "served page is the producer dashboard wearing the injected interactive layer" +} + +ref_for() { + # ref_for e.g. "main/ready-safe" or "decision/captain-choice" + node -e ' + const refs = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).refs; + const wanted = process.argv[2]; + for (const [ref, entry] of Object.entries(refs)) { + if (entry.kind === "item" && entry.value === wanted) { console.log(ref); process.exit(0); } + } + process.exit(1); + ' "$HOME_DIR/state/dash-refs.json" "$1" +} + +test_refs_sidecar_and_rich_work_item_detail() { + local ref + assert_present "$HOME_DIR/state/dash-refs.json" "producer did not write the refs sidecar" + assert_grep 'fm-capacity-refs.v1' "$HOME_DIR/state/dash-refs.json" "refs sidecar lacks its schema" + ref=$(ref_for "main/ready-safe") || fail "refs sidecar does not map the main work item" + req GET "http://127.0.0.1:$PORT/api/detail?ref=$ref" "$CAPTAIN" + [ "$REQ_STATUS" = 200 ] || fail "work item detail failed (got $REQ_STATUS: $RESP)" + assert_contains "$RESP" 'streaming exports' "detail lacks the brief description" + assert_contains "$RESP" 'backward compatible' "detail lacks the test plan" + assert_contains "$RESP" 'pull/999' "detail lacks the PR link" + assert_contains "$RESP" 'demo.tailebcf61.ts.net' "detail lacks the tailnet preview link" + req GET "http://127.0.0.1:$PORT/api/detail?ref=$ref" + [ "$REQ_STATUS" = 403 ] || fail "identity-less detail read was not refused (got $REQ_STATUS)" + pass "clickable work items serve rich detail from briefs, metadata, and previews" +} + +test_decision_detail_options_and_validated_approval() { + local ref record + ref=$(ref_for "decision/captain-choice") || fail "refs sidecar does not map the decision" + req GET "http://127.0.0.1:$PORT/api/detail?ref=$ref" "$CAPTAIN" + [ "$REQ_STATUS" = 200 ] || fail "decision detail failed (got $REQ_STATUS: $RESP)" + assert_contains "$RESP" 'Conservative rollout' "decision detail lacks its first option" + assert_contains "$RESP" 'higher regression risk' "decision detail lacks the option impact" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$ref\",\"option\":7}" + [ "$REQ_STATUS" = 400 ] || fail "an off-record option was not refused (got $REQ_STATUS)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$ref\",\"option\":0}" + [ "$REQ_STATUS" = 200 ] || fail "decision approval failed (got $REQ_STATUS: $RESP)" + record=$(cat "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name "*$ref.json" | head -1)") + assert_contains "$record" '"decision"' "decision record lacks its kind" + assert_contains "$record" 'captain-choice' "decision record lacks the decision key" + assert_contains "$record" 'Conservative rollout' "decision record lacks the chosen option" + assert_contains "$record" 'chat confirmation' "decision record lacks the destructive-consequence boundary" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$ref\",\"option\":1}" + assert_contains "$RESP" 'already-queued' "a second choice for the same decision was not coalesced" + find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name "*$ref.json" -delete + pass "decisions serve option detail and approvals are validated against the record" +} + +test_idea_pitch_and_verdicts() { + local record + req GET "http://127.0.0.1:$PORT/api/detail?idea=IDEA-01" "$CAPTAIN" + [ "$REQ_STATUS" = 200 ] || fail "idea pitch failed (got $REQ_STATUS: $RESP)" + assert_contains "$RESP" 'One command provisions' "idea detail lacks the pitch file content" + req GET "http://127.0.0.1:$PORT/api/detail?idea=IDEA-02" "$CAPTAIN" + assert_contains "$RESP" 'nightly fleet digest' "pitchless idea lacks its concept summary" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"idea":"IDEA-01","verdict":"approve"}' + [ "$REQ_STATUS" = 200 ] || fail "idea approval failed (got $REQ_STATUS: $RESP)" + record=$(cat "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*IDEA-01.json' | head -1)") + assert_contains "$record" '"idea"' "idea record lacks its kind" + assert_contains "$record" 'normal backlog lifecycle' "idea approval does not route creation through firstmate" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"idea":"IDEA-99","verdict":"approve"}' + [ "$REQ_STATUS" = 404 ] || fail "an unlisted idea was not refused (got $REQ_STATUS)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"idea":"IDEA-02","verdict":"suggest","suggestion":"scope it to weekdays only"}' + [ "$REQ_STATUS" = 200 ] || fail "idea suggestion failed (got $REQ_STATUS: $RESP)" + record=$(cat "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*IDEA-02.json' | head -1)") + assert_contains "$record" 'scope it to weekdays only' "suggestion text was not recorded for firstmate" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"idea":"IDEA-02","verdict":"suggest","suggestion":""}' + [ "$REQ_STATUS" = 400 ] || fail "an empty suggestion was not refused (got $REQ_STATUS)" + find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*IDEA-*.json' -delete + pass "ideas render their pitches and verdicts flow through the durable inbox" +} + +test_dispatch_writes_one_durable_record() { + local body record file + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"id":"CAP-06"}' + [ "$REQ_STATUS" = 200 ] || fail "captain dispatch failed (got $REQ_STATUS: $RESP)" + assert_contains "$RESP" '"queued"' "dispatch did not report queued" + file=$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*CAP-06.json' | head -1) + [ -n "$file" ] || fail "dispatch wrote no durable inbox record" + case "$(uname)" in + Darwin) [ "$(stat -f %Lp "$file")" = 600 ] || fail "inbox record is not mode 0600" ;; + *) [ "$(stat -c %a "$file")" = 600 ] || fail "inbox record is not mode 0600" ;; + esac + record=$(cat "$file") + assert_contains "$record" '"fm-dash-command.v1"' "inbox record lacks its schema" + assert_contains "$record" '"CAP-06"' "inbox record lacks the action id" + assert_contains "$record" 'Approve CAP-06' "inbox record lacks the model prompt" + assert_contains "$record" "$CAPTAIN" "inbox record lacks the requesting identity" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"id":"CAP-06"}' + assert_contains "$RESP" 'already-queued' "duplicate dispatch was not coalesced" + [ "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*CAP-06.json' | wc -l | tr -d ' ')" = 1 ] \ + || fail "duplicate dispatch wrote a second record" + pass "a click becomes exactly one durable captain command record" +} + +test_dispatch_refuses_unknown_and_uncurrent_actions() { + local body + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"id":"rm -rf /"}' + [ "$REQ_STATUS" = 400 ] || fail "free-text dispatch was not refused (got $REQ_STATUS)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"id":"CAP-99"}' + [ "$REQ_STATUS" = 403 ] || fail "an action outside the one-click allowlist was not refused (got $REQ_STATUS)" + assert_contains "$RESP" 'captain chat' "the allowlist refusal does not route to captain chat" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"id":"CAP-02"}' + [ "$REQ_STATUS" = 409 ] || fail "an action absent from the current dashboard was not refused (got $REQ_STATUS)" + pass "dispatch refuses free text, non-allowlisted actions, and stale actions" +} + +test_refresh_reruns_producer_server_side() { + local body before after + before=$(grep -o 'generated [^<]*' "$HOME_DIR/data/capacity-dashboard.html" | head -1) + stop_server + start_server "$HOME_DIR" "$PORT" "--snapshot $SNAPSHOT --environment $ENVIRONMENT" + rm -f "$HOME_DIR/data/capacity-dashboard.html" + req POST "http://127.0.0.1:$PORT/api/refresh" "$CAPTAIN" + [ "$REQ_STATUS" = 200 ] || fail "refresh failed (got $REQ_STATUS: $RESP)" + assert_contains "$RESP" 'refreshed' "refresh did not report success" + [ -f "$HOME_DIR/data/capacity-dashboard.html" ] || fail "refresh did not regenerate the dashboard" + after=$(grep -o 'generated [^<]*' "$HOME_DIR/data/capacity-dashboard.html" | head -1) + [ -n "$after" ] || fail "regenerated dashboard has no generated stamp" + : "$before" + pass "refresh reruns the capacity producer server-side and replaces the dashboard" +} + +test_inbox_list_claim_and_archive() { + local out + out=$(FM_HOME="$HOME_DIR" "$INBOX_SH" pending-count) + [ "$out" = 1 ] || fail "pending-count expected 1, got: $out" + out=$(FM_HOME="$HOME_DIR" "$INBOX_SH" list) + assert_contains "$out" 'CAP-06' "list omits the pending action" + assert_contains "$out" "$CAPTAIN" "list omits the requesting identity" + out=$(FM_HOME="$HOME_DIR" "$INBOX_SH" claim) + assert_contains "$out" 'claimed: 1' "claim did not claim the pending command" + assert_contains "$out" 'Approve CAP-06' "claim omits the command prompt" + assert_contains "$out" 'authority limits apply' "claim omits the authority boundary reminder" + [ -z "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*.json' 2>/dev/null)" ] \ + || fail "claim left the record pending" + [ -n "$(find "$HOME_DIR/state/dash-inbox/archive" -name '*CAP-06.json' 2>/dev/null)" ] \ + || fail "claim did not archive the record" + out=$(FM_HOME="$HOME_DIR" "$INBOX_SH" claim) + assert_contains "$out" 'no pending dashboard commands' "second claim re-surfaced the archived command" + pass "inbox claim surfaces each command exactly once and archives it durably" +} + +test_read_only_mode_fails_safe() { + stop_server + cat > "$HOME_DIR/config/dash.json" </dev/null || fail "write-check failed" + [ -f "$HOME_DIR/state/fm-dash.check-trust" ] || fail "write-check did not register the check" + out=$(sh "$HOME_DIR/state/fm-dash.check.sh") + [ -z "$out" ] || fail "check shim woke with an empty inbox: $out" + printf '{"schema":"fm-dash-command.v1","id":"CAP-06","prompt":"x"}\n' > "$HOME_DIR/state/dash-inbox/1-test-CAP-06.json" + out=$(sh "$HOME_DIR/state/fm-dash.check.sh") + assert_contains "$out" '1 captain command(s) pending' "check shim did not report the pending command" + assert_contains "$out" 'fm-dash-inbox.sh claim' "check shim does not name the claim helper" + [ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" = 1 ] || fail "check shim printed more than one line" + rm -f "$HOME_DIR/state/dash-inbox/1-test-CAP-06.json" + pass "the registered watcher check wakes firstmate only while commands are pending" +} + +test_installer_plist_and_funnel_stance() { + local plist + plist=$(FM_HOME="$HOME_DIR" "$INSTALL_SH" print-plist) || fail "print-plist failed" + assert_contains "$plist" 'io.firstmate.dashboard.' "plist lacks the per-home label" + assert_contains "$plist" 'fm-dash-serve.mjs' "plist does not run the dashboard service" + assert_contains "$plist" 'KeepAlive' "plist does not keep the service alive" + assert_contains "$plist" 'RunAtLoad' "plist does not start at load" + assert_contains "$plist" "$HOME_DIR" "plist does not pin FM_HOME" + printf '%s' "$plist" | grep -qi funnel && fail "plist mentions funnel" + grep -n 'tailscale funnel' "$INSTALL_SH" && fail "installer invokes tailscale funnel" + assert_grep 'assert_no_funnel' "$INSTALL_SH" "installer does not verify funnel is off" + assert_grep 'never enables Funnel' "$INSTALL_SH" "installer does not declare the funnel boundary" + pass "the launchd agent survives reboots and the installer is structurally funnel-free" +} + +test_service_contract_docs_and_ownership() { + assert_present "$ROOT/docs/dashboard-service.md" "dashboard service doc is missing" + assert_grep 'dash-inbox' "$ROOT/docs/dashboard-service.md" "service doc omits the inbound channel" + assert_grep 'fm-dash' "$ROOT/AGENTS.md" "AGENTS.md lacks the dashboard command wake trigger" + assert_grep 'dash-inbox' "$ROOT/AGENTS.md" "AGENTS.md state map lacks dash-inbox" + assert_grep 'config/dash.json' "$ROOT/.gitignore" "config/dash.json is not gitignored" + assert_grep 'dashboard service' "$ROOT/.agents/skills/capacity/SKILL.md" "capacity skill does not own dashboard command handling" + assert_grep 'never Funnel' "$ROOT/.agents/skills/capacity/SKILL.md" "capacity skill does not carry the funnel boundary" + pass "the service is documented and wired into the operating contract" +} + +test_identity_fails_closed +test_served_page_wears_dashboard_with_interactive_layer +test_refs_sidecar_and_rich_work_item_detail +test_decision_detail_options_and_validated_approval +test_idea_pitch_and_verdicts +test_dispatch_writes_one_durable_record +test_dispatch_refuses_unknown_and_uncurrent_actions +test_refresh_reruns_producer_server_side +test_inbox_list_claim_and_archive +test_read_only_mode_fails_safe +test_check_shim_wakes_only_when_pending +test_installer_plist_and_funnel_stance +test_service_contract_docs_and_ownership + +echo "fm-dash tests passed" From 4f6483c6a8149153eed5649dcea2fc4ecf77b9ee Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 10:43:37 -0400 Subject: [PATCH 02/24] no-mistakes(review): Harden dashboard rendering, refs, and plist generation --- bin/fm-dash-install.sh | 15 +++++++++++--- bin/fm-dash-serve.mjs | 18 +++++++++++----- tests/fm-dash.test.sh | 47 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/bin/fm-dash-install.sh b/bin/fm-dash-install.sh index 6b1ddf294c3..68f0d6b1999 100755 --- a/bin/fm-dash-install.sh +++ b/bin/fm-dash-install.sh @@ -131,7 +131,12 @@ write_config() { } render_plist() { - local label=$1 node_path=$2 log_dir=$3 + local label node_path log_dir fm_root fm_home + label=$(xml_escape "$1") + node_path=$(xml_escape "$2") + log_dir=$(xml_escape "$3") + fm_root=$(xml_escape "$FM_ROOT") + fm_home=$(xml_escape "$FM_HOME") cat < @@ -141,11 +146,11 @@ render_plist() { ProgramArguments $node_path - $FM_ROOT/bin/fm-dash-serve.mjs + $fm_root/bin/fm-dash-serve.mjs EnvironmentVariables - FM_HOME$FM_HOME + FM_HOME$fm_home RunAtLoad KeepAlive @@ -156,6 +161,10 @@ render_plist() { PLIST } +xml_escape() { + node -e 'process.stdout.write(process.argv[1].replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("\x27", "'"))' "$1" +} + write_check() { mkdir -p "$STATE" cat > "$CHECK" <<'SHIM' diff --git a/bin/fm-dash-serve.mjs b/bin/fm-dash-serve.mjs index 71b55af38b8..6ddb2bdd9cf 100755 --- a/bin/fm-dash-serve.mjs +++ b/bin/fm-dash-serve.mjs @@ -174,10 +174,10 @@ function enqueueCommand(record) { // refs sidecar, the backlog, task briefs, task metadata, status tails, and // scout reports. Detail is served only to the authenticated captain. -function readRefs() { +function readRefs(generated) { try { const parsed = JSON.parse(fs.readFileSync(REFS, "utf8")); - if (parsed.schema !== "fm-capacity-refs.v1" || typeof parsed.refs !== "object") return null; + if (parsed.schema !== "fm-capacity-refs.v1" || parsed.generated !== generated || typeof parsed.refs !== "object") return null; return parsed; } catch { return null; @@ -285,7 +285,8 @@ function refDisplayMap(refsFile) { } function assembleDetail(ref) { - const refsFile = readRefs(); + const dashboard = readDashboard(); + const refsFile = dashboard ? readRefs(dashboard.generated) : null; const entry = refsFile?.refs?.[ref]; if (!entry || entry.kind !== "item") return null; const separator = entry.value.indexOf("/"); @@ -423,13 +424,20 @@ function authorized(req, config) { return login !== "" && config.logins.includes(login); } +function inlineScriptJson(value) { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => { + const escapes = { "<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "\u2028": "\\u2028", "\u2029": "\\u2029" }; + return escapes[character]; + }); +} + // Injected interactive layer. It only talks to this service's own API; the // underlying producer file stays untouched on disk and keeps working offline. // When the producer's refs sidecar is present the layer also de-anonymizes the // page for the authenticated captain: opaque item/project/home references get // their real names, and work items and decisions become clickable detail views. function interactiveLayer(dispatchable, pending, generated, readOnly, extras) { - const config = JSON.stringify({ + const config = inlineScriptJson({ dispatchable, pending, generated, @@ -888,7 +896,7 @@ async function handle(req, res) { return; } const dispatchable = config.readOnly ? [] : [...dashboard.actions.keys()].filter((id) => ONE_CLICK_ACTIONS.has(id)); - const refsFile = readRefs(); + const refsFile = readRefs(dashboard.generated); const layer = interactiveLayer(dispatchable, pendingRecords().length, dashboard.generated, config.readOnly, { refs: refsFile ? refDisplayMap(refsFile) : {}, ideas: parseIdeas().map((idea) => ({ id: idea.id, title: idea.title })), diff --git a/tests/fm-dash.test.sh b/tests/fm-dash.test.sh index 0f1f0927cba..ebb5e224233 100644 --- a/tests/fm-dash.test.sh +++ b/tests/fm-dash.test.sh @@ -176,6 +176,19 @@ test_served_page_wears_dashboard_with_interactive_layer() { pass "served page is the producer dashboard wearing the injected interactive layer" } +test_inline_config_is_script_safe() { + cat >> "$HOME_DIR/data/ideas/idea-backlog.md" <<'EOF' + +## IDEA-03 - & +Inline configuration must remain data. +EOF + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + [ "$REQ_STATUS" = 200 ] || fail "page with hostile operational data failed (got $REQ_STATUS)" + assert_not_contains "$RESP" '' "operational data broke out of the inline script" + assert_contains "$RESP" '\u003c/script\u003e\u003cscript\u003eglobalThis.fmdashPwned=true\u003c/script\u003e\u0026' "inline script data was not safely escaped" + pass "operational data cannot break out of the inline configuration script" +} + ref_for() { # ref_for e.g. "main/ready-safe" or "decision/captain-choice" node -e ' @@ -226,6 +239,32 @@ test_decision_detail_options_and_validated_approval() { pass "decisions serve option detail and approvals are validated against the record" } +test_stale_refs_are_disabled() { + local ref + ref=$(ref_for "decision/captain-choice") || fail "refs sidecar does not map the decision" + node -e ' + const fs = require("node:fs"); + const file = process.argv[1]; + const refs = JSON.parse(fs.readFileSync(file, "utf8")); + refs.generated = "stale-generation"; + fs.writeFileSync(file, `${JSON.stringify(refs, null, 2)}\n`); + ' "$HOME_DIR/state/dash-refs.json" + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + assert_contains "$RESP" '"refs":{}' "stale refs still de-anonymized the served page" + req GET "http://127.0.0.1:$PORT/api/detail?ref=$ref" "$CAPTAIN" + [ "$REQ_STATUS" = 404 ] || fail "stale refs still resolved detail (got $REQ_STATUS)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$ref\",\"option\":0}" + [ "$REQ_STATUS" = 400 ] || fail "stale refs still authorized an approval (got $REQ_STATUS)" + node -e ' + const fs = require("node:fs"); + const file = process.argv[1]; + const refs = JSON.parse(fs.readFileSync(file, "utf8")); + refs.generated = "2026-07-28T10:00:00Z"; + fs.writeFileSync(file, `${JSON.stringify(refs, null, 2)}\n`); + ' "$HOME_DIR/state/dash-refs.json" + pass "refs are usable only for their matching dashboard generation" +} + test_idea_pitch_and_verdicts() { local record req GET "http://127.0.0.1:$PORT/api/detail?idea=IDEA-01" "$CAPTAIN" @@ -355,7 +394,7 @@ test_check_shim_wakes_only_when_pending() { } test_installer_plist_and_funnel_stance() { - local plist + local escaped_home plist plist=$(FM_HOME="$HOME_DIR" "$INSTALL_SH" print-plist) || fail "print-plist failed" assert_contains "$plist" 'io.firstmate.dashboard.' "plist lacks the per-home label" assert_contains "$plist" 'fm-dash-serve.mjs' "plist does not run the dashboard service" @@ -366,6 +405,10 @@ test_installer_plist_and_funnel_stance() { grep -n 'tailscale funnel' "$INSTALL_SH" && fail "installer invokes tailscale funnel" assert_grep 'assert_no_funnel' "$INSTALL_SH" "installer does not verify funnel is off" assert_grep 'never enables Funnel' "$INSTALL_SH" "installer does not declare the funnel boundary" + escaped_home="$HOME_DIR/xml & < >" + plist=$(FM_HOME="$escaped_home" FM_ROOT_OVERRIDE="$HOME_DIR/root & < >" "$INSTALL_SH" print-plist) || fail "print-plist with XML metacharacters failed" + assert_contains "$plist" "$HOME_DIR/xml & < >" "plist did not XML-escape FM_HOME" + assert_contains "$plist" "$HOME_DIR/root & < >/bin/fm-dash-serve.mjs" "plist did not XML-escape the executable path" pass "the launchd agent survives reboots and the installer is structurally funnel-free" } @@ -382,8 +425,10 @@ test_service_contract_docs_and_ownership() { test_identity_fails_closed test_served_page_wears_dashboard_with_interactive_layer +test_inline_config_is_script_safe test_refs_sidecar_and_rich_work_item_detail test_decision_detail_options_and_validated_approval +test_stale_refs_are_disabled test_idea_pitch_and_verdicts test_dispatch_writes_one_durable_record test_dispatch_refuses_unknown_and_uncurrent_actions From 309dd593bd77fa25d46958c54338726b708d797b Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 11:03:55 -0400 Subject: [PATCH 03/24] no-mistakes(review): Harden dashboard commands, decisions, usage, and delivery --- .agents/skills/capacity/SKILL.md | 4 +- bin/fm-capacity.mjs | 20 +- bin/fm-dash-inbox.sh | 21 +- bin/fm-dash-install.sh | 28 ++- bin/fm-dash-serve.mjs | 335 ++++++++++++++++++++++++++----- docs/dashboard-service.md | 38 +++- tests/fm-dash.test.sh | 153 ++++++++++++-- 7 files changed, 509 insertions(+), 90 deletions(-) diff --git a/.agents/skills/capacity/SKILL.md b/.agents/skills/capacity/SKILL.md index 704dbb78ef4..c9eebd26eca 100644 --- a/.agents/skills/capacity/SKILL.md +++ b/.agents/skills/capacity/SKILL.md @@ -25,6 +25,7 @@ Do not assemble a competing snapshot with ad hoc state reads, GitHub calls, term Never infer current state from `state/.status`, because it is append-only wake-event history rather than current-state truth. Do not scrape scout reports, browser review artifacts, or Lavish surfaces to discover decisions. Structured captain holds and the keyed open-decision fold are the only decision inputs. +When filing a decision, Firstmate or its worker must write the deciding home's structured options document using the format owned by `docs/dashboard-service.md`. The generated dashboard is a polished, responsive, accessible, self-contained HTML file that works directly from disk. Do not invoke, depend on, open, poll, share, or embed Lavish for `/capacity`. @@ -92,9 +93,10 @@ The service never executes fleet commands: a click only writes a durable command Its refresh button reruns the producer server-side and is equivalent to a fresh normal invocation, so it needs no Firstmate action. On a `check:` wake naming `fm-dash.check.sh`, run `bin/fm-dash-inbox.sh claim` and handle each claimed record by its kind: +Claim delivery is at-least-once across interruption, so check whether a re-surfaced record was already handled before applying it again. - A `CAP-NN` record is the captain's ordinary chat approval of that action ID under section 4, including its full re-resolution and authority limits. -- A `decision` record is the captain's answer for the named decision key with the recorded option text; route it through `decision-hold-lifecycle` exactly as a chat answer, and re-confirm in chat before acting when the chosen option has a destructive or irreversible consequence. +- A `decision` record is the captain's answer for the named decision key with either the recorded option text or bounded custom answer; route it through `decision-hold-lifecycle` exactly as a chat answer, and re-confirm in chat before acting when the answer has a destructive or irreversible consequence. - An `idea` record is the captain's verdict on the named `data/ideas/` idea: on approve, create the follow-up work item(s) through the normal backlog lifecycle; on deny, record the outcome against the idea; on suggest, treat the suggestion text as captain input on that idea. A claimed record never authorizes a PR merge, `local-only` landing, destructive action, irreversible action, security-sensitive action, or discard of unlanded work; when a claimed action leads to such a choice, escalate it to captain chat exactly as section 4 requires. diff --git a/bin/fm-capacity.mjs b/bin/fm-capacity.mjs index 7da76654d69..6fe98360421 100755 --- a/bin/fm-capacity.mjs +++ b/bin/fm-capacity.mjs @@ -387,6 +387,10 @@ function itemRef(owner, id) { return opaqueRef("item", `${owner}/${id}`); } +function decisionRef(owner, id) { + return opaqueRef("item", `decision/${owner}/${id}`); +} + function ownerRef(owner) { if (owner === "main" || owner === "ephemeral worker") return owner; return `persistent ${opaqueRef("home", String(owner).replace(/^secondmate\s+/, ""))}`; @@ -600,7 +604,7 @@ function classify(snapshot, environment) { decisions.push({ owner: "main", task: itemRef("main", task.id), - key: itemRef("decision", decision.key || task.id), + key: decisionRef("main", decision.key || task.id), reason: "Open decision raised by work already under way.", }); } @@ -641,7 +645,7 @@ function classify(snapshot, environment) { decisions.push({ owner: "main", task: itemRef("main", record.id), - key: itemRef("decision", record.id), + key: decisionRef("main", record.id), reason: "A queued choice is held for your decision.", }); blockedRows.push({ id: itemRef("main", record.id), owner: "main", reason: "captain hold" }); @@ -649,8 +653,9 @@ function classify(snapshot, environment) { continue; } if (record.blocked_by || record.hold_reason) { - blockedRows.push({ id: itemRef("main", record.id), owner: "main", reason: "dependency or structured hold" }); - pipeline.blocked.push(cardFromBacklog(record, "main", "blocked", "Dependency or structured hold")); + const reason = record.blocked_by ? `Blocked by ${itemRef("main", record.blocked_by)}` : "Structured hold"; + blockedRows.push({ id: itemRef("main", record.id), owner: "main", reason }); + pipeline.blocked.push(cardFromBacklog(record, "main", "blocked", reason)); continue; } const timeGate = futureTimeGate(record, now); @@ -705,7 +710,7 @@ function classify(snapshot, environment) { decisions.push({ owner: ownerRef(mate.id), task: itemRef(mate.id, decision.id || mate.id), - key: itemRef("decision", decision.key || decision.id || mate.id), + key: decisionRef(mate.id, decision.key || decision.id || mate.id), reason: "Open decision raised by work already under way.", }); } @@ -766,8 +771,9 @@ function classify(snapshot, environment) { continue; } if (record.blocked_by || record.hold_reason) { - blockedRows.push({ id: itemRef(mate.id, record.id), owner: ownerRef(mate.id), reason: "dependency or structured hold" }); - pipeline.blocked.push(cardFromBacklog(record, mate.id, "blocked", "Dependency or structured hold")); + const reason = record.blocked_by ? `Blocked by ${itemRef(mate.id, record.blocked_by)}` : "Structured hold"; + blockedRows.push({ id: itemRef(mate.id, record.id), owner: ownerRef(mate.id), reason }); + pipeline.blocked.push(cardFromBacklog(record, mate.id, "blocked", reason)); continue; } const timeGate = futureTimeGate(record, now); diff --git a/bin/fm-dash-inbox.sh b/bin/fm-dash-inbox.sh index d0b9d4a4b5f..39559dbf9ce 100755 --- a/bin/fm-dash-inbox.sh +++ b/bin/fm-dash-inbox.sh @@ -3,10 +3,9 @@ # # Single owner of state/dash-inbox/ consumption: listing pending # fm-dash-command.v1 records written by bin/fm-dash-serve.mjs and claiming them -# durably. "claim" atomically archives each record under -# state/dash-inbox/archive/ (newest 50 kept) and prints it, so a command is -# surfaced exactly once even across interrupted turns; a claim that printed is a -# claim that archived. Consumption semantics are owned by the capacity skill: +# durably. "claim" prints each record before archiving it under +# state/dash-inbox/archive/ (newest 50 kept), so an interruption can re-surface +# a command but can never silently lose one. Consumption semantics are owned by the capacity skill: # each claimed prompt is the captain's approval of that CAP action ID with all # of that skill's authority limits, never destructive or merge authority. # @@ -91,14 +90,16 @@ case "${1:-list}" in mkdir -p "$ARCHIVE" chmod 700 "$ARCHIVE" 2>/dev/null || true count=0 - claimed="" while IFS= read -r f; do dest="$ARCHIVE/$(basename "$f")" - # rename-based claim: a record either stays pending or is archived; a - # concurrent claimer loses the rename and skips the record. + if [ -e "$dest" ]; then + rm -f -- "$f" + continue + fi + [ -e "$f" ] || continue + print_record "$f" if mv -n -- "$f" "$dest" 2>/dev/null && [ ! -e "$f" ] && [ -e "$dest" ]; then count=$((count + 1)) - claimed="$claimed$dest"$'\n' fi done </dev/null | node -e ' + local serve_port=$1 status_json + status_json=$("$(tailscale_bin)" serve status --json 2>/dev/null) || { + echo "could not verify Funnel state: tailscale serve status failed" >&2 + return 1 + } + printf '%s' "$status_json" | node -e ' let raw = ""; process.stdin.on("data", (c) => { raw += c; }); process.stdin.on("end", () => { try { - const s = JSON.parse(raw || "{}"); + const s = JSON.parse(raw); + if (!s || Array.isArray(s) || typeof s !== "object" + || !s.TCP || Array.isArray(s.TCP) || typeof s.TCP !== "object" + || !s.Web || Array.isArray(s.Web) || typeof s.Web !== "object" + || !s.TCP[process.argv[1]] || s.TCP[process.argv[1]].HTTPS !== true + || !Object.keys(s.Web).some((hostport) => hostport.endsWith(":" + process.argv[1]))) { + throw new Error("unsupported tailscale serve status schema"); + } + if (s.AllowFunnel !== undefined && (!s.AllowFunnel || Array.isArray(s.AllowFunnel) || typeof s.AllowFunnel !== "object")) { + throw new Error("unsupported AllowFunnel schema"); + } const allow = s.AllowFunnel || {}; for (const [hostport, enabled] of Object.entries(allow)) { + if (typeof enabled !== "boolean") throw new Error("unsupported AllowFunnel value"); if (enabled && hostport.endsWith(":" + process.argv[1])) { console.error("funnel is enabled for " + hostport); process.exit(1); } } - } catch {} + } catch (error) { + console.error("could not verify Funnel state: " + error.message); + process.exit(1); + } }); ' "$serve_port" } @@ -230,7 +248,7 @@ cmd_install() { || err "tailscale serve refused the mapping; is tailscale up?" if ! assert_no_funnel "$serve_port"; then "$(tailscale_bin)" serve --https="$serve_port" off >/dev/null 2>&1 || true - err "funnel exposure detected for port $serve_port; the mapping was removed - this service must stay tailnet-only" + err "could not verify tailnet-only exposure for port $serve_port; the mapping was removed - this service must stay tailnet-only" fi dnsname=$(tailscale_self_dnsname) || err "could not resolve this machine's tailnet name" diff --git a/bin/fm-dash-serve.mjs b/bin/fm-dash-serve.mjs index 6ddb2bdd9cf..605d4263cdf 100755 --- a/bin/fm-dash-serve.mjs +++ b/bin/fm-dash-serve.mjs @@ -9,8 +9,8 @@ * launchd persistence and tailscale serve wiring; bin/fm-dash-inbox.sh owns * firstmate-side consumption of the records this service writes. * - * The service never executes fleet commands, never calls firstmate tooling other - * than the read-mostly bin/fm-capacity.mjs producer, and never mutates any state + * The service never executes fleet commands, calls only the read-mostly capacity + * producer and quota probe, and never mutates any state * outside state/dash-inbox/ and the producer-owned dashboard file. A clicked * CAP action becomes one durable fm-dash-command.v1 record in state/dash-inbox/; * the running firstmate consumes it through its registered fm-dash watcher check @@ -52,8 +52,11 @@ const REFS = path.join(STATE, "dash-refs.json"); const BACKLOG = path.join(DATA, "backlog.md"); const IDEAS = path.join(DATA, "ideas", "idea-backlog.md"); const PITCHES = path.join(DATA, "ideas", "pitches"); +const QUOTA_AXI = process.env.FM_DASH_QUOTA_AXI || "quota-axi"; const REFRESH_TIMEOUT_MS = 180000; -const MAX_BODY_BYTES = 4096; +const QUOTA_TIMEOUT_MS = 8000; +const QUOTA_CACHE_MS = 60000; +const MAX_BODY_BYTES = 16384; // One-click eligible action IDs. Every current CAP action only requests // lifecycle-safe guidance or work that re-enters normal authority checks @@ -83,10 +86,11 @@ auto-render. Routes: GET / dashboard with the interactive layer injected GET /api/pending pending command count POST /api/refresh rerun bin/fm-capacity.mjs server-side (serialized) - POST /api/dispatch {"id":"CAP-NN"} -> durable record in state/dash-inbox/ + POST /api/dispatch validated CAP action, decision answer, or idea verdict All routes except /healthz require a Tailscale-User-Login header matching a configured captain login and fail closed otherwise. Dispatch refuses IDs not in -both the served dashboard and the fixed one-click allowlist. +both the served dashboard and the fixed one-click allowlist. Browser POSTs must +also be same-origin. `); process.exit(exitCode); } @@ -152,7 +156,10 @@ function pendingRecords() { if (!name.endsWith(".json")) continue; try { const record = JSON.parse(fs.readFileSync(path.join(INBOX, name), "utf8")); - if (record && typeof record.id === "string") records.push(record); + if (record && typeof record.id === "string") { + Object.defineProperty(record, "_file", { value: path.join(INBOX, name) }); + records.push(record); + } } catch { // An unreadable record still counts as pending for the inbox owner; skip here. } @@ -169,6 +176,13 @@ function enqueueCommand(record) { return name; } +function replaceCommand(file, record) { + const tmp = path.join(INBOX, `.tmp-${randomBytes(6).toString("hex")}`); + fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tmp, file); + return path.basename(file); +} + // --- read-only detail assembly ------------------------------------------- // Everything below only READS records the home already keeps: the producer's // refs sidecar, the backlog, task briefs, task metadata, status tails, and @@ -253,18 +267,54 @@ function previewLinks(...texts) { return [...links]; } -// Bulleted or numbered body lines of a captain-hold item are its options; each -// option's impact is the text of that line plus any continuation up to the next -// option marker. -function decisionOptions(body) { +function decisionRef(entry) { + const parts = entry.value.split("/"); + if (parts[0] !== "decision") return null; + if (parts.length >= 3) return { home: parts[1], id: parts.slice(2).join("/") }; + return { home: "main", id: parts.slice(1).join("/") }; +} + +function decisionHome(home) { + if (home === "main") return FM_HOME; + const meta = readMeta(home); + return meta.home ? path.resolve(meta.home) : null; +} + +function decisionDocument(home, id) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) return null; + const root = decisionHome(home); + if (!root) return null; + const text = readText(path.join(root, "data", "decisions", `${id}.md`), 131072); + if (!text) return null; + const title = (text.match(/^#\s+(.+)$/m) || [])[1]?.trim(); + const sections = text.split(/^##\s+Options\s*$/im); + if (!title || sections.length < 2) return null; + const context = sections[0].replace(/^#\s+.*$/m, "").trim().slice(0, 4000) || null; const options = []; let current = null; - for (const line of body) { - const marker = line.match(/^(?:[-*]|\d+[.)])\s+(.*)$/); - if (marker) { current = { text: marker[1].trim(), impact: [] }; options.push(current); continue; } - if (current && line !== "") current.impact.push(line); + for (const line of sections.slice(1).join("\n## Options\n").split("\n")) { + const marker = line.match(/^\s*-\s+(?:\[recommended\]\s*)?(.+?)(?:\s+-\s+(.+))?\s*$/i); + if (marker) { + current = { + text: marker[1].trim(), + impact: (marker[2] || "").trim(), + recommended: /^\s*-\s+\[recommended\]/i.test(line), + }; + options.push(current); + continue; + } + if (current && /^\s{2,}\S/.test(line)) current.impact = `${current.impact} ${line.trim()}`.trim(); } - return options.map((option) => ({ text: option.text, impact: option.impact.join(" ").slice(0, 800) })); + const boundedOptions = options + .filter((option) => option.text && option.impact) + .slice(0, 20) + .map((option) => ({ ...option, text: option.text.slice(0, 300), impact: option.impact.slice(0, 1200) })); + if (!context || boundedOptions.length === 0) return null; + return { + title, + context, + options: boundedOptions, + }; } function refDisplayMap(refsFile) { @@ -275,7 +325,8 @@ function refDisplayMap(refsFile) { else if (entry.kind === "item") { const separator = entry.value.indexOf("/"); const owner = entry.value.slice(0, separator); - const id = entry.value.slice(separator + 1); + const decision = decisionRef(entry); + const id = decision ? decision.id : entry.value.slice(separator + 1); display[ref] = owner === "decision" ? { t: "decision", label: id } : { t: "work", label: id, owner }; @@ -291,20 +342,21 @@ function assembleDetail(ref) { if (!entry || entry.kind !== "item") return null; const separator = entry.value.indexOf("/"); const owner = entry.value.slice(0, separator); - const id = entry.value.slice(separator + 1); + const decision = decisionRef(entry); + const id = decision ? decision.id : entry.value.slice(separator + 1); const backlogItem = parseBacklog().find((item) => item.id === id) || null; if (owner === "decision") { - const body = backlogItem ? backlogItem.body : []; + const document = decisionDocument(decision.home, id); return { type: "decision", ref, id, - title: backlogItem ? backlogItem.title : id, - description: body.filter((line) => !/^(?:[-*]|\d+[.)])\s+/.test(line)).join("\n").trim().slice(0, 2000) || null, - options: decisionOptions(body), + title: document?.title || backlogItem?.title || id, + description: document?.context || null, + options: document?.options || [], recent: statusTail(id), - note: backlogItem ? null : "No structured record was found for this decision key; answer it in captain chat.", + note: document ? null : "This legacy decision has no structured options document; answer it in captain chat.", }; } if (owner !== "main") { @@ -403,6 +455,75 @@ function runRefresh() { return refreshing; } +let usageCache = null; +let usageProbe = null; +function probeUsage() { + const now = Date.now(); + if (usageCache && now - usageCache.at < QUOTA_CACHE_MS) return Promise.resolve(usageCache.value); + if (usageProbe) return usageProbe; + usageProbe = new Promise((resolve) => { + const child = spawn(QUOTA_AXI, ["--json"], { cwd: ROOT, env: process.env, stdio: ["ignore", "pipe", "pipe"] }); + const chunks = []; + let size = 0; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + usageCache = { at: Date.now(), value }; + usageProbe = null; + resolve(value); + }; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + finish({ status: "unavailable", providers: [] }); + }, QUOTA_TIMEOUT_MS); + child.stdout.on("data", (chunk) => { + size += chunk.length; + if (size <= 1024 * 1024) chunks.push(chunk); + }); + child.on("error", () => { + clearTimeout(timer); + finish({ status: "unavailable", providers: [] }); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code !== 0 || size > 1024 * 1024) { + finish({ status: "unavailable", providers: [] }); + return; + } + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (parsed.schemaVersion !== 2 || !Array.isArray(parsed.providers)) throw new Error("unsupported schema"); + const allowed = new Set(["claude", "codex", "grok"]); + const providers = parsed.providers + .filter((provider) => allowed.has(provider?.provider)) + .map((provider) => ({ + provider: provider.provider, + label: typeof provider.label === "string" ? provider.label.slice(0, 80) : provider.provider, + windows: Array.isArray(provider.windows) + ? provider.windows.filter((window) => + typeof window?.label === "string" + && Number.isFinite(window.percentUsed) + && window.percentUsed >= 0 + && window.percentUsed <= 100 + && typeof window.resetsAt === "string" + && Number.isFinite(Date.parse(window.resetsAt)) + ).slice(0, 8).map((window) => ({ + label: window.label.slice(0, 100), + percentUsed: window.percentUsed, + resetsAt: window.resetsAt, + })) + : [], + })); + finish({ status: "ok", providers }); + } catch { + finish({ status: "unavailable", providers: [] }); + } + }); + }); + return usageProbe; +} + function sendJson(res, status, payload) { const body = JSON.stringify(payload); res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(body) }); @@ -424,6 +545,18 @@ function authorized(req, config) { return login !== "" && config.logins.includes(login); } +function sameOriginPost(req) { + const origin = req.headers.origin; + const fetchSite = req.headers["sec-fetch-site"]; + if (origin === undefined && fetchSite === undefined) return true; + if (typeof origin !== "string" || fetchSite !== "same-origin") return false; + try { + return new URL(origin).host === req.headers.host; + } catch { + return false; + } +} + function inlineScriptJson(value) { return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => { const escapes = { "<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "\u2028": "\\u2028", "\u2029": "\\u2029" }; @@ -444,6 +577,7 @@ function interactiveLayer(dispatchable, pending, generated, readOnly, extras) { readOnly: readOnly === true, refs: extras?.refs || {}, ideas: extras?.ideas || [], + usage: extras?.usage || { status: "unavailable", providers: [] }, }); return ` `; @@ -1146,10 +1161,19 @@ async function handle(req, res) { const dispatchable = config.readOnly ? [] : [...dashboard.actions.keys()].filter((id) => ONE_CLICK_ACTIONS.has(id)); const refsFile = readRefs(dashboard.generated); const usage = await probeUsage(); + // Degraded-render self-check: when most worker states rendered as unknown, + // the generator likely ran without its state-reader tools (for example a + // stripped launchd environment). Say so loudly rather than presenting + // degraded data as truth. + const unknownStates = (dashboard.html.match(/Authoritative current state: unknown/g) || []).length; + const manifestRows = (dashboard.html.match(/class="mrow"/g) || []).length; + const degraded = unknownStates >= 3 && unknownStates * 2 >= manifestRows; + if (degraded) log(`degraded render detected: ${unknownStates} of ${manifestRows} manifest rows read unknown; check the service environment (PATH/tools)`); const layer = interactiveLayer(dispatchable, pendingRecords().length, dashboard.generated, config.readOnly, { refs: refsFile ? refDisplayMap(refsFile) : {}, ideas: parseIdeas().map((idea) => ({ id: idea.id, title: idea.title })), usage, + degraded, }); sendHtml(res, 200, dashboard.html.replace("", `${layer}`)); return; diff --git a/docs/dashboard-service.md b/docs/dashboard-service.md index 20bf9148f25..b5287530fed 100644 --- a/docs/dashboard-service.md +++ b/docs/dashboard-service.md @@ -14,6 +14,9 @@ The service publishes the producer-generated `data/capacity-dashboard.html` at o - An Ideas section renders `data/ideas/idea-backlog.md`; each idea opens its pitch (`data/ideas/pitches/IDEA-XX.md` when present, else the concept summary) with Approve, Deny, and Add-suggestions controls. - A service bar shows how many captain commands are queued for firstmate. - A Subscription usage band shows cached `quota-axi --json` windows for Claude, Codex, and Grok, including percent used and reset distance with reset time formatted by the captain's browser in local time. +- Every blocked row carries its plain-language blocker chain resolved to the root cause plus an explicit "What you can do" line, so no blocked row leaves the captain guessing; chains stay privacy-safe in the on-disk file and de-anonymize at serve time like every other reference. +- The served page has zero copy-prompt affordances: each producer copy button is replaced by direct dispatch, or removed outright in read-only mode, while the offline file keeps its copy buttons for `file://` use. +- The installer pins the installing shell's `PATH` into the launchd agent so the generator's state-reader tools resolve, and the service marks a render `RENDER DEGRADED` loudly on the page and in its log when most worker states read unknown - degraded data is never presented as truth. `bin/fm-capacity.mjs` remains the single owner of the dashboard's content and look; the service injects its interactive layer at serve time and never modifies the file on disk, so the file keeps working offline exactly as before. The on-disk dashboard stays identity-opaque: the producer's opt-in `--refs` sidecar (`state/dash-refs.json`, `fm-capacity-refs.v1`, mode 0600) carries the opaque-to-real mapping, and only the captain-authenticated service reads it to enrich the served page. diff --git a/tests/fm-capacity.test.sh b/tests/fm-capacity.test.sh index f2fe60e9ac8..61334251f9d 100755 --- a/tests/fm-capacity.test.sh +++ b/tests/fm-capacity.test.sh @@ -913,6 +913,46 @@ test_unknown_project_is_a_definition_gap() { pass "unknown projects remain definition gaps despite safe delivery-mode fallback" } +test_keyless_questions_and_blocker_chains() { + local home="$TMP_ROOT/chains-home" snapshot="$TMP_ROOT/chains-snapshot.json" environment="$TMP_ROOT/chains-environment.json" output json html + output="$home/data/chains.html" + make_fixture "$home" "$snapshot" "$environment" + jq ' + .backlog.records = [ + {"order":1,"state":"in_flight","structured":true,"id":"asker","title":"Build the exporter","repo":"alpha","project_resolved":true,"kind":"ship","since":"2026-07-16","body_excerpt":"Acceptance criteria: exporter ships."}, + {"order":2,"state":"in_flight","structured":true,"id":"keyed-asker","title":"Build the API","repo":"beta","project_resolved":true,"kind":"ship","since":"2026-07-16","body_excerpt":"Acceptance criteria: API ships."}, + {"order":3,"state":"queued","structured":true,"id":"dependent","title":"Publish the dependent release","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"asker","blocked_reason":"needs exporter","body_excerpt":"Acceptance criteria: release ships."}, + {"order":4,"state":"queued","structured":true,"id":"policy-choice","title":"Choose the rollout policy","repo":"alpha","project_resolved":true,"kind":"captain","hold_kind":"captain","hold_reason":"pick conservative or fast"}, + {"order":5,"state":"queued","structured":true,"id":"after-policy","title":"Apply the rollout policy","repo":"delta","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: rollout applied."} + ] + | .tasks = [ + {"id":"asker","kind":"ship","project":"alpha","current_state":{"state":"blocked","source":"status-fold","detail":"awaiting reply"},"endpoint":{"exists":true,"agent_alive":"not_checked"},"hints":{"open_decisions":[{"key":"default","verb":"needs-decision","summary":"which port should the exporter bind"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"asker","title":"Build the exporter","repo":"alpha","project_resolved":true,"kind":"ship","since":"2026-07-16"}}, + {"id":"keyed-asker","kind":"ship","project":"beta","current_state":{"state":"blocked","source":"status-fold","detail":"awaiting decision"},"endpoint":{"exists":true,"agent_alive":"not_checked"},"hints":{"open_decisions":[{"key":"api-shape","verb":"needs-decision","summary":"choose v1 or v2 response shape"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"keyed-asker","title":"Build the API","repo":"beta","project_resolved":true,"kind":"ship","since":"2026-07-16"}} + ] + | .secondmate_current.registry.records = [] + | .secondmate_current.records = [] + | .secondmate_current.total = 0 + | .secondmate_current.shown = 0 + ' "$snapshot" > "$snapshot.tmp" + mv "$snapshot.tmp" "$snapshot" + json=$("$CAPACITY" --json --snapshot "$snapshot" --environment "$environment" --output "$output") || + fail "keyless-question capacity run failed" + printf '%s' "$json" | jq -e ' + (.pipeline.blocked | map(select(.reason | contains("Worker question being handled in chat"))) | length) == 1 + and (.pipeline.blocked | map(select(.reason | contains("Worker question"))) | .[0].what_you_can_do | contains("firstmate is handling")) + and ([.pipeline.blocked[] | select(.waits_on != null) | .waits_on[0]] | any(contains("waiting on your decision"))) + and ([.pipeline.blocked[] | .what_you_can_do // ""] | any(contains("unblocks itself when"))) + and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("blocked by") and contains("currently"))) + and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("which port")) | not) + ' >/dev/null || fail "keyless questions or blocker chains are wrong: $json" + html=$(cat "$output") + assert_contains "$html" 'What you can do:' "blocked rows omit the explicit captain action line" + case "$html" in + *'item-id">default'*) fail "a keyless worker question was fabricated into a decision identity" ;; + esac + pass "keyless worker questions stay chat-handled and blocked rows carry privacy-safe root-cause chains" +} + test_skill_discovery_and_read_mostly_contract test_classification_priority_overlap_and_idle_semantics test_cross_home_overlap_holds_supersession_and_active_count @@ -933,3 +973,4 @@ test_html_is_private_escaped_accessible_and_responsive test_output_replacement_rejects_symlinks_and_enforces_mode test_fleet_snapshot_preserves_registered_scope_provenance test_unknown_project_is_a_definition_gap +test_keyless_questions_and_blocker_chains diff --git a/tests/fm-dash.test.sh b/tests/fm-dash.test.sh index 1e6343aa684..918c7e73a86 100755 --- a/tests/fm-dash.test.sh +++ b/tests/fm-dash.test.sh @@ -828,6 +828,35 @@ EOF pass "custom serve ports persist and old mappings are removed" } +test_launchd_env_and_degraded_render_selfcheck() { + local plist rows + stop_server + start_server "$HOME_DIR" "$PORT" + plist=$(FM_HOME="$HOME_DIR" "$INSTALL_SH" print-plist) || fail "print-plist failed" + assert_contains "$plist" 'PATH' "plist does not pin the installing PATH for launchd (states degrade to unknown without it)" + assert_contains "$plist" "$(dirname "$(command -v node)")" "plist PATH does not carry the node tool directory" + cp "$HOME_DIR/data/capacity-dashboard.html" "$TMP_ROOT/dashboard.bak" + rows="" + for _ in 1 2 3 4; do + rows="$rows
  • Authoritative current state: unknown
  • " + done + printf '%s' "$(sed "s||$rows|" "$TMP_ROOT/dashboard.bak")" > "$HOME_DIR/data/capacity-dashboard.html" + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + assert_contains "$RESP" 'RENDER DEGRADED' "a mostly-unknown render is presented as truth instead of loudly degraded" + cp "$TMP_ROOT/dashboard.bak" "$HOME_DIR/data/capacity-dashboard.html" + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + case "$RESP" in *'"degraded":true'*) fail "a healthy render is marked degraded" ;; esac + pass "launchd env is pinned and a mostly-unknown render is loudly marked degraded" +} + +test_served_page_has_zero_copy_affordances() { + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + assert_contains "$RESP" 'copyButton.replaceWith(send)' "dispatch does not replace the copy button" + assert_contains "$RESP" 'copyButton.remove()' "read-only and non-action copy buttons are not removed" + case "$RESP" in *"copyButton.after(send)"*) fail "copy buttons are supplemented instead of replaced" ;; esac + pass "every copy-prompt affordance on the served page is replaced by direct dispatch" +} + test_service_contract_docs_and_ownership() { assert_present "$ROOT/docs/dashboard-service.md" "dashboard service doc is missing" assert_grep 'dash-inbox' "$ROOT/docs/dashboard-service.md" "service doc omits the inbound channel" @@ -862,6 +891,8 @@ test_read_only_mode_fails_safe test_check_shim_wakes_only_when_pending test_installer_plist_and_funnel_stance test_installer_tracks_custom_serve_port +test_launchd_env_and_degraded_render_selfcheck +test_served_page_has_zero_copy_affordances test_service_contract_docs_and_ownership echo "fm-dash tests passed" From 34751edb8a01e8a74086c0c6543432c0984ca4ef Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 14:18:38 -0400 Subject: [PATCH 19/24] no-mistakes(review): Fix captain decision, blocker, and degraded-state handling --- bin/fm-capacity.mjs | 124 ++++++++++++++++++++++---------------- bin/fm-dash-serve.mjs | 6 +- tests/fm-capacity.test.sh | 19 ++++-- tests/fm-dash.test.sh | 8 ++- 4 files changed, 97 insertions(+), 60 deletions(-) diff --git a/bin/fm-capacity.mjs b/bin/fm-capacity.mjs index 9168959a144..fd4f227dcd0 100755 --- a/bin/fm-capacity.mjs +++ b/bin/fm-capacity.mjs @@ -673,54 +673,56 @@ function classify(snapshot, environment) { }); } - // Resolve a blocked backlog item's full blocker chain to its root cause in - // plain language, so no blocked row leaves the captain guessing what it - // waits on or whether anything is theirs to do. - const describeBlockedRecord = (startRecord) => { - const waits = []; - const seen = new Set(); - let record = startRecord; - for (let hop = 0; hop < 5 && record; hop += 1) { - if (record !== startRecord && record.kind === "captain" && record.hold_kind === "captain") { - const identity = backlogDecisionIdentity(record); - const ref = decisionRef("main", identity.origin, identity.key); - waits.push(`waiting on your decision ${ref}`); - return { waits, action: `Answer decision ${ref} - it is the root cause.` }; + const blockedByIds = (record) => String(record.blocked_by || "").split(",").map((id) => id.trim()).filter(Boolean); + const describeBlockedRecord = (startRecord, owner, records, tasks = []) => { + const recordById = new Map(records.filter((record) => record.structured !== false).map((record) => [record.id, record])); + const currentById = new Map(tasks.map((task) => [task.id, task.current_state?.state || task.state])); + const resolveBlocker = (blockerId, seen, depth) => { + const blockerRef = itemRef(owner, blockerId); + if (seen.has(blockerId) || depth >= 5) { + return [{ wait: `blocked by ${blockerRef}, whose blocker chain could not be resolved`, action: "Nothing yet - firstmate reconciles this blocker and escalates if your input is needed." }]; } - const gate = futureTimeGate(record, now); - if (gate) { - waits.push(`waiting until ${gate}`); - return { waits, action: `Nothing - this unblocks itself when the ${gate} time gate passes.` }; + const blockerRecord = recordById.get(blockerId) || null; + const currentState = currentById.get(blockerId); + if (!blockerRecord && !currentState) { + return [{ wait: `blocked by ${blockerRef}, whose current state is unavailable`, action: "Nothing yet - firstmate reconciles that unavailable blocker and escalates if your input is needed." }]; } - if (!record.blocked_by) { - if (record.hold_reason) { - waits.push("held by a structured hold"); - return { waits, action: "Nothing yet - firstmate watches this hold and escalates if your input is needed." }; - } - break; + const prefix = currentState + ? `blocked by ${blockerRef}, which is currently ${safeState(currentState)}` + : `blocked by ${blockerRef}, which is ${blockerRecord.state === "in_flight" ? "under way" : "queued and not started"}`; + if (blockerRecord?.kind === "captain" && blockerRecord.hold_kind === "captain") { + const identity = backlogDecisionIdentity(blockerRecord); + const ref = decisionRef(owner, identity.origin, identity.key); + return [{ wait: `${prefix}; then waiting on your decision ${ref}`, action: `Answer decision ${ref} - it is the root cause.` }]; } - const blockerId = record.blocked_by; - if (seen.has(blockerId)) break; - seen.add(blockerId); - const blockerTask = taskById.get(blockerId); - const blockerRecord = mainRecords.find((entry) => entry.structured && entry.id === blockerId) || null; - const blockerRef = itemRef("main", blockerId); - if (blockerTask) { - waits.push(`blocked by ${blockerRef} which is currently ${safeState(blockerTask.current_state?.state)}`); - } else if (blockerRecord) { - const nextHop = blockerRecord.blocked_by || futureTimeGate(blockerRecord, now) || (blockerRecord.kind === "captain" && blockerRecord.hold_kind === "captain"); - waits.push(`blocked by ${blockerRef} which is ${blockerRecord.state === "in_flight" ? "under way" : "queued and not started"}${nextHop ? "" : ""}`); - } else { - waits.push(`blocked by ${blockerRef} whose current state is unavailable`); - return { waits, action: "Nothing yet - firstmate reconciles that unavailable blocker and escalates if your input is needed." }; + const gate = blockerRecord && futureTimeGate(blockerRecord, now); + if (gate) { + return [{ wait: `${prefix}; then waiting until ${gate}`, action: `Nothing - this unblocks itself when the ${gate} time gate passes.` }]; + } + const nestedIds = blockerRecord ? blockedByIds(blockerRecord) : []; + if (nestedIds.length > 0) { + const nextSeen = new Set(seen).add(blockerId); + return nestedIds.flatMap((id) => resolveBlocker(id, nextSeen, depth + 1).map((root) => ({ + wait: `${prefix}; then ${root.wait}`, + action: root.action, + }))); } - if (!blockerRecord || (!blockerRecord.blocked_by && !futureTimeGate(blockerRecord, now) && !(blockerRecord.kind === "captain" && blockerRecord.hold_kind === "captain"))) { - return { waits, action: `Nothing - this unblocks itself when ${blockerRef} finishes.` }; + if (blockerRecord?.hold_reason) { + return [{ wait: `${prefix}; then held by a structured hold`, action: "Nothing yet - firstmate watches this hold and escalates if your input is needed." }]; } - record = blockerRecord; + return [{ wait: prefix, action: `Nothing - this unblocks itself when ${blockerRef} finishes.` }]; + }; + const blockerIds = blockedByIds(startRecord); + if (blockerIds.length === 0) { + const gate = futureTimeGate(startRecord, now); + if (gate) return { waits: [`waiting until ${gate}`], action: `Nothing - this unblocks itself when the ${gate} time gate passes.` }; + return { waits: ["held by a structured wait gate"], action: "Nothing yet - firstmate watches this hold and escalates if your input is needed." }; } - if (waits.length === 0) waits.push("its blocker could not be resolved from the structured queue"); - return { waits, action: "Nothing yet - firstmate reconciles this blocker and escalates if your input is needed." }; + const roots = blockerIds.flatMap((id) => resolveBlocker(id, new Set(), 0)); + return { + waits: roots.map((root) => root.wait), + action: [...new Set(roots.map((root) => root.action))].join(" "), + }; }; const queue = mainRecords.filter((record) => record.state === "queued"); @@ -749,8 +751,10 @@ function classify(snapshot, environment) { continue; } if (record.blocked_by || record.hold_reason) { - const reason = record.blocked_by ? `Blocked by ${itemRef("main", record.blocked_by)}` : "Structured hold"; - const chain = describeBlockedRecord(record); + const reason = record.blocked_by + ? `Blocked by ${blockedByIds(record).map((id) => itemRef("main", id)).join(", ")}` + : "Structured hold"; + const chain = describeBlockedRecord(record, "main", mainRecords, snapshot.tasks || []); blockedRows.push({ id: itemRef("main", record.id), owner: "main", reason }); pipeline.blocked.push(Object.assign(cardFromBacklog(record, "main", "blocked", reason), { waits_on: chain.waits, @@ -810,7 +814,7 @@ function classify(snapshot, environment) { if (!scopeAvailable) markUnavailable(opaqueRef("home", mate.id), "persistent secondmate", "registered routing scope unavailable"); if (!runtime) markUnavailable(opaqueRef("home", mate.id), "persistent secondmate", "home-owned runtime lane evidence unavailable"); for (const decision of mate.decisions_open || []) { - if ((decision.key || "default") === "default" && !decision.id) continue; + if (!decision.key || decision.key === "default") continue; decisions.push({ owner: ownerRef(mate.id), task: itemRef(mate.id, decision.id || mate.id), @@ -831,7 +835,11 @@ function classify(snapshot, environment) { } const ageDays = dateAgeDays(hold.since, now); blockedRows.push({ id: itemRef(mate.id, hold.id), owner: ownerRef(mate.id), reason: "structured wait gate" }); - pipeline.blocked.push(cardFromBacklog(hold, mate.id, "blocked", "Structured wait gate")); + const chain = describeBlockedRecord(hold, mate.id, [...(mate.holds || []), ...(mate.queued || [])], mate.active_children || []); + pipeline.blocked.push(Object.assign(cardFromBacklog(hold, mate.id, "blocked", "Structured wait gate"), { + waits_on: chain.waits, + what_you_can_do: chain.action, + })); if (ageDays !== null && ageDays >= 7) { aging.push({ id: itemRef(mate.id, hold.id), owner: ownerRef(mate.id), age_days: ageDays, state: "held", evidence: "structured backlog age; structured wait gate" }); } @@ -870,21 +878,35 @@ function classify(snapshot, environment) { if (isSuperseded(record) || heldIds.has(record.id)) continue; secondmateQueuedConsidered += 1; if (record.kind === "captain" && record.hold_kind === "captain") { + const identity = backlogDecisionIdentity(record); + const ref = decisionRef(mate.id, identity.origin, identity.key); blockedRows.push({ id: itemRef(mate.id, record.id), owner: ownerRef(mate.id), reason: "captain hold" }); - pipeline.blocked.push(cardFromBacklog(record, mate.id, "blocked", "Captain hold")); + pipeline.blocked.push(Object.assign(cardFromBacklog(record, mate.id, "blocked", "Captain hold"), { + waits_on: [`waiting on your decision ${ref}`], + what_you_can_do: `Answer decision ${ref} - it is the root cause.`, + })); continue; } if (record.blocked_by || record.hold_reason) { - const reason = record.blocked_by ? `Blocked by ${itemRef(mate.id, record.blocked_by)}` : "Structured hold"; + const reason = record.blocked_by + ? `Blocked by ${blockedByIds(record).map((id) => itemRef(mate.id, id)).join(", ")}` + : "Structured hold"; + const chain = describeBlockedRecord(record, mate.id, [...(mate.holds || []), ...(mate.queued || [])], mate.active_children || []); blockedRows.push({ id: itemRef(mate.id, record.id), owner: ownerRef(mate.id), reason }); - pipeline.blocked.push(cardFromBacklog(record, mate.id, "blocked", reason)); + pipeline.blocked.push(Object.assign(cardFromBacklog(record, mate.id, "blocked", reason), { + waits_on: chain.waits, + what_you_can_do: chain.action, + })); continue; } const timeGate = futureTimeGate(record, now); if (timeGate) { const reason = `time gate until ${timeGate}`; blockedRows.push({ id: itemRef(mate.id, record.id), owner: ownerRef(mate.id), reason }); - pipeline.blocked.push(cardFromBacklog(record, mate.id, "blocked", reason)); + pipeline.blocked.push(Object.assign(cardFromBacklog(record, mate.id, "blocked", reason), { + waits_on: [`waiting until ${timeGate}`], + what_you_can_do: `Nothing - this unblocks itself when the ${timeGate} time gate passes.`, + })); continue; } const gaps = definitionGaps(record, true); @@ -1336,7 +1358,7 @@ function artifact(value) { // captain action (or explicit nothing-to-do), rendered under a blocked row. function blockedContext(card) { if (!card.waits_on || card.waits_on.length === 0) return ""; - const chain = card.waits_on.map((wait) => h(wait)).join("; then "); + const chain = card.waits_on.map((wait) => h(wait)).join("; "); const action = card.what_you_can_do ? `What you can do: ${h(card.what_you_can_do)}` : ""; return `${chain}.${action}`; } diff --git a/bin/fm-dash-serve.mjs b/bin/fm-dash-serve.mjs index 4c9e886cdc6..91106d125c1 100755 --- a/bin/fm-dash-serve.mjs +++ b/bin/fm-dash-serve.mjs @@ -1166,9 +1166,9 @@ async function handle(req, res) { // stripped launchd environment). Say so loudly rather than presenting // degraded data as truth. const unknownStates = (dashboard.html.match(/Authoritative current state: unknown/g) || []).length; - const manifestRows = (dashboard.html.match(/class="mrow"/g) || []).length; - const degraded = unknownStates >= 3 && unknownStates * 2 >= manifestRows; - if (degraded) log(`degraded render detected: ${unknownStates} of ${manifestRows} manifest rows read unknown; check the service environment (PATH/tools)`); + const authoritativeStates = (dashboard.html.match(/Authoritative current state:/g) || []).length; + const degraded = unknownStates >= 3 && unknownStates * 2 >= authoritativeStates; + if (degraded) log(`degraded render detected: ${unknownStates} of ${authoritativeStates} authoritative worker states read unknown; check the service environment (PATH/tools)`); const layer = interactiveLayer(dispatchable, pendingRecords().length, dashboard.generated, config.readOnly, { refs: refsFile ? refDisplayMap(refsFile) : {}, ideas: parseIdeas().map((idea) => ({ id: idea.id, title: idea.title })), diff --git a/tests/fm-capacity.test.sh b/tests/fm-capacity.test.sh index 61334251f9d..60b781adad9 100755 --- a/tests/fm-capacity.test.sh +++ b/tests/fm-capacity.test.sh @@ -547,20 +547,28 @@ test_secondmate_captain_holds_are_pipeline_waiting_work() { ] | .secondmate_current.records[0].decisions_open = [ + {"id":"mate-question","key":"default","verb":"needs-decision","summary":"Sensitive question","source":"child-state"}, {"id":"mate-choice","key":"mate-choice","verb":"captain-hold","summary":"Sensitive choice","source":"backlog"} ] + | .secondmate_current.records[0].holds = [ + {"id":"mate-held","title":"Wait for external completion","repo":"delta","project_resolved":true,"kind":"ship","since":"2026-07-20","source":"child-state"} + ] | .secondmate_current.records[0].queued += [ - {"id":"mate-choice","title":"Choose the secondmate rollout","repo":"delta","project_resolved":true,"kind":"captain","hold_kind":"captain","hold_reason":"Sensitive reason"} + {"id":"mate-choice","title":"Choose the secondmate rollout","repo":"delta","project_resolved":true,"kind":"captain","hold_kind":"captain","hold_reason":"Sensitive reason"}, + {"id":"mate-structured","title":"Wait on a structured hold","repo":"delta","project_resolved":true,"kind":"ship","hold_reason":"Sensitive reason"}, + {"id":"mate-time","title":"Resume after 2026-08-15","repo":"delta","project_resolved":true,"kind":"ship","body_excerpt":"Acceptance criteria: resume safely."} ] - | .secondmate_current.records[0].counts = {"active_children":0,"decisions_open":1,"holds":0,"queued":2} + | .secondmate_current.records[0].counts = {"active_children":0,"decisions_open":2,"holds":1,"queued":4} ' "$snapshot" > "$snapshot.tmp" mv "$snapshot.tmp" "$snapshot" json=$("$CAPACITY" --json --snapshot "$snapshot" --environment "$environment" --output "$output") || fail "secondmate captain-hold capacity run failed" printf '%s' "$json" | jq -e ' - (.pipeline.blocked | length) == 5 + (.pipeline.blocked | length) == 8 and .measures.open_captain_actions == 4 and (.recommendations[] | select(.id == "CAP-01") | .evidence | startswith("4 structured captain")) + and ([.pipeline.blocked[] | select(.owner | contains("persistent"))] | length) == 4 + and ([.pipeline.blocked[] | select(.owner | contains("persistent")) | .what_you_can_do] | all(type == "string" and length > 0)) ' >/dev/null || fail "secondmate captain hold was missing or double-counted: $json" [ "$(grep -o 'class="verb verb-decide"' "$output" | wc -l | tr -d ' ')" = 4 ] || fail "captain decisions were collapsed or duplicated in the needs-you roll call" @@ -923,7 +931,8 @@ test_keyless_questions_and_blocker_chains() { {"order":2,"state":"in_flight","structured":true,"id":"keyed-asker","title":"Build the API","repo":"beta","project_resolved":true,"kind":"ship","since":"2026-07-16","body_excerpt":"Acceptance criteria: API ships."}, {"order":3,"state":"queued","structured":true,"id":"dependent","title":"Publish the dependent release","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"asker","blocked_reason":"needs exporter","body_excerpt":"Acceptance criteria: release ships."}, {"order":4,"state":"queued","structured":true,"id":"policy-choice","title":"Choose the rollout policy","repo":"alpha","project_resolved":true,"kind":"captain","hold_kind":"captain","hold_reason":"pick conservative or fast"}, - {"order":5,"state":"queued","structured":true,"id":"after-policy","title":"Apply the rollout policy","repo":"delta","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: rollout applied."} + {"order":5,"state":"queued","structured":true,"id":"after-policy","title":"Apply the rollout policy","repo":"delta","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: rollout applied."}, + {"order":6,"state":"queued","structured":true,"id":"multi-dependent","title":"Publish after two blockers","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"asker,missing-root","body_excerpt":"Acceptance criteria: both blockers clear."} ] | .tasks = [ {"id":"asker","kind":"ship","project":"alpha","current_state":{"state":"blocked","source":"status-fold","detail":"awaiting reply"},"endpoint":{"exists":true,"agent_alive":"not_checked"},"hints":{"open_decisions":[{"key":"default","verb":"needs-decision","summary":"which port should the exporter bind"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"asker","title":"Build the exporter","repo":"alpha","project_resolved":true,"kind":"ship","since":"2026-07-16"}}, @@ -943,6 +952,8 @@ test_keyless_questions_and_blocker_chains() { and ([.pipeline.blocked[] | select(.waits_on != null) | .waits_on[0]] | any(contains("waiting on your decision"))) and ([.pipeline.blocked[] | .what_you_can_do // ""] | any(contains("unblocks itself when"))) and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("blocked by") and contains("currently"))) + and ([.pipeline.blocked[] | select((.waits_on // []) | length == 2)] | length) == 1 + and ([.pipeline.blocked[] | select((.waits_on // []) | length == 2) | .waits_on | join(" ")] | .[0] | contains("unavailable")) and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("which port")) | not) ' >/dev/null || fail "keyless questions or blocker chains are wrong: $json" html=$(cat "$output") diff --git a/tests/fm-dash.test.sh b/tests/fm-dash.test.sh index 918c7e73a86..a86423621ae 100755 --- a/tests/fm-dash.test.sh +++ b/tests/fm-dash.test.sh @@ -829,7 +829,7 @@ EOF } test_launchd_env_and_degraded_render_selfcheck() { - local plist rows + local plist rows non_worker_rows stop_server start_server "$HOME_DIR" "$PORT" plist=$(FM_HOME="$HOME_DIR" "$INSTALL_SH" print-plist) || fail "print-plist failed" @@ -837,10 +837,14 @@ test_launchd_env_and_degraded_render_selfcheck() { assert_contains "$plist" "$(dirname "$(command -v node)")" "plist PATH does not carry the node tool directory" cp "$HOME_DIR/data/capacity-dashboard.html" "$TMP_ROOT/dashboard.bak" rows="" + non_worker_rows="" for _ in 1 2 3 4; do rows="$rows
  • Authoritative current state: unknown
  • " done - printf '%s' "$(sed "s||$rows|" "$TMP_ROOT/dashboard.bak")" > "$HOME_DIR/data/capacity-dashboard.html" + for _ in 1 2 3 4 5 6 7 8 9 10; do + non_worker_rows="$non_worker_rows
  • Queued backlog item
  • " + done + printf '%s' "$(sed "s||$rows$non_worker_rows|" "$TMP_ROOT/dashboard.bak")" > "$HOME_DIR/data/capacity-dashboard.html" req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" assert_contains "$RESP" 'RENDER DEGRADED' "a mostly-unknown render is presented as truth instead of loudly degraded" cp "$TMP_ROOT/dashboard.bak" "$HOME_DIR/data/capacity-dashboard.html" From 4d3cfd243ec33bad5fe767dc08be9030e6291a5c Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 14:24:17 -0400 Subject: [PATCH 20/24] no-mistakes(review): Preserve captain blocker roots and tiny-fleet degradation --- bin/fm-capacity.mjs | 12 ++++++----- bin/fm-dash-serve.mjs | 2 +- bin/fm-fleet-snapshot.sh | 30 +++++++++++++++++++++++----- tests/fm-capacity.test.sh | 13 +++++++++++- tests/fm-dash.test.sh | 6 ++++++ tests/fm-fleet-snapshot-view.test.sh | 9 +++++---- 6 files changed, 56 insertions(+), 16 deletions(-) diff --git a/bin/fm-capacity.mjs b/bin/fm-capacity.mjs index fd4f227dcd0..5f9920f0b2a 100755 --- a/bin/fm-capacity.mjs +++ b/bin/fm-capacity.mjs @@ -673,13 +673,15 @@ function classify(snapshot, environment) { }); } - const blockedByIds = (record) => String(record.blocked_by || "").split(",").map((id) => id.trim()).filter(Boolean); + const blockedByIds = (record) => (Array.isArray(record.blocked_by_all) + ? record.blocked_by_all + : String(record.blocked_by || "").split(",")).map((id) => String(id).trim()).filter(Boolean); const describeBlockedRecord = (startRecord, owner, records, tasks = []) => { const recordById = new Map(records.filter((record) => record.structured !== false).map((record) => [record.id, record])); const currentById = new Map(tasks.map((task) => [task.id, task.current_state?.state || task.state])); - const resolveBlocker = (blockerId, seen, depth) => { + const resolveBlocker = (blockerId, seen) => { const blockerRef = itemRef(owner, blockerId); - if (seen.has(blockerId) || depth >= 5) { + if (seen.has(blockerId)) { return [{ wait: `blocked by ${blockerRef}, whose blocker chain could not be resolved`, action: "Nothing yet - firstmate reconciles this blocker and escalates if your input is needed." }]; } const blockerRecord = recordById.get(blockerId) || null; @@ -702,7 +704,7 @@ function classify(snapshot, environment) { const nestedIds = blockerRecord ? blockedByIds(blockerRecord) : []; if (nestedIds.length > 0) { const nextSeen = new Set(seen).add(blockerId); - return nestedIds.flatMap((id) => resolveBlocker(id, nextSeen, depth + 1).map((root) => ({ + return nestedIds.flatMap((id) => resolveBlocker(id, nextSeen).map((root) => ({ wait: `${prefix}; then ${root.wait}`, action: root.action, }))); @@ -718,7 +720,7 @@ function classify(snapshot, environment) { if (gate) return { waits: [`waiting until ${gate}`], action: `Nothing - this unblocks itself when the ${gate} time gate passes.` }; return { waits: ["held by a structured wait gate"], action: "Nothing yet - firstmate watches this hold and escalates if your input is needed." }; } - const roots = blockerIds.flatMap((id) => resolveBlocker(id, new Set(), 0)); + const roots = blockerIds.flatMap((id) => resolveBlocker(id, new Set())); return { waits: roots.map((root) => root.wait), action: [...new Set(roots.map((root) => root.action))].join(" "), diff --git a/bin/fm-dash-serve.mjs b/bin/fm-dash-serve.mjs index 91106d125c1..d3a8b7a9ff6 100755 --- a/bin/fm-dash-serve.mjs +++ b/bin/fm-dash-serve.mjs @@ -1167,7 +1167,7 @@ async function handle(req, res) { // degraded data as truth. const unknownStates = (dashboard.html.match(/Authoritative current state: unknown/g) || []).length; const authoritativeStates = (dashboard.html.match(/Authoritative current state:/g) || []).length; - const degraded = unknownStates >= 3 && unknownStates * 2 >= authoritativeStates; + const degraded = authoritativeStates > 0 && unknownStates * 2 >= authoritativeStates; if (degraded) log(`degraded render detected: ${unknownStates} of ${authoritativeStates} authoritative worker states read unknown; check the service environment (PATH/tools)`); const layer = interactiveLayer(dispatchable, pendingRecords().length, dashboard.generated, config.readOnly, { refs: refsFile ? refDisplayMap(refsFile) : {}, diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index d21ad1279d6..432ef5f53b0 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -18,6 +18,8 @@ # Structured records with a repo include its resolved delivery_mode and # project_resolved provenance from data/projects.md; unresolved projects # retain the fail-safe no-mistakes mode while project_resolved stays false. +# blocked_by is the comma-compatible dependency string and blocked_by_all +# preserves every normalized dependency edge in source order. # Structured rows preserve captain-hold metadata such as hold_kind and # hold_reason when tasks-axi emits it. # tasks[]: one row per state/.meta, sorted by id. @@ -294,6 +296,13 @@ backlog_json() { # [] - defaults to this home's $BACKLOG | if $reason == null then null else ($reason | clean_title | if . == "" then null else . end) end; + def blocked_by_all($rest): + [$rest + | scan("blocked-by:[[:space:]]*[^[:space:])]+") + | sub("^blocked-by:[[:space:]]*"; "") + | split(",")[] + | trim + | select(length > 0)]; def local_note($rest): cap(($rest | strip_trailing_metadata); ".*(?:^|[[:space:]]+-[[:space:]]+|[[:space:]])(?local main)$"); def completion($rest): @@ -317,6 +326,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG {order:$order,state:$section,structured:false,id:null,raw:$line,body_lines:[],body_excerpt:null} else ($m.rest) as $rest + | (blocked_by_all($rest)) as $blocked_by_all | {order:$order, state:$section, structured:true, @@ -328,7 +338,8 @@ backlog_json() { # [] - defaults to this home's $BACKLOG priority:metadata($rest; "priority"), hold_reason:metadata($rest; "hold"), hold_kind:metadata($rest; "hold-kind"), - blocked_by:cap($rest; ".*blocked-by:[[:space:]]*(?[^[:space:])]+).*"), + blocked_by:($blocked_by_all | if length == 0 then null else join(",") end), + blocked_by_all:$blocked_by_all, blocked_reason:blocked_reason($rest), since:metadata_word($rest; "since"), merged:metadata_word($rest; "merged"), @@ -617,7 +628,9 @@ secondmate_home_summary_json() { # repo:((.repo // null) | if . == null then null else trunc(120) end), kind:((.kind // null) | if . == null then null else trunc(40) end), since:((.since // null) | if . == null then null else trunc(20) end), - blocked_by:(.blocked_by | trunc(120)),reason:((.blocked_reason // "blocked") | trunc(120)),source:"backlog"} ] + blocked_by:(.blocked_by | trunc(120)), + blocked_by_all:((.blocked_by_all // []) | map(trunc(120))), + reason:((.blocked_reason // "blocked") | trunc(120)),source:"backlog"} ] + [ $owned_in_flight[] as $work | $tasks[] | select(.id == $work.id and (.current_state.state == "parked" or .current_state.state == "paused" or .current_state.state == "blocked")) @@ -627,7 +640,7 @@ secondmate_home_summary_json() { # delivery_mode:(($work.delivery_mode // null) | if . == null then null else trunc(40) end), project_resolved:($work.project_resolved == true), since:(($work.since // null) | if . == null then null else trunc(20) end), - blocked_by:null, + blocked_by:null,blocked_by_all:[], reason:((.current_state.detail // .current_state.state) | trunc(120)),source:"child-state"} ]) as $holds_all | ($backlog.present == true and ($unstructured_current | length) == 0 @@ -663,6 +676,7 @@ secondmate_home_summary_json() { # holds:$holds_all[:$queued_n], queued:([$queued_all[] | {id:(.id | trunc(120)),title:(.title | trunc(120)), blocked_by:((.blocked_by // null) | if . == null then null else trunc(120) end), + blocked_by_all:((.blocked_by_all // []) | map(trunc(120))), blocked_reason:((.blocked_reason // null) | if . == null then null else trunc(160) end), hold_reason:((.hold_reason // null) | if . == null then null else trunc(160) end), hold_kind:((.hold_kind // null) | if . == null then null else trunc(40) end), @@ -995,7 +1009,10 @@ parent_evidence_reconciliation_json() { # = 6 + and ($chain | contains("waiting on your decision")))) and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("which port")) | not) ' >/dev/null || fail "keyless questions or blocker chains are wrong: $json" html=$(cat "$output") diff --git a/tests/fm-dash.test.sh b/tests/fm-dash.test.sh index a86423621ae..6a88b137ce2 100755 --- a/tests/fm-dash.test.sh +++ b/tests/fm-dash.test.sh @@ -847,6 +847,12 @@ test_launchd_env_and_degraded_render_selfcheck() { printf '%s' "$(sed "s||$rows$non_worker_rows|" "$TMP_ROOT/dashboard.bak")" > "$HOME_DIR/data/capacity-dashboard.html" req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" assert_contains "$RESP" 'RENDER DEGRADED' "a mostly-unknown render is presented as truth instead of loudly degraded" + printf '%s' "$(sed "s||
  • Authoritative current state: unknown
  • $non_worker_rows|" "$TMP_ROOT/dashboard.bak")" > "$HOME_DIR/data/capacity-dashboard.html" + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + assert_contains "$RESP" 'RENDER DEGRADED' "a one-worker all-unknown fleet is presented as healthy" + sed 's/Authoritative current state:/Observed worker state:/g' "$TMP_ROOT/dashboard.bak" > "$HOME_DIR/data/capacity-dashboard.html" + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + case "$RESP" in *'"degraded":true'*) fail "an empty authoritative worker set is marked degraded" ;; esac cp "$TMP_ROOT/dashboard.bak" "$HOME_DIR/data/capacity-dashboard.html" req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" case "$RESP" in *'"degraded":true'*) fail "a healthy render is marked degraded" ;; esac diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 80c95113516..be91e85b16e 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -264,7 +264,7 @@ test_backlog_tasks_axi_forms_and_overrides() { ## Queued - [ ] queued-comma - Queued Comma Task (repo: beta, since 2026-07-08) (kind: ship) - [ ] parenthetical-title - Refresh sidebar (mobile) (repo: beta) (kind: ship) -- [ ] blocked-reason - Blocked Reason (repo: beta) (kind: ship) blocked-by: queued-comma - waits on queued-comma +- [ ] blocked-reason - Blocked Reason (repo: beta) (kind: ship) blocked-by: queued-comma blocked-by: missing-edge - waits on both blockers - [ ] sample-decision-route - Choose sample route (repo: sample) (kind: captain) (since 2026-07-14) (hold: captain route choice pending) (hold-kind: captain) ## Done @@ -313,8 +313,9 @@ EOF .backlog.records[] | select(.id == "blocked-reason") | .title == "Blocked Reason" and .repo == "beta" - and .blocked_by == "queued-comma" - and .blocked_reason == "waits on queued-comma" + and .blocked_by == "queued-comma,missing-edge" + and .blocked_by_all == ["queued-comma","missing-edge"] + and .blocked_reason == "waits on both blockers" ' >/dev/null || fail "blocked suffix did not parse into title and reason" printf '%s' "$out" | jq -e ' .backlog.records[] | select(.id == "sample-decision-route") @@ -362,7 +363,7 @@ EOF view=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$data" FM_PROJECTS_OVERRIDE="$projects" "$VIEW") assert_contains "$view" "| bold-task | done / status-log | scout | alpha | tmux | present | $data/bold-task/report.md" \ "view should render bold in-flight row from snapshot" - assert_contains "$view" "| blocked-reason | Blocked Reason | beta | ship | queued-comma - waits on queued-comma | - |" \ + assert_contains "$view" "| blocked-reason | Blocked Reason | beta | ship | queued-comma,missing-edge - waits on both blockers | - |" \ "view should render blocked reason without title metadata" assert_contains "$view" "| done-bracket-pr | Done Bracket PR | gamma | ship | - | https://github.com/kunchenguid/firstmate/pull/43 |" \ "view should render bracketed PR artifact outside the title" From 4da546191bfd1ef1978dbcd61f9e2086e9197f6f Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 14:33:06 -0400 Subject: [PATCH 21/24] no-mistakes(review): Resolve captain blocker roots with bounded traversal --- bin/fm-capacity.mjs | 184 +++++++++++++++++++++++++++++--------- tests/fm-capacity.test.sh | 19 +++- 2 files changed, 159 insertions(+), 44 deletions(-) diff --git a/bin/fm-capacity.mjs b/bin/fm-capacity.mjs index 5f9920f0b2a..b719743d82a 100755 --- a/bin/fm-capacity.mjs +++ b/bin/fm-capacity.mjs @@ -586,6 +586,53 @@ function classify(snapshot, environment) { if (registry?.available === false || registry?.complete === false) { markUnavailable("secondmate-registry", "main", "registry projection incomplete"); } + const taskRootContext = (task, owner, dependency = false) => { + const open = task.hints?.open_decisions || []; + const state = safeState(task.current_state?.state || task.state); + const chatQuestion = open.some((decision) => !decision.key || decision.key === "default"); + const keyedDecision = open.find((decision) => decision.key && decision.key !== "default"); + if (dependency && ["done", "failed"].includes(state)) { + return { + kind: "stale", + wait: `it is already ${state}; the dependency edge is stale`, + action: "Nothing yet - firstmate reconciles this stale dependency", + }; + } + if (chatQuestion) { + return { + kind: "chat", + wait: "a worker question is being handled in chat", + action: "Nothing - firstmate is handling the worker's question in chat.", + }; + } + if (keyedDecision) { + const ref = decisionRef(owner, keyedDecision.origin || task.id, keyedDecision.key); + return { + kind: "decision", + wait: `waiting on your decision ${ref}`, + action: `Answer decision ${ref} - it is the root cause.`, + }; + } + if (state === "paused") { + return { + kind: "paused", + wait: "waiting on a declared external delay expected to clear on its own", + action: "Nothing - this unblocks itself when the external wait clears.", + }; + } + if (state === "unknown") { + return { + kind: "unknown", + wait: "its current state could not be read", + action: "Nothing yet - firstmate reconciles the unavailable state and escalates if your input is needed.", + }; + } + return { + kind: "worker", + wait: `the worker reports state: ${state}`, + action: "Nothing yet - firstmate is on it and will escalate if your input is needed.", + }; + }; for (const record of mainRecords.filter((item) => item.state === "in_flight" && item.structured)) { const task = taskById.get(record.id); const repo = record.repo || task?.project || null; @@ -629,28 +676,12 @@ function classify(snapshot, environment) { aging.push({ id: itemRef("main", task.id), owner: "main", age_days: ageDays, state: safeState(task.current_state?.state), evidence: `structured backlog age; current source ${safeSource(task.current_state?.source)}` }); } const approvalReady = taskApprovalReady(task); - const keyedOpen = open.filter((decision) => decision.key && decision.key !== "default"); let taskWaits = null; let taskCanDo = null; if (stage === "blocked") { - const state = safeState(task.current_state?.state); - if (chatQuestionCount > 0) { - taskWaits = ["a worker question is being handled in chat"]; - taskCanDo = "Nothing - firstmate is handling the worker's question in chat."; - } else if (keyedOpen.length > 0) { - const ref = decisionRef("main", task.id, keyedOpen[0].key); - taskWaits = [`waiting on your decision ${ref}`]; - taskCanDo = `Answer decision ${ref} - it is the root cause.`; - } else if (state === "paused") { - taskWaits = ["waiting on a declared external delay expected to clear on its own"]; - taskCanDo = "Nothing - this unblocks itself when the external wait clears."; - } else if (state === "unknown") { - taskWaits = ["its current state could not be read"]; - taskCanDo = "Nothing yet - firstmate reconciles the unavailable state and escalates if your input is needed."; - } else { - taskWaits = [`the worker reports state: ${state}`]; - taskCanDo = "Nothing yet - firstmate is on it and will escalate if your input is needed."; - } + const context = taskRootContext(task, "main"); + taskWaits = [context.wait]; + taskCanDo = context.action; } pipeline[stage].push({ id: itemRef("main", task.id), @@ -678,52 +709,114 @@ function classify(snapshot, environment) { : String(record.blocked_by || "").split(",")).map((id) => String(id).trim()).filter(Boolean); const describeBlockedRecord = (startRecord, owner, records, tasks = []) => { const recordById = new Map(records.filter((record) => record.structured !== false).map((record) => [record.id, record])); - const currentById = new Map(tasks.map((task) => [task.id, task.current_state?.state || task.state])); - const resolveBlocker = (blockerId, seen) => { - const blockerRef = itemRef(owner, blockerId); - if (seen.has(blockerId)) { - return [{ wait: `blocked by ${blockerRef}, whose blocker chain could not be resolved`, action: "Nothing yet - firstmate reconciles this blocker and escalates if your input is needed." }]; + const blockerTaskById = new Map(tasks.map((task) => [task.id, task])); + const roots = new Map(); + const parentById = new Map(); + const segmentById = new Map(); + const scheduled = new Set(); + const stack = []; + const chainFor = (blockerId, suffix = null) => { + const segments = []; + let current = blockerId; + while (current !== null) { + if (segmentById.has(current)) segments.push(segmentById.get(current)); + current = parentById.get(current) ?? null; + } + segments.reverse(); + if (suffix) segments.push(suffix); + return segments.join("; then "); + }; + const addRoot = (key, blockerId, suffix, action) => { + if (!roots.has(key)) roots.set(key, { wait: chainFor(blockerId, suffix), action }); + }; + const isAncestor = (blockerId, candidate) => { + let current = blockerId; + while (current !== null) { + if (current === candidate) return true; + current = parentById.get(current) ?? null; } + return false; + }; + const schedule = (blockerId, parent) => { + if (scheduled.has(blockerId)) return; + scheduled.add(blockerId); + parentById.set(blockerId, parent); + stack.push(blockerId); + }; + for (const blockerId of [...blockedByIds(startRecord)].reverse()) schedule(blockerId, null); + while (stack.length > 0) { + const blockerId = stack.pop(); + const blockerRef = itemRef(owner, blockerId); const blockerRecord = recordById.get(blockerId) || null; - const currentState = currentById.get(blockerId); - if (!blockerRecord && !currentState) { - return [{ wait: `blocked by ${blockerRef}, whose current state is unavailable`, action: "Nothing yet - firstmate reconciles that unavailable blocker and escalates if your input is needed." }]; + const blockerTask = blockerTaskById.get(blockerId) || null; + const currentState = blockerTask ? safeState(blockerTask.current_state?.state || blockerTask.state) : null; + if (!blockerRecord && !blockerTask) { + segmentById.set(blockerId, `blocked by ${blockerRef}, whose current state is unavailable`); + addRoot(`missing:${blockerId}`, blockerId, null, "Nothing yet - firstmate reconciles that unavailable blocker and escalates if your input is needed."); + continue; + } + const terminalState = ["done", "failed"].includes(currentState) + ? currentState + : (["done", "failed"].includes(blockerRecord?.state) ? blockerRecord.state : null); + if (terminalState) { + segmentById.set(blockerId, `blocked by ${blockerRef}, but it is already ${terminalState}; the dependency edge is stale`); + addRoot(`stale:${blockerId}`, blockerId, null, "Nothing yet - firstmate reconciles this stale dependency"); + continue; } const prefix = currentState ? `blocked by ${blockerRef}, which is currently ${safeState(currentState)}` : `blocked by ${blockerRef}, which is ${blockerRecord.state === "in_flight" ? "under way" : "queued and not started"}`; + segmentById.set(blockerId, prefix); + const taskContext = blockerTask ? taskRootContext(blockerTask, owner, true) : null; + if (taskContext && ["chat", "decision", "paused", "unknown"].includes(taskContext.kind)) { + const rootKey = taskContext.kind === "decision" + ? `decision:${taskContext.wait}` + : `${taskContext.kind}:${blockerId}`; + addRoot(rootKey, blockerId, taskContext.wait, taskContext.action); + continue; + } if (blockerRecord?.kind === "captain" && blockerRecord.hold_kind === "captain") { const identity = backlogDecisionIdentity(blockerRecord); const ref = decisionRef(owner, identity.origin, identity.key); - return [{ wait: `${prefix}; then waiting on your decision ${ref}`, action: `Answer decision ${ref} - it is the root cause.` }]; + addRoot(`decision:${ref}`, blockerId, `waiting on your decision ${ref}`, `Answer decision ${ref} - it is the root cause.`); + continue; } const gate = blockerRecord && futureTimeGate(blockerRecord, now); if (gate) { - return [{ wait: `${prefix}; then waiting until ${gate}`, action: `Nothing - this unblocks itself when the ${gate} time gate passes.` }]; + addRoot(`gate:${blockerId}`, blockerId, `waiting until ${gate}`, `Nothing - this unblocks itself when the ${gate} time gate passes.`); + continue; } const nestedIds = blockerRecord ? blockedByIds(blockerRecord) : []; if (nestedIds.length > 0) { - const nextSeen = new Set(seen).add(blockerId); - return nestedIds.flatMap((id) => resolveBlocker(id, nextSeen).map((root) => ({ - wait: `${prefix}; then ${root.wait}`, - action: root.action, - }))); + for (const nestedId of [...nestedIds].reverse()) { + if (isAncestor(blockerId, nestedId)) { + addRoot(`cycle:${[blockerId, nestedId].sort().join(":")}`, blockerId, `blocked by ${itemRef(owner, nestedId)}, whose blocker chain contains a cycle`, "Nothing yet - firstmate reconciles this blocker and escalates if your input is needed."); + } else { + schedule(nestedId, blockerId); + } + } + continue; } if (blockerRecord?.hold_reason) { - return [{ wait: `${prefix}; then held by a structured hold`, action: "Nothing yet - firstmate watches this hold and escalates if your input is needed." }]; + addRoot(`hold:${blockerId}`, blockerId, "held by a structured hold", "Nothing yet - firstmate watches this hold and escalates if your input is needed."); + continue; } - return [{ wait: prefix, action: `Nothing - this unblocks itself when ${blockerRef} finishes.` }]; - }; + if (taskContext?.kind === "worker" && currentState === "blocked") { + addRoot(`worker:${blockerId}`, blockerId, taskContext.wait, taskContext.action); + continue; + } + addRoot(`finish:${blockerId}`, blockerId, null, `Nothing - this unblocks itself when ${blockerRef} finishes.`); + } const blockerIds = blockedByIds(startRecord); if (blockerIds.length === 0) { const gate = futureTimeGate(startRecord, now); if (gate) return { waits: [`waiting until ${gate}`], action: `Nothing - this unblocks itself when the ${gate} time gate passes.` }; return { waits: ["held by a structured wait gate"], action: "Nothing yet - firstmate watches this hold and escalates if your input is needed." }; } - const roots = blockerIds.flatMap((id) => resolveBlocker(id, new Set())); + const resolvedRoots = [...roots.values()]; return { - waits: roots.map((root) => root.wait), - action: [...new Set(roots.map((root) => root.action))].join(" "), + waits: resolvedRoots.map((root) => root.wait), + action: [...new Set(resolvedRoots.map((root) => root.action))].join(" "), }; }; @@ -824,6 +917,13 @@ function classify(snapshot, environment) { reason: "Open decision raised by work already under way.", }); } + const mateTaskEvidence = (mate.active_children || []).map((child) => ({ + ...child, + hints: { + ...(child.hints || {}), + open_decisions: (mate.decisions_open || []).filter((decision) => decision.id === child.id), + }, + })); const heldIds = new Set(); for (const hold of mate.holds || []) { heldIds.add(hold.id); @@ -837,7 +937,7 @@ function classify(snapshot, environment) { } const ageDays = dateAgeDays(hold.since, now); blockedRows.push({ id: itemRef(mate.id, hold.id), owner: ownerRef(mate.id), reason: "structured wait gate" }); - const chain = describeBlockedRecord(hold, mate.id, [...(mate.holds || []), ...(mate.queued || [])], mate.active_children || []); + const chain = describeBlockedRecord(hold, mate.id, [...(mate.holds || []), ...(mate.queued || [])], mateTaskEvidence); pipeline.blocked.push(Object.assign(cardFromBacklog(hold, mate.id, "blocked", "Structured wait gate"), { waits_on: chain.waits, what_you_can_do: chain.action, @@ -893,7 +993,7 @@ function classify(snapshot, environment) { const reason = record.blocked_by ? `Blocked by ${blockedByIds(record).map((id) => itemRef(mate.id, id)).join(", ")}` : "Structured hold"; - const chain = describeBlockedRecord(record, mate.id, [...(mate.holds || []), ...(mate.queued || [])], mate.active_children || []); + const chain = describeBlockedRecord(record, mate.id, [...(mate.holds || []), ...(mate.queued || [])], mateTaskEvidence); blockedRows.push({ id: itemRef(mate.id, record.id), owner: ownerRef(mate.id), reason }); pipeline.blocked.push(Object.assign(cardFromBacklog(record, mate.id, "blocked", reason), { waits_on: chain.waits, diff --git a/tests/fm-capacity.test.sh b/tests/fm-capacity.test.sh index 8c3c29a05b4..6627e40acb1 100755 --- a/tests/fm-capacity.test.sh +++ b/tests/fm-capacity.test.sh @@ -939,7 +939,13 @@ test_keyless_questions_and_blocker_chains() { {"order":10,"state":"queued","structured":true,"id":"deep-3","title":"Deep dependency three","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"deep-4","body_excerpt":"Acceptance criteria: continue."}, {"order":11,"state":"queued","structured":true,"id":"deep-4","title":"Deep dependency four","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"deep-5","body_excerpt":"Acceptance criteria: continue."}, {"order":12,"state":"queued","structured":true,"id":"deep-5","title":"Deep dependency five","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"deep-6","body_excerpt":"Acceptance criteria: continue."}, - {"order":13,"state":"queued","structured":true,"id":"deep-6","title":"Deep dependency six","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: choose policy."} + {"order":13,"state":"queued","structured":true,"id":"deep-6","title":"Deep dependency six","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: choose policy."}, + {"order":14,"state":"queued","structured":true,"id":"behind-keyed-worker","title":"Publish after the API decision","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"keyed-asker","body_excerpt":"Acceptance criteria: the API decision clears."}, + {"order":15,"state":"done","structured":true,"id":"finished-root","title":"Already finished dependency","repo":"gamma","project_resolved":true,"kind":"ship","body_excerpt":"Acceptance criteria: finished."}, + {"order":16,"state":"queued","structured":true,"id":"stale-dependent","title":"Reconcile a stale dependency","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"finished-root","body_excerpt":"Acceptance criteria: stale edge clears."}, + {"order":17,"state":"queued","structured":true,"id":"branching-dependent","title":"Publish after converging branches","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"branch-a,branch-b","blocked_by_all":["branch-a","branch-b"],"body_excerpt":"Acceptance criteria: both branches clear."}, + {"order":18,"state":"queued","structured":true,"id":"branch-a","title":"First decision branch","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: choose policy."}, + {"order":19,"state":"queued","structured":true,"id":"branch-b","title":"Second decision branch","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: choose policy."} ] | .tasks = [ {"id":"asker","kind":"ship","project":"alpha","current_state":{"state":"blocked","source":"status-fold","detail":"awaiting reply"},"endpoint":{"exists":true,"agent_alive":"not_checked"},"hints":{"open_decisions":[{"key":"default","verb":"needs-decision","summary":"which port should the exporter bind"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"asker","title":"Build the exporter","repo":"alpha","project_resolved":true,"kind":"ship","since":"2026-07-16"}}, @@ -957,7 +963,6 @@ test_keyless_questions_and_blocker_chains() { (.pipeline.blocked | map(select(.reason | contains("Worker question being handled in chat"))) | length) == 1 and (.pipeline.blocked | map(select(.reason | contains("Worker question"))) | .[0].what_you_can_do | contains("firstmate is handling")) and ([.pipeline.blocked[] | select(.waits_on != null) | .waits_on[0]] | any(contains("waiting on your decision"))) - and ([.pipeline.blocked[] | .what_you_can_do // ""] | any(contains("unblocks itself when"))) and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("blocked by") and contains("currently"))) and ([.pipeline.blocked[] | select((.waits_on // []) | length == 2)] | length) == 1 and ([.pipeline.blocked[] | select((.waits_on // []) | length == 2) | .waits_on | join(" ")] | .[0] | contains("unavailable")) @@ -965,6 +970,16 @@ test_keyless_questions_and_blocker_chains() { ((.waits_on // [] | join(" ")) as $chain | ([$chain | scan("blocked by")] | length) >= 6 and ($chain | contains("waiting on your decision")))) + and any(.pipeline.blocked[]; + ((.waits_on // [] | join(" ")) | contains("currently blocked") + and contains("waiting on your decision"))) + and any(.pipeline.blocked[]; + ((.waits_on // [] | join(" ")) | contains("dependency edge is stale")) + and .what_you_can_do == "Nothing yet - firstmate reconciles this stale dependency") + and any(.pipeline.blocked[]; + (.reason | contains(",")) + and ((.waits_on // []) | length) == 1 + and ((.waits_on | join(" ")) | contains("waiting on your decision"))) and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("which port")) | not) ' >/dev/null || fail "keyless questions or blocker chains are wrong: $json" html=$(cat "$output") From d5604adca664c02dd321550a794eba3cb85f4045 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 14:41:49 -0400 Subject: [PATCH 22/24] no-mistakes(review): Complete captain blocker roots across cycles and holds --- bin/fm-capacity.mjs | 160 +++++++++++++++-------------- bin/fm-fleet-snapshot.sh | 1 + tests/fm-bearings-snapshot.test.sh | 2 +- tests/fm-capacity.test.sh | 39 +++++-- 4 files changed, 113 insertions(+), 89 deletions(-) diff --git a/bin/fm-capacity.mjs b/bin/fm-capacity.mjs index b719743d82a..245bb6dc7b4 100755 --- a/bin/fm-capacity.mjs +++ b/bin/fm-capacity.mjs @@ -586,52 +586,60 @@ function classify(snapshot, environment) { if (registry?.available === false || registry?.complete === false) { markUnavailable("secondmate-registry", "main", "registry projection incomplete"); } - const taskRootContext = (task, owner, dependency = false) => { + const taskRootContexts = (task, owner, dependency = false) => { const open = task.hints?.open_decisions || []; const state = safeState(task.current_state?.state || task.state); const chatQuestion = open.some((decision) => !decision.key || decision.key === "default"); - const keyedDecision = open.find((decision) => decision.key && decision.key !== "default"); + const keyedDecisions = open.filter((decision) => decision.key && decision.key !== "default"); if (dependency && ["done", "failed"].includes(state)) { - return { + return [{ kind: "stale", + key: `stale:${task.id}`, wait: `it is already ${state}; the dependency edge is stale`, action: "Nothing yet - firstmate reconciles this stale dependency", - }; + }]; } + const contexts = []; if (chatQuestion) { - return { + contexts.push({ kind: "chat", + key: `chat:${task.id}`, wait: "a worker question is being handled in chat", action: "Nothing - firstmate is handling the worker's question in chat.", - }; + }); } - if (keyedDecision) { + for (const keyedDecision of keyedDecisions) { const ref = decisionRef(owner, keyedDecision.origin || task.id, keyedDecision.key); - return { + contexts.push({ kind: "decision", + key: `decision:${ref}`, wait: `waiting on your decision ${ref}`, action: `Answer decision ${ref} - it is the root cause.`, - }; + }); } + if (contexts.length > 0) return [...new Map(contexts.map((context) => [context.key, context])).values()]; if (state === "paused") { - return { + return [{ kind: "paused", + key: `paused:${task.id}`, wait: "waiting on a declared external delay expected to clear on its own", action: "Nothing - this unblocks itself when the external wait clears.", - }; + }]; } if (state === "unknown") { - return { + return [{ kind: "unknown", + key: `unknown:${task.id}`, wait: "its current state could not be read", action: "Nothing yet - firstmate reconciles the unavailable state and escalates if your input is needed.", - }; + }]; } - return { + return [{ kind: "worker", + key: `worker:${task.id}`, wait: `the worker reports state: ${state}`, action: "Nothing yet - firstmate is on it and will escalate if your input is needed.", - }; + }]; }; for (const record of mainRecords.filter((item) => item.state === "in_flight" && item.structured)) { const task = taskById.get(record.id); @@ -679,9 +687,9 @@ function classify(snapshot, environment) { let taskWaits = null; let taskCanDo = null; if (stage === "blocked") { - const context = taskRootContext(task, "main"); - taskWaits = [context.wait]; - taskCanDo = context.action; + const contexts = taskRootContexts(task, "main"); + taskWaits = contexts.map((context) => context.wait); + taskCanDo = [...new Set(contexts.map((context) => context.action))].join(" "); } pipeline[stage].push({ id: itemRef("main", task.id), @@ -711,109 +719,104 @@ function classify(snapshot, environment) { const recordById = new Map(records.filter((record) => record.structured !== false).map((record) => [record.id, record])); const blockerTaskById = new Map(tasks.map((task) => [task.id, task])); const roots = new Map(); - const parentById = new Map(); - const segmentById = new Map(); - const scheduled = new Set(); + const expandedEdges = new Set(); const stack = []; - const chainFor = (blockerId, suffix = null) => { - const segments = []; - let current = blockerId; - while (current !== null) { - if (segmentById.has(current)) segments.push(segmentById.get(current)); - current = parentById.get(current) ?? null; - } - segments.reverse(); - if (suffix) segments.push(suffix); - return segments.join("; then "); - }; - const addRoot = (key, blockerId, suffix, action) => { - if (!roots.has(key)) roots.set(key, { wait: chainFor(blockerId, suffix), action }); - }; - const isAncestor = (blockerId, candidate) => { - let current = blockerId; - while (current !== null) { - if (current === candidate) return true; - current = parentById.get(current) ?? null; - } - return false; - }; - const schedule = (blockerId, parent) => { - if (scheduled.has(blockerId)) return; - scheduled.add(blockerId); - parentById.set(blockerId, parent); - stack.push(blockerId); + const addRoot = (key, segments, suffix, action) => { + if (roots.has(key)) return; + roots.set(key, { + wait: [...segments, ...(suffix ? [suffix] : [])].join("; then "), + action, + }); }; - for (const blockerId of [...blockedByIds(startRecord)].reverse()) schedule(blockerId, null); + const blockerIds = blockedByIds(startRecord); + const startTask = blockerTaskById.get(startRecord.id); + if (blockerIds.length === 0 && startTask) { + const contexts = taskRootContexts(startTask, owner, true); + return { + waits: contexts.map((context) => context.wait), + action: [...new Set(contexts.map((context) => context.action))].join(" "), + }; + } + for (const blockerId of [...blockerIds].reverse()) { + stack.push({ blockerId, segments: [], ancestors: new Set() }); + } while (stack.length > 0) { - const blockerId = stack.pop(); + const { blockerId, segments, ancestors } = stack.pop(); const blockerRef = itemRef(owner, blockerId); const blockerRecord = recordById.get(blockerId) || null; const blockerTask = blockerTaskById.get(blockerId) || null; const currentState = blockerTask ? safeState(blockerTask.current_state?.state || blockerTask.state) : null; if (!blockerRecord && !blockerTask) { - segmentById.set(blockerId, `blocked by ${blockerRef}, whose current state is unavailable`); - addRoot(`missing:${blockerId}`, blockerId, null, "Nothing yet - firstmate reconciles that unavailable blocker and escalates if your input is needed."); + addRoot(`missing:${blockerId}`, [...segments, `blocked by ${blockerRef}, whose current state is unavailable`], null, "Nothing yet - firstmate reconciles that unavailable blocker and escalates if your input is needed."); continue; } const terminalState = ["done", "failed"].includes(currentState) ? currentState : (["done", "failed"].includes(blockerRecord?.state) ? blockerRecord.state : null); if (terminalState) { - segmentById.set(blockerId, `blocked by ${blockerRef}, but it is already ${terminalState}; the dependency edge is stale`); - addRoot(`stale:${blockerId}`, blockerId, null, "Nothing yet - firstmate reconciles this stale dependency"); + addRoot(`stale:${blockerId}`, [...segments, `blocked by ${blockerRef}, but it is already ${terminalState}; the dependency edge is stale`], null, "Nothing yet - firstmate reconciles this stale dependency"); continue; } const prefix = currentState ? `blocked by ${blockerRef}, which is currently ${safeState(currentState)}` : `blocked by ${blockerRef}, which is ${blockerRecord.state === "in_flight" ? "under way" : "queued and not started"}`; - segmentById.set(blockerId, prefix); - const taskContext = blockerTask ? taskRootContext(blockerTask, owner, true) : null; - if (taskContext && ["chat", "decision", "paused", "unknown"].includes(taskContext.kind)) { - const rootKey = taskContext.kind === "decision" - ? `decision:${taskContext.wait}` - : `${taskContext.kind}:${blockerId}`; - addRoot(rootKey, blockerId, taskContext.wait, taskContext.action); + const nextSegments = [...segments, prefix]; + const taskContexts = blockerTask ? taskRootContexts(blockerTask, owner, true) : []; + const applicableContexts = taskContexts.filter((context) => ["chat", "decision", "paused", "unknown"].includes(context.kind)); + if (applicableContexts.length > 0) { + for (const context of applicableContexts) addRoot(context.key, nextSegments, context.wait, context.action); continue; } if (blockerRecord?.kind === "captain" && blockerRecord.hold_kind === "captain") { const identity = backlogDecisionIdentity(blockerRecord); const ref = decisionRef(owner, identity.origin, identity.key); - addRoot(`decision:${ref}`, blockerId, `waiting on your decision ${ref}`, `Answer decision ${ref} - it is the root cause.`); + addRoot(`decision:${ref}`, nextSegments, `waiting on your decision ${ref}`, `Answer decision ${ref} - it is the root cause.`); continue; } const gate = blockerRecord && futureTimeGate(blockerRecord, now); if (gate) { - addRoot(`gate:${blockerId}`, blockerId, `waiting until ${gate}`, `Nothing - this unblocks itself when the ${gate} time gate passes.`); + addRoot(`gate:${blockerId}`, nextSegments, `waiting until ${gate}`, `Nothing - this unblocks itself when the ${gate} time gate passes.`); continue; } const nestedIds = blockerRecord ? blockedByIds(blockerRecord) : []; if (nestedIds.length > 0) { + const nextAncestors = new Set(ancestors).add(blockerId); for (const nestedId of [...nestedIds].reverse()) { - if (isAncestor(blockerId, nestedId)) { - addRoot(`cycle:${[blockerId, nestedId].sort().join(":")}`, blockerId, `blocked by ${itemRef(owner, nestedId)}, whose blocker chain contains a cycle`, "Nothing yet - firstmate reconciles this blocker and escalates if your input is needed."); + if (nextAncestors.has(nestedId)) { + addRoot(`cycle:${[...nextAncestors, nestedId].sort().join(":")}`, nextSegments, `circular dependency through ${itemRef(owner, nestedId)}`, "Nothing yet - firstmate reconciles this circular dependency."); } else { - schedule(nestedId, blockerId); + const edgeKey = `${blockerId}\0${nestedId}`; + if (!expandedEdges.has(edgeKey)) { + expandedEdges.add(edgeKey); + stack.push({ blockerId: nestedId, segments: nextSegments, ancestors: nextAncestors }); + } } } continue; } if (blockerRecord?.hold_reason) { - addRoot(`hold:${blockerId}`, blockerId, "held by a structured hold", "Nothing yet - firstmate watches this hold and escalates if your input is needed."); + addRoot(`hold:${blockerId}`, nextSegments, "held by a structured hold", "Nothing yet - firstmate watches this hold and escalates if your input is needed."); continue; } - if (taskContext?.kind === "worker" && currentState === "blocked") { - addRoot(`worker:${blockerId}`, blockerId, taskContext.wait, taskContext.action); + const workerContext = taskContexts.find((context) => context.kind === "worker"); + if (workerContext && currentState === "blocked") { + addRoot(workerContext.key, nextSegments, workerContext.wait, workerContext.action); continue; } - addRoot(`finish:${blockerId}`, blockerId, null, `Nothing - this unblocks itself when ${blockerRef} finishes.`); + addRoot(`finish:${blockerId}`, nextSegments, null, `Nothing - this unblocks itself when ${blockerRef} finishes.`); } - const blockerIds = blockedByIds(startRecord); if (blockerIds.length === 0) { const gate = futureTimeGate(startRecord, now); if (gate) return { waits: [`waiting until ${gate}`], action: `Nothing - this unblocks itself when the ${gate} time gate passes.` }; return { waits: ["held by a structured wait gate"], action: "Nothing yet - firstmate watches this hold and escalates if your input is needed." }; } const resolvedRoots = [...roots.values()]; + if (resolvedRoots.length === 0) { + return { + waits: ["circular dependency could not be resolved from the blocker graph"], + action: "Nothing yet - firstmate reconciles this circular dependency.", + }; + } return { waits: resolvedRoots.map((root) => root.wait), action: [...new Set(resolvedRoots.map((root) => root.action))].join(" "), @@ -917,13 +920,14 @@ function classify(snapshot, environment) { reason: "Open decision raised by work already under way.", }); } - const mateTaskEvidence = (mate.active_children || []).map((child) => ({ - ...child, - hints: { - ...(child.hints || {}), - open_decisions: (mate.decisions_open || []).filter((decision) => decision.id === child.id), - }, - })); + const mateTaskEvidence = [...(mate.active_children || []), ...(mate.holds || []).filter((hold) => hold.source === "child-state" && hold.state)] + .map((child) => ({ + ...child, + hints: { + ...(child.hints || {}), + open_decisions: (mate.decisions_open || []).filter((decision) => decision.id === child.id), + }, + })); const heldIds = new Set(); for (const hold of mate.holds || []) { heldIds.add(hold.id); diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 432ef5f53b0..8eff211aadb 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -640,6 +640,7 @@ secondmate_home_summary_json() { # delivery_mode:(($work.delivery_mode // null) | if . == null then null else trunc(40) end), project_resolved:($work.project_resolved == true), since:(($work.since // null) | if . == null then null else trunc(20) end), + state:.current_state.state, blocked_by:null,blocked_by_all:[], reason:((.current_state.detail // .current_state.state) | trunc(120)),source:"child-state"} ]) as $holds_all | ($backlog.present == true diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 68d66de761b..1a4819a46df 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -664,7 +664,7 @@ EOF .secondmate_current.records[] | select(.id == "states") | .current.state == "captain_decision" and .active_children == [] - and (.holds | any(.id == "parked" and .source == "child-state")) + and (.holds | any(.id == "parked" and .source == "child-state" and .state == "parked")) ' >/dev/null || fail "parked child was classified as active work: $canonical" cat > "$mate/data/backlog.md" <<'EOF' ## In flight diff --git a/tests/fm-capacity.test.sh b/tests/fm-capacity.test.sh index 6627e40acb1..6f7c99600cc 100755 --- a/tests/fm-capacity.test.sh +++ b/tests/fm-capacity.test.sh @@ -542,6 +542,7 @@ test_secondmate_captain_holds_are_pipeline_waiting_work() { make_fixture "$home" "$snapshot" "$environment" jq ' .tasks[0].hints.open_decisions = [ + {"key":"default"}, {"key":"build-choice-one"}, {"key":"build-choice-two"} ] @@ -551,24 +552,27 @@ test_secondmate_captain_holds_are_pipeline_waiting_work() { {"id":"mate-choice","key":"mate-choice","verb":"captain-hold","summary":"Sensitive choice","source":"backlog"} ] | .secondmate_current.records[0].holds = [ - {"id":"mate-held","title":"Wait for external completion","repo":"delta","project_resolved":true,"kind":"ship","since":"2026-07-20","source":"child-state"} + {"id":"mate-held","title":"Wait for external completion","repo":"delta","project_resolved":true,"kind":"ship","since":"2026-07-20","state":"blocked","source":"child-state"} ] | .secondmate_current.records[0].queued += [ {"id":"mate-choice","title":"Choose the secondmate rollout","repo":"delta","project_resolved":true,"kind":"captain","hold_kind":"captain","hold_reason":"Sensitive reason"}, {"id":"mate-structured","title":"Wait on a structured hold","repo":"delta","project_resolved":true,"kind":"ship","hold_reason":"Sensitive reason"}, - {"id":"mate-time","title":"Resume after 2026-08-15","repo":"delta","project_resolved":true,"kind":"ship","body_excerpt":"Acceptance criteria: resume safely."} + {"id":"mate-time","title":"Resume after 2026-08-15","repo":"delta","project_resolved":true,"kind":"ship","body_excerpt":"Acceptance criteria: resume safely."}, + {"id":"after-mate-held","title":"Continue after held work","repo":"delta","project_resolved":true,"kind":"ship","blocked_by":"mate-held","body_excerpt":"Acceptance criteria: held work clears."} ] - | .secondmate_current.records[0].counts = {"active_children":0,"decisions_open":2,"holds":1,"queued":4} + | .secondmate_current.records[0].decisions_open[0].id = "mate-held" + | .secondmate_current.records[0].counts = {"active_children":0,"decisions_open":2,"holds":1,"queued":5} ' "$snapshot" > "$snapshot.tmp" mv "$snapshot.tmp" "$snapshot" json=$("$CAPACITY" --json --snapshot "$snapshot" --environment "$environment" --output "$output") || fail "secondmate captain-hold capacity run failed" printf '%s' "$json" | jq -e ' - (.pipeline.blocked | length) == 8 + (.pipeline.blocked | length) == 9 and .measures.open_captain_actions == 4 and (.recommendations[] | select(.id == "CAP-01") | .evidence | startswith("4 structured captain")) - and ([.pipeline.blocked[] | select(.owner | contains("persistent"))] | length) == 4 + and ([.pipeline.blocked[] | select(.owner | contains("persistent"))] | length) == 5 and ([.pipeline.blocked[] | select(.owner | contains("persistent")) | .what_you_can_do] | all(type == "string" and length > 0)) + and ([.pipeline.blocked[] | select(.owner | contains("persistent")) | .waits_on // [] | join(" ")] | map(select(contains("worker question"))) | length) == 2 ' >/dev/null || fail "secondmate captain hold was missing or double-counted: $json" [ "$(grep -o 'class="verb verb-decide"' "$output" | wc -l | tr -d ' ')" = 4 ] || fail "captain decisions were collapsed or duplicated in the needs-you roll call" @@ -945,11 +949,17 @@ test_keyless_questions_and_blocker_chains() { {"order":16,"state":"queued","structured":true,"id":"stale-dependent","title":"Reconcile a stale dependency","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"finished-root","body_excerpt":"Acceptance criteria: stale edge clears."}, {"order":17,"state":"queued","structured":true,"id":"branching-dependent","title":"Publish after converging branches","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"branch-a,branch-b","blocked_by_all":["branch-a","branch-b"],"body_excerpt":"Acceptance criteria: both branches clear."}, {"order":18,"state":"queued","structured":true,"id":"branch-a","title":"First decision branch","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: choose policy."}, - {"order":19,"state":"queued","structured":true,"id":"branch-b","title":"Second decision branch","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: choose policy."} + {"order":19,"state":"queued","structured":true,"id":"branch-b","title":"Second decision branch","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"policy-choice","body_excerpt":"Acceptance criteria: choose policy."}, + {"order":20,"state":"in_flight","structured":true,"id":"mixed-asker","title":"Resolve several worker questions","repo":"alpha","project_resolved":true,"kind":"ship","body_excerpt":"Acceptance criteria: all questions resolve."}, + {"order":21,"state":"queued","structured":true,"id":"behind-mixed-worker","title":"Publish after every worker question","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"mixed-asker","body_excerpt":"Acceptance criteria: all roots clear."}, + {"order":22,"state":"queued","structured":true,"id":"cycle-dependent","title":"Reconcile sibling cycle","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"cycle-a,cycle-b","blocked_by_all":["cycle-a","cycle-b"],"body_excerpt":"Acceptance criteria: cycle clears."}, + {"order":23,"state":"queued","structured":true,"id":"cycle-a","title":"Cycle side A","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"cycle-b","body_excerpt":"Acceptance criteria: cycle clears."}, + {"order":24,"state":"queued","structured":true,"id":"cycle-b","title":"Cycle side B","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"cycle-a","body_excerpt":"Acceptance criteria: cycle clears."} ] | .tasks = [ {"id":"asker","kind":"ship","project":"alpha","current_state":{"state":"blocked","source":"status-fold","detail":"awaiting reply"},"endpoint":{"exists":true,"agent_alive":"not_checked"},"hints":{"open_decisions":[{"key":"default","verb":"needs-decision","summary":"which port should the exporter bind"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"asker","title":"Build the exporter","repo":"alpha","project_resolved":true,"kind":"ship","since":"2026-07-16"}}, - {"id":"keyed-asker","kind":"ship","project":"beta","current_state":{"state":"blocked","source":"status-fold","detail":"awaiting decision"},"endpoint":{"exists":true,"agent_alive":"not_checked"},"hints":{"open_decisions":[{"key":"api-shape","verb":"needs-decision","summary":"choose v1 or v2 response shape"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"keyed-asker","title":"Build the API","repo":"beta","project_resolved":true,"kind":"ship","since":"2026-07-16"}} + {"id":"keyed-asker","kind":"ship","project":"beta","current_state":{"state":"blocked","source":"status-fold","detail":"awaiting decision"},"endpoint":{"exists":true,"agent_alive":"not_checked"},"hints":{"open_decisions":[{"key":"api-shape","verb":"needs-decision","summary":"choose v1 or v2 response shape"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"keyed-asker","title":"Build the API","repo":"beta","project_resolved":true,"kind":"ship","since":"2026-07-16"}}, + {"id":"mixed-asker","kind":"ship","project":"alpha","current_state":{"state":"blocked","source":"status-fold","detail":"awaiting several answers"},"endpoint":{"exists":true,"agent_alive":"not_checked"},"hints":{"open_decisions":[{"key":"default","verb":"needs-decision"},{"key":"route-one","verb":"needs-decision"},{"key":"route-two","verb":"needs-decision"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"mixed-asker","title":"Resolve several worker questions","repo":"alpha","project_resolved":true,"kind":"ship"}} ] | .secondmate_current.registry.records = [] | .secondmate_current.records = [] @@ -960,12 +970,14 @@ test_keyless_questions_and_blocker_chains() { json=$("$CAPACITY" --json --snapshot "$snapshot" --environment "$environment" --output "$output") || fail "keyless-question capacity run failed" printf '%s' "$json" | jq -e ' - (.pipeline.blocked | map(select(.reason | contains("Worker question being handled in chat"))) | length) == 1 + (.pipeline.blocked | map(select(.reason | contains("Worker question being handled in chat"))) | length) == 2 and (.pipeline.blocked | map(select(.reason | contains("Worker question"))) | .[0].what_you_can_do | contains("firstmate is handling")) and ([.pipeline.blocked[] | select(.waits_on != null) | .waits_on[0]] | any(contains("waiting on your decision"))) and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("blocked by") and contains("currently"))) - and ([.pipeline.blocked[] | select((.waits_on // []) | length == 2)] | length) == 1 - and ([.pipeline.blocked[] | select((.waits_on // []) | length == 2) | .waits_on | join(" ")] | .[0] | contains("unavailable")) + and any(.pipeline.blocked[]; + ((.waits_on // []) | length) == 2 + and ((.waits_on | join(" ")) | contains("worker question")) + and ((.waits_on | join(" ")) | contains("unavailable"))) and any(.pipeline.blocked[]; ((.waits_on // [] | join(" ")) as $chain | ([$chain | scan("blocked by")] | length) >= 6 @@ -980,6 +992,13 @@ test_keyless_questions_and_blocker_chains() { (.reason | contains(",")) and ((.waits_on // []) | length) == 1 and ((.waits_on | join(" ")) | contains("waiting on your decision"))) + and any(.pipeline.blocked[]; + ((.waits_on // []) | length) == 3 + and ((.waits_on | join(" ")) | contains("worker question")) + and (([.waits_on[] | select(contains("waiting on your decision"))] | length) == 2)) + and any(.pipeline.blocked[]; + ((.waits_on // [] | join(" ")) | contains("circular dependency")) + and (.what_you_can_do | contains("firstmate reconciles this circular dependency"))) and ([.pipeline.blocked[] | .waits_on // [] | join(" ")] | any(contains("which port")) | not) ' >/dev/null || fail "keyless questions or blocker chains are wrong: $json" html=$(cat "$output") From 6d7b2b1ec21251abe9f6e2005ab82c9506be185f Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 15:07:26 -0400 Subject: [PATCH 23/24] no-mistakes(document): Document dashboard command handling and keyless questions --- docs/dashboard-service.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/dashboard-service.md b/docs/dashboard-service.md index b5287530fed..3a9c652b358 100644 --- a/docs/dashboard-service.md +++ b/docs/dashboard-service.md @@ -15,6 +15,7 @@ The service publishes the producer-generated `data/capacity-dashboard.html` at o - A service bar shows how many captain commands are queued for firstmate. - A Subscription usage band shows cached `quota-axi --json` windows for Claude, Codex, and Grok, including percent used and reset distance with reset time formatted by the captain's browser in local time. - Every blocked row carries its plain-language blocker chain resolved to the root cause plus an explicit "What you can do" line, so no blocked row leaves the captain guessing; chains stay privacy-safe in the on-disk file and de-anonymize at serve time like every other reference. +- A keyless `needs-decision` status event renders honestly as a worker question that Firstmate is handling in chat, with no fabricated decision identity, `Decide` framing, or dead-end decision detail view. - The served page has zero copy-prompt affordances: each producer copy button is replaced by direct dispatch, or removed outright in read-only mode, while the offline file keeps its copy buttons for `file://` use. - The installer pins the installing shell's `PATH` into the launchd agent so the generator's state-reader tools resolve, and the service marks a render `RENDER DEGRADED` loudly on the page and in its log when most worker states read unknown - degraded data is never presented as truth. @@ -34,10 +35,10 @@ The on-disk dashboard stays identity-opaque: the producer's opt-in `--refs` side Button clicks never execute anything. The design keeps the web process outside every fleet-mutation path: -1. The captain clicks "Send to firstmate" on a `CAP-NN` action. +1. The captain clicks a send or verdict control for a `CAP-NN` action, structured decision answer, or idea verdict. 2. The service validates the request (see trust design) and writes one durable `fm-dash-command.v1` record into `state/dash-inbox/` with an atomic temp-file rename, mode 0600. 3. The registered `fm-dash` watcher check notices the pending record on its normal cadence (`FM_CHECK_INTERVAL`, default 300 seconds) and wakes the running firstmate through the standard durable wake queue. -4. Firstmate claims the records with `bin/fm-dash-inbox.sh claim` and handles each prompt as the captain's approval of that action ID under the capacity skill's section 4 semantics. +4. Firstmate claims the records with `bin/fm-dash-inbox.sh claim` and handles each record by its kind under the capacity skill's dashboard-command semantics. Consequences of that shape: From 00a5b57fb14c3db5f323e206e0220e9046029a63 Mon Sep 17 00:00:00 2001 From: Matt McCarthy Date: Tue, 28 Jul 2026 15:11:31 -0400 Subject: [PATCH 24/24] no-mistakes(document): Correct dashboard claim handling reminder --- bin/fm-dash-inbox.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/fm-dash-inbox.sh b/bin/fm-dash-inbox.sh index d0875a8f649..04c9364a4fb 100755 --- a/bin/fm-dash-inbox.sh +++ b/bin/fm-dash-inbox.sh @@ -114,7 +114,8 @@ EOF fi printf 'delivered: %s captain dashboard command(s)\n' "$delivered" printf 'archived: %s captain dashboard command(s)\n' "$archived" - echo "apply idempotency checks to every delivered prompt, then handle it as the captain's approval of that action ID under the capacity skill; its authority limits apply and nothing here authorizes a merge, discard, or other destructive act." + echo "apply idempotency checks to every delivered prompt, then handle it by kind under the capacity skill; its authority limits apply and nothing here authorizes a merge, discard, or other destructive act." + echo "CAP records approve that action ID; decision records answer the named owner-qualified decision through the decision lifecycle, with destructive consequences re-confirmed in chat; idea records are captain verdicts: approve creates work through the normal backlog lifecycle, deny records the outcome, and suggestions are captain input." prune_archive ;; *)