@dougharness/cli 1.1.0 → 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 (59) hide show
  1. package/dist/bin.js +10 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +45 -10
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +33 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/init.js +1 -1
  9. package/dist/init.js.map +1 -1
  10. package/dist/memory-setup.d.ts +7 -0
  11. package/dist/memory-setup.js +75 -48
  12. package/dist/memory-setup.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  14. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  16. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  17. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  18. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  19. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  20. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  21. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  22. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  23. package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
  24. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  25. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  26. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  27. package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
  28. package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
  29. package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
  30. package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
  31. package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
  32. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  33. package/node_modules/@dougharness/flow/package.json +1 -1
  34. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  35. package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
  36. package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
  37. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  38. package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
  39. package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
  40. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
  41. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
  42. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  43. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  44. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
  45. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
  46. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  47. package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
  48. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  49. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  50. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
  51. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
  52. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  53. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  54. package/node_modules/@dougharness/gates/package.json +1 -1
  55. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  56. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
  57. package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
  58. package/package.json +4 -4
  59. package/templates/board-app.js +48 -1
@@ -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,11 +88,12 @@ 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";
95
95
  import { docsGateDecision } from "../lib/docs-gate.mjs";
96
+ import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
96
97
  import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
97
98
 
98
99
  // Only the last this many characters of last_assistant_message are ever scanned for a partial claim
@@ -491,7 +492,24 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
491
492
  // readAgentStart) and it equals the current one (same HEAD, same dirty set, same identities), and the agent's
492
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
493
494
  // agentMadeNoChanges counts as a change. No record, no git, any failure -> false (run the gate).
494
- 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) {
495
513
  try {
496
514
  if (!subagent || !currentFingerprint) return false;
497
515
  if (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path))) return false;
@@ -502,6 +520,24 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
502
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;
503
521
  }
504
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
+ }
505
541
  return sameTree(start, currentFingerprint);
506
542
  } catch {
507
543
  return false;
@@ -511,7 +547,8 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
511
547
  // card worktree-agent-gate, card worktree-gate-mixed-tree-agent: the trees a SubagentStop verifies, in
512
548
  // first-touched order. A worktree-isolated subagent edits under <dir>/.claude/worktrees/<wt>/ (input.cwd inside
513
549
  // it, or its own successful Edit-type trace lines carry absolute paths there); a path inside <dir> but outside
514
- // .claude/worktrees names the main checkout; any other path names nothing. The set is the cwd's worktree plus
550
+ // .claude/worktrees names the main checkout, except <dir>/.doug/.state and anything under it (untracked state,
551
+ // never project code) which names nothing; any other path names nothing. The set is the cwd's worktree plus
515
552
  // every tree its trace paths name (a worktree that no longer exists as a directory is skipped). Empty, a main
516
553
  // Stop, or any failure resolves to [dir]. Only the verification target varies per tree: state, trace,
517
554
  // gate.lock, the ledger, and evidence stay under `dir`. Never throws.
@@ -527,8 +564,11 @@ function verifyDirs(dir, cfg, sessionId, input, subagent) {
527
564
  };
528
565
  const inMain = (p) => {
529
566
  if (typeof p !== "string" || !isAbsolute(p)) return false;
530
- const rel = relative(dir, resolve(p));
531
- return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
567
+ const abs = resolve(p);
568
+ const rel = relative(dir, abs);
569
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
570
+ const st = relative(join(dir, ".doug/.state"), abs);
571
+ return !(st === "" || (!st.startsWith("..") && !isAbsolute(st)));
532
572
  };
533
573
  const trees = [];
534
574
  const add = (t) => {
@@ -716,6 +756,7 @@ function contextNotice(dir, cfg, state, sessionId, boundary) {
716
756
  const anchorFile = join(dir, ".doug/anchor.md");
717
757
  const section = snapshotSection({
718
758
  plan: readPlanSummary(dir),
759
+ plans: readPlanSummaries(dir),
719
760
  decisions: readDecisions(dir),
720
761
  state,
721
762
  now: new Date(),
@@ -967,70 +1008,6 @@ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
967
1008
  return notice ? systemMessage(notice) : allow();
968
1009
  }
969
1010
 
970
- // Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
971
- // at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
972
- // 2026-09-16 process storm). `.doug/.state/gate.lock` holds `{ pid, session, at }` while a gate's commands
973
- // run.
974
- const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
975
- function gateLockPath(dir) {
976
- return join(dir, GATE_LOCK_RELPATH);
977
- }
978
-
979
- // Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
980
- // content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
981
- // caller treats exactly like a stale one.
982
- function readGateLock(dir) {
983
- try {
984
- const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
985
- // A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
986
- // group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
987
- // throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
988
- return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
989
- } catch {
990
- return null;
991
- }
992
- }
993
-
994
- function pidIsLive(pid) {
995
- try {
996
- process.kill(pid, 0);
997
- return true;
998
- } catch (err) {
999
- // EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
1000
- // that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
1001
- // anything else) reads as dead.
1002
- return err && err.code === "EPERM";
1003
- }
1004
- }
1005
-
1006
- // Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
1007
- // caller decides what a failure means.
1008
- function writeGateLock(dir, sessionId) {
1009
- mkdirSync(join(dir, ".doug/.state"), { recursive: true });
1010
- writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
1011
- }
1012
-
1013
- // Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
1014
- // simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
1015
- // it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
1016
- // does not hold).
1017
- function takeOverGateLock(dir, sessionId) {
1018
- try {
1019
- mkdirSync(join(dir, ".doug/.state"), { recursive: true });
1020
- writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
1021
- } catch {
1022
- // best-effort takeover only
1023
- }
1024
- }
1025
-
1026
- // Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
1027
- // Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
1028
- // missing, non-number, or non-positive value reads as the platform's own default, 600.
1029
- function resolveHookTimeoutSec(cfg) {
1030
- const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
1031
- return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
1032
- }
1033
-
1034
1011
  // Computed once per gate run: the wall-clock instant past which the platform itself would cancel this
1035
1012
  // hook (F1 — a timed-out hook is killed, fails open, and its output, including a block message, is
1036
1013
  // discarded), less a margin so the gate's own block reaches Claude Code before that happens. gateStart
@@ -1049,26 +1026,8 @@ function gateBudget(cfg, inherited) {
1049
1026
  return { hookTimeoutSec, hookTimeoutMs, deadline };
1050
1027
  }
1051
1028
 
1052
- // A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
1053
- // legitimate holder can still be running that long, since the platform itself would have killed its hook
1054
- // by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
1055
- function lockStaleByAge(lock, hookTimeoutMs) {
1056
- return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
1057
- }
1058
-
1059
- // Card stop-gate-timeout-normalise: cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout
1060
- // and the wait bound below, so the two never disagree on what a garbage or fractional value means. A finite
1061
- // number above 0 floors to an integer, clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a
1062
- // timeout of 0 as none at all, letting a command run past the budget unclamped; 2.5 must not reach spawnSync
1063
- // un-floored — it throws ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else
1064
- // (non-number, NaN, Infinity, 0, negative, null) is "unset" (null), read by each call site as its own default.
1065
- function normalizedTimeoutMs(cfg) {
1066
- const t = cfg.stopGate.timeoutMs;
1067
- return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
1068
- }
1069
-
1070
1029
  // Card stop-gate-deferral-cap (R2): cfg.stopGate.maxDeferrals normalised the same shape as
1071
- // normalizedTimeoutMs above — a hook reader must never throw (F6/F10), so a finite number >= 1 is floored
1030
+ // normalizedTimeoutMs (lib/gate-lock.mjs) — a hook reader must never throw (F6/F10), so a finite number >= 1 is floored
1072
1031
  // and used; anything else (missing, 0, negative, NaN, a string, null, an object) reads as the default, 10.
1073
1032
  function normalizedMaxDeferrals(cfg) {
1074
1033
  const m = cfg && cfg.stopGate && cfg.stopGate.maxDeferrals;
@@ -1085,10 +1044,99 @@ function normalizedDeferrals(state) {
1085
1044
  return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
1086
1045
  }
1087
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
+
1088
1135
  const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
1089
1136
 
1090
1137
  runHook("stop-gate", async (input) => {
1091
- if (input.stop_hook_active) return allow();
1138
+ if (input.stop_hook_active) return allowRaw();
1139
+ if (input.hook_event_name !== "SubagentStop") landingInput = input;
1092
1140
  const dir = projectDir(input);
1093
1141
  const cfg = loadConfig(dir);
1094
1142
  let state = loadState(dir, input.session_id);
@@ -1471,8 +1519,8 @@ runHook("stop-gate", async (input) => {
1471
1519
  // Draft and rejected plans are not contracts yet, so they are ignored.
1472
1520
  let scope = null;
1473
1521
  if (Array.isArray(changed) && cfg.stopGate.planScope !== false) {
1474
- const { plan, reason, error } = loadScopePlan(dir);
1475
- 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`);
1476
1524
  if (plan) {
1477
1525
  const violations = scopeViolations(changed, plan, { ignore: cfg.stopGate.ignoreChangedPaths || [] });
1478
1526
  scope = { plan: plan.title, status: plan.status, outOfScope: violations.map((v) => v.file) };
@@ -1526,7 +1574,10 @@ runHook("stop-gate", async (input) => {
1526
1574
  state.deferrals = n;
1527
1575
  state.lastDeferral = { ids, fingerprint: currentFingerprint };
1528
1576
  saveState(dir, input.session_id, state);
1529
- if (repeat) return allow();
1577
+ if (repeat) {
1578
+ if (landingInput) sweepLandingMarkers(landingInput);
1579
+ return allowRaw();
1580
+ }
1530
1581
  return systemMessage(
1531
1582
  `[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
1532
1583
  );
@@ -1575,7 +1626,7 @@ runHook("stop-gate", async (input) => {
1575
1626
  };
1576
1627
  const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
1577
1628
  ? "no-agent-changes"
1578
- : subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
1629
+ : subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint, vdir, forced)
1579
1630
  ? "tree-unchanged"
1580
1631
  : readOnlyTurn
1581
1632
  ? "read-only-turn"
@@ -1665,7 +1716,7 @@ runHook("stop-gate", async (input) => {
1665
1716
 
1666
1717
  // card stop-gate-process-storm, case 3: only one gate runs the verification commands at a time per
1667
1718
  // project (the 2026-09-16 process storm: four concurrent SubagentStops each ran the full suite, each
1668
- // 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;
1669
1720
  // a second gate that finds a live lock waits for it, bounded by stopGate.timeoutMs, then reloads state and
1670
1721
  // reapplies the skip above — the holder's own green run may have just recorded the very `verified` tree
1671
1722
  // this gate needs, letting it skip instead of also running the full suite.
@@ -1709,7 +1760,7 @@ runHook("stop-gate", async (input) => {
1709
1760
  // timeoutMs no longer defaults to a flat 300000ms here — the budget deadline below is the real bound
1710
1761
  // either way, so an unset value waits only for what remains of it, unmultiplied.
1711
1762
  const normalizedTimeout = normalizedTimeoutMs(cfg);
1712
- const waitBoundMs = normalizedTimeout !== null ? normalizedTimeout * Math.max(1, resolveCommands(cfg).length) : budgetDeadline - Date.now();
1763
+ const waitBoundMs = normalizedTimeout !== null ? lockWaitBoundMs(normalizedTimeout, resolveCommands(cfg).length) : budgetDeadline - Date.now();
1713
1764
  const waitDeadline = Date.now() + waitBoundMs;
1714
1765
  // Card stop-gate-budget-under-hook-timeout: waitDeadline alone can outrun the platform's own hook
1715
1766
  // timeout (F1), so a waiter also stops at budgetDeadline — but there it refuses the stop instead of
@@ -4,11 +4,13 @@
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 no 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
+ import { unlinkSync } from "node:fs";
10
11
  import { resolve } from "node:path";
11
12
  import { loadConfig } from "../lib/config.mjs";
13
+ import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
12
14
  import { runCommand, tail } from "../lib/run.mjs";
13
15
  import { parseTotals, readSummaries, sumTotals } from "../lib/test-totals.mjs";
14
16
  import { computeTree, readRecord, writeRecord, removeRecord, creditable, resolveCommands } from "../lib/verified-tree.mjs";
@@ -75,8 +77,11 @@ function main() {
75
77
  process.stderr.write("[doug] verify: no commands (stopGate.commands is empty and no --command given)\n");
76
78
  return 2;
77
79
  }
78
- const treeBefore = computeTree(cwd);
80
+ let treeBefore = computeTree(cwd);
81
+ let lockWaitMs = 0;
79
82
 
83
+ // Credits a verified-tree record of the same tree when one exists; returns the exit code, or null.
84
+ const tryCredit = () => {
80
85
  if (!o.noCredit && treeBefore) {
81
86
  const wanted = commands.map((c) => c.command);
82
87
  let record = null;
@@ -94,7 +99,7 @@ function main() {
94
99
  const entries = record.commands.map((c) => ({ name: c.name, exit: c.exit, passed: passed(record.totals[c.command]) }));
95
100
  if (o.json) {
96
101
  const results = record.commands.map((c) => ({ name: c.name, command: c.command, ok: true, status: c.exit, timedOut: false, durationMs: c.durationMs, outputTail: "" }));
97
- 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");
98
103
  } else {
99
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`);
100
105
  process.stdout.write(gateLine(entries) + "\n");
@@ -102,8 +107,62 @@ function main() {
102
107
  return 0;
103
108
  }
104
109
  }
110
+ return null;
111
+ };
112
+ const first = tryCredit();
113
+ if (first !== null) return first;
105
114
 
106
- const timeoutMs = o.timeoutMs ? o.timeoutMs : Number.isInteger(cfg.stopGate && cfg.stopGate.timeoutMs) && cfg.stopGate.timeoutMs > 0 ? cfg.stopGate.timeoutMs : 600000;
115
+ const timeoutMs = o.timeoutMs ? o.timeoutMs : (cfg.stopGate && normalizedTimeoutMs(cfg)) ?? 600000;
116
+ const waitBoundMs = lockWaitBoundMs(timeoutMs, commands.length);
117
+
118
+ // Card verify-takes-gate-lock: one full suite at a time per checkout, the same gate.lock the Stop gate takes
119
+ // (lib/gate-lock.mjs). A live holder is waited on for up to timeoutMs x command count (the Stop gate's bound), then the record is re-checked (the
120
+ // holder's green run may have just written it); a lock still held after the bound is taken over and the
121
+ // commands run. Released only by the exit handler below, so every return path (green, red, throw) frees it.
122
+ let lockOwned = false;
123
+ process.on("exit", () => {
124
+ try {
125
+ if (!lockOwned) return;
126
+ const lock = readGateLock(dir);
127
+ if (lock && lock.pid === process.pid) unlinkSync(gateLockPath(dir));
128
+ } catch {
129
+ // never throw from an exit handler
130
+ }
131
+ });
132
+ try {
133
+ writeGateLock(dir, o.by || "verify");
134
+ lockOwned = true;
135
+ } catch (err) {
136
+ if (!err || err.code !== "EEXIST") {
137
+ lockOwned = false; // an unwritable .doug/.state: run unlocked rather than hang
138
+ } else {
139
+ const hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
140
+ const stale = (l) => !l || !pidIsLive(l.pid) || lockStaleByAge(l, hookTimeoutMs);
141
+ const held = readGateLock(dir);
142
+ if (stale(held)) {
143
+ takeOverGateLock(dir, o.by || "verify");
144
+ lockOwned = true;
145
+ } else {
146
+ const waitStart = Date.now();
147
+ const deadline = waitStart + waitBoundMs;
148
+ let live = held;
149
+ const nap = new Int32Array(new SharedArrayBuffer(4));
150
+ while (true) {
151
+ live = readGateLock(dir);
152
+ if (stale(live) || Date.now() >= deadline) break;
153
+ Atomics.wait(nap, 0, 0, 200);
154
+ }
155
+ lockWaitMs = Date.now() - waitStart;
156
+ if (!o.json && lockWaitMs > 0) process.stdout.write(`[doug] verify: waited ${lockWaitMs} ms on gate.lock\n`);
157
+ treeBefore = computeTree(cwd);
158
+ const again = tryCredit();
159
+ if (again !== null) return again;
160
+ if (!stale(live)) process.stderr.write(`[doug] verify: gate.lock still held by pid ${live.pid} after ${waitBoundMs} ms; running the commands anyway.\n`);
161
+ takeOverGateLock(dir, o.by || "verify");
162
+ lockOwned = true;
163
+ }
164
+ }
165
+ }
107
166
  const results = [];
108
167
  const recCommands = [];
109
168
  const totals = {};
@@ -131,7 +190,7 @@ function main() {
131
190
  } else {
132
191
  removeRecord(recordDir);
133
192
  }
134
- 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");
135
194
  else process.stdout.write(gateLine(entries) + "\n");
136
195
  return ok ? 0 : 1;
137
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/cli",
3
- "version": "1.1.0",
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",
@@ -13,7 +13,7 @@
13
13
  "access": "public"
14
14
  },
15
15
  "bin": {
16
- "doug": "./dist/bin.js"
16
+ "doug": "dist/bin.js"
17
17
  },
18
18
  "files": [
19
19
  "dist",
@@ -23,8 +23,8 @@
23
23
  "node": ">=22.16"
24
24
  },
25
25
  "dependencies": {
26
- "@dougharness/flow": "1.1.0",
27
- "@dougharness/gates": "1.1.0"
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>
@@ -339,7 +382,8 @@ function app() {
339
382
  });
340
383
  if (res.status === 200) {
341
384
  board = await res.json();
342
- say("Saved to " + config.record + ".");
385
+ const warning = res.headers.get("x-doug-warning");
386
+ say("Saved to " + config.record + "." + (warning ? " " + warning : ""));
343
387
  } else {
344
388
  let message = "The move was rejected (" + res.status + ").";
345
389
  try { const body = await res.json(); if (body && body.error) message = body.error; } catch (_) {}
@@ -508,6 +552,8 @@ function app() {
508
552
  readOnly = false;
509
553
  document.body.classList.remove("ro");
510
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(() => {});
511
557
  // Once served, follow the record live: another writer's move (or this tab's own, echoed
512
558
  // back) re-renders in place, or reloads when the columns or filters themselves changed.
513
559
  connectLive({
@@ -515,6 +561,7 @@ function app() {
515
561
  url: "/api/events",
516
562
  setTimeout: window.setTimeout.bind(window),
517
563
  clearTimeout: window.clearTimeout.bind(window),
564
+ onAutopilot: (view) => applyAutopilot(view),
518
565
  onBoard: (next) => {
519
566
  const updated = applyLiveBoard(board, next, config);
520
567
  if (updated) {