@dougharness/cli 1.5.0 → 1.6.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 (33) hide show
  1. package/dist/generate/agents.js +4 -0
  2. package/dist/generate/agents.js.map +1 -1
  3. package/dist/generate/claude-md.js +1 -1
  4. package/dist/generate/claude-md.js.map +1 -1
  5. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  6. package/node_modules/@dougharness/flow/agents/coder.md +2 -0
  7. package/node_modules/@dougharness/flow/agents/implementer.md +2 -0
  8. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  9. package/node_modules/@dougharness/flow/agents/reviewer.md +2 -0
  10. package/node_modules/@dougharness/flow/agents/tester.md +2 -0
  11. package/node_modules/@dougharness/flow/lib/card-loop.mjs +16 -6
  12. package/node_modules/@dougharness/flow/lib/code-index.mjs +2 -2
  13. package/node_modules/@dougharness/flow/lib/coordinator.mjs +4 -2
  14. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -2
  15. package/node_modules/@dougharness/flow/lib/learn.mjs +11 -4
  16. package/node_modules/@dougharness/flow/lib/memory.mjs +46 -21
  17. package/node_modules/@dougharness/flow/package.json +1 -1
  18. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +16 -0
  19. package/node_modules/@dougharness/flow/scripts/board.mjs +5 -1
  20. package/node_modules/@dougharness/flow/scripts/learn.mjs +3 -1
  21. package/node_modules/@dougharness/flow/scripts/memory.mjs +218 -9
  22. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +3 -1
  23. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +2 -0
  24. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -3
  25. package/node_modules/@dougharness/flow/workflows/card-queen.js +16 -6
  26. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  27. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +1 -0
  28. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +175 -4
  29. package/node_modules/@dougharness/gates/lib/test-totals.mjs +1 -1
  30. package/node_modules/@dougharness/gates/package.json +1 -1
  31. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +69 -99
  32. package/node_modules/@dougharness/gates/scripts/verify.mjs +27 -9
  33. package/package.json +3 -3
@@ -1,9 +1,11 @@
1
1
  // The gate.lock helpers shared by the Stop gate (scripts/stop-gate.mjs) and verify.mjs (card
2
2
  // verify-takes-gate-lock). Moved verbatim from stop-gate.mjs; the Stop gate's behaviour is unchanged.
3
3
 
4
- import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { execFileSync } from "node:child_process";
4
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { execFileSync, spawnSync } from "node:child_process";
6
6
  import { dirname, join, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { loadConfig } from "./config.mjs";
7
9
 
8
10
  // Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
9
11
  // at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
@@ -72,10 +74,10 @@ export function pidIsLive(pid) {
72
74
 
73
75
  // Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
74
76
  // caller decides what a failure means.
75
- export function writeGateLock(dir, sessionId) {
77
+ export function writeGateLock(dir, sessionId, pid = process.pid) {
76
78
  const p = gateLockPath(dir);
77
79
  mkdirSync(dirname(p), { recursive: true });
78
- writeFileSync(p, JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
80
+ writeFileSync(p, JSON.stringify({ pid, session: sessionId, at: Date.now() }), { flag: "wx" });
79
81
  }
80
82
 
81
83
  // Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
@@ -92,6 +94,48 @@ export function takeOverGateLock(dir, sessionId) {
92
94
  }
93
95
  }
94
96
 
97
+ // Card gate-load-timeouts: exclusive acquisition for a waiter that saw the lock released, dead or stale. The
98
+ // old path overwrote it, so two waiters leaving the wait loop together both ran their suites (ledger blocks 425
99
+ // and 426). Creates with wx; a stale lock is removed only while the content judged stale is still in place, and
100
+ // EEXIST reports "busy" so the caller keeps waiting. Returns "acquired", "busy" or "unwritable" (the lock cannot
101
+ // be written at all: run unlocked). Never throws.
102
+ export function acquireGateLock(dir, sessionId, hookTimeoutMs, pid = process.pid) {
103
+ try {
104
+ const p = gateLockPath(dir);
105
+ for (let attempt = 0; attempt < 3; attempt++) {
106
+ try {
107
+ writeGateLock(dir, sessionId, pid);
108
+ return "acquired";
109
+ } catch (err) {
110
+ if (!err || err.code !== "EEXIST") return "unwritable";
111
+ }
112
+ let raw;
113
+ try {
114
+ raw = readFileSync(p, "utf8");
115
+ } catch {
116
+ continue; // released between the create and the read: try the create again
117
+ }
118
+ let lock = null;
119
+ try {
120
+ const o = JSON.parse(raw);
121
+ lock = o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
122
+ } catch {
123
+ lock = null;
124
+ }
125
+ if (lock && pidIsLive(lock.pid) && !lockStaleByAge(lock, hookTimeoutMs)) return "busy";
126
+ try {
127
+ if (readFileSync(p, "utf8") !== raw) return "busy"; // someone else replaced it meanwhile
128
+ unlinkSync(p);
129
+ } catch {
130
+ // already gone, or not removable: the next create decides
131
+ }
132
+ }
133
+ return "busy";
134
+ } catch {
135
+ return "unwritable";
136
+ }
137
+ }
138
+
95
139
  // Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
96
140
  // Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
97
141
  // missing, non-number, or non-positive value reads as the platform's own default, 600.
@@ -124,3 +168,130 @@ export function normalizedTimeoutMs(cfg) {
124
168
  export function lockWaitBoundMs(perCommandMs, commandCount) {
125
169
  return perCommandMs * Math.max(1, commandCount);
126
170
  }
171
+
172
+ // Card gate-load-timeouts: CLI for .githooks/pre-commit, so a commit's suites queue on the same gate.lock as the
173
+ // Stop gate and verify.mjs (ledger blocks 422, 432, 435 had a lock-free pre-commit suite next to a gate's).
174
+ // `node gate-lock.mjs run -- <command> [args...]`, run from the repo top level: waits on a live lock up to
175
+ // stopGate.timeoutMs (default 300000), then runs anyway; a stale lock is taken at once; a lock that cannot be
176
+ // written runs unlocked. Leading NAME=VALUE arguments set the command's env; one remaining argument runs via `sh -c`.
177
+ // Holds the lock while the command runs, releases it, exits with the command's status.
178
+ function isMain() {
179
+ try {
180
+ return !!process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+ if (isMain()) {
186
+ const args = process.argv.slice(2);
187
+ const dir = process.cwd();
188
+ const sub = args[0];
189
+ if (sub === "acquire" || sub === "release") {
190
+ // Card gate-lock-takeover-exclusive: one lock for a whole shell hook, held under the hook shell's own pid
191
+ // (`--pid $$`, alive for the whole hook). `acquire` waits like `run` and exits 0 whether it owns the lock,
192
+ // gave up waiting, or the lock is unwritable (the hook then runs unlocked); a non-zero exit is a runner
193
+ // failure. `release` removes the lock only when it names that pid, and never fails.
194
+ const pidArg = args.indexOf("--pid") === -1 ? NaN : Number(args[args.indexOf("--pid") + 1]);
195
+ if (!Number.isInteger(pidArg) || pidArg <= 0) {
196
+ process.stderr.write(`usage: gate-lock.mjs ${sub} --pid <pid>\n`);
197
+ process.exit(2);
198
+ }
199
+ if (sub === "release") {
200
+ try {
201
+ const lock = readGateLock(dir);
202
+ if (lock && lock.pid === pidArg) unlinkSync(gateLockPath(dir));
203
+ } catch {
204
+ // never fail a release
205
+ }
206
+ process.exit(0);
207
+ }
208
+ let waitMs = 300000;
209
+ let hookTimeoutMs = 600000;
210
+ try {
211
+ const cfg = loadConfig(dir);
212
+ waitMs = normalizedTimeoutMs(cfg) ?? waitMs;
213
+ hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
214
+ } catch {
215
+ // defaults
216
+ }
217
+ const deadline = Date.now() + waitMs;
218
+ const nap = new Int32Array(new SharedArrayBuffer(4));
219
+ while (true) {
220
+ const r = acquireGateLock(dir, "pre-commit", hookTimeoutMs, pidArg);
221
+ if (r !== "busy") break;
222
+ if (Date.now() >= deadline) {
223
+ process.stderr.write(`[doug] pre-commit: gate.lock still held after ${waitMs} ms; running anyway.\n`);
224
+ break;
225
+ }
226
+ Atomics.wait(nap, 0, 0, 200);
227
+ }
228
+ process.exit(0);
229
+ }
230
+ const sep = args.indexOf("--");
231
+ const cmd = sep === -1 ? [] : args.slice(sep + 1);
232
+ if (args[0] !== "run" || cmd.length === 0) {
233
+ process.stderr.write("usage: gate-lock.mjs run -- <command> [args...]\n");
234
+ process.exit(2);
235
+ }
236
+ let owned = false;
237
+ const release = () => {
238
+ try {
239
+ if (!owned) return;
240
+ owned = false;
241
+ const lock = readGateLock(dir);
242
+ if (lock && lock.pid === process.pid) unlinkSync(gateLockPath(dir));
243
+ } catch {
244
+ // never throw from a release
245
+ }
246
+ };
247
+ process.on("exit", release);
248
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(128));
249
+ try {
250
+ let waitMs = 300000;
251
+ let hookTimeoutMs = 600000;
252
+ try {
253
+ const cfg = loadConfig(dir);
254
+ waitMs = normalizedTimeoutMs(cfg) ?? waitMs;
255
+ hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
256
+ } catch {
257
+ // defaults
258
+ }
259
+ const deadline = Date.now() + waitMs;
260
+ const nap = new Int32Array(new SharedArrayBuffer(4));
261
+ while (true) {
262
+ const r = acquireGateLock(dir, "pre-commit", hookTimeoutMs);
263
+ if (r === "acquired") {
264
+ owned = true;
265
+ break;
266
+ }
267
+ if (r === "unwritable") break;
268
+ if (Date.now() >= deadline) {
269
+ process.stderr.write(`[doug] pre-commit: gate.lock still held after ${waitMs} ms; running ${cmd[0]} anyway.\n`);
270
+ break;
271
+ }
272
+ Atomics.wait(nap, 0, 0, 200);
273
+ }
274
+ } catch {
275
+ // run unlocked rather than block a commit on a lock fault
276
+ }
277
+ // Card gate-lock-run-shell-forms: leading NAME=VALUE arguments go into the child's environment, a single
278
+ // remaining argument runs through `sh -c` (a quoted compound command), and several arguments spawn as argv
279
+ // unchanged. A command that cannot be spawned says so on stderr and exits 127 instead of a silent 1.
280
+ const env = { ...process.env };
281
+ let argv = cmd;
282
+ while (argv.length > 1 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0])) {
283
+ const eq = argv[0].indexOf("=");
284
+ env[argv[0].slice(0, eq)] = argv[0].slice(eq + 1);
285
+ argv = argv.slice(1);
286
+ }
287
+ const res =
288
+ argv.length === 1
289
+ ? spawnSync("sh", ["-c", argv[0]], { stdio: "inherit", env })
290
+ : spawnSync(argv[0], argv.slice(1), { stdio: "inherit", env });
291
+ release();
292
+ if (res.error) {
293
+ process.stderr.write(`[doug] gate-lock run: cannot run ${argv[0]}: ${res.error.message}\n`);
294
+ process.exit(127);
295
+ }
296
+ process.exit(res.status === null ? 1 : res.status);
297
+ }
@@ -5,7 +5,7 @@
5
5
  // - Counts cannot see delete-one-add-one: a session that removes a test and adds an unrelated one keeps
6
6
  // every number level and passes.
7
7
  // - The verified-unchanged skip runs no test command, so there is no output to count there; no count check
8
- // happens on it. (The pre-commit credit path is counted from the totals the hook records in last-run.json.)
8
+ // happens on it. (The credited path is counted from the totals the verified-tree record carries.)
9
9
  // - The baseline is the number of `Tests` summaries a command prints plus their sum (ADR 0013): a session
10
10
  // that adds or removes a package changes the number of summaries and blocks with no waiver until a new
11
11
  // baseline is taken.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/gates",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "private": true,
5
5
  "description": "Deterministic Claude Code hooks: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, stop gate, re-anchor after compaction.",
6
6
  "type": "module",
@@ -91,9 +91,9 @@ import { isAppliedContent } from "../lib/proposals.mjs";
91
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
- import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine, outputTail, redactText } from "../lib/block-ledger.mjs";
94
+ import { failingTestFilesFrom, tscErrorPaths, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine, outputTail, redactText } 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
+ import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, acquireGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
97
97
  import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable, totalsUsable, exitCreditUsable } from "../lib/verified-tree.mjs";
98
98
 
99
99
  // Only the last this many characters of last_assistant_message are ever scanned for a partial claim
@@ -560,7 +560,10 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
560
560
  // .claude/worktrees names the main checkout, except <dir>/.doug/.state and anything under it (untracked state,
561
561
  // never project code) which names nothing; any other path names nothing. The set is the cwd's worktree plus
562
562
  // every tree its trace paths name (a worktree that no longer exists as a directory is skipped). Empty, a main
563
- // Stop, or any failure resolves to [dir]. Only the verification target varies per tree: state, trace,
563
+ // Stop, or any failure resolves to [dir]. Card queen-seat-stop-gates-worktree: a Bash or Monitor line of the
564
+ // agent (Pre or Post, whatever ok) also names a worktree by any absolute path token in its detail (a seat
565
+ // that edits only through `cd <wt> && ...`); a shell token never names the main checkout.
566
+ // Only the verification target varies per tree: state, trace,
564
567
  // gate.lock, the ledger, and evidence stay under `dir`. Never throws.
565
568
  function verifyDirs(dir, cfg, sessionId, input, subagent) {
566
569
  try {
@@ -598,7 +601,22 @@ function verifyDirs(dir, cfg, sessionId, input, subagent) {
598
601
  const file = tracePath(dir, sessionId);
599
602
  if (existsSync(file)) {
600
603
  for (const l of readTrace(file)) {
601
- if (l.agent !== subagent.id || l.event !== "PostToolUse" || !CHANGE_TOOLS.has(l.tool) || l.ok === false) continue;
604
+ if (l.agent !== subagent.id) continue;
605
+ if (SHELL_TOOLS.has(l.tool) && (l.event === "PreToolUse" || l.event === "PostToolUse")) {
606
+ // card queen-seat-stop-gates-worktree: a shell command names a worktree by an absolute path token;
607
+ // a main-checkout path in it names nothing.
608
+ for (const tok of String(l.detail || "").split(/[\s;&|()<>'"`=]+/)) {
609
+ if (!tok.startsWith("/")) continue;
610
+ // the trace clips a detail at 120 chars with a trailing ellipsis; a clipped token names a worktree
611
+ // only when the name is followed by "/" inside it (else the cut name may be a sibling's prefix)
612
+ const clipped = tok.endsWith("…");
613
+ const t = clipped ? tok.slice(0, -1) : tok;
614
+ const n = wtName(t);
615
+ if (n && (!clipped || t.includes("/" + n + "/"))) addWt(n);
616
+ }
617
+ continue;
618
+ }
619
+ if (l.event !== "PostToolUse" || !CHANGE_TOOLS.has(l.tool) || l.ok === false) continue;
602
620
  const name = wtName(l.detail);
603
621
  if (name) addWt(name);
604
622
  else if (inMain(l.detail)) add(dir);
@@ -865,14 +883,14 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
865
883
  // logging must never fail the gate
866
884
  }
867
885
  };
868
- // Each entry is `{ command, stdout }` (a real run: parsed here) or `{ command, result }` (a credited pre-commit
886
+ // Each entry is `{ command, stdout }` (a real run: parsed here) or `{ command, result }` (a credited verified-tree
869
887
  // run: the parseTotals-shaped result the hook recorded). Both take the identical path below.
870
888
  for (const { command, stdout, result } of ran) {
871
889
  try {
872
890
  const p = result !== undefined ? result : parseTotals(stdout);
873
891
  const summaries = p.status === "ok" ? readSummaries(p) : null;
874
892
  if (!summaries) {
875
- note(`count check did not run for \`${command}\`: no test summary in its output`);
893
+ note(`count check did not run for \`${redactText(command, cfg.secrets)}\`: no test summary in its output`);
876
894
  continue;
877
895
  }
878
896
  const baseline = baselineFor(command);
@@ -880,11 +898,11 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
880
898
  const baseList = all ? readSummaries(all[command]) : null;
881
899
  if (!baseList) {
882
900
  parsed[command] = summaries;
883
- note(`count check did not run for \`${command}\`: no baseline totals for it (${summaries.length} test summar${summaries.length === 1 ? "y" : "ies"} printed: ${summaries.map((s) => `total ${s.total}, skipped ${s.skipped}, todo ${s.todo}`).join("; ")})`);
901
+ note(`count check did not run for \`${redactText(command, cfg.secrets)}\`: no baseline totals for it (${summaries.length} test summar${summaries.length === 1 ? "y" : "ies"} printed: ${summaries.map((s) => `total ${s.total}, skipped ${s.skipped}, todo ${s.todo}`).join("; ")})`);
884
902
  continue;
885
903
  }
886
904
  if (baseList.length !== summaries.length) {
887
- problems.push(`\`${command}\` printed ${summaries.length} test summar${summaries.length === 1 ? "y" : "ies"}; the baseline has ${baseList.length}. The test count cannot be trusted. A waiver does not cover this; take a new baseline if a package was added or removed.`);
905
+ problems.push(`\`${redactText(command, cfg.secrets)}\` printed ${summaries.length} test summar${summaries.length === 1 ? "y" : "ies"}; the baseline has ${baseList.length}. The test count cannot be trusted. A waiver does not cover this; take a new baseline if a package was added or removed.`);
888
906
  continue;
889
907
  }
890
908
  parsed[command] = summaries;
@@ -896,10 +914,10 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
896
914
  if (waiverCovers(waiverSources(dir, state.baselineHead), delta)) continue;
897
915
  const lines = found.map((f) => `${f.kind} ${f.kind === "total" ? "dropped" : "rose"} from ${f.baseline} (baseline) to ${f.now} (now)`);
898
916
  problems.push(
899
- `\`${command}\` test count regressed: ${lines.join("; ")}. Deleting or skipping tests does not make a change green. Restore them, or waive it with a line \`test-count-waiver: total=${delta.total} skipped=${delta.skipped} todo=${delta.todo}; <reason>\` in the commit message or ${WAIVER_FILE_RELPATH}.`,
917
+ `\`${redactText(command, cfg.secrets)}\` test count regressed: ${lines.join("; ")}. Deleting or skipping tests does not make a change green. Restore them, or waive it with a line \`test-count-waiver: total=${delta.total} skipped=${delta.skipped} todo=${delta.todo}; <reason>\` in the commit message or ${WAIVER_FILE_RELPATH}.`,
900
918
  );
901
919
  } catch {
902
- note(`count check did not run for \`${command}\`: error while checking`);
920
+ note(`count check did not run for \`${redactText(command, cfg.secrets)}\`: error while checking`);
903
921
  }
904
922
  }
905
923
  return { problems, parsed };
@@ -923,19 +941,6 @@ function writeLastGreen(dir, parsed) {
923
941
  }
924
942
  }
925
943
 
926
- // card stop-gate-credits-pre-commit: `git rev-parse HEAD^{tree}`, the tree sha of the current commit — what
927
- // the pre-commit hook's own record (.doug/.state/pre-commit/last-run.json) compares its `tree` field against.
928
- // Null on any failure (no git, no HEAD yet, ...): a reader must never throw.
929
- function headTreeSha(dir) {
930
- try {
931
- const out = execFileSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
932
- const sha = out.trim();
933
- return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
934
- } catch {
935
- return null;
936
- }
937
- }
938
-
939
944
  // card ratchet-pre-commit-credit: a recorded parseTotals result, normalised, or null when it is not readable.
940
945
  function readableRecordedTotals(t) {
941
946
  try {
@@ -948,10 +953,10 @@ function readableRecordedTotals(t) {
948
953
  }
949
954
 
950
955
  // card verified-tree-record: a verified-tree record (lib/verified-tree.mjs) of `tree` (the working tree now), same
951
- // Node and command set, credits the commands instead of re-running them. Same return shape as preCommitCredit:
956
+ // Node and command set, credits the commands instead of re-running them. Returns
952
957
  // one `{ name, command, ok: true, credited: "verified-tree", recorded? }` per resolveCommands entry in config
953
958
  // order. A command with a baseline in state.testTotals and no readable recorded totals returns null (the commands
954
- // run), the same rule as preCommitCredit. Never throws: any failure is null, and null runs the commands.
959
+ // run). Never throws: any failure is null, and null runs the commands.
955
960
  function treeCredit(dir, cfg, state, tree, mainDir) {
956
961
  try {
957
962
  if (!tree) return null;
@@ -977,59 +982,6 @@ function treeCredit(dir, cfg, state, tree, mainDir) {
977
982
  }
978
983
  }
979
984
 
980
- // card stop-gate-credits-pre-commit: the pre-commit hook (.githooks/pre-commit) writes a machine-readable
981
- // record of its own run to .doug/.state/pre-commit/last-run.json (version, tree, ok, at, commands: [{ name,
982
- // command, exit, durationMs, totals? }], totals being the hook's parseTotals result for that command; a command with a baseline in state.testTotals is credited only if its totals are readable (a list of summaries, or an old ok or conflict shape), else the commands run). When the working tree has no changes beyond HEAD (nothing left in `changed`
983
- // once stopGate.ignoreChangedPaths paths are removed) and that record is a passing run, against HEAD's own
984
- // tree, of exactly today's configured commands in order, the Stop gate credits it instead of re-running the
985
- // same commands a second time — returning `{ name, command, ok: true, credited: "pre-commit" }` per command,
986
- // same length and order as resolveCommands(cfg). Returns null (run as today) on a missing, unparsable,
987
- // wrong-shape, failing, stale-tree, or mismatched-commands record, or a dirty tree — fail closed to running,
988
- // never to passing. A reader must never throw: every path here is wrapped, so a corrupt record never crashes
989
- // the hook (`runHook` fails open on a throw, which here would mean passing without running — forbidden).
990
- function preCommitCredit(dir, cfg, changed, state, mainDir) {
991
- try {
992
- if (!Array.isArray(changed)) return null;
993
- const ignore = cfg.stopGate.ignoreChangedPaths || [];
994
- const remaining = changed.filter((f) => !matchAny(ignore, f));
995
- if (remaining.length > 0) return null;
996
- const raw = readFileSync(join(dir, ".doug/.state/pre-commit/last-run.json"), "utf8");
997
- const record = JSON.parse(raw);
998
- if (!record || typeof record !== "object") return null;
999
- if (record.version !== 1) return null;
1000
- if (record.ok !== true) return null;
1001
- if (typeof record.tree !== "string") return null;
1002
- if (!Array.isArray(record.commands)) return null;
1003
- const head = headTreeSha(dir);
1004
- if (!head || record.tree !== head) return null;
1005
- for (const c of record.commands) {
1006
- if (!c || typeof c !== "object" || c.exit !== 0) return null;
1007
- }
1008
- const configured = resolveCommands(cfg);
1009
- if (record.commands.length !== configured.length) return null;
1010
- for (let i = 0; i < configured.length; i++) {
1011
- if (!record.commands[i] || record.commands[i].command !== configured[i].command) return null;
1012
- }
1013
- // card ratchet-pre-commit-credit: a command with a baseline in state.testTotals is credited only when the
1014
- // record carries readable totals for it (a list of valid summaries, or an old ok or conflict shape);
1015
- // otherwise null and the commands really run. `recorded` is the parseTotals-shaped result for the count check.
1016
- // dir is the verify dir (a worktree for a worktree agent); mainDir the main checkout, as countCheck's dir.
1017
- const baselineFor = countBaselineFor(mainDir ?? dir, state, dir);
1018
- const out = [];
1019
- for (let i = 0; i < configured.length; i++) {
1020
- const { name, command } = configured[i];
1021
- const recorded = readableRecordedTotals(record.commands[i].totals);
1022
- const base = baselineFor(command);
1023
- const hasBaseline = base ? readSummaries(base[command]) !== null : false;
1024
- if (hasBaseline && (!recorded || recorded.status === "none")) return null;
1025
- out.push({ name, command, ok: true, credited: "pre-commit", ...(recorded ? { recorded } : {}) });
1026
- }
1027
- return out;
1028
- } catch {
1029
- return null;
1030
- }
1031
- }
1032
-
1033
985
  // card stop-gate-process-storm, case 2 (second tester pass, reviewer-found gap): a verified-unchanged skip
1034
986
  // is a green gate in every way that matters, not a bare allow — it resets the same counters an ordinary
1035
987
  // green gate resets, and on the main Stop it still surfaces the context-window notice, exactly like the
@@ -1735,7 +1687,6 @@ runHook("stop-gate", async (input) => {
1735
1687
  currentFingerprint &&
1736
1688
  (sameTree(rec.fingerprint, currentFingerprint) || (rec.scope === scopeNow && (redViaNoTestPaths = onlyNoTestPathsChanged(vdir, cfg, rec.fingerprint, vchanged)))) &&
1737
1689
  !(subagent && (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path)))) &&
1738
- !preCommitCredit(vdir, cfg, vchanged, state, dir) &&
1739
1690
  !treeCredit(vdir, cfg, state, computeTree(vdir), dir)
1740
1691
  ) {
1741
1692
  lastRed = rec;
@@ -1779,6 +1730,7 @@ runHook("stop-gate", async (input) => {
1779
1730
  failures: Array.isArray(lastRed.failures) ? lastRed.failures : [],
1780
1731
  cwd: typeof lastRed.cwd === "string" ? lastRed.cwd : null,
1781
1732
  tree: typeof lastRed.tree === "string" ? lastRed.tree : null,
1733
+ files: Array.isArray(lastRed.files) ? lastRed.files : [],
1782
1734
  };
1783
1735
  logBlock(reason, reuseClass, reuseRecord);
1784
1736
  traceMainBlock(reuseRecord);
@@ -1818,13 +1770,17 @@ runHook("stop-gate", async (input) => {
1818
1770
  lockOwned = false;
1819
1771
  } else {
1820
1772
  const lock = readGateLock(dir);
1821
- if (!lock || !pidIsLive(lock.pid) || lockStaleByAge(lock, hookTimeoutMs)) {
1773
+ // Card gate-lock-takeover-exclusive: a stale-on-arrival lock is acquired exclusively; "busy" (a rival just
1774
+ // took it) enters the wait branch below like a live holder, "unwritable" runs unlocked.
1775
+ let arrivalWait = !(!lock || !pidIsLive(lock.pid) || lockStaleByAge(lock, hookTimeoutMs));
1776
+ if (!arrivalWait) {
1822
1777
  // Stale (a dead pid, a lock file that fails to parse, or one older than the hook-timeout budget —
1823
- // card stop-gate-budget-under-hook-timeout: no legitimate holder can still be running that long):
1824
- // take it over at once, no wait.
1825
- takeOverGateLock(dir, input.session_id);
1826
- lockOwned = true;
1827
- } else {
1778
+ // card stop-gate-budget-under-hook-timeout: no legitimate holder can still be running that long).
1779
+ const got = acquireGateLock(dir, input.session_id, hookTimeoutMs);
1780
+ if (got === "acquired") lockOwned = true;
1781
+ else if (got === "busy") arrivalWait = true;
1782
+ }
1783
+ if (arrivalWait) {
1828
1784
  // The bound scales with the number of configured commands (reviewer-found gap): a holder legitimately
1829
1785
  // runs each of resolveCommands(cfg) with its own timeoutMs budget, one after another, so a flat
1830
1786
  // timeoutMs would give up on — and duplicate — a holder still legitimately partway through its own
@@ -1839,11 +1795,22 @@ runHook("stop-gate", async (input) => {
1839
1795
  // taking the lock over, since taking over and running past the platform's own timeout would just get
1840
1796
  // this gate killed mid-command too (an ungated stop, its block message discarded).
1841
1797
  let refusedLock = null;
1798
+ let acquired = false;
1842
1799
  while (true) {
1843
1800
  const live = readGateLock(dir);
1844
- if (!live || !pidIsLive(live.pid) || lockStaleByAge(live, hookTimeoutMs)) break; // released, died, or stale by age
1801
+ if (!live || !pidIsLive(live.pid) || lockStaleByAge(live, hookTimeoutMs)) {
1802
+ // Released, died, or stale by age. Card gate-load-timeouts: take it exclusively, so two waiters
1803
+ // leaving this loop together do not both run their suites (ledger blocks 425 and 426). A lost race
1804
+ // goes back to waiting, still bounded by the two deadlines below.
1805
+ const got = acquireGateLock(dir, input.session_id, hookTimeoutMs);
1806
+ if (got !== "busy") {
1807
+ acquired = got === "acquired";
1808
+ lockOwned = acquired;
1809
+ break;
1810
+ }
1811
+ }
1845
1812
  if (Date.now() >= budgetDeadline) {
1846
- refusedLock = live;
1813
+ refusedLock = readGateLock(dir) || live || {};
1847
1814
  break;
1848
1815
  }
1849
1816
  if (Date.now() >= waitDeadline) break; // bounded: never hang the gate on someone else's lock
@@ -1891,8 +1858,10 @@ runHook("stop-gate", async (input) => {
1891
1858
  return verifiedUnchangedAllow(dir, cfg, state, input, subagent, deferralCapNote);
1892
1859
  }
1893
1860
  // Still different, or the wait hit its bound with the lock still held: take it over and run.
1894
- takeOverGateLock(dir, input.session_id);
1895
- lockOwned = true;
1861
+ if (!acquired) {
1862
+ takeOverGateLock(dir, input.session_id);
1863
+ lockOwned = true;
1864
+ }
1896
1865
  }
1897
1866
  }
1898
1867
  }
@@ -1971,20 +1940,21 @@ runHook("stop-gate", async (input) => {
1971
1940
  const r = failedRuns[i] || {};
1972
1941
  return { command: redactText(cf.command, cfg.secrets), exit: typeof r.status === "number" ? r.status : null, timedOut: !!r.timedOut, tail: outputTail(cf.output, cfg.secrets) };
1973
1942
  });
1974
- return { failures, cwd: vdir, tree: treeBefore ?? null };
1943
+ // card memory-failures-wiring: the sorted, unique, repo-relative paths the failed commands' output names.
1944
+ const files = [...new Set(commandFailures.flatMap((cf) => [...failingTestFilesFrom(cf.output, vdir), ...tscErrorPaths(cf.output, vdir)]))].sort();
1945
+ return { failures, cwd: vdir, tree: treeBefore ?? null, files };
1975
1946
  } catch {
1976
- return { failures: [], cwd: vdir, tree: null }; // the line is written without the failures; the decision is unchanged
1947
+ return { failures: [], cwd: vdir, tree: null, files: [] }; // the line is written without the failures; the decision is unchanged
1977
1948
  }
1978
1949
  };
1979
1950
  const failedRuns = []; // parallel to commandFailures: { status, timedOut } of each failed run (card block-ledger-failing-output)
1980
- const ranGreen = []; // count check input: { command, stdout } of each command that really ran and exited 0, plus { command, result } of each credited pre-commit command whose recorded totals are readable
1981
- // card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
1982
- // stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
1983
- // red-by-design, state.verified, the block ledger) runs exactly as it would after a real run.
1951
+ const ranGreen = []; // count check input: { command, stdout } of each command that really ran and exited 0, plus { command, result } of each credited verified-tree command whose recorded totals are readable
1984
1952
  // card verified-tree-record: treeBefore is the working tree immediately before the first command; a record of
1985
- // it (any writer: Stop, verify.mjs, land, pre-commit) is tried before preCommitCredit's last-run.json.
1953
+ // it (any writer: Stop, verify.mjs, land, pre-commit) for this exact tree and command set stands in for
1954
+ // actually running them. Every other check below (evidence, red-by-design, state.verified, the block ledger)
1955
+ // runs exactly as it would after a real run.
1986
1956
  const treeBefore = skipVerification ? null : computeTree(vdir);
1987
- const credited = skipVerification ? null : (treeCredit(vdir, cfg, state, treeBefore, dir) ?? preCommitCredit(vdir, cfg, vchanged, state, dir));
1957
+ const credited = skipVerification ? null : treeCredit(vdir, cfg, state, treeBefore, dir);
1988
1958
  // card gate-docs-skip-unless-named: a targeted run is never a full run (no record, no ratchet, no state.verified).
1989
1959
  const targeted = !credited && targetedFiles ? targetedFiles : null;
1990
1960
  if (!skipVerification && credited) {
@@ -2004,7 +1974,7 @@ runHook("stop-gate", async (input) => {
2004
1974
  if (remaining <= 0) {
2005
1975
  results.push({ name, command, ok: false, skipped: "budget" });
2006
1976
  problems.push(
2007
- `\`${command}\` not run: the stop gate's budget (hook timeout ${hookTimeoutSec} s) was used up before it could start.`,
1977
+ `\`${redactText(command, cfg.secrets)}\` not run: the stop gate's budget (hook timeout ${hookTimeoutSec} s) was used up before it could start.`,
2008
1978
  );
2009
1979
  continue;
2010
1980
  }
@@ -2073,7 +2043,7 @@ runHook("stop-gate", async (input) => {
2073
2043
  let evidence = null;
2074
2044
  if (!skipVerification && cfg.stopGate.requireEvidence !== false && sessionMadeChanges) {
2075
2045
  evidence = verificationEvidence(state.commands || [], cfg);
2076
- if (evidence.ran.length === 0) problems.push(describeMissingEvidence(evidence.looked, results.every((r) => r.ok)));
2046
+ if (evidence.ran.length === 0) problems.push(describeMissingEvidence(evidence.looked.map((c) => redactText(c, cfg.secrets)), results.every((r) => r.ok)));
2077
2047
  }
2078
2048
  const evidenceProblemAdded = problems.length > postCommandProblems;
2079
2049
 
@@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process";
10
10
  import { unlinkSync, realpathSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
12
  import { loadConfig } from "../lib/config.mjs";
13
- import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
13
+ import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, acquireGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
14
14
  import { runCommand, tail } from "../lib/run.mjs";
15
15
  import { parseTotals, readSummaries, sumTotals } from "../lib/test-totals.mjs";
16
16
  import { computeTree, readRecord, writeRecord, removeRecord, creditable, exitCreditUsable, resolveCommands } from "../lib/verified-tree.mjs";
@@ -155,17 +155,33 @@ function main() {
155
155
  const hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
156
156
  const stale = (l) => !l || !pidIsLive(l.pid) || lockStaleByAge(l, hookTimeoutMs);
157
157
  const held = readGateLock(dir);
158
- if (stale(held)) {
159
- takeOverGateLock(dir, o.by || "verify");
160
- lockOwned = true;
161
- } else {
158
+ // Card gate-lock-takeover-exclusive: a lock read as stale on arrival is acquired exclusively; "busy" (a rival
159
+ // just took it) is waited on like a live holder, never overwritten; "unwritable" runs unlocked.
160
+ let needWait = !stale(held);
161
+ if (!needWait) {
162
+ const got = acquireGateLock(dir, o.by || "verify", hookTimeoutMs);
163
+ if (got === "acquired") lockOwned = true;
164
+ else if (got === "busy") needWait = true;
165
+ }
166
+ if (needWait) {
162
167
  const waitStart = Date.now();
163
168
  const deadline = waitStart + waitBoundMs;
164
169
  let live = held;
170
+ let acquired = false;
165
171
  const nap = new Int32Array(new SharedArrayBuffer(4));
166
172
  while (true) {
167
173
  live = readGateLock(dir);
168
- if (stale(live) || Date.now() >= deadline) break;
174
+ if (stale(live)) {
175
+ // Card gate-load-timeouts: take a released or stale lock exclusively (two waiters must not both run);
176
+ // a lost race goes back to waiting, bounded by the deadline.
177
+ const got = acquireGateLock(dir, o.by || "verify", hookTimeoutMs);
178
+ if (got !== "busy") {
179
+ acquired = got === "acquired";
180
+ lockOwned = acquired;
181
+ break;
182
+ }
183
+ }
184
+ if (Date.now() >= deadline) break;
169
185
  Atomics.wait(nap, 0, 0, 200);
170
186
  }
171
187
  lockWaitMs = Date.now() - waitStart;
@@ -173,9 +189,11 @@ function main() {
173
189
  treeBefore = computeTree(cwd);
174
190
  const again = tryCredit();
175
191
  if (again !== null) return again;
176
- if (!stale(live)) process.stderr.write(`[doug] verify: gate.lock still held by pid ${live.pid} after ${waitBoundMs} ms; running the commands anyway.\n`);
177
- takeOverGateLock(dir, o.by || "verify");
178
- lockOwned = true;
192
+ if (!acquired) {
193
+ if (!stale(live)) process.stderr.write(`[doug] verify: gate.lock still held by pid ${live.pid} after ${waitBoundMs} ms; running the commands anyway.\n`);
194
+ takeOverGateLock(dir, o.by || "verify");
195
+ lockOwned = true;
196
+ }
179
197
  }
180
198
  }
181
199
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.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.5.0",
27
- "@dougharness/gates": "1.5.0"
26
+ "@dougharness/flow": "1.6.0",
27
+ "@dougharness/gates": "1.6.0"
28
28
  },
29
29
  "bundleDependencies": [
30
30
  "@dougharness/flow",