@dougharness/cli 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/board-serve.js +40 -6
  2. package/dist/board-serve.js.map +1 -1
  3. package/dist/init.js +1 -1
  4. package/dist/init.js.map +1 -1
  5. package/dist/memory-setup.d.ts +7 -0
  6. package/dist/memory-setup.js +70 -46
  7. package/dist/memory-setup.js.map +1 -1
  8. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  9. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  10. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  11. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  12. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  13. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  14. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  15. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  16. package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
  17. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  18. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  19. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  20. package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
  21. package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
  22. package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
  23. package/node_modules/@dougharness/flow/lib/memory.mjs +48 -12
  24. package/node_modules/@dougharness/flow/lib/plan.mjs +32 -4
  25. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  26. package/node_modules/@dougharness/flow/package.json +1 -1
  27. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  28. package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
  29. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +20 -9
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +100 -30
  32. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  33. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
  34. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  35. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  36. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
  37. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  38. package/node_modules/@dougharness/flow/workflows/doug-implement.js +7 -7
  39. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  40. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  41. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
  42. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
  43. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  44. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  45. package/node_modules/@dougharness/gates/package.json +1 -1
  46. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  47. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +138 -10
  48. package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
  49. package/package.json +3 -3
  50. package/templates/board-app.js +46 -0
@@ -73,11 +73,11 @@
73
73
  // Fails closed exactly like subagentsInFlight: with trace.enabled false, or the trace file missing or
74
74
  // unreadable, there is no evidence either way, so the gate runs as today.
75
75
 
76
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
76
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
77
77
  import { execFileSync, spawnSync } from "node:child_process";
78
78
  import { fileURLToPath } from "node:url";
79
79
  import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
80
- import { runHook, allow, blockStop, systemMessage } from "../lib/io.mjs";
80
+ import { runHook, allow as allowRaw, blockStop, systemMessage as systemMessageRaw } from "../lib/io.mjs";
81
81
  import { loadConfig, projectDir } from "../lib/config.mjs";
82
82
  import { loadState, saveState } from "../lib/state.mjs";
83
83
  import { matchAny } from "../lib/glob.mjs";
@@ -88,7 +88,7 @@ import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.m
88
88
  import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
89
89
  import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
90
90
  import { isAppliedContent } from "../lib/proposals.mjs";
91
- import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection } from "../lib/anchor.mjs";
91
+ import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, readPlanSummaries, snapshotSection } from "../lib/anchor.mjs";
92
92
  import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
93
93
  import { parseTotals, readSummaries, sumTotals, compareTotals, deltaTotals, waiverCovers } from "../lib/test-totals.mjs";
94
94
  import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
@@ -492,7 +492,24 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
492
492
  // readAgentStart) and it equals the current one (same HEAD, same dirty set, same identities), and the agent's
493
493
  // final message carries no tests_red_by_design claim, and its trace shows no successful Edit-type call. Catches read-only agents that run Bash, which
494
494
  // agentMadeNoChanges counts as a change. No record, no git, any failure -> false (run the gate).
495
- function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFingerprint) {
495
+ // card stop-gate-worktree-agent-fingerprint: the start record is a fingerprint of the main checkout (the trace
496
+ // hook runs there), so for a worktree verification dir it is compared like with like instead: unchanged only if
497
+ // the record exists, every verification dir has no changed files, and every one's HEAD carries no commit of its
498
+ // own: it equals or is an ancestor of the record's HEAD, or of the main checkout's refs/remotes/origin/HEAD (Claude
499
+ // Code branches a fresh isolation worktree from the remote default branch, which local main may be ahead of or
500
+ // behind). Any git failure reads as changed. A verification set that mixes a worktree with `dir` reads as
501
+ // changed. A main-checkout agent keeps the fingerprint comparison.
502
+ function isAncestorIn(dir, a, b) {
503
+ try {
504
+ if (!/^[0-9a-f]{7,64}$/.test(String(a)) || !/^[0-9a-f]{7,64}$/.test(String(b))) return false;
505
+ execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: dir, stdio: "ignore", timeout: 5000 });
506
+ return true;
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
511
+
512
+ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFingerprint, vdir = dir, forced = null) {
496
513
  try {
497
514
  if (!subagent || !currentFingerprint) return false;
498
515
  if (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path))) return false;
@@ -503,6 +520,24 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
503
520
  if (existsSync(file) && readTrace(file).some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false)) return false;
504
521
  }
505
522
  const start = readAgentStart(dir, sessionId, subagent.id);
523
+ if (vdir !== dir) {
524
+ if (!start || typeof start !== "object" || !start.head) return false;
525
+ const dirs = forced ? [vdir] : verifyDirs(dir, cfg, sessionId, input, subagent);
526
+ if (!dirs.includes(vdir) || dirs.includes(dir)) return false;
527
+ let remoteHead;
528
+ try {
529
+ remoteHead = execFileSync("git", ["rev-parse", "--verify", "-q", "refs/remotes/origin/HEAD^{commit}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim() || null;
530
+ } catch {
531
+ remoteHead = null;
532
+ }
533
+ return dirs.every((d) => {
534
+ const h = headAt(d);
535
+ if (!h) return false;
536
+ if (!isAncestorIn(dir, h, start.head) && !(remoteHead && isAncestorIn(dir, h, remoteHead))) return false;
537
+ const c = changedFiles(d);
538
+ return Array.isArray(c) && c.length === 0;
539
+ });
540
+ }
506
541
  return sameTree(start, currentFingerprint);
507
542
  } catch {
508
543
  return false;
@@ -721,6 +756,7 @@ function contextNotice(dir, cfg, state, sessionId, boundary) {
721
756
  const anchorFile = join(dir, ".doug/anchor.md");
722
757
  const section = snapshotSection({
723
758
  plan: readPlanSummary(dir),
759
+ plans: readPlanSummaries(dir),
724
760
  decisions: readDecisions(dir),
725
761
  state,
726
762
  now: new Date(),
@@ -1008,10 +1044,99 @@ function normalizedDeferrals(state) {
1008
1044
  return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
1009
1045
  }
1010
1046
 
1047
+ // Card landing-block-enforced: board.mjs record/summary leave .doug/.state/reports/<id>/landing-block.txt and an
1048
+ // empty landing-block.pending (move <id> done re-arms it). A main Stop that would otherwise allow checks each
1049
+ // pending block against the lead's final message and blocks, once, until every line of it is there verbatim. The
1050
+ // marker is deleted the first time it is checked, so the block happens at most once per landing. Every exit that
1051
+ // lets a main Stop end plainly goes through allow() below. The systemMessage exits (the block-cap and turn-budget
1052
+ // stand-downs, the deferral notice, the repeat-deferral exit) return their text unchanged and never block (card
1053
+ // landing-block-shown-on-deferral): systemMessage() below first sweeps quietly, deleting a marker whose lines are
1054
+ // all in the final message and leaving any other, so a block printed on a deferral stop is not demanded again at
1055
+ // a later plain stop. A red gate (blockStop) keeps its own reason. A marker holds the session that landed (empty =
1056
+ // any); another session's marker is left untouched. Fails open: any throw, an unreadable block
1057
+ // file, or a final message that is not a string allows with one stderr line.
1058
+ let landingInput = null;
1059
+ // The pending markers this session owns, as [id, root] pairs; null when there is no reports directory.
1060
+ function ownedLandingMarkers(input) {
1061
+ const root = join(projectDir(input), ".doug/.state/reports");
1062
+ if (!existsSync(root)) return { root, pending: [] };
1063
+ const mine = (id) => {
1064
+ try {
1065
+ const owner = readFileSync(join(root, id, "landing-block.pending"), "utf8").trim();
1066
+ return owner === "" || owner === input.session_id;
1067
+ } catch {
1068
+ return false;
1069
+ }
1070
+ };
1071
+ return { root, pending: readdirSync(root).filter((id) => existsSync(join(root, id, "landing-block.pending")) && mine(id)).sort() };
1072
+ }
1073
+ function landingBlockLines(root, id) {
1074
+ return readFileSync(join(root, id, "landing-block.txt"), "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
1075
+ }
1076
+ function landingBlockReason(input) {
1077
+ try {
1078
+ const { root, pending } = ownedLandingMarkers(input);
1079
+ if (!pending.length) return null;
1080
+ const message = input.last_assistant_message;
1081
+ if (typeof message !== "string") {
1082
+ // The markers stay, so a later Stop with a readable final message still checks them.
1083
+ process.stderr.write("[doug] landing-block check skipped: the final message is unreadable\n");
1084
+ return null;
1085
+ }
1086
+ const missing = [];
1087
+ for (const id of pending) {
1088
+ const marker = join(root, id, "landing-block.pending");
1089
+ try {
1090
+ const lines = landingBlockLines(root, id);
1091
+ if (lines.length && !lines.every((l) => message.includes(l))) missing.push({ id, lines });
1092
+ } catch (e) {
1093
+ process.stderr.write(`[doug] landing-block check skipped: ${id}: ${e && e.message ? e.message : String(e)}\n`);
1094
+ }
1095
+ try {
1096
+ unlinkSync(marker);
1097
+ } catch {}
1098
+ }
1099
+ if (!missing.length) return null;
1100
+ return missing.map((m) => `Landing block missing from your final message for ${m.id}. Paste these lines verbatim:\n${m.lines.join("\n")}`).join("\n\n");
1101
+ } catch (e) {
1102
+ process.stderr.write(`[doug] landing-block check skipped: ${e && e.message ? e.message : String(e)}\n`);
1103
+ return null;
1104
+ }
1105
+ }
1106
+ // Quiet sweep for the systemMessage exits: delete each owned marker whose lines are all in the final message,
1107
+ // keep the rest. Never blocks, never changes the exit's text.
1108
+ function sweepLandingMarkers(input) {
1109
+ try {
1110
+ const message = input.last_assistant_message;
1111
+ if (typeof message !== "string") return;
1112
+ const { root, pending } = ownedLandingMarkers(input);
1113
+ for (const id of pending) {
1114
+ try {
1115
+ const lines = landingBlockLines(root, id);
1116
+ if (lines.length && lines.every((l) => message.includes(l))) unlinkSync(join(root, id, "landing-block.pending"));
1117
+ } catch (e) {
1118
+ process.stderr.write(`[doug] landing-block check skipped: ${id}: ${e && e.message ? e.message : String(e)}\n`);
1119
+ }
1120
+ }
1121
+ } catch (e) {
1122
+ process.stderr.write(`[doug] landing-block check skipped: ${e && e.message ? e.message : String(e)}\n`);
1123
+ }
1124
+ }
1125
+ function systemMessage(message) {
1126
+ if (landingInput) sweepLandingMarkers(landingInput);
1127
+ return systemMessageRaw(message);
1128
+ }
1129
+ function allow() {
1130
+ const reason = landingInput ? landingBlockReason(landingInput) : null;
1131
+ if (reason) return blockStop(reason);
1132
+ return allowRaw();
1133
+ }
1134
+
1011
1135
  const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
1012
1136
 
1013
1137
  runHook("stop-gate", async (input) => {
1014
- if (input.stop_hook_active) return allow();
1138
+ if (input.stop_hook_active) return allowRaw();
1139
+ if (input.hook_event_name !== "SubagentStop") landingInput = input;
1015
1140
  const dir = projectDir(input);
1016
1141
  const cfg = loadConfig(dir);
1017
1142
  let state = loadState(dir, input.session_id);
@@ -1394,8 +1519,8 @@ runHook("stop-gate", async (input) => {
1394
1519
  // Draft and rejected plans are not contracts yet, so they are ignored.
1395
1520
  let scope = null;
1396
1521
  if (Array.isArray(changed) && cfg.stopGate.planScope !== false) {
1397
- const { plan, reason, error } = loadScopePlan(dir);
1398
- if (reason === "unreadable") process.stderr.write(`[doug] ${PLAN_RELPATH} is unreadable (${error}); scope not checked\n`);
1522
+ const { plan, reason, error, file } = loadScopePlan(dir);
1523
+ if (reason === "unreadable") process.stderr.write(`[doug] ${file || PLAN_RELPATH} is unreadable (${error}); scope not checked\n`);
1399
1524
  if (plan) {
1400
1525
  const violations = scopeViolations(changed, plan, { ignore: cfg.stopGate.ignoreChangedPaths || [] });
1401
1526
  scope = { plan: plan.title, status: plan.status, outOfScope: violations.map((v) => v.file) };
@@ -1449,7 +1574,10 @@ runHook("stop-gate", async (input) => {
1449
1574
  state.deferrals = n;
1450
1575
  state.lastDeferral = { ids, fingerprint: currentFingerprint };
1451
1576
  saveState(dir, input.session_id, state);
1452
- if (repeat) return allow();
1577
+ if (repeat) {
1578
+ if (landingInput) sweepLandingMarkers(landingInput);
1579
+ return allowRaw();
1580
+ }
1453
1581
  return systemMessage(
1454
1582
  `[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
1455
1583
  );
@@ -1498,7 +1626,7 @@ runHook("stop-gate", async (input) => {
1498
1626
  };
1499
1627
  const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
1500
1628
  ? "no-agent-changes"
1501
- : subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
1629
+ : subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint, vdir, forced)
1502
1630
  ? "tree-unchanged"
1503
1631
  : readOnlyTurn
1504
1632
  ? "read-only-turn"
@@ -1588,7 +1716,7 @@ runHook("stop-gate", async (input) => {
1588
1716
 
1589
1717
  // card stop-gate-process-storm, case 3: only one gate runs the verification commands at a time per
1590
1718
  // project (the 2026-09-16 process storm: four concurrent SubagentStops each ran the full suite, each
1591
- // 300s timeout orphaning ten vitest workers). A gate holds .doug/.state/gate.lock while its commands run;
1719
+ // 300s timeout orphaning ten vitest workers). A gate holds gate.lock (gateLockPath: under the git common dir) while its commands run;
1592
1720
  // a second gate that finds a live lock waits for it, bounded by stopGate.timeoutMs, then reloads state and
1593
1721
  // reapplies the skip above — the holder's own green run may have just recorded the very `verified` tree
1594
1722
  // this gate needs, letting it skip instead of also running the full suite.
@@ -4,7 +4,7 @@
4
4
  // node .doug/hooks/scripts/verify.mjs [--no-credit] [--json] [--dir <project>] [--cwd <tree dir>] [--timeout-ms <n>]
5
5
  // [--record-dir <dir>] [--credit-dir <dir>]... [--by <writer>] [--command <cmd>]...
6
6
  // Exit: 0 all green or credited; 1 any command failed or timed out; 2 usage error or no commands.
7
- // No count ratchet and no scans here (no session baseline); the Stop gate does those. Takes the Stop gate's .doug/.state/gate.lock around the commands (card verify-takes-gate-lock).
7
+ // No count ratchet and no scans here (no session baseline); the Stop gate does those. Takes the Stop gate's gate.lock (under the git common dir, shared by every worktree) around the commands (card verify-takes-gate-lock).
8
8
 
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { unlinkSync } from "node:fs";
@@ -78,6 +78,7 @@ function main() {
78
78
  return 2;
79
79
  }
80
80
  let treeBefore = computeTree(cwd);
81
+ let lockWaitMs = 0;
81
82
 
82
83
  // Credits a verified-tree record of the same tree when one exists; returns the exit code, or null.
83
84
  const tryCredit = () => {
@@ -98,7 +99,7 @@ function main() {
98
99
  const entries = record.commands.map((c) => ({ name: c.name, exit: c.exit, passed: passed(record.totals[c.command]) }));
99
100
  if (o.json) {
100
101
  const results = record.commands.map((c) => ({ name: c.name, command: c.command, ok: true, status: c.exit, timedOut: false, durationMs: c.durationMs, outputTail: "" }));
101
- process.stdout.write(JSON.stringify({ ok: true, credited: "verified-tree", tree: treeBefore, record, results }) + "\n");
102
+ process.stdout.write(JSON.stringify({ ok: true, credited: "verified-tree", tree: treeBefore, record, results, lockWaitMs }) + "\n");
102
103
  } else {
103
104
  process.stdout.write(`[doug] verify: credited verified-tree record (tree ${treeBefore.slice(0, 7)}, by ${record.by || "unknown"}, ${record.at}, node ${record.node}); commands not re-run. --no-credit runs them.\n`);
104
105
  process.stdout.write(gateLine(entries) + "\n");
@@ -142,7 +143,8 @@ function main() {
142
143
  takeOverGateLock(dir, o.by || "verify");
143
144
  lockOwned = true;
144
145
  } else {
145
- const deadline = Date.now() + waitBoundMs;
146
+ const waitStart = Date.now();
147
+ const deadline = waitStart + waitBoundMs;
146
148
  let live = held;
147
149
  const nap = new Int32Array(new SharedArrayBuffer(4));
148
150
  while (true) {
@@ -150,6 +152,8 @@ function main() {
150
152
  if (stale(live) || Date.now() >= deadline) break;
151
153
  Atomics.wait(nap, 0, 0, 200);
152
154
  }
155
+ lockWaitMs = Date.now() - waitStart;
156
+ if (!o.json && lockWaitMs > 0) process.stdout.write(`[doug] verify: waited ${lockWaitMs} ms on gate.lock\n`);
153
157
  treeBefore = computeTree(cwd);
154
158
  const again = tryCredit();
155
159
  if (again !== null) return again;
@@ -186,7 +190,7 @@ function main() {
186
190
  } else {
187
191
  removeRecord(recordDir);
188
192
  }
189
- if (o.json) process.stdout.write(JSON.stringify({ ok, credited: null, tree: treeBefore, record: null, results }) + "\n");
193
+ if (o.json) process.stdout.write(JSON.stringify({ ok, credited: null, tree: treeBefore, record: null, results, lockWaitMs }) + "\n");
190
194
  else process.stdout.write(gateLine(entries) + "\n");
191
195
  return ok ? 0 : 1;
192
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "doug: tailored, approval-gated harness install for Claude Code.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,8 +23,8 @@
23
23
  "node": ">=22.16"
24
24
  },
25
25
  "dependencies": {
26
- "@dougharness/flow": "1.1.1",
27
- "@dougharness/gates": "1.1.1"
26
+ "@dougharness/flow": "1.2.0",
27
+ "@dougharness/gates": "1.2.0"
28
28
  },
29
29
  "bundleDependencies": [
30
30
  "@dougharness/flow",
@@ -45,6 +45,15 @@ details{margin-top:6px}summary{font-size:12px;color:var(--accent);cursor:pointer
45
45
  .move{margin-top:8px;display:flex;gap:6px;align-items:center}.move select{font-size:12px;padding:3px 6px}
46
46
  .ro .card{cursor:default}.ro .move{display:none}
47
47
  footer{padding:0 24px 24px;font-size:12px;color:var(--ink-soft)}
48
+ #autopilot{margin:14px 24px 0;padding:10px 14px;background:var(--surface);border:1px solid var(--rule-strong)}
49
+ #autopilot[hidden]{display:none}
50
+ #autopilot h2{font-family:"IBM Plex Mono",Menlo,monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;margin:0 0 6px}
51
+ #autopilot ul{list-style:none;margin:4px 0;padding:0;font-size:13px}
52
+ #autopilot li{display:flex;gap:8px;flex-wrap:wrap;align-items:baseline}
53
+ #autopilot .st{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;text-transform:uppercase;color:var(--ink-soft);min-width:5.5em}
54
+ #autopilot .st.running{color:var(--accent);font-weight:600}
55
+ #autopilot .st.stopped,#autopilot .st.interrupted{color:var(--c-decision)}
56
+ #autopilot .caps,#autopilot .filed{font-size:12px;color:var(--ink-soft);margin-top:4px}
48
57
  @media (max-width:720px){.board{grid-auto-columns:minmax(85vw,1fr)}}
49
58
  `;
50
59
 
@@ -163,6 +172,12 @@ function connectLive(opts) {
163
172
  if (source !== es) return;
164
173
  opts.onBoard(JSON.parse(e.data));
165
174
  });
175
+ es.addEventListener("autopilot", (e) => {
176
+ if (source !== es || !opts.onAutopilot) return;
177
+ let view = null;
178
+ try { view = JSON.parse(e.data); } catch (_) {}
179
+ opts.onAutopilot(view);
180
+ });
166
181
  }
167
182
  open();
168
183
 
@@ -246,6 +261,33 @@ function cardMatches(card, filters) {
246
261
  return true;
247
262
  }
248
263
 
264
+ // The autopilot panel's inner HTML for a view from GET /api/autopilot (lib/autopilot-view.mjs autopilotView);
265
+ // "" for no view, which leaves the panel hidden.
266
+ function autopilotHtml(view) {
267
+ if (!view || typeof view !== "object") return "";
268
+ const cards = Array.isArray(view.cards) ? view.cards : [];
269
+ const filed = Array.isArray(view.filed) ? view.filed : [];
270
+ const caps = view.caps || {};
271
+ const items = cards.map((c) => {
272
+ const shape = [c.size, c.class].filter(Boolean).join("/");
273
+ return `<li><span class="st ${esc(c.state)}">${esc(c.state)}</span><code>${esc(c.id)}</code>${c.title ? `<span>${esc(c.title)}</span>` : ""}${shape ? `<span class="meta">${esc(shape)}</span>` : ""}${c.detail ? `<span class="meta">${esc(c.detail)}</span>` : ""}</li>`;
274
+ }).join("");
275
+ const filedHtml = filed.length
276
+ ? `<div class="filed">filed: ${filed.map((f) => `<code>${esc(f.id)}</code> (${esc(f.kind)}${f.from ? " from " + esc(f.from) : ""}) ${esc(f.position || "")}`).join("; ")}</div>`
277
+ : "";
278
+ return `<h2>${esc(view.header || "Autopilot")}</h2><ul>${items}</ul><div class="caps">usd ${esc(caps.usd || "")} · wall ${esc(caps.wall || "")} · context ${esc(caps.context || "")}</div>${filedHtml}`;
279
+ }
280
+
281
+ // Shows a view in the #autopilot container, or hides the container for none.
282
+ function applyAutopilot(view, doc) {
283
+ doc = doc !== undefined ? doc : (typeof document !== "undefined" ? document : null);
284
+ const el = doc && doc.getElementById("autopilot");
285
+ if (!el) return;
286
+ const html = autopilotHtml(view);
287
+ el.innerHTML = html;
288
+ el.hidden = html === "";
289
+ }
290
+
249
291
  // The complete document for a given board state; `doug board serve` renders it from the record on
250
292
  // every request.
251
293
  function renderDoc(board, appSource, config) {
@@ -265,6 +307,7 @@ ${tagFilter ? tagFilter + "\n" : ""}<span class="status" id="filter-note"></span
265
307
  <span class="status" id="status"></span>
266
308
  </div>
267
309
  </header>
310
+ <section id="autopilot" aria-label="Autopilot" hidden></section>
268
311
  <main class="board" id="board">
269
312
  ${board.columns.map((col) => columnHtml(col, board.cards, board.columns)).join("\n")}
270
313
  </main>
@@ -509,6 +552,8 @@ function app() {
509
552
  readOnly = false;
510
553
  document.body.classList.remove("ro");
511
554
  say("");
555
+ // The autopilot panel: the run's view now, then live over the same event stream.
556
+ fetch("/api/autopilot").then((r) => (r && r.status === 200 ? r.json() : null)).then((view) => applyAutopilot(view)).catch(() => {});
512
557
  // Once served, follow the record live: another writer's move (or this tab's own, echoed
513
558
  // back) re-renders in place, or reloads when the columns or filters themselves changed.
514
559
  connectLive({
@@ -516,6 +561,7 @@ function app() {
516
561
  url: "/api/events",
517
562
  setTimeout: window.setTimeout.bind(window),
518
563
  clearTimeout: window.clearTimeout.bind(window),
564
+ onAutopilot: (view) => applyAutopilot(view),
519
565
  onBoard: (next) => {
520
566
  const updated = applyLiveBoard(board, next, config);
521
567
  if (updated) {