@dougharness/cli 1.2.0 → 1.4.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 (47) hide show
  1. package/dist/bin.js +13 -4
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-page.d.ts +5 -1
  4. package/dist/board-page.js +19 -5
  5. package/dist/board-page.js.map +1 -1
  6. package/dist/board-serve.js +34 -10
  7. package/dist/board-serve.js.map +1 -1
  8. package/dist/board.d.ts +1 -1
  9. package/dist/board.js +160 -13
  10. package/dist/board.js.map +1 -1
  11. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  12. package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
  13. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  14. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  15. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  16. package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
  17. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  18. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  19. package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
  20. package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
  21. package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
  22. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  23. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  24. package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
  25. package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
  26. package/node_modules/@dougharness/flow/package.json +1 -1
  27. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
  28. package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
  29. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
  32. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  33. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
  34. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
  35. package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
  36. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
  37. package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
  38. package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
  39. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  40. package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
  41. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
  42. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  43. package/node_modules/@dougharness/gates/package.json +1 -1
  44. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  45. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
  46. package/package.json +3 -3
  47. package/templates/board-app.js +240 -30
@@ -27,9 +27,11 @@
27
27
  // Loopback exemption (card research-cap-localhost, whole-command grammar, fix pass 5): a local model probe is not
28
28
  // web research, so it is neither counted nor denied. The exemption applies only when the entire raw command
29
29
  // string, before any splitting, is ONE simple fetch (isLoopbackFetch):
30
- // 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick, a
31
- // backslash or !; an unterminated quote is not exempt. Outside quotes only space, tab, [A-Za-z0-9._:/=@%+,-]
32
- // and a redirect's > are allowed, so & | ; ( ) { } [ ] * ? ~ # $ ` \ ! < and a newline are never exempt.
30
+ // 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick or !,
31
+ // and a backslash only as \" or \\ (a literal char; any other backslash is not exempt); an unterminated quote
32
+ // is not exempt. Outside quotes only space, tab, [A-Za-z0-9._:/=@%+,-], a redirect's >, the exact word 2>&1
33
+ // and a backslash-newline continuation (removed, no word break) are allowed, so & | ; ( ) { } [ ] * ? ~ # $
34
+ // ` \ ! < and a bare newline are otherwise never exempt (2>&1>x, a&b, 2>&1; are not the exact word).
33
35
  // 2. The first word is exactly curl or wget: no path, wrapper, assignment or other case.
34
36
  // 3. Every other word is an allowlisted option (value-less -s -S -f -L -i -v -g or --silent and similar,
35
37
  // combinable in one short token; value-taking -o -H -d -X -w -m for curl, -O -T and --header for wget,
@@ -41,11 +43,18 @@
41
43
  // exactly /dev/null or does not start with /dev/ (bash opens /dev/tcp and /dev/udp as sockets).
42
44
  // A command outside the grammar is not an error: it is counted by bashFetchVerbs exactly as at HEAD, one count
43
45
  // per split command whose first resolved fetch form is curl or wget. Only Bash curl/wget is affected; WebFetch
44
- // and WebSearch always count. Anything doubtful counts: a compound line, a pipe, a wrapper, a mixed local and
45
- // remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
46
+ // and WebSearch always count.
46
47
  // Accepted limits of the exemption: an env proxy (https_proxy=http://evil.com in the environment, not on the
47
48
  // command) routes the request out; -L, and wget by default, follow a redirect a local server can point at a
48
49
  // remote host; a ~/.curlrc or ~/.wgetrc can add a URL or a proxy.
50
+ // Anything doubtful counts: a compound line, a pipe that is not a safe pipe consumer (below), a wrapper, a mixed
51
+ // local and remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
52
+ //
53
+ // Pipe consumers (card research-cap-loopback-post): the same exemption holds for a pipeline of unquoted single "|"
54
+ // (never ||, |& or an empty segment) whose first segment is a loopback fetch unchanged and whose every later
55
+ // segment is, with no redirect, head/tail with only a count (-c N, -n N, -cN, -nN, -N) or exactly
56
+ // python3|python -m json.tool. Anything else (jq, sh, curl, python -c) counts as at HEAD. Each counted Bash
57
+ // counter line also carries hosts, the URL hosts of that split command, so a miss is diagnosable.
49
58
  //
50
59
  // Known limit: several calls issued in one message can each read the same count before any of them appends, so
51
60
  // a single burst can push an agent's count past the cap by the number of calls it issues at once.
@@ -305,14 +314,29 @@ function tokenize(text) {
305
314
  gt = false;
306
315
  open = false;
307
316
  };
308
- for (const ch of text) {
317
+ for (let i = 0; i < text.length; i += 1) {
318
+ const ch = text[i];
309
319
  if (quote) {
320
+ if (quote === '"' && ch === "\\") {
321
+ // Only \" and \\ are escapes inside double quotes; both give a literal char. Any other backslash
322
+ // (\$, \`, \!, backslash-newline) leaves the grammar.
323
+ const next = text[i + 1];
324
+ if (next !== '"' && next !== "\\") return null;
325
+ raw += ch + next;
326
+ val += next;
327
+ i += 1;
328
+ continue;
329
+ }
310
330
  raw += ch;
311
331
  if (ch === quote) quote = "";
312
- else if (quote === '"' && "$`\\!".includes(ch)) return null;
332
+ else if (quote === '"' && "$`!".includes(ch)) return null;
313
333
  else val += ch;
314
334
  } else if (ch === " " || ch === "\t") end();
315
- else {
335
+ else if (ch === "\\" && text[i + 1] === "\n") i += 1; // a line continuation: removed, no word break
336
+ else if (!open && text.startsWith("2>&1", i) && (i + 4 === text.length || " \t".includes(text[i + 4]))) {
337
+ words.push({ raw: "2>&1", val: "2>&1", gt: false, dup: true }); // the exact word only, never a prefix
338
+ i += 3;
339
+ } else {
316
340
  open = true;
317
341
  raw += ch;
318
342
  if (ch === "'" || ch === '"') quote = ch;
@@ -337,7 +361,8 @@ function isLoopbackFetch(command) {
337
361
  let urls = 0;
338
362
  let wantValue = false;
339
363
  for (let k = 1; k < words.length; k += 1) {
340
- const { raw, val, gt } = words[k];
364
+ const { raw, val, gt, dup } = words[k];
365
+ if (dup) continue;
341
366
  if (gt) {
342
367
  const m = REDIRECT.exec(raw);
343
368
  if (!m) return false;
@@ -368,19 +393,98 @@ function isLoopbackFetch(command) {
368
393
  return urls > 0;
369
394
  }
370
395
 
371
- // Returns one verb ("curl" or "wget") per split command of a Bash command line that resolves to a fetch;
372
- // non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
396
+ // Splits a raw command on unquoted single "|" (card research-cap-loopback-post). Null when the text has a "||", a
397
+ // "|&", an empty segment, or no pipe at all, so only a plain pipeline is ever split.
398
+ function splitPipeline(command) {
399
+ const text = String(command);
400
+ const segs = [];
401
+ let quote = "";
402
+ let cur = "";
403
+ for (let i = 0; i < text.length; i += 1) {
404
+ const ch = text[i];
405
+ if (quote) {
406
+ if (quote === '"' && ch === "\\" && i + 1 < text.length) {
407
+ cur += ch + text[i + 1];
408
+ i += 1;
409
+ continue;
410
+ }
411
+ if (ch === quote) quote = "";
412
+ cur += ch;
413
+ } else if (ch === "'" || ch === '"') {
414
+ quote = ch;
415
+ cur += ch;
416
+ } else if (ch === "|") {
417
+ if (text[i + 1] === "|" || text[i + 1] === "&" || cur.trim() === "") return null;
418
+ segs.push(cur);
419
+ cur = "";
420
+ } else cur += ch;
421
+ }
422
+ if (quote || cur.trim() === "" || segs.length === 0) return null;
423
+ segs.push(cur);
424
+ return segs;
425
+ }
426
+
427
+ // One pipe consumer that cannot fetch: head or tail with only a count, or python[3] -m json.tool. No redirect.
428
+ function isSafeConsumer(segment) {
429
+ const words = tokenize(segment.trim());
430
+ if (!words || words.length === 0 || words.some((w) => w.gt || w.raw !== w.val)) return false;
431
+ const v = words.map((w) => w.val);
432
+ if (v[0] === "head" || v[0] === "tail") {
433
+ const rest = v.slice(1);
434
+ if (rest.length === 0) return false;
435
+ if (rest.length === 1) return /^-(?:[cn]?\d+)$/.test(rest[0]);
436
+ return rest.length === 2 && /^-[cn]$/.test(rest[0]) && /^\d+$/.test(rest[1]);
437
+ }
438
+ return (v[0] === "python3" || v[0] === "python") && v.length === 3 && v[1] === "-m" && v[2] === "json.tool";
439
+ }
440
+
441
+ // True when the whole command is one loopback fetch, or one followed by safe consumers (see the header).
442
+ function isLoopbackCommand(command) {
443
+ if (isLoopbackFetch(command)) return true;
444
+ const segs = splitPipeline(command);
445
+ return !!segs && isLoopbackFetch(segs[0]) && segs.slice(1).every(isSafeConsumer);
446
+ }
447
+
448
+ // URL hosts of one split command, lower-cased, unique, in order (card research-cap-loopback-post). Diagnostic
449
+ // only: never throws and never returns the command text.
450
+ function urlHosts(cmd) {
451
+ const hosts = [];
452
+ try {
453
+ for (const w of String(cmd).match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) {
454
+ const word = w.replace(/["']/g, "");
455
+ if (word === "" || word.startsWith("-")) continue;
456
+ let host = "";
457
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(word);
458
+ if (scheme) {
459
+ const auth = scheme[1].slice(scheme[1].lastIndexOf("@") + 1);
460
+ const m = /^(\[[^\]]*\]|[^:]*)/.exec(auth);
461
+ host = m ? m[1] : "";
462
+ } else {
463
+ const m = /^(\[::1\]|[a-z0-9.-]+)(:\d+)?([/?#]|$)/i.exec(word);
464
+ if (m && (/^(?:localhost|127\.0\.0\.1|\[::1\])$/i.test(m[1]) || m[2] || m[3])) host = m[1];
465
+ }
466
+ host = host.toLowerCase();
467
+ if (host && !hosts.includes(host)) hosts.push(host);
468
+ }
469
+ } catch {
470
+ // diagnostic only
471
+ }
472
+ return hosts;
473
+ }
474
+
475
+ // Returns one {verb, hosts} per split command of a Bash command line that resolves to a fetch (verb "curl" or
476
+ // "wget"); non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
373
477
  // fetches. commandForms runs on each split command's original text with its quotes intact (it does its own
374
478
  // quote parsing for a shell -c script), never on stripped or reconstructed text. A command that is one simple
375
- // loopback fetch (isLoopbackFetch) is not a fetch to count.
479
+ // loopback fetch, alone or piped to a safe consumer (isLoopbackCommand), is not a fetch to count.
376
480
  function bashFetchVerbs(command) {
377
- if (isLoopbackFetch(command)) return [];
481
+ if (isLoopbackCommand(command)) return [];
378
482
  const verbs = [];
379
483
  for (const cmd of splitTopLevel(command)) {
380
484
  for (const form of commandForms(cmd)) {
381
485
  const verb = firstWordBasename(form);
382
486
  if (FETCH_VERBS.has(verb)) {
383
- verbs.push(verb);
487
+ verbs.push({ verb, hosts: urlHosts(cmd) });
384
488
  break;
385
489
  }
386
490
  }
@@ -401,7 +505,7 @@ runHook("research-cap", async (input) => {
401
505
  if (input.tool_name === "Bash") {
402
506
  const verbs = bashFetchVerbs(input.tool_input && input.tool_input.command);
403
507
  if (verbs.length === 0) return allow(); // an ordinary Bash command is never counted or denied here
404
- entries = verbs.map((verb) => ({ tool: "Bash", verb }));
508
+ entries = verbs.map(({ verb, hosts }) => ({ tool: "Bash", verb, hosts }));
405
509
  } else {
406
510
  entries = [{ tool: input.tool_name }];
407
511
  }
@@ -85,13 +85,13 @@ import { runCommand, failureSummary } from "../lib/run.mjs";
85
85
  import { loadScopePlan, scopeViolations, describeViolations, PLAN_RELPATH } from "../lib/scope.mjs";
86
86
  import { checkpoint, describeCheckpoint } from "../lib/checkpoint.mjs";
87
87
  import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.mjs";
88
- import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
88
+ import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, turnTreeStatusHeadMoved, 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
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 } from "../lib/block-ledger.mjs";
94
+ import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine, outputTail, redactText } from "../lib/block-ledger.mjs";
95
95
  import { docsGateDecision } from "../lib/docs-gate.mjs";
96
96
  import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
97
97
  import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
@@ -468,6 +468,21 @@ function countingInFlight(dir, cfg, sessionId, inFlight) {
468
468
  // and whose ok is not exactly false (a null ok counts as success — conservative). No change found -> true.
469
469
  //
470
470
  // Without trace evidence, true only when subagent.type is in READ_ONLY_AGENT_TYPES.
471
+ // card ratchet-stale-session-baseline: true only when the trace vouches for the agent's whole life (its
472
+ // SubagentStart line is there) and shows no successful Edit-type PostToolUse of its own. Bash does not count.
473
+ function agentMadeNoEditCalls(dir, cfg, sessionId, subagent) {
474
+ try {
475
+ if (!subagent || !cfg.trace || cfg.trace.enabled === false) return false;
476
+ const file = tracePath(dir, sessionId);
477
+ if (!existsSync(file)) return false;
478
+ const lines = readTrace(file);
479
+ if (!lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id)) return false;
480
+ return !lines.some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false);
481
+ } catch {
482
+ return false;
483
+ }
484
+ }
485
+
471
486
  function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
472
487
  try {
473
488
  if (!subagent) return false;
@@ -802,10 +817,32 @@ function waiverSources(dir, baselineHead) {
802
817
  return sources;
803
818
  }
804
819
 
820
+ // A tree's own last-green record, read live; null when absent or unreadable (card worktree-count-baseline).
821
+ function readLastGreen(dir) {
822
+ try {
823
+ const parsed = JSON.parse(readFileSync(join(dir, LAST_GREEN_RELPATH), "utf8"));
824
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
825
+ } catch {
826
+ return null;
827
+ }
828
+ }
829
+
805
830
  // The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
806
831
  // block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
807
832
  // record). A missing or unparseable summary, or no baseline, is a trace line and no check.
808
- function countCheck(dir, cfg, state, input, ran) {
833
+ function countCheck(dir, cfg, state, input, ran, vdir) {
834
+ // card worktree-count-baseline: a worktree run compares against its own earlier green runs, never main's record
835
+ // (a worktree can lack the dist build main has, so its skipped count differs). No record yet means no check.
836
+ const inWorktree = typeof vdir === "string" && vdir !== dir;
837
+ // card ratchet-stale-session-baseline: a main-checkout run compares against the main tree's live last-green
838
+ // record too (per command), not the session-start copy; the copy is the fallback when the record has no
839
+ // readable entry for the command, so a deleted record does not remove the check.
840
+ const worktreeBaseline = inWorktree ? readLastGreen(vdir) : null;
841
+ const mainRecord = inWorktree ? null : readLastGreen(dir);
842
+ const baselineFor = (command) => {
843
+ if (inWorktree) return worktreeBaseline;
844
+ return mainRecord && readSummaries(mainRecord[command]) ? mainRecord : state.testTotals;
845
+ };
809
846
  const problems = [];
810
847
  const parsed = {};
811
848
  // The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
@@ -829,7 +866,8 @@ function countCheck(dir, cfg, state, input, ran) {
829
866
  note(`count check did not run for \`${command}\`: no test summary in its output`);
830
867
  continue;
831
868
  }
832
- const all = state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
869
+ const baseline = baselineFor(command);
870
+ const all = baseline && typeof baseline === "object" ? baseline : null;
833
871
  const baseList = all ? readSummaries(all[command]) : null;
834
872
  if (!baseList) {
835
873
  parsed[command] = summaries;
@@ -1195,8 +1233,8 @@ runHook("stop-gate", async (input) => {
1195
1233
  traceSubagentDecision("allow", traceReason, extra);
1196
1234
  return allow();
1197
1235
  };
1198
- const blockStopDecided = (reason) => {
1199
- traceSubagentDecision("block", null);
1236
+ const blockStopDecided = (reason, extra) => {
1237
+ traceSubagentDecision("block", null, extra);
1200
1238
  return blockStop(reason);
1201
1239
  };
1202
1240
  const systemMessageDecided = (message, traceReason, extra) => {
@@ -1206,8 +1244,19 @@ runHook("stop-gate", async (input) => {
1206
1244
  // Card stop-gate-block-ledger: one ledger line per block/stand-down below, written synchronously right
1207
1245
  // before the blockStop/systemMessage call it belongs to (those call process.exit(0) themselves, so
1208
1246
  // nothing queued after them ever runs). appendBlockLine never throws.
1209
- const logBlock = (reason, cls) =>
1210
- appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id });
1247
+ const logBlock = (reason, cls, record) =>
1248
+ appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id, record });
1249
+ // card block-ledger-failing-output: a main Stop verification block has no decision trace line of its own;
1250
+ // append one carrying the record. A SubagentStop's line comes from traceSubagentDecision. Never throws.
1251
+ const traceMainBlock = (record) => {
1252
+ if (subagent) return;
1253
+ try {
1254
+ if (!cfg.trace || cfg.trace.enabled === false) return;
1255
+ appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "Stop", decision: "block", reason: null, ...record });
1256
+ } catch {
1257
+ // never block the real exit on a trace failure
1258
+ }
1259
+ };
1211
1260
  // Card block-ledger-stood-down-repeats: a stood-down line is written at most once per session per
1212
1261
  // cause, so a session standing down at its cap does not append a fresh identical line on every later
1213
1262
  // Stop/SubagentStop past it. Cause key: "turn-budget" (lead only), or "block-cap:<lead|agent id>" — the
@@ -1404,7 +1453,10 @@ runHook("stop-gate", async (input) => {
1404
1453
  let readOnly = null;
1405
1454
  try {
1406
1455
  const start = readTurnStart(dir, input.session_id);
1407
- const status = turnTreeStatus(cfg, start, treeFingerprint(dir, cfg, changed));
1456
+ const currentFp = treeFingerprint(dir, cfg, changed);
1457
+ let status = turnTreeStatus(cfg, start, currentFp);
1458
+ // A turn that only committed noTestPaths files moved HEAD but changed no code (card stop-gate-read-only-turn-commits).
1459
+ if (status && !status.same) status = turnTreeStatusHeadMoved(dir, cfg, start, currentFp) || status;
1408
1460
  if (status && status.same) {
1409
1461
  let editedThisTurn = false;
1410
1462
  if (cfg.trace && cfg.trace.enabled !== false) {
@@ -1709,8 +1761,14 @@ runHook("stop-gate", async (input) => {
1709
1761
  [...treeProblems, lastRed.summary].join("\n\n");
1710
1762
  const reuseClass = typeof lastRed.class === "string" ? lastRed.class : "real";
1711
1763
  const capped = deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason;
1712
- logBlock(reason, reuseClass);
1713
- return blockStopDecided(capped);
1764
+ const reuseRecord = {
1765
+ failures: Array.isArray(lastRed.failures) ? lastRed.failures : [],
1766
+ cwd: typeof lastRed.cwd === "string" ? lastRed.cwd : null,
1767
+ tree: typeof lastRed.tree === "string" ? lastRed.tree : null,
1768
+ };
1769
+ logBlock(reason, reuseClass, reuseRecord);
1770
+ traceMainBlock(reuseRecord);
1771
+ return blockStopDecided(capped, reuseRecord);
1714
1772
  }
1715
1773
  }
1716
1774
 
@@ -1855,9 +1913,9 @@ runHook("stop-gate", async (input) => {
1855
1913
  if (deferralCapNote && !subagent) return systemMessageDecided(deferralCapNote, traceReason, extra);
1856
1914
  return allowDecided(traceReason, extra);
1857
1915
  };
1858
- const blockStopReleasing = (reason) => {
1916
+ const blockStopReleasing = (reason, extra) => {
1859
1917
  releaseLock();
1860
- return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason);
1918
+ return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason, extra);
1861
1919
  };
1862
1920
  const systemMessageReleasing = (message, traceReason, extra) => {
1863
1921
  releaseLock();
@@ -1892,6 +1950,19 @@ runHook("stop-gate", async (input) => {
1892
1950
  // One entry per failed command, `{ command, output }` (stderr + stdout, untruncated), for the
1893
1951
  // tests_red_by_design coverage check below (design point 2, card subagent-stop-gate-tester-red).
1894
1952
  const commandFailures = [];
1953
+ // card block-ledger-failing-output: the failing commands, the directory and the tree this block verified.
1954
+ const buildBlockRecord = () => {
1955
+ try {
1956
+ const failures = commandFailures.map((cf, i) => {
1957
+ const r = failedRuns[i] || {};
1958
+ return { command: redactText(cf.command, cfg.secrets), exit: typeof r.status === "number" ? r.status : null, timedOut: !!r.timedOut, tail: outputTail(cf.output, cfg.secrets) };
1959
+ });
1960
+ return { failures, cwd: vdir, tree: treeBefore ?? null };
1961
+ } catch {
1962
+ return { failures: [], cwd: vdir, tree: null }; // the line is written without the failures; the decision is unchanged
1963
+ }
1964
+ };
1965
+ const failedRuns = []; // parallel to commandFailures: { status, timedOut } of each failed run (card block-ledger-failing-output)
1895
1966
  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
1896
1967
  // card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
1897
1968
  // stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
@@ -1939,6 +2010,7 @@ runHook("stop-gate", async (input) => {
1939
2010
  // commandFailures below keeps the untruncated output for the tests_red_by_design coverage check.
1940
2011
  problems.push(`\`${command}\` failed (${why}):\n${failureSummary(output, 40)}`);
1941
2012
  commandFailures.push({ command, output });
2013
+ failedRuns.push({ status: r.status, timedOut: r.timedOut });
1942
2014
  }
1943
2015
  }
1944
2016
  }
@@ -1991,9 +2063,18 @@ runHook("stop-gate", async (input) => {
1991
2063
  const evidenceProblemAdded = problems.length > postCommandProblems;
1992
2064
 
1993
2065
  // 3c. Test-count ratchet (card test-count-ratchet): its own kind of problem, not a failed command.
1994
- const counted = countCheck(dir, cfg, state, input, ranGreen);
1995
- problems.push(...counted.problems);
1996
- const countProblemAdded = counted.problems.length > 0;
2066
+ const counted = countCheck(dir, cfg, state, input, ranGreen, vdir);
2067
+ // card ratchet-stale-session-baseline: a seat with no Edit-type call is never blocked by a count change it did
2068
+ // not cause. Its count problems become one trace note, and that stop writes neither the record nor state.verified.
2069
+ const countExempt = counted.problems.length > 0 && !!subagent && agentMadeNoEditCalls(dir, cfg, input.session_id, subagent);
2070
+ if (countExempt) {
2071
+ try {
2072
+ if (cfg.trace && cfg.trace.enabled !== false) appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "CountCheck", reason: `count change not blocking a seat that made no Edit-type call: ${counted.problems[0].split("\n")[0].slice(0, 300)}` });
2073
+ } catch {
2074
+ // logging must never fail the gate
2075
+ }
2076
+ } else problems.push(...counted.problems);
2077
+ const countProblemAdded = !countExempt && counted.problems.length > 0;
1997
2078
 
1998
2079
  // A main Stop's skip has no decision-tagged exit line like a SubagentStop's (allowReleasing folds the reason
1999
2080
  // into that); record the reason here so the trace carries it (card stop-gate-no-test-paths).
@@ -2011,8 +2092,8 @@ runHook("stop-gate", async (input) => {
2011
2092
  // (card stop-gate-read-only-subagent-skip): nothing was actually verified.
2012
2093
  // A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
2013
2094
  if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
2014
- if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
2015
- if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
2095
+ if (problems.length === 0 && !skipVerification && !targeted && !countExempt) state.verified = verifiedCandidate;
2096
+ if (problems.length === 0 && !skipVerification && !countExempt) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
2016
2097
  if (problems.length === 0 && subagent) {
2017
2098
  // A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
2018
2099
  state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
@@ -2112,6 +2193,7 @@ runHook("stop-gate", async (input) => {
2112
2193
  summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
2113
2194
  class: redClass,
2114
2195
  who,
2196
+ ...buildBlockRecord(),
2115
2197
  };
2116
2198
  }
2117
2199
  } catch {
@@ -2150,6 +2232,8 @@ runHook("stop-gate", async (input) => {
2150
2232
  let finalClass = "real";
2151
2233
  try {
2152
2234
  finalClass = classifyFinalBlock({
2235
+ countProblems: countExempt ? 0 : counted.problems.length,
2236
+ totalProblems: problems.length,
2153
2237
  preVerificationProblems,
2154
2238
  evidenceProblemAdded,
2155
2239
  commandFailures,
@@ -2163,6 +2247,7 @@ runHook("stop-gate", async (input) => {
2163
2247
  } catch {
2164
2248
  finalClass = "real";
2165
2249
  }
2250
+ const blockRecord = buildBlockRecord();
2166
2251
  // card stop-gate-no-change-restop: record this red for reuse only when every configured command really
2167
2252
  // ran (not credited, none dropped for budget or timed out) and at least one failed, every failure by a
2168
2253
  // numeric non-zero exit outside 129..192 (a signal kill has status null, a shell-reported one 128+signal; both load-suspect). Stored with the block's ledger
@@ -2186,14 +2271,16 @@ runHook("stop-gate", async (input) => {
2186
2271
  summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
2187
2272
  class: finalClass,
2188
2273
  who,
2274
+ ...blockRecord,
2189
2275
  };
2190
2276
  saveState(dir, input.session_id, state);
2191
2277
  }
2192
2278
  } catch {
2193
2279
  state.lastRed = null;
2194
2280
  }
2195
- logBlock(reason, finalClass);
2196
- return blockStopReleasing(reason);
2281
+ logBlock(reason, finalClass, blockRecord);
2282
+ traceMainBlock(blockRecord);
2283
+ return blockStopReleasing(reason, blockRecord);
2197
2284
  };
2198
2285
 
2199
2286
  return runVerification();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/cli",
3
- "version": "1.2.0",
3
+ "version": "1.4.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.2.0",
27
- "@dougharness/gates": "1.2.0"
26
+ "@dougharness/flow": "1.4.0",
27
+ "@dougharness/gates": "1.4.0"
28
28
  },
29
29
  "bundleDependencies": [
30
30
  "@dougharness/flow",