diff --git a/.agents/skills/capacity/SKILL.md b/.agents/skills/capacity/SKILL.md index 3e67fbf0a86..4ed7c3ad8c6 100644 --- a/.agents/skills/capacity/SKILL.md +++ b/.agents/skills/capacity/SKILL.md @@ -25,10 +25,11 @@ 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. +Decision filing and its structured options document are owned by the decision-hold lifecycle. 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 +85,20 @@ 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: +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 owner-qualified decision identity with either the recorded option text or bounded custom answer; route `decision_origin` in `decision_home` 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. +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/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 5db5690ebc9..d4ce32af2d2 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -14,6 +14,8 @@ This skill is the single policy owner for unresolved captain decisions discovere ## Policy +Before filing a new decision, author its title, context, options, and per-option impacts in the origin-qualified format owned by `docs/dashboard-service.md`, then pass that file to `bin/fm-decision-hold.sh hold --options-file`; legacy holds that already exist without a document remain answer-in-chat decisions. + Every unresolved decision that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must become a structured captain-held work item in the authoritative backlog of the home that owns the originating work before that work or review may be treated as complete. The agent performs the semantic inventory because scripts must not infer decisions from report prose, visual-review artifacts, terminal output, or chat. Give each distinct unresolved decision a stable privacy-safe key, register it through `bin/fm-decision-hold.sh hold`, and use the same key on retry so registration is idempotent while different decisions retain different durable identities. @@ -29,7 +31,7 @@ Bearings reads the resulting structured state and must never compensate by scrap 1. Read the complete investigation result and complete the visual review before declaring either complete. 2. Inventory only genuine unresolved choices that require the captain. -3. For each choice, choose a stable key and use the script's `hold` command with a concise title, reason, and repository. +3. For each choice, choose a stable key, author its options document, and use the script's `hold --options-file` command with a concise title, reason, and repository. 4. Run the script's `complete` command with the full unresolved-key inventory for that review pass. 5. Relay the choices to the captain as decisions from Bearings' Captain's Call section under `AGENTS.md` section 9; do not use the word hold in captain chat. 6. After the captain decides, record dependent work with normal tasks-axi commands and block it by the hold identity. 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..32d19816997 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; delivered at least once with idempotency checks 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/README.md b/README.md index 8422be811ae..786fb5feb47 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away | | `/bearings` | Generate a standalone current-status report from bounded local fleet and registered-secondmate state, with live PR enrichment only when requested, written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | -| `/capacity` | Diagnose meaningful ready-work supply and delivery bottlenecks, render a private offline pipeline dashboard with stable action IDs, and keep all approved follow-ups inside the normal safety lifecycle | +| `/capacity` | Diagnose meaningful ready-work supply and delivery bottlenecks, render a private offline pipeline dashboard with stable action IDs, optionally publish it through the persistent tailnet-only dashboard service, and keep all approved follow-ups inside the normal safety lifecycle | | `/user-journey-audit` | Audit one isolated local application through browser-driven, product-derived personas, automatically route confirmed ordinary bugs for fixes, and queue grounded feature opportunities without implementing or merging them | | `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | | `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | diff --git a/bin/fm-capacity.mjs b/bin/fm-capacity.mjs index a9cefa29e4a..245bb6dc7b4 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; } @@ -377,6 +387,17 @@ function itemRef(owner, id) { return opaqueRef("item", `${owner}/${id}`); } +function decisionRef(owner, origin, key) { + return opaqueRef("item", `decision/${owner}/${origin}/${key}`); +} + +function backlogDecisionIdentity(record) { + const body = String(record.body_excerpt || ""); + const origin = body.match(/(?:^|\n)Origin:\s*([A-Za-z0-9._-]+)/)?.[1] || record.id; + const key = body.match(/(?:^|\n)Decision key:\s*([A-Za-z0-9._-]+)/)?.[1] || record.id; + return { origin, key }; +} + function ownerRef(owner) { if (owner === "main" || owner === "ephemeral worker") return owner; return `persistent ${opaqueRef("home", String(owner).replace(/^secondmate\s+/, ""))}`; @@ -565,6 +586,61 @@ function classify(snapshot, environment) { if (registry?.available === false || registry?.complete === false) { markUnavailable("secondmate-registry", "main", "registry projection incomplete"); } + 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 keyedDecisions = open.filter((decision) => decision.key && decision.key !== "default"); + if (dependency && ["done", "failed"].includes(state)) { + 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) { + 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.", + }); + } + for (const keyedDecision of keyedDecisions) { + const ref = decisionRef(owner, keyedDecision.origin || task.id, keyedDecision.key); + 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 [{ + 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 [{ + 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 [{ + 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); const repo = record.repo || task?.project || null; @@ -586,11 +662,18 @@ function classify(snapshot, environment) { markUnavailable(itemRef("main", task.id), "main", "in-flight work lacks project provenance"); } const open = task.hints?.open_decisions || []; + // A keyless fold entry (key "default") is a worker question firstmate is + // handling in chat, never a fabricated captain decision: it gets no + // decision identity, no Decide row, and no dead-end detail view. Event + // summaries stay out of this privacy-bounded artifact; the authenticated + // dashboard service surfaces the concrete text in its detail views. + const chatQuestionCount = open.filter((decision) => !decision.key || decision.key === "default").length; for (const decision of open) { + if (!decision.key || decision.key === "default") continue; decisions.push({ owner: "main", task: itemRef("main", task.id), - key: itemRef("decision", decision.key || task.id), + key: decisionRef("main", task.id, decision.key), reason: "Open decision raised by work already under way.", }); } @@ -601,6 +684,13 @@ 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); + let taskWaits = null; + let taskCanDo = null; + if (stage === "blocked") { + 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), title: `${STAGE_LABELS[stage]} work item`, @@ -608,16 +698,131 @@ function classify(snapshot, environment) { repo: projectRef(taskRepo), kind: ["ship", "scout"].includes(task.kind) ? task.kind : null, stage, - reason: `Authoritative current state: ${safeState(task.current_state?.state)}`, + reason: chatQuestionCount > 0 + ? "Worker question being handled in chat" + : `Authoritative current state: ${safeState(task.current_state?.state)}`, artifact: null, provenance: `current state from ${safeSource(task.current_state?.source)}`, age_days: ageDays, approval_ready: approvalReady, approval_authority: approvalReady ? (task.yolo === "on" ? "firstmate" : "captain") : null, captain_approval_required: approvalReady && task.yolo !== "on", + waits_on: taskWaits, + what_you_can_do: taskCanDo, }); } + 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 blockerTaskById = new Map(tasks.map((task) => [task.id, task])); + const roots = new Map(); + const expandedEdges = new Set(); + const stack = []; + const addRoot = (key, segments, suffix, action) => { + if (roots.has(key)) return; + roots.set(key, { + wait: [...segments, ...(suffix ? [suffix] : [])].join("; then "), + action, + }); + }; + 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, 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) { + 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) { + 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"}`; + 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}`, 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}`, 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 (nextAncestors.has(nestedId)) { + addRoot(`cycle:${[...nextAncestors, nestedId].sort().join(":")}`, nextSegments, `circular dependency through ${itemRef(owner, nestedId)}`, "Nothing yet - firstmate reconciles this circular dependency."); + } else { + 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}`, nextSegments, "held by a structured hold", "Nothing yet - firstmate watches this hold and escalates if your input is needed."); + continue; + } + const workerContext = taskContexts.find((context) => context.kind === "worker"); + if (workerContext && currentState === "blocked") { + addRoot(workerContext.key, nextSegments, workerContext.wait, workerContext.action); + continue; + } + addRoot(`finish:${blockerId}`, nextSegments, null, `Nothing - this unblocks itself when ${blockerRef} finishes.`); + } + 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(" "), + }; + }; + const queue = mainRecords.filter((record) => record.state === "queued"); const candidates = []; for (const record of queue) { @@ -628,26 +833,41 @@ function classify(snapshot, environment) { } if (isSuperseded(record)) continue; if (record.kind === "captain" && record.hold_kind === "captain") { + const decision = backlogDecisionIdentity(record); + const holdRef = decisionRef("main", decision.origin, decision.key); decisions.push({ owner: "main", task: itemRef("main", record.id), - key: itemRef("decision", record.id), + key: holdRef, reason: "A queued choice is held for your decision.", }); blockedRows.push({ id: itemRef("main", record.id), owner: "main", reason: "captain hold" }); - pipeline.blocked.push(cardFromBacklog(record, "main", "blocked", "Captain hold")); + pipeline.blocked.push(Object.assign(cardFromBacklog(record, "main", "blocked", "Captain hold"), { + waits_on: [`waiting on your decision ${holdRef}`], + what_you_can_do: `Answer decision ${holdRef} - it is the root cause.`, + })); 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 ${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, + what_you_can_do: chain.action, + })); continue; } const timeGate = futureTimeGate(record, now); if (timeGate) { const reason = `time gate until ${timeGate}`; blockedRows.push({ id: itemRef("main", record.id), owner: "main", reason }); - pipeline.blocked.push(cardFromBacklog(record, "main", "blocked", reason)); + pipeline.blocked.push(Object.assign(cardFromBacklog(record, "main", "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); @@ -692,13 +912,22 @@ 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 || decision.key === "default") continue; 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.origin || decision.id || mate.id, decision.key || decision.id || mate.id), reason: "Open decision raised by work already under way.", }); } + 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); @@ -712,7 +941,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 || [])], mateTaskEvidence); + 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" }); } @@ -751,20 +984,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) { - 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 ${blockedByIds(record).map((id) => itemRef(mate.id, id)).join(", ")}` + : "Structured hold"; + 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, + 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); @@ -1212,12 +1460,21 @@ function artifact(value) { return `${h(safe)}`; } +// Plain-language blocker context: the recursive chain plus the explicit +// 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("; "); + const action = card.what_you_can_do ? `What you can do: ${h(card.what_you_can_do)}` : ""; + return `${chain}.${action}`; +} + function manifestRow(card) { const meta = [card.kind, card.age_days !== null && card.age_days !== undefined ? `${card.age_days}d` : null] .filter(Boolean).map((part) => h(part)).join(" · "); return `
  • ${h(card.id)}${h(card.owner)}${card.repo ? ` · ${h(card.repo)}` : ""} - ${h(card.reason || "No additional gate detail.")}${card.artifact ? ` ${artifact(card.artifact)}` : ""} + ${h(card.reason || "No additional gate detail.")}${card.artifact ? ` ${artifact(card.artifact)}` : ""}${blockedContext(card)} ${meta}
  • `; } @@ -1255,7 +1512,7 @@ function renderHtml(model, captainActions) { ...captainApprovalCards.map((card) => `
  • Approve${h(card.id)} ${h(card.owner)}${card.repo ? ` · ${h(card.repo)}` : ""}Finished work is ready for your approval.
  • `), ...captainActions.map((action) => `
  • Decide${h(action.key)} ${h(action.owner)} · work ${h(action.task)}${h(action.reason)}
  • `), ].join(""); - const blockedRows = otherBlockedCards.map((card) => `
  • Stuck${h(card.id)} ${h(card.owner)}${card.repo ? ` · ${h(card.repo)}` : ""}${h(card.reason || "Unspecified gate")}
  • `).join(""); + const blockedRows = otherBlockedCards.map((card) => `
  • Stuck${h(card.id)} ${h(card.owner)}${card.repo ? ` · ${h(card.repo)}` : ""}${h(card.reason || "Unspecified gate")}${blockedContext(card)}
  • `).join(""); const blockedReasonCounts = new Map(); for (const card of otherBlockedCards) { @@ -1343,10 +1600,13 @@ 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} .why{color:var(--ink2);font-size:.92rem;min-width:0} + .chain{display:block;color:var(--muted);font-size:.8rem;margin-top:.25rem} + .cando{display:block;color:var(--ink);font-size:.8rem;font-weight:650;margin-top:.15rem} .item-id{font:700 .82rem ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink)} .next{margin-top:2rem;font-size:clamp(1.05rem,1.8vw,1.25rem)} .prompt{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.7rem;align-items:center;margin-top:.9rem;border:1px solid var(--line);padding:.65rem .8rem;background:color-mix(in srgb,var(--bg) 55%,transparent)} @@ -1474,6 +1734,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..04c9364a4fb --- /dev/null +++ b/bin/fm-dash-inbox.sh @@ -0,0 +1,124 @@ +#!/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" 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. Delivery is at-least-once across +# interruption, and the capacity skill requires idempotency checks before +# handling re-surfaced CAP actions, decision answers, or idea verdicts. +# Each claimed prompt carries the capacity skill's authority limits and never +# grants destructive or merge authority. +# +# Usage: fm-dash-inbox.sh [list|claim|pending-count] +# list print pending commands without consuming them +# claim print and archive 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 + delivered=0 + archived=0 + while IFS= read -r f; do + dest="$ARCHIVE/$(basename "$f")" + if [ -e "$dest" ]; then + rm -f -- "$f" + continue + fi + [ -e "$f" ] || continue + print_record "$f" + delivered=$((delivered + 1)) + if mv -n -- "$f" "$dest" 2>/dev/null && [ ! -e "$f" ] && [ -e "$dest" ]; then + archived=$((archived + 1)) + fi + done <&2 + ;; +esac diff --git a/bin/fm-dash-install.sh b/bin/fm-dash-install.sh new file mode 100755 index 00000000000..22625297545 --- /dev/null +++ b/bin/fm-dash-install.sh @@ -0,0 +1,535 @@ +#!/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, launchd agent, and watcher registration; +# 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 +# unregister-check remove only the fm-dash watcher registration +# 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 remove 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" +CHECK_TRUST="$STATE/fm-dash.check-trust" +DEFAULT_PORT=8847 +DEFAULT_SERVE_PORT=8443 +TX_ACTIVE=false +TX_CONFIG_BACKUP="" +TX_CONFIG_EXISTED=false +TX_PLIST_BACKUP="" +TX_PLIST_EXISTED=false +TX_CHECK_BACKUP="" +TX_CHECK_EXISTED=false +TX_TRUST_BACKUP="" +TX_TRUST_EXISTED=false +TX_CONFIG_STAGE="" +TX_PLIST_STAGE="" +TX_PRIOR_LOADED=false +TX_PREVIOUS_SERVE_PORT="" +TX_PREVIOUS_MAPPING_PRESENT=false +TX_PREVIOUS_MAPPING_TARGET="" +TX_NEW_SERVE_PORT="" +TX_NEW_MAPPING=false +TX_LABEL="" +TX_PLIST_PATH="" + +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 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); + 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 (error) { + console.error("could not verify Funnel state: " + error.message); + process.exit(1); + } + }); + ' "$serve_port" +} + +write_config_file() { + local target=$1 port=$2 serve_port=$3 read_only=$4 + shift 4 + node -e ' + const fs = require("node:fs"); + const [config, port, servePort, readOnly, ...logins] = process.argv.slice(1); + const payload = { port: Number(port), serve_port: Number(servePort), captain_logins: logins, read_only: readOnly === "true" }; + fs.writeFileSync(config, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 }); + const verified = JSON.parse(fs.readFileSync(config, "utf8")); + if (verified.port !== Number(port) || verified.serve_port !== Number(servePort)) process.exit(1); + ' "$target" "$port" "$serve_port" "$read_only" "$@" || return 1 + chmod 600 "$target" 2>/dev/null || true +} + +configured_serve_port() { + [ -f "$CONFIG" ] || return 0 + node -e ' + const fs = require("node:fs"); + try { + const parsed = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const port = parsed.serve_port === undefined ? Number(process.argv[2]) : parsed.serve_port; + if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(1); + console.log(port); + } catch { + process.exit(1); + } + ' "$CONFIG" "$DEFAULT_SERVE_PORT" +} + +configured_mapping_target() { + [ -f "$CONFIG" ] || return 0 + node -e ' + const fs = require("node:fs"); + try { + const parsed = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const port = parsed.port === undefined ? Number(process.argv[2]) : parsed.port; + if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(1); + console.log("http://127.0.0.1:" + port); + } catch { + process.exit(1); + } + ' "$CONFIG" "$DEFAULT_PORT" +} + +snapshot_serve_mapping() { + local serve_port=$1 status_json + status_json=$("$(tailscale_bin)" serve status --json 2>/dev/null) || 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); + if (!s || Array.isArray(s) || typeof s !== "object") throw new Error(); + const tcp = s.TCP === undefined ? {} : s.TCP; + const web = s.Web === undefined ? {} : s.Web; + if (!tcp || Array.isArray(tcp) || typeof tcp !== "object" + || !web || Array.isArray(web) || typeof web !== "object") throw new Error(); + const tcpMapping = tcp[process.argv[1]]; + const webMappings = Object.entries(web).filter(([hostport]) => hostport.endsWith(":" + process.argv[1])); + if (tcpMapping === undefined && webMappings.length === 0) return; + if (!tcpMapping || tcpMapping.HTTPS !== true || webMappings.length !== 1) { + console.log("foreign"); + return; + } + const handlers = webMappings[0][1] && webMappings[0][1].Handlers; + if (!handlers || Array.isArray(handlers) || typeof handlers !== "object") { + console.log("foreign"); + return; + } + const paths = Object.keys(handlers); + const proxy = handlers["/"] && handlers["/"].Proxy; + if (paths.length !== 1 || paths[0] !== "/" || typeof proxy !== "string" || proxy.length === 0) { + console.log("foreign"); + return; + } + console.log(proxy); + } catch { + process.exit(1); + } + }); + ' "$serve_port" +} + +disable_serve_port() { + "$(tailscale_bin)" serve --https="$1" off >/dev/null 2>&1 +} + +disable_owned_serve_port() { + local serve_port=$1 expected_target=$2 live_target + live_target=$(snapshot_serve_mapping "$serve_port") || return 1 + [ -n "$live_target" ] || return 0 + if [ -z "$expected_target" ] || [ "$live_target" != "$expected_target" ]; then + printf 'kept: serve port %s carries a non-dashboard mapping\n' "$serve_port" + return 0 + fi + disable_serve_port "$serve_port" +} + +cleanup_install_transaction() { + local file + for file in "$TX_CONFIG_BACKUP" "$TX_PLIST_BACKUP" "$TX_CHECK_BACKUP" "$TX_TRUST_BACKUP" "$TX_CONFIG_STAGE" "$TX_PLIST_STAGE"; do + [ -z "$file" ] || rm -f -- "$file" + done +} + +fail_staging() { + local message=$1 + cleanup_install_transaction + err "$message" +} + +restore_snapshot() { + local path=$1 backup=$2 existed=$3 + if [ "$existed" = true ]; then + mv -f -- "$backup" "$path" + else + rm -f -- "$path" + fi +} + +rollback_install() { + local status=0 + TX_ACTIVE=false + if [ "$TX_NEW_MAPPING" = true ]; then + disable_serve_port "$TX_NEW_SERVE_PORT" || status=1 + fi + restore_snapshot "$CONFIG" "$TX_CONFIG_BACKUP" "$TX_CONFIG_EXISTED" || status=1 + restore_snapshot "$TX_PLIST_PATH" "$TX_PLIST_BACKUP" "$TX_PLIST_EXISTED" || status=1 + restore_snapshot "$CHECK" "$TX_CHECK_BACKUP" "$TX_CHECK_EXISTED" || status=1 + restore_snapshot "$CHECK_TRUST" "$TX_TRUST_BACKUP" "$TX_TRUST_EXISTED" || status=1 + launchctl bootout "gui/$(id -u)/$TX_LABEL" >/dev/null 2>&1 || true + if [ "$TX_PRIOR_LOADED" = true ]; then + launchctl bootstrap "gui/$(id -u)" "$TX_PLIST_PATH" >/dev/null 2>&1 || status=1 + launchctl kickstart "gui/$(id -u)/$TX_LABEL" >/dev/null 2>&1 || status=1 + fi + if [ "$TX_PREVIOUS_MAPPING_PRESENT" = true ]; then + "$(tailscale_bin)" serve --bg --https="$TX_PREVIOUS_SERVE_PORT" "$TX_PREVIOUS_MAPPING_TARGET" >/dev/null 2>&1 || status=1 + assert_no_funnel "$TX_PREVIOUS_SERVE_PORT" >/dev/null 2>&1 || status=1 + fi + cleanup_install_transaction + return "$status" +} + +fail_install() { + local message=$1 + if rollback_install; then + err "$message; the previous dashboard installation was restored" + fi + err "$message; rollback was incomplete and requires manual inspection" +} + +snapshot_file() { + local path=$1 template=$2 backup_var=$3 existed_var=$4 backup + if [ -e "$path" ]; then + backup=$(mktemp "$template") || return 1 + printf -v "$backup_var" '%s' "$backup" + cp -p -- "$path" "$backup" || return 1 + printf -v "$existed_var" '%s' true + fi +} + +render_plist() { + local label node_path log_dir fm_root fm_home path_env + 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") + # launchd starts agents with a minimal PATH that cannot resolve the state + # reader tools (tmux, no-mistakes, tasks-axi, gh), which silently degrades + # every rendered state to unknown. Capture the installing shell's full PATH + # so the service and the generator it runs see the same tools the captain's + # interactive runs do. + path_env=$(xml_escape "$PATH") + cat < + + + + Label$label + ProgramArguments + + $node_path + $fm_root/bin/fm-dash-serve.mjs + + EnvironmentVariables + + FM_HOME$fm_home + PATH$path_env + + RunAtLoad + KeepAlive + StandardOutPath$log_dir/dash-serve.log + StandardErrorPath$log_dir/dash-serve.log + + +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' +#!/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" +} + +unregister_check() { + rm -f -- "$CHECK" "$CHECK_TRUST" || err "could not unregister the fm-dash watcher check" +} + +cmd_install() { + local port=$DEFAULT_PORT serve_port=$DEFAULT_SERVE_PORT previous_serve_port previous_mapping_target requested_mapping_target expected_mapping_target 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 + previous_serve_port=$(configured_serve_port) || err "could not read the previously configured dashboard serve port" + expected_mapping_target=$(configured_mapping_target) || err "could not read the previously configured dashboard mapping target" + if [ -n "$previous_serve_port" ]; then + previous_mapping_target=$(snapshot_serve_mapping "$previous_serve_port") || err "could not inspect the previously configured dashboard serve port" + if [ -n "$previous_mapping_target" ] && [ "$previous_mapping_target" != "$expected_mapping_target" ]; then + err "configured dashboard serve port $previous_serve_port carries a non-dashboard mapping; refusing to replace it" + fi + TX_PREVIOUS_MAPPING_TARGET=$previous_mapping_target + [ -z "$previous_mapping_target" ] || TX_PREVIOUS_MAPPING_PRESENT=true + fi + if [ "$serve_port" = "$previous_serve_port" ]; then + requested_mapping_target=$previous_mapping_target + else + requested_mapping_target=$(snapshot_serve_mapping "$serve_port") || err "could not inspect requested dashboard serve port $serve_port" + fi + if [ -n "$requested_mapping_target" ] && { [ "$serve_port" != "$previous_serve_port" ] || [ "$requested_mapping_target" != "$expected_mapping_target" ]; }; then + err "requested dashboard serve port $serve_port carries a non-dashboard mapping; choose another --serve-port" + fi + + 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 + + label=$(home_label) + plist_path="$HOME/Library/LaunchAgents/$label.plist" + mkdir -p "$CONFIG_DIR" "$HOME/Library/LaunchAgents" "$STATE" + TX_LABEL=$label + TX_PLIST_PATH=$plist_path + TX_PREVIOUS_SERVE_PORT=$previous_serve_port + TX_NEW_SERVE_PORT=$serve_port + launchctl print "gui/$(id -u)/$label" >/dev/null 2>&1 && TX_PRIOR_LOADED=true + snapshot_file "$CONFIG" "$CONFIG_DIR/.dash.json.backup.XXXXXX" TX_CONFIG_BACKUP TX_CONFIG_EXISTED || fail_staging "could not snapshot $CONFIG" + snapshot_file "$plist_path" "$HOME/Library/LaunchAgents/.$label.backup.XXXXXX" TX_PLIST_BACKUP TX_PLIST_EXISTED || fail_staging "could not snapshot $plist_path" + snapshot_file "$CHECK" "$STATE/.fm-dash.check.backup.XXXXXX" TX_CHECK_BACKUP TX_CHECK_EXISTED || fail_staging "could not snapshot $CHECK" + snapshot_file "$CHECK_TRUST" "$STATE/.fm-dash.check-trust.backup.XXXXXX" TX_TRUST_BACKUP TX_TRUST_EXISTED || fail_staging "could not snapshot $CHECK_TRUST" + TX_CONFIG_STAGE=$(mktemp "$CONFIG_DIR/.dash.json.stage.XXXXXX") || fail_staging "could not stage $CONFIG" + TX_PLIST_STAGE=$(mktemp "$HOME/Library/LaunchAgents/.$label.stage.XXXXXX") || fail_staging "could not stage $plist_path" + write_config_file "$TX_CONFIG_STAGE" "$port" "$serve_port" "$read_only" "${captains[@]}" || fail_staging "could not stage $CONFIG" + render_plist "$label" "$node_path" "$STATE" > "$TX_PLIST_STAGE" || fail_staging "could not stage $plist_path" + TX_ACTIVE=true + trap '[ "$TX_ACTIVE" != true ] || rollback_install' EXIT + mv -f -- "$TX_CONFIG_STAGE" "$CONFIG" || fail_install "could not activate $CONFIG" + TX_CONFIG_STAGE="" + mv -f -- "$TX_PLIST_STAGE" "$plist_path" || fail_install "could not activate $plist_path" + TX_PLIST_STAGE="" + launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$plist_path" || fail_install "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" >/dev/null 2>&1 || fail_install "launchctl kickstart failed for $label" + + # 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 \ + || fail_install "tailscale serve refused the replacement mapping" + TX_NEW_MAPPING=true + if ! assert_no_funnel "$serve_port"; then + fail_install "could not verify tailnet-only exposure for port $serve_port" + fi + if [ -n "$previous_serve_port" ] && [ "$previous_serve_port" != "$serve_port" ]; then + if ! disable_serve_port "$previous_serve_port"; then + fail_install "could not remove the previous dashboard mapping for port $previous_serve_port" + fi + fi + if [ "$read_only" = true ]; then + unregister_check + else + write_check + fi + dnsname=$(tailscale_self_dnsname) || fail_install "could not resolve this machine's tailnet name" + TX_ACTIVE=false + cleanup_install_transaction + trap - EXIT + + 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="" configured_port configured_target 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" + configured_port=$(configured_serve_port) || err "could not read the configured dashboard serve port" + configured_target=$(configured_mapping_target) || err "could not read the configured dashboard mapping target" + [ -n "$serve_port" ] || serve_port=${configured_port:-$DEFAULT_SERVE_PORT} + label=$(home_label) + plist_path="$HOME/Library/LaunchAgents/$label.plist" + if command -v tailscale >/dev/null 2>&1; then + disable_owned_serve_port "$serve_port" "$configured_target" || err "could not inspect or remove the dashboard mapping for port $serve_port" + if [ -n "$configured_port" ] && [ "$configured_port" != "$serve_port" ]; then + disable_owned_serve_port "$configured_port" "$configured_target" || err "could not inspect or remove the configured dashboard mapping for port $configured_port" + fi + fi + launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true + rm -f "$plist_path" + unregister_check + 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 ;; + unregister-check) shift; unregister_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..d3a8b7a9ff6 --- /dev/null +++ b/bin/fm-dash-serve.mjs @@ -0,0 +1,1385 @@ +#!/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, calls only the read-mostly capacity + * producer and quota probe, and mutates only state/dash-inbox/ plus the + * producer-owned dashboard and private refs sidecar. 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. + * The only free text accepted anywhere is bounded captain-authored content: an + * idea suggestion or decision custom answer delivered to firstmate as data, + * never interpreted or executed by this service. 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 { createHash, 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 QUOTA_AXI = process.env.FM_DASH_QUOTA_AXI || "quota-axi"; +const REFRESH_TIMEOUT_MS = 180000; +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 +// (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, "serve_port": 8443, + "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 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. Browser POSTs must +also be same-origin. +`); + 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]; +} + +function decisionRef(entry) { + const parts = entry.value.split("/"); + if (parts[0] !== "decision") return null; + if (parts.length >= 4) return { home: parts[1], origin: parts[2], key: parts.slice(3).join("/") }; + if (parts.length === 3) return { home: parts[1], origin: null, key: parts[2] }; + return { home: "main", origin: null, key: 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, origin, key) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(key)) return null; + if (origin !== null && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(origin)) return null; + const root = decisionHome(home); + if (!root) return null; + const file = origin === null + ? path.join(root, "data", "decisions", `${key}.md`) + : path.join(root, "data", origin, "decisions", `${key}.md`); + const text = readText(file, 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 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(); + } + 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) { + 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 decision = decisionRef(entry); + const id = decision ? `${decision.origin ? `${decision.origin}/` : ""}${decision.key}` : entry.value.slice(separator + 1); + display[ref] = owner === "decision" + ? { t: "decision", label: id } + : { t: "work", label: id, owner }; + } + } + return display; +} + +function assembleDetail(ref) { + 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("/"); + const owner = entry.value.slice(0, separator); + const decision = decisionRef(entry); + const id = decision ? decision.key : entry.value.slice(separator + 1); + const holdId = decision?.origin ? `${decision.origin}-decision-${decision.key}` : id; + const backlogItem = parseBacklog().find((item) => item.id === holdId) || null; + + if (owner === "decision") { + const document = decisionDocument(decision.home, decision.origin, decision.key); + return { + type: "decision", + ref, + id, + decision_home: decision.home, + decision_origin: decision.origin, + decision_identity: `${decision.home}/${decision.origin || ""}/${decision.key}`, + title: document?.title || backlogItem?.title || id, + description: document?.context || null, + options: document?.options || [], + recent: statusTail(decision.origin || id), + note: document ? null : "This legacy decision has no structured options document; 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; +} + +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) }); + 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); +} + +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" }; + 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 = inlineScriptJson({ + dispatchable, + pending, + generated, + readOnly: readOnly === true, + refs: extras?.refs || {}, + ideas: extras?.ideas || [], + usage: extras?.usage || { status: "unavailable", providers: [] }, + degraded: extras?.degraded === true, + }); + 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 === "POST" && !sameOriginPost(req)) { + sendJson(res, 403, { status: "forbidden", error: "cross-origin browser posts are refused" }); + 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(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 authoritativeStates = (dashboard.html.match(/Authoritative current state:/g) || []).length; + 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) : {}, + ideas: parseIdeas().map((idea) => ({ id: idea.id, title: idea.title })), + usage, + degraded, + }); + 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 customAnswer = typeof body.answer === "string" ? body.answer.trim() : ""; + const hasOption = Number.isInteger(body.option) + && body.option >= 0 + && body.option < detail.options.length; + if (!hasOption && (!customAnswer || customAnswer.length > 2000 || detail.options.length === 0)) { + sendJson(res, 400, { status: "refused", error: "the chosen option is not on the decision's record" }); + return; + } + const option = hasOption ? detail.options[body.option] : null; + const pending = pendingRecords(); + if (pending.some((record) => record.kind === "decision" && record.decision_identity === detail.decision_identity)) { + 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, + decision_home: detail.decision_home, + decision_origin: detail.decision_origin, + decision_identity: detail.decision_identity, + option_text: option?.text || null, + custom_answer: option ? null : customAnswer, + requested_by: requesterLogin(req), + requested_at: new Date().toISOString(), + prompt: option + ? `Captain approved decision ${detail.id} for ${detail.decision_origin || "legacy"} in ${detail.decision_home}: choose "${option.text}". Route it through the normal decision lifecycle; a destructive or irreversible consequence still needs chat confirmation.` + : `Captain answered decision ${detail.id} for ${detail.decision_origin || "legacy"} in ${detail.decision_home}: ${customAnswer}. 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" && typeof body.suggestion === "string" ? body.suggestion.trim() : null; + if (verdict === "suggest" && (!suggestion || suggestion.length > 2000)) { + sendJson(res, 400, { status: "refused", error: "suggestions need text" }); + return; + } + const pending = pendingRecords(); + const priorVerdict = verdict === "suggest" + ? null + : pending.find((record) => record.kind === "idea" && record.idea === idea.id && record.verdict !== "suggest"); + if (priorVerdict?.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); + if (priorVerdict) removePendingIfUnchanged(priorVerdict); + log(`queued idea ${idea.id} ${verdict} as ${name} for ${record.requested_by}`); + sendJson(res, 200, { status: priorVerdict ? "replaced" : "queued", pending: pendingRecords().length }); + 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/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index e8865091aa5..b401cd958c3 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -19,7 +19,7 @@ # Usage: # fm-decision-hold.sh id # fm-decision-hold.sh hold \ -# --title --reason <reason> [--repo <repo>] +# --title <title> --reason <reason> --options-file <path> [--repo <repo>] # fm-decision-hold.sh complete <origin-id> (--none | <decision-key>...) # fm-decision-hold.sh verify <origin-id> # fm-decision-hold.sh resolve <origin-id> <decision-key> \ @@ -124,6 +124,48 @@ origin_exists_here() { # <origin-id> task_show "$1" >/dev/null 2>&1 } +validate_options_file() { # <path> <title> + local file=$1 title=$2 bytes document_title + [ -f "$file" ] || fail "options file is not a regular file: $file" + bytes=$(wc -c < "$file" | tr -d '[:space:]') + [ "$bytes" -gt 0 ] && [ "$bytes" -le 131072 ] || fail "options file must contain 1 to 131072 bytes" + document_title=$(sed -n 's/^# //p' "$file" | head -1) + [ "$document_title" = "$title" ] || fail "options file title must match --title" + awk ' + /^# / && !title { title = 1; next } + /^## Options[[:space:]]*$/ { options = 1; next } + title && !options && /[^[:space:]]/ { context = 1 } + options && /^- (\[recommended\] )?.+[[:space:]]-[[:space:]].+/ { choices += 1 } + END { exit !(title && context && options && choices > 0) } + ' "$file" || fail "options file must include context and at least one option with its impact" +} + +publish_options_file() { # <origin> <key> <source> + local origin=$1 key=$2 source=$3 directory target tmp + directory="$DATA/$origin/decisions" + target="$directory/$key.md" + if [ -e "$target" ]; then + cmp -s "$source" "$target" || fail "decision options already exist with different content: $target" + return 0 + fi + mkdir -p "$directory" + chmod 700 "$directory" 2>/dev/null || true + tmp=$(mktemp "$directory/.$key.XXXXXX") || fail "could not stage decision options" + if ! cp "$source" "$tmp" || ! chmod 600 "$tmp"; then + rm -f "$tmp" + fail "could not stage decision options: $target" + fi + if mv -n "$tmp" "$target" && [ ! -e "$tmp" ] && [ -e "$target" ]; then + return 0 + fi + if [ -e "$target" ] && cmp -s "$source" "$target"; then + rm -f "$tmp" + return 0 + fi + rm -f "$tmp" + fail "could not publish decision options: $target" +} + list_has_key() { # <comma-list> <key> case ",$1," in *",$2,"*) return 0 ;; @@ -236,7 +278,7 @@ command_id() { } command_hold() { - local origin=${1:-} key=${2:-} title='' reason='' repo='' id show state kind existing_title body + local origin=${1:-} key=${2:-} title='' reason='' repo='' options_file='' id show state kind existing_title body [ "$#" -ge 2 ] || { usage >&2; exit 2; } shift 2 while [ "$#" -gt 0 ]; do @@ -244,6 +286,7 @@ command_hold() { --title) shift; title=${1:-} ;; --reason) shift; reason=${1:-} ;; --repo) shift; repo=${1:-} ;; + --options-file) shift; options_file=${1:-} ;; *) usage >&2; exit 2 ;; esac shift @@ -252,6 +295,7 @@ command_hold() { validate_slug decision-key "$key" validate_one_line title "$title" validate_one_line reason "$reason" + [ -z "$options_file" ] || validate_options_file "$options_file" "$title" case "$reason" in *'('*|*')'*) fail "reason must not contain parentheses (tasks-axi hold contract)" ;; esac require_tasks_axi origin_exists_here "$origin" || fail "origin $origin is not owned by the active home $FM_HOME" @@ -263,7 +307,10 @@ command_hold() { [ "$state" != "done" ] || fail "captain decision $id is already durably resolved; use a new decision key for a new decision" [ "$kind" = captain ] || fail "existing backlog identity $id is not kind captain" [ "$existing_title" = "$title" ] || fail "existing captain hold $id has a different title" + [ -z "$options_file" ] || publish_options_file "$origin" "$key" "$options_file" else + [ -n "$options_file" ] || fail "new captain hold $id requires --options-file" + publish_options_file "$origin" "$key" "$options_file" if [ -z "$repo" ] && [ -f "$STATE/$origin.meta" ]; then repo=$(meta_value "$STATE/$origin.meta" project) repo=${repo%/} diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index a6c1306e900..8eff211aadb 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/<id>.meta, sorted by id. @@ -294,6 +296,13 @@ backlog_json() { # [<backlog-path>] - 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:]])(?<v>local main)$"); def completion($rest): @@ -317,6 +326,7 @@ backlog_json() { # [<backlog-path>] - 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() { # [<backlog-path>] - 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:]]*(?<v>[^[: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"), @@ -572,7 +583,13 @@ secondmate_home_summary_json() { # <backlog-json> <tasks-json> | ([ $backlog.records[]? | select(.state == "queued" and .structured) ]) as $queued_all | ([ $queued_all[] | select(.kind == "captain" and .hold_kind == "captain" and .hold_reason != null) - | {id,key:.id,verb:"captain-hold",summary:(.title | trunc(160)), + | . as $hold + | {id,origin:(([($hold.body_excerpt // "" | split("\n")[]) + | select(startswith("Origin: ")) + | ltrimstr("Origin: ")] | first) // $hold.id), + key:(([($hold.body_excerpt // "" | split("\n")[]) + | select(startswith("Decision key: ")) + | ltrimstr("Decision key: ")] | first) // $hold.id),verb:"captain-hold",summary:(.title | trunc(160)), reason:(.hold_reason | trunc(160)),source:"backlog"} ]) as $captain_holds_all | ([ $backlog.records[]? | select(.state == "done" and .structured and .kind != "captain") | {id:(.id | trunc(120)),title:(.title | trunc(120)), @@ -611,7 +628,9 @@ secondmate_home_summary_json() { # <backlog-json> <tasks-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")) @@ -621,7 +640,8 @@ secondmate_home_summary_json() { # <backlog-json> <tasks-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, + 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 and ($unstructured_current | length) == 0 @@ -657,6 +677,7 @@ secondmate_home_summary_json() { # <backlog-json> <tasks-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), @@ -989,7 +1010,10 @@ parent_evidence_reconciliation_json() { # <summary-json> <activities-json> <dec "active_children") elif $e.verb == "paused" then ([ $summary.holds[] - | select(if ($e.key | keyed) then .id == $e.key or .blocked_by == $e.key else true end) + | select(if ($e.key | keyed) + then .id == $e.key + or (((.blocked_by_all // ((.blocked_by // "") | split(","))) | index($e.key)) != null) + else true end) | {surface:"holds",id,key:(.blocked_by // null),verb:"paused"}]) as $matches | result($e; $matches; $summary.counts.holds == ($summary.holds | length); @@ -1012,7 +1036,10 @@ parent_evidence_reconciliation_json() { # <summary-json> <activities-json> <dec | select(if ($e.key | keyed) then .key == $e.key or .id == $e.key else true end) | {surface:"decisions_open",id,key,verb}] + [ $summary.holds[] - | select(if ($e.key | keyed) then .id == $e.key or .blocked_by == $e.key else true end) + | select(if ($e.key | keyed) + then .id == $e.key + or (((.blocked_by_all // ((.blocked_by // "") | split(","))) | index($e.key)) != null) + else true end) | {surface:"holds",id,key:(.blocked_by // null),verb:"blocked"}]) as $matches | result($e; $matches; ($summary.counts.decisions_open == ($summary.decisions_open | length) diff --git a/docs/configuration.md b/docs/configuration.md index 17f1e16091c..63b2a42f2c5 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), `serve_port` (active tailnet HTTPS port, default 8443), `captain_logins` (the tailnet logins allowed to reach the service), `read_only` (serve, refresh, and auto-render without command dispatch), 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..3a9c652b358 --- /dev/null +++ b/docs/dashboard-service.md @@ -0,0 +1,121 @@ +# 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: `docs/configuration.md` (config schema), `bin/fm-dash-serve.mjs --help` (routes and 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 service also reruns the producer automatically on the configured interval. +- 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 with `data/<origin>/decisions/<key>.md` records show each recorded option with its impact, a per-option Approve button, and a bounded custom-answer control. +- 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. +- 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. + +`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 prints each record before archiving it under `state/dash-inbox/archive/`, so an interruption may re-surface a command but cannot silently lose one. + +## Inbound command channel + +Button clicks never execute anything. +The design keeps the web process outside every fleet-mutation path: + +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 record by its kind under the capacity skill's dashboard-command 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 dashboard command 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. +- Consumption is at-least-once across an interruption between delivery and archive, so Firstmate must apply normal idempotency checks when handling a re-surfaced record. + +## 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`). +- Authenticated browser POSTs must also carry a matching Origin and `Sec-Fetch-Site: same-origin`; same-machine clients without Origin or `Sec-Fetch-Site` headers remain allowed because a local process is already inside the filesystem trust boundary. +- 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 answer must name a decision in the producer's current-generation refs sidecar, and its integer option index must match the options document the server just parsed; a custom answer is accepted only for a decision with that document and is bounded to 2,000 characters. +- Decision answers persist and deduplicate against an owner-qualified home, origin, and key so equal keys from different origins or homes remain independently routable after refs regenerate. +- 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 bounded captain-authored content: an idea-suggestion note or decision custom answer authenticated as above and delivered as data for Firstmate, never interpreted or executed by the service. +- A newer approve or deny verdict is published under a fresh immutable inbox name before the unchanged older pending verdict is removed, while suggestions remain additive. +- 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. + +## Decision options document + +The home that owns a decision writes `data/<origin>/decisions/<key>.md`, where `<origin>` is the originating work ID and `<key>` is its stable decision key. +The refs sidecar carries `decision/<home>/<origin>/<key>`, preserving the same durable identity even when the key is not a backlog item ID. +The document is producer-owned decision data and uses this format: + +```markdown +# Choose the rollout policy + +Explain the decision context and constraints here. + +## Options + +- [recommended] Conservative rollout - Slower delivery with the lowest regression risk. +- Fast rollout - Reaches everyone this week with higher regression risk. +``` + +The first level-one heading is the title, prose before `## Options` is context, and each option is one bullet with an optional `[recommended]` marker followed by ` - ` and its impact. +Indented continuation lines extend the preceding impact. +The service accepts at most 20 options and applies bounded text limits while rendering. +Firstmate and workers author this document when filing a new decision, and `bin/fm-decision-hold.sh hold --options-file` publishes it under the originating work in the deciding home through the decision-hold lifecycle. +Legacy decisions without the document remain visible but route the captain to answer in chat. +In v1, a secondmate-owned work item deliberately shows only a limited ownership note because the main service does not ingest rich task records across home boundaries. + +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. +Unreadable, malformed, or schema-unexpected `tailscale serve status --json` output is a verification failure and triggers the same teardown. + +## 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, command dispatch refuses, and any watcher registration from a prior writable install is removed. + +Install is idempotent and prints the stable URL, `https://<machine>.<tailnet>.ts.net:8443/`. +Changing `--serve-port` stages the full replacement, verifies it, removes the previous mapping, and restores the prior config, launchd state, watcher registration, and observed live mapping state if any step fails. +Install refuses a requested serve port that is already owned by another mapping. +Uninstall removes only serve mappings whose live shape is a single root proxy to this dashboard's configured loopback target, plus the launchd agent, watcher check, and watcher trust registration, while keeping `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; cross-origin browser posts were refused; an authorized dispatch wrote one mode-0600 `fm-dash-command.v1` record; decision documents, custom answers, newest-verdict replacement, usage filtering, and blocked chains were exercised; `/api/refresh` regenerated the dashboard through the real producer; claim delivered before archive and safely replayed after a simulated archive failure; 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/decision-hold-lifecycle.md b/docs/decision-hold-lifecycle.md index e29fe1f3d28..9e8612a8ad2 100644 --- a/docs/decision-hold-lifecycle.md +++ b/docs/decision-hold-lifecycle.md @@ -10,6 +10,8 @@ The command runs tasks-axi in the active `FM_HOME`, so the existing backlog rema It never reads report bodies, review artifacts, terminal output, or chat. The `hold` subcommand maps an originating work id and stable decision key to `<origin-id>-decision-<decision-key>`. +For a new hold, it requires `--options-file`, validates the dashboard format owned by `docs/dashboard-service.md`, and atomically publishes it as `data/<origin>/decisions/<key>.md` in the active home before creating the backlog item. +An already existing legacy hold can still be retried without an options document and remains on the dashboard's answer-in-chat fallback. It creates a kind `captain` backlog item when absent and invokes `tasks-axi hold <id> --reason <reason> --kind captain` on every retry. It rejects an identity collision, a changed title, and attempts to reopen an already resolved identity. 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-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 1ad0e41e190..6f7c99600cc 100755 --- a/tests/fm-capacity.test.sh +++ b/tests/fm-capacity.test.sh @@ -113,7 +113,7 @@ test_classification_priority_overlap_and_idle_semantics() { and .readiness.available == true and (.pipeline.ready | length) == 2 and (.readiness.conservative_overlap_gates | length) == 1 - and (.readiness.explicit_gates | any(.reason == "dependency or structured hold")) + and (.readiness.explicit_gates | any(.reason == "Blocked by item-01")) and (.readiness.explicit_gates | any(.reason == "time gate until 2026-08-01")) and (.readiness.definition_gaps | any(.gaps | index("project unresolved"))) and (.lanes.persistent_secondmates | any(.utilization == "idle with grounded ready in-scope work")) @@ -542,25 +542,37 @@ 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"} ] | .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","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-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":"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":1,"holds":0,"queued":2} + | .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) == 5 + (.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) == 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" @@ -913,6 +925,90 @@ 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."}, + {"order":6,"state":"queued","structured":true,"id":"multi-dependent","title":"Publish after two blockers","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"missing-root","blocked_by_all":["asker","missing-root"],"body_excerpt":"Acceptance criteria: both blockers clear."}, + {"order":7,"state":"queued","structured":true,"id":"deep-dependent","title":"Publish after a deep chain","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"deep-1","body_excerpt":"Acceptance criteria: the full chain clears."}, + {"order":8,"state":"queued","structured":true,"id":"deep-1","title":"Deep dependency one","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"deep-2","body_excerpt":"Acceptance criteria: continue."}, + {"order":9,"state":"queued","structured":true,"id":"deep-2","title":"Deep dependency two","repo":"gamma","project_resolved":true,"kind":"ship","blocked_by":"deep-3","body_excerpt":"Acceptance criteria: continue."}, + {"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":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."}, + {"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":"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 = [] + | .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) == 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 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 + 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 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") + 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 +1029,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 new file mode 100755 index 00000000000..6a88b137ce2 --- /dev/null +++ b/tests/fm-dash.test.sh @@ -0,0 +1,908 @@ +#!/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) +QUOTA_STUB="$TMP_ROOT/quota-axi" + +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" <<EOF +{ + "schema": "fm-fleet-snapshot.v1", + "generated": "2026-07-28T10:00:00Z", + "fm_home": "$home", + "roots": {"fm_root":"$ROOT","state":"$home/state","data":"$home/data","config":"$home/config","projects":"$home/projects"}, + "backlog": { + "path": "$home/data/backlog.md", + "present": true, + "records": [ + {"order":0,"state":"in_flight","structured":true,"id":"active-task","title":"Run the active rollout","repo":"alpha","project_resolved":true,"kind":"ship","body_excerpt":"Acceptance criteria: rollout remains observable."}, + {"order":1,"state":"queued","structured":true,"id":"ready-safe","title":"Ship the gamma feature","repo":"gamma","project_resolved":true,"kind":"ship","body_excerpt":"Acceptance criteria: bounded regression tests pass."}, + {"order":2,"state":"queued","structured":true,"id":"captain-choice","title":"Choose the rollout policy","repo":"alpha","project_resolved":true,"kind":"captain","hold_kind":"captain","hold_reason":"pick conservative or fast rollout","body_excerpt":"Origin: active-task\nDecision key: rollout-policy\nState: awaiting captain decision."}, + {"order":3,"state":"queued","structured":true,"id":"captain-choice-two","title":"Choose the secondary rollout policy","repo":"alpha","project_resolved":true,"kind":"captain","hold_kind":"captain","hold_reason":"pick a secondary rollout","body_excerpt":"Origin: secondary-task\nDecision key: rollout-policy\nState: awaiting captain decision."}, + {"order":4,"state":"queued","structured":true,"id":"blocked-child","title":"Ship after rollout choice","repo":"alpha","project_resolved":true,"kind":"ship","blocked_by":"captain-choice","body_excerpt":"Acceptance criteria: follows the selected rollout."} + ] + }, + "tasks": [ + {"id":"active-task","kind":"ship","project":"alpha","current_state":{"state":"working","source":"pane","detail":"running rollout"},"endpoint":{"exists":true},"hints":{"open_decisions":[{"key":"runtime-policy"}]},"pr":{"url":null},"paths":{"report":{"present":false}},"backlog":{"id":"active-task","title":"Run the active rollout","repo":"alpha","project_resolved":true,"kind":"ship"}} + ], + "scout_reports": [], + "secondmate_current": {"registry":{"available":true,"complete":true,"records":[]},"records":[],"total":0,"shown":0,"truncated":0}, + "secondmate_landed": {"records":[],"truncated":[],"unreadable":[]} +} +EOF + cat > "$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 +- [ ] active-task - Run the active rollout (repo: alpha) (kind: ship) + Acceptance criteria: rollout remains observable. + +## 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 +- [ ] captain-choice-two - Choose the secondary rollout policy (repo: alpha) (kind: captain) + Pick the secondary rollout pace independently. +- [ ] blocked-child - Ship after rollout choice (repo: alpha) (kind: ship) (blocked-by: captain-choice) + Acceptance criteria: follows the selected rollout. + +## Done +EOF + mkdir -p "$home/data/ready-safe" "$home/data/ideas/pitches" "$home/data/active-task/decisions" "$home/data/secondary-task/decisions" + 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" + cat > "$home/data/active-task/decisions/rollout-policy.md" <<'EOF' +# Choose the rollout policy + +Pick the alpha rollout pace before dependent work starts. + +## Options + +- [recommended] Conservative rollout - Slower delivery with the lowest regression risk. +- Fast rollout - Reaches everyone this week with higher regression risk. +EOF + cat > "$home/data/active-task/decisions/runtime-policy.md" <<'EOF' +# Choose the runtime policy + +Choose how the active rollout should continue. + +## Options + +- [recommended] Conservative rollout - Slower delivery with the lowest regression risk. +- Fast rollout - Reaches everyone this week with higher regression risk. +EOF + cat > "$home/data/secondary-task/decisions/rollout-policy.md" <<'EOF' +# Choose the secondary rollout policy + +Choose the independent secondary rollout pace. + +## Options + +- [recommended] Staged secondary rollout - Keeps secondary users isolated during validation. +- Immediate secondary rollout - Moves faster with broader secondary exposure. +EOF +} + +write_config() { + local home=$1 port=$2 + cat > "$home/config/dash.json" <<EOF +{"port": $port, "captain_logins": ["$CAPTAIN"]} +EOF +} + +pick_port() { + node -e 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>{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" FM_DASH_QUOTA_AXI="$QUOTA_STUB" 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 <method> <url> [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" +cat > "$QUOTA_STUB" <<'EOF' +#!/bin/sh +printf '%s\n' '{"schemaVersion":2,"providers":[{"provider":"claude","label":"Claude","windows":[{"label":"session","percentUsed":42,"resetsAt":"2026-07-29T10:00:00Z"}]},{"provider":"codex","label":"Codex","windows":[{"label":"week","percentUsed":61,"resetsAt":"2026-08-01T10:00:00Z"}]},{"provider":"grok","label":"Grok","windows":[{"label":"credits","percentUsed":7,"resetsAt":"2026-07-30T10:00:00Z"}]},{"provider":"cursor","label":"Cursor","windows":[{"label":"month","percentUsed":99,"resetsAt":"2026-08-28T10:00:00Z"}]}]}' +EOF +chmod 700 "$QUOTA_STUB" +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_browser_posts_require_same_origin() { + REQ_STATUS=$(curl -s -o "$TMP_ROOT/resp.body" -w '%{http_code}' -X POST \ + -H "Tailscale-User-Login: $CAPTAIN" \ + -H 'Origin: https://evil.example' \ + -H 'Sec-Fetch-Site: cross-site' \ + -H 'content-type: application/json' \ + -d '{"id":"CAP-06"}' \ + "http://127.0.0.1:$PORT/api/dispatch") + [ "$REQ_STATUS" = 403 ] || fail "cross-site browser dispatch was not refused (got $REQ_STATUS)" + REQ_STATUS=$(curl -s -o "$TMP_ROOT/resp.body" -w '%{http_code}' -X POST \ + -H "Tailscale-User-Login: $CAPTAIN" \ + -H 'Sec-Fetch-Site: same-origin' \ + -H 'content-type: application/json' \ + -d '{"id":"CAP-06"}' \ + "http://127.0.0.1:$PORT/api/dispatch") + [ "$REQ_STATUS" = 403 ] || fail "browser dispatch without Origin was not refused (got $REQ_STATUS)" + REQ_STATUS=$(curl -s -o "$TMP_ROOT/resp.body" -w '%{http_code}' -X POST \ + -H "Tailscale-User-Login: $CAPTAIN" \ + -H "Origin: http://127.0.0.1:$PORT" \ + -H 'Sec-Fetch-Site: same-origin' \ + -H 'content-type: application/json' \ + -d '{"id":"CAP-02"}' \ + "http://127.0.0.1:$PORT/api/dispatch") + [ "$REQ_STATUS" = 409 ] || fail "same-origin browser dispatch did not reach normal validation (got $REQ_STATUS)" + [ -z "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*.json' 2>/dev/null)" ] \ + || fail "refused browser dispatch wrote an inbox record" + pass "authenticated browser posts require a matching same origin" +} + +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" + assert_contains "$RESP" 'postJson({ id })' "served prompt actions still require copy-paste" + assert_contains "$RESP" 'Blocked by item-' "served dashboard does not render the blocked dependency chain" + pass "served page is the producer dashboard wearing the injected interactive layer" +} + +test_subscription_usage_panel() { + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + assert_contains "$RESP" 'Subscription usage' "served page lacks the subscription usage panel" + assert_contains "$RESP" '"label":"Claude"' "usage panel lacks Claude" + assert_contains "$RESP" '"label":"Codex"' "usage panel lacks Codex" + assert_contains "$RESP" '"label":"Grok"' "usage panel lacks Grok" + assert_not_contains "$RESP" '"label":"Cursor"' "usage panel included a forbidden provider" + assert_contains "$RESP" 'percentUsed":42' "usage panel lacks percent-used data" + assert_contains "$RESP" 'toLocaleString()' "usage reset time is not rendered in the captain local timezone" + assert_contains "$RESP" 'resets in ' "usage panel lacks reset-distance rendering" + stop_server + rm -f "$QUOTA_STUB" + start_server "$HOME_DIR" "$PORT" + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + assert_contains "$RESP" '"usage":{"status":"unavailable","providers":[]}' "missing quota-axi did not degrade gracefully" + pass "subscription usage is bounded to Claude, Codex, and Grok" +} + +test_inline_config_is_script_safe() { + cat >> "$HOME_DIR/data/ideas/idea-backlog.md" <<'EOF' + +## IDEA-03 - </script><script>globalThis.fmdashPwned=true</script>& +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" '</script><script>globalThis.fmdashPwned=true</script>' "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 <kind-owner-prefix> e.g. "main/ready-safe" or "decision/main/active-task/runtime-policy" + 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 decision_ref same_key_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" + decision_ref=$(ref_for "decision/main/active-task/rollout-policy") || fail "captain hold ref did not preserve its filed decision identity" + req GET "http://127.0.0.1:$PORT/api/detail?ref=$decision_ref" "$CAPTAIN" + assert_contains "$RESP" 'Conservative rollout' "filed captain hold did not resolve its options document" + same_key_ref=$(ref_for "decision/main/secondary-task/rollout-policy") || fail "second same-home decision did not retain its origin-qualified ref" + [ "$same_key_ref" != "$decision_ref" ] || fail "same-key decisions from distinct origins shared one opaque ref" + req GET "http://127.0.0.1:$PORT/api/detail?ref=$same_key_ref" "$CAPTAIN" + assert_contains "$RESP" 'Staged secondary rollout' "second same-key decision did not resolve its own options document" + 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/main/active-task/runtime-policy") || fail "refs sidecar does not map an origin-qualified non-backlog decision key" + 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" + assert_contains "$RESP" '"recommended":true' "decision detail lacks its recommended marker" + 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\":\"length\"}" + [ "$REQ_STATUS" = 400 ] || fail "an array property was accepted as an option index (got $REQ_STATUS)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$ref\",\"option\":\"constructor\"}" + [ "$REQ_STATUS" = 400 ] || fail "an inherited property was accepted as an option index (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" 'runtime-policy' "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 + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$ref\",\"answer\":\"Use a 10% canary for 48 hours\"}" + [ "$REQ_STATUS" = 200 ] || fail "custom decision answer failed (got $REQ_STATUS: $RESP)" + record=$(cat "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name "*$ref.json" | head -1)") + assert_contains "$record" 'Use a 10% canary for 48 hours' "decision record lacks the custom answer" + find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name "*$ref.json" -delete + mv "$HOME_DIR/data/active-task/decisions/runtime-policy.md" "$HOME_DIR/data/active-task/decisions/runtime-policy.md.off" + req GET "http://127.0.0.1:$PORT/api/detail?ref=$ref" "$CAPTAIN" + assert_contains "$RESP" 'legacy decision' "legacy decision does not route to captain chat" + assert_contains "$RESP" '"options":[]' "legacy backlog bullets were treated as structured decision options" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$ref\",\"answer\":\"unsafe fallback\"}" + [ "$REQ_STATUS" = 400 ] || fail "legacy decision accepted free text without an options document" + mv "$HOME_DIR/data/active-task/decisions/runtime-policy.md.off" "$HOME_DIR/data/active-task/decisions/runtime-policy.md" + pass "decision documents validate option picks and bounded custom answers" +} + +test_decision_answers_are_qualified_by_home_and_origin() { + local main_ref same_home_ref mate_ref files records file + mkdir -p "$HOME_DIR/data/origin-alpha/decisions" "$HOME_DIR/data/origin-beta/decisions" "$HOME_DIR/design/data/design-origin/decisions" + printf 'home=%s\n' "$HOME_DIR/design" > "$HOME_DIR/state/design.meta" + cp "$HOME_DIR/data/active-task/decisions/runtime-policy.md" "$HOME_DIR/data/origin-alpha/decisions/shared-policy.md" + cp "$HOME_DIR/data/active-task/decisions/rollout-policy.md" "$HOME_DIR/data/origin-beta/decisions/shared-policy.md" + cp "$HOME_DIR/data/active-task/decisions/runtime-policy.md" "$HOME_DIR/design/data/design-origin/decisions/shared-policy.md" + # JavaScript template literals are intentionally single-quoted for the shell. + # shellcheck disable=SC2016 + node -e ' + const fs = require("node:fs"); + const file = process.argv[1]; + const refs = JSON.parse(fs.readFileSync(file, "utf8")); + refs.refs["item-90"] = { kind: "item", value: "decision/main/origin-alpha/shared-policy" }; + refs.refs["item-91"] = { kind: "item", value: "decision/main/origin-beta/shared-policy" }; + refs.refs["item-92"] = { kind: "item", value: "decision/design/design-origin/shared-policy" }; + fs.writeFileSync(file, `${JSON.stringify(refs, null, 2)}\n`); + ' "$HOME_DIR/state/dash-refs.json" + main_ref=item-90 + same_home_ref=item-91 + mate_ref=item-92 + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$main_ref\",\"option\":0}" + [ "$REQ_STATUS" = 200 ] || fail "main-home decision answer failed (got $REQ_STATUS: $RESP)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$same_home_ref\",\"option\":0}" + [ "$REQ_STATUS" = 200 ] || fail "same-key same-home decision was wrongly coalesced (got $REQ_STATUS: $RESP)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" "{\"ref\":\"$mate_ref\",\"option\":0}" + [ "$REQ_STATUS" = 200 ] || fail "same-key secondmate decision was wrongly coalesced (got $REQ_STATUS: $RESP)" + files=$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*item-9[012].json') + [ "$(printf '%s\n' "$files" | grep -c .)" = 3 ] || fail "origin-qualified decision answers did not produce three records" + records='' + while IFS= read -r file; do + records="$records$(cat "$file")" + rm -f "$file" + done <<EOF +$files +EOF + assert_contains "$records" '"decision_identity": "main/origin-alpha/shared-policy"' "first main decision record lacks durable origin identity" + assert_contains "$records" '"decision_identity": "main/origin-beta/shared-policy"' "same-home decision record lacks distinct origin identity" + assert_contains "$records" '"decision_identity": "design/design-origin/shared-policy"' "secondmate decision record lacks durable owner identity" + pass "equal decision keys remain distinct across origins and homes" +} + +test_stale_refs_are_disabled() { + local ref + ref=$(ref_for "decision/main/active-task/runtime-policy") || fail "refs sidecar does not map the decision" + # JavaScript template literals are intentionally single-quoted for the shell. + # shellcheck disable=SC2016 + 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)" + # JavaScript template literals are intentionally single-quoted for the shell. + # shellcheck disable=SC2016 + 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 prior_name + 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" + prior_name=$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*IDEA-01.json' | head -1) + mkdir -p "$HOME_DIR/state/dash-inbox/archive" + cp "$prior_name" "$HOME_DIR/state/dash-inbox/archive/$(basename "$prior_name")" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"idea":"IDEA-01","verdict":"deny"}' + assert_contains "$RESP" '"replaced"' "newest contradictory idea verdict did not replace the pending verdict" + [ "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*IDEA-01.json' | wc -l | tr -d ' ')" = 1 ] \ + || fail "verdict replacement left contradictory pending records" + record=$(cat "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*IDEA-01.json' | head -1)") + assert_contains "$record" '"verdict": "deny"' "verdict replacement did not retain the newest choice" + [ "$(basename "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*IDEA-01.json' | head -1)")" != "$(basename "$prior_name")" ] \ + || fail "verdict replacement reused a claimable inbox identity" + 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":"include landed work"}' + [ "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*IDEA-02.json' | wc -l | tr -d ' ')" = 2 ] \ + || fail "additive idea suggestions were coalesced" + 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 stub_bin + 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" + stub_bin="$TMP_ROOT/failing-mv" + mkdir -p "$stub_bin" + cat > "$stub_bin/mv" <<'EOF' +#!/bin/sh +exit 1 +EOF + chmod 700 "$stub_bin/mv" + out=$(PATH="$stub_bin:$PATH" FM_HOME="$HOME_DIR" "$INBOX_SH" claim) + assert_contains "$out" 'Approve CAP-06' "claim did not deliver before attempting archive" + assert_contains "$out" 'delivered: 1' "failed archive did not report the delivered command" + assert_contains "$out" 'archived: 0' "failed archive did not report its archive count" + assert_contains "$out" 'idempotency checks' "failed archive omitted the replay handling reminder" + [ -n "$(find "$HOME_DIR/state/dash-inbox" -maxdepth 1 -name '*CAP-06.json' 2>/dev/null)" ] \ + || fail "failed archive silently removed the delivered command" + out=$(FM_HOME="$HOME_DIR" "$INBOX_SH" claim) + assert_contains "$out" 'delivered: 1' "claim did not report the delivered command" + assert_contains "$out" 'archived: 1' "claim did not report the archived 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 delivers before archive and safely permits replay" +} + +test_read_only_mode_fails_safe() { + stop_server + cat > "$HOME_DIR/config/dash.json" <<EOF +{"port": $PORT, "captain_logins": ["$CAPTAIN"], "read_only": true, "auto_refresh_seconds": 0} +EOF + start_server "$HOME_DIR" "$PORT" + req GET "http://127.0.0.1:$PORT/" "$CAPTAIN" + [ "$REQ_STATUS" = 200 ] || fail "read-only page read failed (got $REQ_STATUS)" + assert_contains "$RESP" '"readOnly":true' "read-only page does not declare read-only mode" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"id":"CAP-06"}' + [ "$REQ_STATUS" = 403 ] || fail "read-only dispatch was not refused (got $REQ_STATUS)" + req POST "http://127.0.0.1:$PORT/api/dispatch" "$CAPTAIN" '{"idea":"IDEA-01","verdict":"approve"}' + [ "$REQ_STATUS" = 403 ] || fail "read-only idea verdict was not refused (got $REQ_STATUS)" + stop_server + write_config "$HOME_DIR" "$PORT" + pass "read-only mode serves the page and refuses every mutation" +} + +test_check_shim_wakes_only_when_pending() { + local out pending_record + FM_HOME="$HOME_DIR" "$INSTALL_SH" write-check >/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" + pending_record="$HOME_DIR/state/dash-inbox/1-test-CAP-06.json" + printf '{"schema":"fm-dash-command.v1","id":"CAP-06","prompt":"x"}\n' > "$pending_record" + 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" + FM_HOME="$HOME_DIR" "$INSTALL_SH" unregister-check >/dev/null || fail "unregister-check failed" + [ ! -e "$HOME_DIR/state/fm-dash.check.sh" ] || fail "unregister-check left the watcher check installed" + [ ! -e "$HOME_DIR/state/fm-dash.check-trust" ] || fail "unregister-check left the watcher trust registration installed" + [ -f "$pending_record" ] || fail "unregister-check removed a pending inbox record" + rm -f "$pending_record" + pass "watcher registration follows writable mode without deleting pending commands" +} + +test_installer_plist_and_funnel_stance() { + local escaped_home funnel_check 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" '<key>KeepAlive</key><true/>' "plist does not keep the service alive" + assert_contains "$plist" '<key>RunAtLoad</key><true/>' "plist does not start at load" + assert_contains "$plist" "<string>$HOME_DIR</string>" "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" + funnel_check=$(sed -n '/^assert_no_funnel()/,/^write_config_file()/p' "$INSTALL_SH") + assert_contains "$funnel_check" 'unsupported tailscale serve status schema' "Funnel verification does not reject unexpected schemas" + assert_contains "$funnel_check" 'could not verify Funnel state' "Funnel verification does not report unreadable status" + assert_contains "$funnel_check" 'process.exit(1)' "Funnel verification does not fail closed" + assert_contains "$(sed -n '/if ! assert_no_funnel/,/fi/p' "$INSTALL_SH")" 'fail_install' "failed Funnel verification does not enter transactional rollback" + # The single-quoted assertion is intentionally literal. + # shellcheck disable=SC2016 + assert_contains "$(sed -n '/^rollback_install()/,/^fail_install()/p' "$INSTALL_SH")" 'disable_serve_port "$TX_NEW_SERVE_PORT"' "transactional rollback does not tear down the replacement mapping" + assert_contains "$(sed -n '/^cmd_install()/,/^cmd_uninstall()/p' "$INSTALL_SH")" 'unregister_check' "read-only install does not unregister a prior writable watcher" + assert_contains "$(sed -n '/^cmd_uninstall()/,/^cmd_status()/p' "$INSTALL_SH")" 'unregister_check' "uninstall does not unregister the watcher" + 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" +} + +test_installer_tracks_custom_serve_port() { + local fake_bin install_home launch_home launchctl_log launchctl_state occupied_error prior_config real_mv tailscale_log tailscale_state uninstall_output + fake_bin="$TMP_ROOT/fake-bin" + install_home="$TMP_ROOT/install-home" + launch_home="$TMP_ROOT/launch-home" + launchctl_log="$TMP_ROOT/launchctl.log" + launchctl_state="$TMP_ROOT/launchctl.state" + prior_config="$TMP_ROOT/prior-dash.json" + real_mv=$(command -v mv) + tailscale_log="$TMP_ROOT/tailscale.log" + tailscale_state="$TMP_ROOT/tailscale.state" + mkdir -p "$fake_bin" "$install_home" "$launch_home" + cat > "$fake_bin/uname" <<'EOF' +#!/bin/sh +echo Darwin +EOF + cat > "$fake_bin/launchctl" <<'EOF' +#!/bin/sh +case "$1" in + print) + [ -f "$LAUNCHCTL_STATE" ] && [ "$(cat "$LAUNCHCTL_STATE")" = loaded ] + ;; + bootout) + : > "$LAUNCHCTL_STATE" + printf 'bootout\n' >> "$LAUNCHCTL_LOG" + ;; + bootstrap) + if [ -n "${LAUNCHCTL_FAIL_ONCE:-}" ] && [ ! -e "$LAUNCHCTL_FAIL_ONCE" ]; then + : > "$LAUNCHCTL_FAIL_ONCE" + printf 'bootstrap-failed\n' >> "$LAUNCHCTL_LOG" + exit 1 + fi + printf 'loaded\n' > "$LAUNCHCTL_STATE" + printf 'bootstrap\n' >> "$LAUNCHCTL_LOG" + ;; + kickstart) + printf 'kickstart\n' >> "$LAUNCHCTL_LOG" + ;; + *) exit 1 ;; +esac +EOF + cat > "$fake_bin/mv" <<'EOF' +#!/bin/sh +last="" +for arg in "$@"; do last=$arg; done +if [ -n "${MV_FAIL_DEST:-}" ] && [ "$last" = "$MV_FAIL_DEST" ] && [ ! -e "$MV_FAIL_MARKER" ]; then + : > "$MV_FAIL_MARKER" + exit 1 +fi +exec "$REAL_MV" "$@" +EOF + cat > "$fake_bin/tailscale" <<'EOF' +#!/bin/sh +if [ "$1" = status ] && [ "${2:-}" = --json ]; then + printf '%s\n' '{"Self":{"UserID":1,"DNSName":"dash.tail.ts.net."},"User":{"1":{"LoginName":"captain@example.com"}}}' +elif [ "$1" = serve ] && [ "${2:-}" = status ] && [ "${3:-}" = --json ]; then + if [ "${TAILSCALE_INVALID_SERVE_STATUS:-}" = 1 ]; then + printf '{\n' + exit 0 + fi + node -e ' + const fs = require("node:fs"); + const state = fs.existsSync(process.argv[1]) ? fs.readFileSync(process.argv[1], "utf8").trim().split("\n").filter(Boolean) : []; + const payload = { TCP: {}, Web: {}, AllowFunnel: {} }; + for (const record of state) { + const [port, target, shape] = record.split("|"); + const hostport = "dash.tail.ts.net:" + port; + payload.TCP[port] = { HTTPS: true }; + payload.Web[hostport] = shape === "complex" + ? { Handlers: { "/api": { Proxy: target }, "/": { Text: "foreign service" } } } + : { Handlers: { "/": { Proxy: target } } }; + payload.AllowFunnel[hostport] = process.argv[2] === port; + } + console.log(JSON.stringify(payload)); + ' "$TAILSCALE_STATE" "${TAILSCALE_FUNNEL_PORT:-}" +elif [ "$1" = serve ] && [ "${2:-}" = --bg ]; then + port=${3#--https=} + if [ "${TAILSCALE_FAIL_MAP_PORT:-}" = "$port" ]; then + printf 'map-failed %s\n' "$port" >> "$TAILSCALE_LOG" + exit 1 + fi + awk -F '|' -v port="$port" '$1 != port' "$TAILSCALE_STATE" > "$TAILSCALE_STATE.next" + printf '%s|%s\n' "$port" "$4" >> "$TAILSCALE_STATE.next" + mv "$TAILSCALE_STATE.next" "$TAILSCALE_STATE" + printf 'map %s\n' "$port" >> "$TAILSCALE_LOG" +elif [ "$1" = serve ] && [ "${2#--https=}" != "$2" ] && [ "${3:-}" = off ]; then + port=${2#--https=} + if [ "${TAILSCALE_FAIL_OFF_PORT:-}" = "$port" ] && [ ! -e "$TAILSCALE_FAIL_OFF_MARKER" ]; then + : > "$TAILSCALE_FAIL_OFF_MARKER" + printf 'off-failed %s\n' "$port" >> "$TAILSCALE_LOG" + exit 1 + fi + printf 'off %s\n' "$port" >> "$TAILSCALE_LOG" + awk -F '|' -v port="$port" '$1 != port' "$TAILSCALE_STATE" > "$TAILSCALE_STATE.next" + mv "$TAILSCALE_STATE.next" "$TAILSCALE_STATE" +else + exit 1 +fi +EOF + chmod 700 "$fake_bin/uname" "$fake_bin/launchctl" "$fake_bin/mv" "$fake_bin/tailscale" + : > "$tailscale_state" + HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --read-only --port 18847 --serve-port 19443 --captain "$CAPTAIN" >/dev/null \ + || fail "custom-port install failed" + node -e 'const c=require(process.argv[1]); if(c.serve_port !== 19443) process.exit(1)' "$install_home/config/dash.json" \ + || fail "install did not persist the custom serve port" + cp "$install_home/config/dash.json" "$prior_config" + : > "$tailscale_log" + printf '%s\n' '20553|http://127.0.0.1:29999' >> "$tailscale_state" + occupied_error=$(HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --read-only --port 18847 --serve-port 20553 --captain "$CAPTAIN" 2>&1) && fail "occupied replacement port was reported as installed" + assert_contains "$occupied_error" 'requested dashboard serve port 20553 carries a non-dashboard mapping' "occupied replacement port refusal was unclear" + cmp -s "$prior_config" "$install_home/config/dash.json" || fail "occupied replacement port mutated the dashboard config" + [ "$(cat "$launchctl_state")" = loaded ] || fail "occupied replacement port mutated the launchd service" + assert_no_grep 'map 20553' "$tailscale_log" "occupied replacement port was overwritten" + assert_no_grep 'off 20553' "$tailscale_log" "occupied replacement port was removed" + HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$fake_bin/tailscale" serve --https=20553 off >/dev/null || fail "could not remove the foreign mapping fixture" + : > "$tailscale_log" + if HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" TAILSCALE_FAIL_MAP_PORT=19663 PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --port 18848 --serve-port 19663 --captain "other@example.com" >/dev/null 2>&1; then + fail "failed replacement mapping was reported as installed" + fi + cmp -s "$prior_config" "$install_home/config/dash.json" || fail "failed replacement mapping did not restore the full prior config" + [ "$(cat "$launchctl_state")" = loaded ] || fail "failed replacement mapping did not restore the prior launchd service" + assert_grep 'map 19443' "$tailscale_log" "failed replacement mapping did not restore the prior mapping" + assert_no_grep 'off 19443' "$tailscale_log" "failed replacement mapping removed the recorded active mapping" + : > "$tailscale_log" + if HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" TAILSCALE_FUNNEL_PORT=19664 PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --port 18848 --serve-port 19664 --captain "other@example.com" >/dev/null 2>&1; then + fail "Funnel-exposed replacement mapping was reported as installed" + fi + cmp -s "$prior_config" "$install_home/config/dash.json" || fail "failed Funnel verification did not restore the full prior config" + [ "$(cat "$launchctl_state")" = loaded ] || fail "failed Funnel verification did not restore the prior launchd service" + assert_grep 'off 19664' "$tailscale_log" "failed Funnel verification did not remove the replacement mapping" + assert_grep 'map 19443' "$tailscale_log" "failed Funnel verification did not restore the prior mapping" + assert_no_grep 'off 19443' "$tailscale_log" "failed Funnel verification removed the recorded active mapping" + : > "$tailscale_log" + if HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_FAIL_ONCE="$TMP_ROOT/bootstrap-failed" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --port 18848 --serve-port 19665 --captain "other@example.com" >/dev/null 2>&1; then + fail "failed launchd replacement was reported as installed" + fi + cmp -s "$prior_config" "$install_home/config/dash.json" || fail "failed launchd replacement did not restore the full prior config" + [ "$(cat "$launchctl_state")" = loaded ] || fail "failed launchd replacement did not restore the prior service" + assert_grep 'map 19443' "$tailscale_log" "failed launchd replacement did not restore the prior mapping" + : > "$tailscale_log" + if HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" MV_FAIL_DEST="$install_home/config/dash.json" MV_FAIL_MARKER="$TMP_ROOT/config-mv-failed" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --port 18848 --serve-port 19666 --captain "other@example.com" >/dev/null 2>&1; then + fail "failed config activation was reported as installed" + fi + cmp -s "$prior_config" "$install_home/config/dash.json" || fail "failed config activation did not restore the full prior config" + [ "$(cat "$launchctl_state")" = loaded ] || fail "failed config activation did not restore the prior service" + assert_grep 'map 19443' "$tailscale_log" "failed config activation did not restore the prior mapping" + : > "$tailscale_log" + if HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_FAIL_OFF_MARKER="$TMP_ROOT/off-failed" TAILSCALE_FAIL_OFF_PORT=19443 TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --port 18848 --serve-port 19667 --captain "other@example.com" >/dev/null 2>&1; then + fail "failed old-mapping teardown was reported as installed" + fi + cmp -s "$prior_config" "$install_home/config/dash.json" || fail "failed old-mapping teardown did not restore the full prior config" + [ "$(cat "$launchctl_state")" = loaded ] || fail "failed old-mapping teardown did not restore the prior service" + assert_grep 'off 19667' "$tailscale_log" "failed old-mapping teardown did not remove the replacement mapping" + assert_grep 'map 19443' "$tailscale_log" "failed old-mapping teardown did not restore the prior mapping" + : > "$tailscale_log" + HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --read-only --port 18847 --serve-port 19553 --captain "$CAPTAIN" >/dev/null \ + || fail "custom-port replacement install failed" + assert_grep 'off 19443' "$tailscale_log" "port-change install did not remove the recorded previous mapping" + node -e 'const c=require(process.argv[1]); if(c.serve_port !== 19553) process.exit(1)' "$install_home/config/dash.json" \ + || fail "replacement install did not persist the active serve port" + : > "$tailscale_log" + uninstall_output=$(HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_INVALID_SERVE_STATUS=1 TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" uninstall 2>&1) && fail "uninstall accepted unreadable serve status" + assert_contains "$uninstall_output" 'could not inspect or remove the dashboard mapping for port 19553' "uninstall did not fail clearly on unreadable serve status" + [ "$(cat "$launchctl_state")" = loaded ] || fail "unreadable serve status removed the launchd service" + assert_no_grep 'off 19553' "$tailscale_log" "unreadable serve status removed the dashboard mapping" + : > "$tailscale_log" + HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" uninstall >/dev/null || fail "plain uninstall failed" + assert_grep 'off 19553' "$tailscale_log" "plain uninstall did not remove the recorded active mapping" + printf '%s\n' '19553|http://127.0.0.1:29998' '20554|http://127.0.0.1:29999|complex' >> "$tailscale_state" + : > "$tailscale_log" + uninstall_output=$(HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" uninstall --serve-port 20554) || fail "repeated uninstall with foreign mappings failed" + assert_contains "$uninstall_output" 'kept: serve port 20554 carries a non-dashboard mapping' "uninstall did not report the foreign requested mapping" + assert_contains "$uninstall_output" 'kept: serve port 19553 carries a non-dashboard mapping' "uninstall did not report the foreign configured mapping" + assert_no_grep 'off 20554' "$tailscale_log" "uninstall removed a foreign requested mapping" + assert_no_grep 'off 19553' "$tailscale_log" "uninstall removed a foreign configured mapping" + assert_grep '19553|http://127.0.0.1:29998' "$tailscale_state" "uninstall mutated the foreign configured mapping" + assert_grep '20554|http://127.0.0.1:29999|complex' "$tailscale_state" "uninstall mutated the complex foreign requested mapping" + HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$fake_bin/tailscale" serve --https=19553 off >/dev/null || fail "could not remove the configured foreign mapping fixture" + HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" PATH="$fake_bin:$PATH" \ + "$fake_bin/tailscale" serve --https=20554 off >/dev/null || fail "could not remove the requested foreign mapping fixture" + cp "$install_home/config/dash.json" "$prior_config" + : > "$tailscale_log" + if HOME="$launch_home" FM_HOME="$install_home" LAUNCHCTL_LOG="$launchctl_log" LAUNCHCTL_STATE="$launchctl_state" REAL_MV="$real_mv" TAILSCALE_LOG="$tailscale_log" TAILSCALE_STATE="$tailscale_state" TAILSCALE_FAIL_MAP_PORT=19668 PATH="$fake_bin:$PATH" \ + "$INSTALL_SH" install --read-only --port 18848 --serve-port 19668 --captain "$CAPTAIN" >/dev/null 2>&1; then + fail "failed reinstall after uninstall was reported as installed" + fi + cmp -s "$prior_config" "$install_home/config/dash.json" || fail "failed reinstall after uninstall did not restore the retained config" + [ ! -s "$launchctl_state" ] || fail "failed reinstall after uninstall restored an absent launchd service" + [ ! -s "$tailscale_state" ] || fail "failed reinstall after uninstall recreated an absent mapping" + assert_no_grep 'map 19553' "$tailscale_log" "failed reinstall after uninstall recreated the removed prior mapping" + pass "custom serve ports persist and old mappings are removed" +} + +test_launchd_env_and_degraded_render_selfcheck() { + 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" + assert_contains "$plist" '<key>PATH</key>' "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="" + non_worker_rows="" + for _ in 1 2 3 4; do + rows="$rows<li class=\"mrow\"><span class=\"mreason\">Authoritative current state: unknown</span></li>" + done + for _ in 1 2 3 4 5 6 7 8 9 10; do + non_worker_rows="$non_worker_rows<li class=\"mrow\"><span class=\"mreason\">Queued backlog item</span></li>" + done + printf '%s' "$(sed "s|</body>|$rows$non_worker_rows</body>|" "$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|</body>|<li class=\"mrow\"><span class=\"mreason\">Authoritative current state: unknown</span></li>$non_worker_rows</body>|" "$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 + 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" + 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" + assert_grep 'data/<origin>/decisions/<key>.md' "$ROOT/docs/dashboard-service.md" "service doc does not own the origin-qualified decision-options format" + assert_grep 'hold --options-file' "$ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md" "decision lifecycle does not own options-document filing" + assert_grep 'secondmate-owned work item deliberately shows only a limited ownership note' "$ROOT/docs/dashboard-service.md" "service doc omits the accepted secondmate detail boundary" + assert_grep 'only free text accepted anywhere is bounded captain-authored content' "$ROOT/docs/dashboard-service.md" "service doc omits the bounded free-text boundary" + assert_grep 'at-least-once' "$INBOX_SH" "inbox consumer omits its delivery contract" + pass "the service is documented and wired into the operating contract" +} + +test_identity_fails_closed +test_browser_posts_require_same_origin +test_served_page_wears_dashboard_with_interactive_layer +test_subscription_usage_panel +test_inline_config_is_script_safe +test_refs_sidecar_and_rich_work_item_detail +test_decision_detail_options_and_validated_approval +test_decision_answers_are_qualified_by_home_and_origin +test_stale_refs_are_disabled +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_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" diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 09087cce940..0777a5fb60d 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -121,6 +121,22 @@ write_origin_meta() { # <home> <id> [kind] "mode=$kind" } +write_options() { # <home> <key> <title> + local home=$1 key=$2 title=$3 file + file="$home/options-$key.md" + cat > "$file" <<EOF +# $title + +Choose the bounded synthetic route for this decision. + +## Options + +- [recommended] Conservative route - Keeps the synthetic change narrow. +- Fast route - Delivers sooner with a wider synthetic risk. +EOF + printf '%s\n' "$file" +} + test_structured_holds_survive_teardown_and_route_resolution() { local home id route_hold access_hold before after json open show home=$(make_home durable-lifecycle) @@ -147,8 +163,17 @@ EOF assert_no_grep "decisions_reviewed=1" "$home/state/$id.meta" \ "failed completion recorded a false completion attestation" + if run_decisions "$home" hold "$id" undocumented \ + --title "Choose an undocumented route" --reason "captain undocumented choice pending" --repo sample \ + > "$home/undocumented.out" 2> "$home/undocumented.err"; then + fail "new hold succeeded without a structured options document" + fi + assert_grep "requires --options-file" "$home/undocumented.err" "missing options refusal was not explicit" + assert_no_grep "$id-decision-undocumented" "$home/data/backlog.md" "refused undocumented hold mutated the backlog" + route_hold=$(run_decisions "$home" hold "$id" route \ - --title "Choose the sample route" --reason "captain route choice pending" --repo sample) \ + --title "Choose the sample route" --reason "captain route choice pending" \ + --options-file "$(write_options "$home" route "Choose the sample route")" --repo sample) \ || fail "could not register route hold" [ "$route_hold" = "$id-decision-route" ] || fail "route hold identity was not deterministic: $route_hold" run_decisions "$home" hold "$id" route \ @@ -158,13 +183,16 @@ EOF fail "completion succeeded while one of two distinct decisions lacked a hold" fi access_hold=$(run_decisions "$home" hold "$id" access \ - --title "Choose the sample access level" --reason "captain access choice pending" --repo sample) \ + --title "Choose the sample access level" --reason "captain access choice pending" \ + --options-file "$(write_options "$home" access "Choose the sample access level")" --repo sample) \ || fail "could not register access hold" [ "$access_hold" = "$id-decision-access" ] || fail "access hold identity was not distinct: $access_hold" [ "$(grep -cE "^- \[ \] $route_hold -" "$home/data/backlog.md")" = 1 ] \ || fail "idempotent retry duplicated the route hold" [ "$(grep -cE "^- \[ \] $access_hold -" "$home/data/backlog.md")" = 1 ] \ || fail "second decision did not retain one distinct backlog identity" + assert_grep "Conservative route" "$home/data/$id/decisions/route.md" "route options were not published with the hold" + assert_grep "Fast route" "$home/data/$id/decisions/access.md" "access options were not published with the hold" run_decisions "$home" complete "$id" route access >/dev/null \ || fail "shared investigation completion gate failed" @@ -344,7 +372,8 @@ test_visual_review_uses_shared_completion_owner() { mkdir -p "$home/.lavish" printf '<html><body>Synthetic sample board</body></html>\n' > "$home/.lavish/sample-board.html" hold=$(run_decisions "$home" hold "$id" layout \ - --title "Choose the sample layout" --reason "captain layout choice pending" --repo sample) \ + --title "Choose the sample layout" --reason "captain layout choice pending" \ + --options-file "$(write_options "$home" layout "Choose the sample layout")" --repo sample) \ || fail "post-teardown visual review could not use the shared hold owner" run_decisions "$home" complete "$id" layout >/dev/null \ || fail "post-teardown visual review could not use the shared completion owner" @@ -437,7 +466,8 @@ EOF printf 'done: report and visual review complete\n' > "$mate/state/$origin.status" printf '# Sample secondmate review\n\nOne captain choice remains.\n' > "$mate/data/$origin/report.md" hold=$(run_decisions "$mate" hold "$origin" release \ - --title "Choose the sample release" --reason "captain release choice pending" --repo sample) \ + --title "Choose the sample release" --reason "captain release choice pending" \ + --options-file "$(write_options "$mate" release "Choose the sample release")" --repo sample) \ || fail "secondmate-owned hold creation failed" run_decisions "$mate" complete "$origin" release >/dev/null \ || fail "secondmate-owned completion failed" @@ -451,13 +481,43 @@ EOF "firstmate:fm-sample-mate" sample json=$(run_bearings "$parent") || fail "parent Bearings could not read secondmate hold" printf '%s' "$json" | jq -e --arg hold "$hold" ' - .decisions_open | any(.owner == "sample-mate" and .verb == "captain-hold" and (.id | endswith($hold))) + .decisions_open | any(.owner == "sample-mate" and .key == "release" and .verb == "captain-hold" and (.id | endswith($hold))) ' >/dev/null || fail "secondmate captain hold did not surface with authoritative owner: $json" assert_no_grep "$hold" "$parent/data/backlog.md" "secondmate hold leaked into the main backlog" assert_grep "$hold" "$mate/data/backlog.md" "secondmate hold left its authoritative backlog" + assert_grep "Conservative route" "$mate/data/$origin/decisions/release.md" "secondmate options were not published in the owning home" pass "main-home and secondmate-home captain holds remain correctly routed" } +test_same_home_same_key_decisions_are_origin_qualified() { + local home first second first_hold second_hold + home=$(make_home same-home-same-key) + first=sample-alpha-review + second=sample-beta-review + mkdir -p "$home/data/$first" "$home/data/$second" + tasks_in "$home" add "$first" "Review sample alpha" --kind scout --repo sample --start >/dev/null \ + || fail "could not create first same-key origin" + tasks_in "$home" add "$second" "Review sample beta" --kind scout --repo sample --start >/dev/null \ + || fail "could not create second same-key origin" + write_origin_meta "$home" "$first" + write_origin_meta "$home" "$second" + first_hold=$(run_decisions "$home" hold "$first" route \ + --title "Choose the alpha route" --reason "captain alpha route pending" \ + --options-file "$(write_options "$home" route "Choose the alpha route")" --repo sample) \ + || fail "first same-key decision was not filed" + second_hold=$(run_decisions "$home" hold "$second" route \ + --title "Choose the beta route" --reason "captain beta route pending" \ + --options-file "$(write_options "$home" route "Choose the beta route")" --repo sample) \ + || fail "second same-key decision collided with the first" + [ "$first_hold" = "$first-decision-route" ] || fail "first same-key hold lost its origin identity" + [ "$second_hold" = "$second-decision-route" ] || fail "second same-key hold lost its origin identity" + assert_grep "Choose the alpha route" "$home/data/$first/decisions/route.md" "first origin options were not retained" + assert_grep "Choose the beta route" "$home/data/$second/decisions/route.md" "second origin options were not retained" + cmp -s "$home/data/$first/decisions/route.md" "$home/data/$second/decisions/route.md" \ + && fail "distinct same-key options collapsed to one document" + pass "same-home same-key decisions retain distinct origin-qualified documents" +} + test_overlong_captain_hold_is_non_dispatchable() { local home origin key hold show out rc home=$(make_home overlong-captain-hold) @@ -466,7 +526,8 @@ test_overlong_captain_hold_is_non_dispatchable() { mkdir -p "$home/data/$origin" write_origin_meta "$home" "$origin" hold=$(run_decisions "$home" hold "$origin" "$key" \ - --title "Choose the sample release route" --reason "captain release route pending" --repo sample) \ + --title "Choose the sample release route" --reason "captain release route pending" \ + --options-file "$(write_options "$home" "$key" "Choose the sample release route")" --repo sample) \ || fail "overlong captain hold creation failed" [ "${#hold}" -gt "$FM_TASK_ID_MAX_LENGTH" ] \ || fail "captain hold fixture was not overlong: $hold" @@ -503,16 +564,20 @@ test_resolve_matches_quoted_blocked_by_edges() { printf '# Quote edge review\n\nThree edge decisions and one absent control.\n' > "$home/data/$origin/report.md" hold_first=$(run_decisions "$home" hold "$origin" edge-first \ - --title "First edge decision" --reason "captain first pending" --repo sample) \ + --title "First edge decision" --reason "captain first pending" \ + --options-file "$(write_options "$home" edge-first "First edge decision")" --repo sample) \ || fail "could not register first-edge hold" hold_mid=$(run_decisions "$home" hold "$origin" edge-mid \ - --title "Middle edge decision" --reason "captain mid pending" --repo sample) \ + --title "Middle edge decision" --reason "captain mid pending" \ + --options-file "$(write_options "$home" edge-mid "Middle edge decision")" --repo sample) \ || fail "could not register mid-edge hold" hold_last=$(run_decisions "$home" hold "$origin" edge-last \ - --title "Last edge decision" --reason "captain last pending" --repo sample) \ + --title "Last edge decision" --reason "captain last pending" \ + --options-file "$(write_options "$home" edge-last "Last edge decision")" --repo sample) \ || fail "could not register last-edge hold" hold_absent=$(run_decisions "$home" hold "$origin" edge-absent \ - --title "Absent edge decision" --reason "captain absent pending" --repo sample) \ + --title "Absent edge decision" --reason "captain absent pending" \ + --options-file "$(write_options "$home" edge-absent "Absent edge decision")" --repo sample) \ || fail "could not register absent-edge hold" tasks_in "$home" add pad-a "Pad A" --kind ship --repo sample >/dev/null \ @@ -592,5 +657,6 @@ test_visual_review_uses_shared_completion_owner test_none_inventory_and_resolved_prose_do_not_create_holds test_terminal_single_owner_status_decision_does_not_block_empty_inventory test_secondmate_hold_stays_in_authoritative_home +test_same_home_same_key_decisions_are_origin_qualified test_overlong_captain_hold_is_non_dispatchable test_resolve_matches_quoted_blocked_by_edges 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"