@dougharness/cli 1.0.1 → 1.1.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 (89) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +9 -3
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.js +10 -2
  6. package/dist/board.js.map +1 -1
  7. package/dist/detect/repo.js +14 -1
  8. package/dist/detect/repo.js.map +1 -1
  9. package/dist/detect/types.d.ts +2 -0
  10. package/dist/doctor.d.ts +46 -0
  11. package/dist/doctor.js +491 -0
  12. package/dist/doctor.js.map +1 -0
  13. package/dist/generate/agents.d.ts +7 -0
  14. package/dist/generate/agents.js +62 -12
  15. package/dist/generate/agents.js.map +1 -1
  16. package/dist/generate/claude-md.js +4 -0
  17. package/dist/generate/claude-md.js.map +1 -1
  18. package/dist/generate/config.d.ts +8 -1
  19. package/dist/generate/config.js +5 -12
  20. package/dist/generate/config.js.map +1 -1
  21. package/dist/generate/proposal.js +24 -6
  22. package/dist/generate/proposal.js.map +1 -1
  23. package/dist/generate/settings.d.ts +2 -0
  24. package/dist/generate/settings.js +17 -2
  25. package/dist/generate/settings.js.map +1 -1
  26. package/dist/init.d.ts +14 -0
  27. package/dist/init.js +94 -8
  28. package/dist/init.js.map +1 -1
  29. package/dist/memory-setup.d.ts +28 -0
  30. package/dist/memory-setup.js +323 -0
  31. package/dist/memory-setup.js.map +1 -0
  32. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  33. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  34. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  35. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  36. package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
  37. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  38. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  39. package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
  40. package/node_modules/@dougharness/flow/lib/data/pairs.json +103 -0
  41. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -2
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +189 -27
  43. package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +61 -4
  45. package/node_modules/@dougharness/flow/lib/memory-import.mjs +22 -4
  46. package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
  47. package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
  48. package/node_modules/@dougharness/flow/package.json +2 -1
  49. package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
  50. package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
  51. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  52. package/node_modules/@dougharness/flow/scripts/memory.mjs +196 -39
  53. package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
  54. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  55. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  56. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  57. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +10 -11
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
  63. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  64. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  65. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  66. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  67. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  68. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  69. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  70. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  71. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  72. package/node_modules/@dougharness/gates/lib/state.mjs +5 -0
  73. package/node_modules/@dougharness/gates/lib/test-totals.mjs +139 -0
  74. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  75. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  76. package/node_modules/@dougharness/gates/package.json +1 -1
  77. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  78. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +14 -0
  79. package/node_modules/@dougharness/gates/scripts/statusline.mjs +33 -7
  80. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +794 -67
  81. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  82. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  83. package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
  84. package/package.json +3 -3
  85. package/templates/board-app.js +2 -2
  86. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  87. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  88. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  89. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -5,7 +5,7 @@
5
5
  // SessionStart script (its earlier capture, when the platform fires it).
6
6
 
7
7
  import { execFileSync } from "node:child_process";
8
- import { readFileSync, readdirSync } from "node:fs";
8
+ import { readFileSync, readdirSync, mkdirSync, writeFileSync } from "node:fs";
9
9
  import { createHash } from "node:crypto";
10
10
  import { join, resolve, sep } from "node:path";
11
11
  import { matchAny } from "./glob.mjs";
@@ -158,3 +158,168 @@ export function captureBaseline(dir, paths) {
158
158
  export function sinceBaseline(dir, paths, baseline) {
159
159
  return paths.filter((p) => !(p in baseline) || fileIdentity(join(dir, p)) !== baseline[p]);
160
160
  }
161
+
162
+ // The verified-tree fingerprint (card stop-gate-process-storm, goal 2): the HEAD sha plus the content
163
+ // identity of every dirty path and every proposal-path file on disk, at the moment it is computed. Null
164
+ // when `changed` is not the array shape changedFiles returns (no git, or git unavailable) — there is
165
+ // nothing to fingerprint reliably, so the tree can never be judged "unchanged" on this path.
166
+ export function treeFingerprint(dir, cfg, changed) {
167
+ if (!Array.isArray(changed)) return null;
168
+ const walked = walkProposalPathFiles(dir, cfg.proposalPaths);
169
+ const paths = [...new Set([...changed, ...walked])];
170
+ // `at` (state.mjs's comment for state.verified) is when this fingerprint was taken; sameTree below never
171
+ // reads it, so recomputing it on the after-wait path (a fresh Date.now()) never affects equality.
172
+ return { head: headAt(dir), tree: captureBaseline(dir, paths), at: Date.now() };
173
+ }
174
+
175
+ // Whether two treeFingerprint results describe the same tree: both present, the same HEAD, the same set of
176
+ // paths, and an identical identity for each. Never throws (a hook reader must never throw): a malformed
177
+ // fingerprint — e.g. a hand-edited state.verified — just compares unequal rather than blocking the gate.
178
+ export function sameTree(a, b) {
179
+ try {
180
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
181
+ if (a.head !== b.head) return false;
182
+ const at = a.tree && typeof a.tree === "object" ? a.tree : null;
183
+ const bt = b.tree && typeof b.tree === "object" ? b.tree : null;
184
+ if (!at || !bt) return false;
185
+ const ak = Object.keys(at).sort();
186
+ const bk = Object.keys(bt).sort();
187
+ if (ak.length !== bk.length) return false;
188
+ for (let i = 0; i < ak.length; i++) if (ak[i] !== bk[i]) return false;
189
+ return ak.every((k) => at[k] === bt[k]);
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ // card stop-gate-no-test-paths: whether every path changed since the last green verification (`verified`, a
196
+ // treeFingerprint recorded in state.verified) is on preCommit.noTestPaths, read with the pre-commit hook's
197
+ // exact rule (.githooks/pre-commit): an entry matches a path when equal, or, ending in "/", when the path
198
+ // starts with it. Not a glob. "Changed since" is the union of the paths committed since verified.head that are
199
+ // absent from verified.tree or whose current identity differs from the recorded one (a path committed with the
200
+ // verified content is not a change), every path dirty now (read with --no-renames, both sides of a rename) that is absent from verified.tree or whose identity moved, and every verified.tree path
201
+ // whose identity moved (reverted or committed with other content). Fails closed: false when there is no
202
+ // verified fingerprint, its head is unknown to git, git is unavailable, the union is empty, the list is
203
+ // missing, empty, or holds a non-string or empty entry, or any path is off the list. Never throws.
204
+ export function onlyNoTestPathsChanged(dir, cfg, verified, changed) {
205
+ try {
206
+ const list = cfg && cfg.preCommit && cfg.preCommit.noTestPaths;
207
+ if (!Array.isArray(list) || list.length === 0 || !list.every((e) => typeof e === "string" && e !== "")) return false;
208
+ const moved = movedSinceVerified(dir, verified, changed);
209
+ if (!moved || moved.size === 0) return false;
210
+ const hit = (p) => list.some((e) => (e.endsWith("/") ? p.startsWith(e) : p === e));
211
+ return [...moved].every(hit);
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ // The "changed since the last green" set onlyNoTestPathsChanged judges (card gate-docs-skip-unless-named factors
218
+ // it out so the docs rule reads the same set): a Set of paths, or null when there is no usable verified
219
+ // fingerprint, `changed` is not an array, its head is unknown to git, or anything throws. Never throws.
220
+ export function movedSinceVerified(dir, verified, changed) {
221
+ try {
222
+ if (!verified || typeof verified !== "object" || !verified.tree || typeof verified.tree !== "object" || Array.isArray(verified.tree)) return null;
223
+ if (!Array.isArray(changed)) return null;
224
+ const committed = committedSince(dir, verified.head);
225
+ if (!Array.isArray(committed)) return null;
226
+ const moved = new Set();
227
+ for (const p of committed) {
228
+ if (!(p in verified.tree) || fileIdentity(join(dir, p)) !== verified.tree[p]) moved.add(p);
229
+ }
230
+ // changedFiles collapses a rename `old -> new` to `new`, hiding a path moved out of a source location, so
231
+ // the dirty set is collected here with --no-renames (both sides), exactly as pre-commit does.
232
+ const git = (args) =>
233
+ execFileSync("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
234
+ .split("\n")
235
+ .map((l) => l.trim())
236
+ .filter(Boolean)
237
+ .filter((p) => !p.startsWith(".claude/worktrees/") && !p.startsWith(".doug/.state/") && p !== ".doug/anchor.md");
238
+ const dirty = [...git(["diff", "--name-only", "--no-renames", "HEAD", "--"]), ...git(["ls-files", "--others", "--exclude-standard"])];
239
+ for (const p of new Set([...dirty, ...Object.keys(verified.tree)])) {
240
+ if (!(p in verified.tree) || fileIdentity(join(dir, p)) !== verified.tree[p]) moved.add(p);
241
+ }
242
+ return moved;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ // card gate-skip-readonly-researchers: the start fingerprint of a subagent, written by the trace hook on
249
+ // SubagentStart and read by the stop gate. One small JSON file per agent under .doug/.state/agent-start/<session>/.
250
+ // Ids are reduced to [A-Za-z0-9_-] so one can never escape the directory. Both functions never throw: a failed
251
+ // write means no record, an unreadable record reads as null (the gate then runs as before).
252
+ function agentStartFile(dir, sessionId, agentId) {
253
+ const clean = (v) => String(v ?? "").replace(/[^A-Za-z0-9_-]/g, "_") || "_";
254
+ return join(dir, ".doug/.state/agent-start", clean(sessionId), `${clean(agentId)}.json`);
255
+ }
256
+
257
+ export function writeAgentStart(dir, cfg, sessionId, agentId) {
258
+ try {
259
+ const fp = treeFingerprint(dir, cfg, changedFiles(dir));
260
+ if (!fp) return;
261
+ const file = agentStartFile(dir, sessionId, agentId);
262
+ mkdirSync(join(file, ".."), { recursive: true });
263
+ writeFileSync(file, JSON.stringify(fp));
264
+ } catch {
265
+ // no record
266
+ }
267
+ }
268
+
269
+ export function readAgentStart(dir, sessionId, agentId) {
270
+ try {
271
+ return JSON.parse(readFileSync(agentStartFile(dir, sessionId, agentId), "utf8"));
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ // card stop-gate-runs-only-on-code-turns: the turn-start fingerprint, written by scripts/turn-start.mjs on
278
+ // UserPromptSubmit and read by the main Stop gate. One small JSON file per session under
279
+ // .doug/.state/turn-start/<session>.json, same id sanitising as agentStartFile. Both functions never throw: a
280
+ // failed write means no record, an unreadable record reads as null (the gate then verifies as before).
281
+ function turnStartFile(dir, sessionId) {
282
+ const clean = String(sessionId ?? "").replace(/[^A-Za-z0-9_-]/g, "_") || "_";
283
+ return join(dir, ".doug/.state/turn-start", `${clean}.json`);
284
+ }
285
+
286
+ export function writeTurnStart(dir, cfg, sessionId) {
287
+ try {
288
+ const fp = treeFingerprint(dir, cfg, changedFiles(dir));
289
+ if (!fp) return;
290
+ const file = turnStartFile(dir, sessionId);
291
+ mkdirSync(join(file, ".."), { recursive: true });
292
+ writeFileSync(file, JSON.stringify(fp));
293
+ } catch {
294
+ // no record
295
+ }
296
+ }
297
+
298
+ export function readTurnStart(dir, sessionId) {
299
+ try {
300
+ return JSON.parse(readFileSync(turnStartFile(dir, sessionId), "utf8"));
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ // Whether the tree still matches a turn-start record once every path on preCommit.noTestPaths is dropped from
307
+ // both (the pre-commit hook's rule: equal, or a prefix when the entry ends in "/"). Returns null when it cannot
308
+ // tell (no or malformed record, no current fingerprint, any throw) so the caller verifies; otherwise
309
+ // { same, exact, dirty }: `exact` = equal with nothing dropped; `same` = same HEAD, same code paths, same identities; `dirty` = the current tree still holds
310
+ // code paths. Never throws.
311
+ export function turnTreeStatus(cfg, start, current) {
312
+ try {
313
+ if (!start || !current || typeof start !== "object" || typeof current !== "object") return null;
314
+ if (!start.tree || typeof start.tree !== "object" || Array.isArray(start.tree)) return null;
315
+ if (!current.tree || typeof current.tree !== "object" || Array.isArray(current.tree)) return null;
316
+ const list = cfg && cfg.preCommit && Array.isArray(cfg.preCommit.noTestPaths) ? cfg.preCommit.noTestPaths.filter((e) => typeof e === "string" && e !== "") : [];
317
+ const skip = (p) => list.some((e) => (e.endsWith("/") ? p.startsWith(e) : p === e));
318
+ const strip = (fp) => ({ head: fp.head, tree: Object.fromEntries(Object.entries(fp.tree).filter(([p]) => !skip(p))) });
319
+ const a = strip(start);
320
+ const b = strip(current);
321
+ return { same: sameTree(a, b), exact: sameTree(start, current), dirty: Object.keys(b.tree).length > 0 };
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
@@ -18,6 +18,60 @@ function stripLeadingDotSlash(f) {
18
18
  return typeof f === "string" && f.startsWith("./") ? f.slice(2) : f;
19
19
  }
20
20
 
21
+ const TEST_FILE_RE = /\.(?:test|spec)\.[A-Za-z]+$/;
22
+
23
+ // Every path a tsc error line names (card stop-gate-tsc-red-claim): a path token immediately followed by
24
+ // `(<line>,<col>): error TS<digits>` anywhere on the line, so a `pnpm -r` prefix does not hide it. Same
25
+ // normalisation as failingTestFilesFrom (leading "./", then `dir + "/"`). Never throws: reads as none.
26
+ export function tscErrorPaths(output, dir) {
27
+ try {
28
+ const paths = new Set();
29
+ const MARK = "): error TS";
30
+ const isDigit = (c) => c >= "0" && c <= "9";
31
+ const isStop = (c) => /[\s"'`()]/.test(c);
32
+ for (const line of String(output || "").split("\n")) {
33
+ // Cheap pre-filter, and no backtracking regex: every step below is a linear walk from a marker.
34
+ let at = line.indexOf(MARK);
35
+ while (at !== -1) {
36
+ const after = at + MARK.length;
37
+ let i = at - 1;
38
+ const colEnd = i;
39
+ while (i >= 0 && isDigit(line[i])) i--;
40
+ let ok = i < colEnd && line[i] === "," ;
41
+ if (ok) {
42
+ i--;
43
+ const lineEnd = i;
44
+ while (i >= 0 && isDigit(line[i])) i--;
45
+ ok = i < lineEnd && line[i] === "(";
46
+ }
47
+ if (ok && isDigit(line[after] || "")) {
48
+ const pathEnd = i;
49
+ let j = pathEnd;
50
+ while (j > 0 && !isStop(line[j - 1])) j--;
51
+ if (j < pathEnd) {
52
+ let f = stripLeadingDotSlash(line.slice(j, pathEnd));
53
+ if (typeof dir === "string" && dir && f.startsWith(dir + "/")) f = f.slice(dir.length + 1);
54
+ paths.add(f);
55
+ }
56
+ }
57
+ at = line.indexOf(MARK, at + 1);
58
+ }
59
+ }
60
+ return [...paths];
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ // The tsc error paths in `output` that are not test files; one of these means the failure is not red by design.
67
+ export function nonTestTscPathsFrom(output, dir) {
68
+ try {
69
+ return tscErrorPaths(output, dir).filter((f) => !TEST_FILE_RE.test(f));
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
21
75
  // Test files a failed verification command's output names: every path token ending in ".test.<ext>" or
22
76
  // ".spec.<ext>" (ext = letters) on a line containing "FAIL" or "failed", deduplicated, normalised to a
23
77
  // repository-relative path when absolute under `dir`. Moved here from stop-gate.mjs (card
@@ -28,6 +82,7 @@ export function failingTestFilesFrom(output, dir) {
28
82
  try {
29
83
  const files = new Set();
30
84
  const tokenRe = /[^\s"'`]+\.(?:test|spec)\.[A-Za-z]+/g;
85
+ for (const p of tscErrorPaths(output, dir)) if (TEST_FILE_RE.test(p)) files.add(p);
31
86
  for (const line of String(output || "").split("\n")) {
32
87
  if (!line.includes("FAIL") && !line.includes("failed")) continue;
33
88
  for (const m of line.matchAll(tokenRe)) {
@@ -72,13 +127,15 @@ export function classifyFinalBlock({ preVerificationProblems, evidenceProblemAdd
72
127
  const namedByCommand = failures.map((cf) => failingTestFilesFrom(cf.output, dir));
73
128
  const allNamed = [...new Set(namedByCommand.flat())];
74
129
 
130
+ const nonTestTsc = failures.some((cf) => nonTestTscPathsFrom(cf.output, dir).length > 0);
131
+
75
132
  const onlyFailedCommands =
76
133
  preVerificationProblems === 0 &&
77
134
  !evidenceProblemAdded &&
78
135
  failures.length > 0 &&
79
136
  postCommandProblems - preVerificationProblems === failures.length;
80
137
  const everyCommandNamedAFile = failures.length > 0 && namedByCommand.every((files) => files.length > 0);
81
- if (onlyFailedCommands && everyCommandNamedAFile && Array.isArray(redByDesignClaimFiles) && redByDesignClaimFiles.length > 0) {
138
+ if (onlyFailedCommands && !nonTestTsc && everyCommandNamedAFile && Array.isArray(redByDesignClaimFiles) && redByDesignClaimFiles.length > 0) {
82
139
  const claimSet = new Set(redByDesignClaimFiles);
83
140
  if (allNamed.every((f) => claimSet.has(f))) return "expected-red";
84
141
  }
@@ -43,7 +43,7 @@ export function refName(at = new Date()) {
43
43
 
44
44
  export function commitMessage(cfg, gateResults) {
45
45
  const head = (cfg.checkpoint && cfg.checkpoint.message) || DEFAULT_MESSAGE;
46
- const gate = (gateResults || []).map((r) => `${r.name} ${r.ok ? "ok" : "failed"}`).join(", ");
46
+ const gate = (gateResults || []).map((r) => `${r.name}${r.targeted ? ` (targeted: ${r.targeted} files)` : ""} ${r.ok ? "ok" : "failed"}`).join(", ");
47
47
  return gate ? `${head}\n\ngate: ${gate}` : head;
48
48
  }
49
49
 
@@ -83,6 +83,9 @@ export const DEFAULTS = Object.freeze({
83
83
  // Per-subagent cap on WebSearch plus WebFetch calls (scripts/research-cap.mjs, card research-fetch-cap): null
84
84
  // or 0 turns it off. The main session is never capped.
85
85
  research: { maxFetches: 6 },
86
+ // Card autopilot-unlimited: the context percentage (1..100) at which `/doug-next --auto` ends and hands off; read by
87
+ // doug-flow's scripts/context.mjs. A value that is not a finite number in that range folds back to 60 (loadConfig).
88
+ autopilot: { contextThreshold: 60 },
86
89
  });
87
90
 
88
91
  export function projectDir(input) {
@@ -152,6 +155,22 @@ function normaliseArrayShapes(cfg) {
152
155
  }
153
156
  }
154
157
 
158
+ function normaliseAutopilot(cfg) {
159
+ const a = cfg.autopilot;
160
+ if (a === null || typeof a !== "object" || Array.isArray(a)) {
161
+ cfg.autopilot = { ...DEFAULTS.autopilot };
162
+ return;
163
+ }
164
+ const v = a.contextThreshold;
165
+ if (v === undefined || v === null) {
166
+ cfg.autopilot = { ...a, contextThreshold: DEFAULTS.autopilot.contextThreshold };
167
+ return;
168
+ }
169
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 100) return;
170
+ process.stderr.write(`[doug] ${CONFIG_RELPATH} autopilot.contextThreshold must be a number from 1 to 100; using the default\n`);
171
+ cfg.autopilot = { ...a, contextThreshold: DEFAULTS.autopilot.contextThreshold };
172
+ }
173
+
155
174
  export function loadConfig(dir) {
156
175
  const file = join(dir, CONFIG_RELPATH);
157
176
  if (!existsSync(file)) return { ...DEFAULTS, _present: false };
@@ -159,6 +178,7 @@ export function loadConfig(dir) {
159
178
  const raw = JSON.parse(readFileSync(file, "utf8"));
160
179
  const merged = { ...deepMerge(DEFAULTS, raw), _present: true };
161
180
  normaliseArrayShapes(merged);
181
+ normaliseAutopilot(merged);
162
182
  return merged;
163
183
  } catch (err) {
164
184
  // A broken config must never silently disable gates. Fall back to defaults and say so.
@@ -0,0 +1,95 @@
1
+ // card gate-docs-skip-unless-named: the docs rule shared by the Stop gate and the pre-commit hook
2
+ // (.githooks/pre-commit). A changed docs/**/*.md file does not by itself run the suite. When every changed path
3
+ // is on preCommit.noTestPaths or is a docs .md (and at least one is a docs .md not on the list), the tracked
4
+ // test files whose text names that doc (its repo-relative path, or its basename, which also covers a
5
+ // join("docs", "decisions", "<basename>") spelling) decide: none -> { kind: "skip" }; some -> { kind:
6
+ // "targeted", files } (repo-relative, sorted). Anything else, or any doubt, returns null: run the full gate.
7
+ // Never throws (a throw would reach runHook, which fails open and would skip the gate).
8
+ // CLI (used by .githooks/pre-commit): staged paths on stdin, the config JSON in DOUG_NO_TEST_CONFIG, cwd =
9
+ // the repo; prints "skip", or "targeted" then one file per line; prints nothing for the full gate.
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { readFileSync } from "node:fs";
13
+ import { basename, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const TEST_FILE = /\.test\.(mjs|js|ts|cjs)$/;
17
+ const CODE_FILE = /\.(mjs|js|ts|cjs|sh)$/;
18
+ // Only plainly shell-safe paths are handed to a command line; anything else runs the full gate.
19
+ const SAFE_PATH = /^[A-Za-z0-9_.@+/-]+$/;
20
+
21
+ export function isDocsMarkdown(p) {
22
+ return typeof p === "string" && p.startsWith("docs/") && p.endsWith(".md");
23
+ }
24
+
25
+ function noTestHit(list, p) {
26
+ return list.some((e) => (e.endsWith("/") ? p.startsWith(e) : p === e));
27
+ }
28
+
29
+ function validList(cfg) {
30
+ const list = cfg && cfg.preCommit && cfg.preCommit.noTestPaths;
31
+ return Array.isArray(list) && list.length > 0 && list.every((e) => typeof e === "string" && e !== "") ? list : null;
32
+ }
33
+
34
+ // -> sorted test files naming any of `docs`, or null (unreadable tree, or a file that cannot be mapped to test
35
+ // files names a doc).
36
+ function namingTests(dir, docs, tracked) {
37
+ const needles = [...new Set(docs.flatMap((d) => [d, basename(d)]))];
38
+ const found = [];
39
+ for (const f of tracked) {
40
+ const isTest = TEST_FILE.test(f);
41
+ const underTests = f.startsWith("tests/") || f.includes("/tests/");
42
+ const code = CODE_FILE.test(f) && !f.startsWith("docs/");
43
+ if (!isTest && !underTests && !code) continue;
44
+ if (f.startsWith("docs/")) continue;
45
+ const text = readFileSync(join(dir, f), "utf8");
46
+ if (!needles.some((n) => text.includes(n))) continue;
47
+ if (!isTest) return null; // a helper or source file reads the doc: cannot be mapped to test files
48
+ found.push(f);
49
+ }
50
+ return found.sort();
51
+ }
52
+
53
+ // preCommit.docsTests: absent or [] -> []; otherwise the entries, or null when malformed or not a tracked file.
54
+ function docsTestsList(cfg, tracked) {
55
+ const v = cfg && cfg.preCommit && cfg.preCommit.docsTests;
56
+ if (v === undefined) return [];
57
+ if (!Array.isArray(v) || !v.every((e) => typeof e === "string" && e !== "")) return null;
58
+ const set = new Set(tracked);
59
+ return v.every((e) => set.has(e)) ? v : null;
60
+ }
61
+
62
+ // moved: the changed paths (Set or array). Returns { kind: "skip" } | { kind: "targeted", files } | null.
63
+ export function docsGateDecision(dir, cfg, moved) {
64
+ try {
65
+ const list = validList(cfg);
66
+ if (!list) return null;
67
+ const paths = [...moved];
68
+ if (paths.length === 0 || !paths.every((p) => typeof p === "string" && p !== "")) return null;
69
+ const docs = paths.filter((p) => !noTestHit(list, p));
70
+ if (docs.length === 0 || !docs.every(isDocsMarkdown)) return null;
71
+ const raw = execFileSync("git", ["ls-files", "-z"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000, maxBuffer: 64 * 1024 * 1024 });
72
+ const tracked = raw.split("\0").filter(Boolean);
73
+ const named = namingTests(dir, docs, tracked);
74
+ const extra = docsTestsList(cfg, tracked);
75
+ if (!named || !extra) return null;
76
+ const files = [...new Set([...named, ...extra])].sort();
77
+ if (files.length === 0) return { kind: "skip" };
78
+ if (!files.every((f) => SAFE_PATH.test(f))) return null;
79
+ return { kind: "targeted", files };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
86
+ try {
87
+ const cfg = JSON.parse(process.env.DOUG_NO_TEST_CONFIG);
88
+ const staged = readFileSync(0, "utf8").split("\n").filter((l) => l !== "");
89
+ const d = docsGateDecision(process.cwd(), cfg, staged);
90
+ if (d && d.kind === "skip") process.stdout.write("skip\n");
91
+ else if (d) process.stdout.write(`targeted\n${d.files.join("\n")}\n`);
92
+ } catch {
93
+ // no output: the hook runs the full gate
94
+ }
95
+ }
@@ -20,6 +20,8 @@ export const RUNNER_PATTERNS = [
20
20
  /\bgradle\w*\b.*\btest\b/,
21
21
  /\bdotnet\s+test\b/,
22
22
  /\bmake\s+(test|check)\b/,
23
+ // card verified-tree-record: the vendored or plugin verify.mjs only; a project's own verify.mjs must not count.
24
+ /(\.doug\/hooks|doug-gates)\/scripts\/verify\.mjs\b/,
23
25
  ];
24
26
 
25
27
  // The commands the stop gate runs, by name, plus every other configured project command except install.
@@ -6,6 +6,10 @@ import { spawnSync } from "node:child_process";
6
6
  export function commandEnv(base = process.env) {
7
7
  const env = { ...base, CI: base.CI ?? "1", NO_COLOR: "1" };
8
8
  delete env.FORCE_COLOR;
9
+ // card worktree-gate-mixed-tree-agent: the stop gate's per-tree plumbing never reaches the commands it runs.
10
+ delete env.DOUG_STOP_GATE_TREE;
11
+ delete env.DOUG_STOP_GATE_DEADLINE;
12
+ delete env.DOUG_STOP_GATE_CHILD;
9
13
  return env;
10
14
  }
11
15
 
@@ -93,6 +93,11 @@ export function emptyState() {
93
93
  // the Decide card stop-gate-honours-tester-claim's to add. Absent from an old state file loads as null
94
94
  // via the emptyState spread above.
95
95
  redByDesignClaim: null,
96
+ // Test totals per verification command, { [command]: { total, skipped, todo } } (card test-count-ratchet):
97
+ // copied from the last-green record (.doug/.state/test-totals.json) by session-start-baseline.mjs in the same
98
+ // write as `baseline`, never overwritten once non-null. The Stop gate compares against this, never against
99
+ // the file, so a session cannot lower its own bar by going green. null = no baseline, no count check.
100
+ testTotals: null,
96
101
  // context (card context-window-handoff): { pct, at, notifiedAt? } — absent until the status line first
97
102
  // records a reading (statusline.mjs), so not a default key here. pct and at are written by the status line
98
103
  // on every rounded-pct change; notifiedAt is written by the Stop gate (stop-gate.mjs) when it notices, and
@@ -0,0 +1,139 @@
1
+ // Test-count ratchet (card test-count-ratchet): pure logic for parsing a vitest summary, comparing it with a
2
+ // baseline, and matching a waiver. The Stop gate calls it; nothing here touches the filesystem or throws.
3
+ //
4
+ // Known limits, stated on purpose:
5
+ // - Counts cannot see delete-one-add-one: a session that removes a test and adds an unrelated one keeps
6
+ // every number level and passes.
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.)
9
+ // - The baseline is the number of `Tests` summaries a command prints plus their sum (ADR 0013): a session
10
+ // that adds or removes a package changes the number of summaries and blocks with no waiver until a new
11
+ // baseline is taken.
12
+ // - Runners other than vitest print no `Tests` summary in this shape: no summary is parsed, no check runs.
13
+
14
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
15
+
16
+ export function stripAnsi(text) {
17
+ return String(text).replace(ANSI_RE, "");
18
+ }
19
+
20
+ const SEGMENT_RE = /^(\d+) (failed|passed|skipped|todo)$/;
21
+ const TESTS_LINE_RE = /^\s*Tests\s+(.+?)\s*$/;
22
+ const TOTAL_RE = /^(.*) \((\d+)\)$/;
23
+
24
+ // One line's value part ("1 failed | 2 passed | 3 skipped (6)") -> { total, skipped, todo } or null.
25
+ function parseSummaryValue(value) {
26
+ const m = TOTAL_RE.exec(value);
27
+ if (!m) return null;
28
+ const counts = { skipped: 0, todo: 0 };
29
+ for (const seg of m[1].split(" | ")) {
30
+ const s = SEGMENT_RE.exec(seg.trim());
31
+ if (!s) return null;
32
+ if (s[2] === "skipped" || s[2] === "todo") counts[s[2]] = Number(s[1]);
33
+ }
34
+ return { total: Number(m[2]), skipped: counts.skipped, todo: counts.todo };
35
+ }
36
+
37
+ // Every `Tests` summary line is kept as its own entry, in output order; a byte-identical duplicate counts
38
+ // twice (ADR 0013: the number of summaries is part of the baseline).
39
+ // -> { status: "ok", summaries: [{ total, skipped, todo }, ...], totals: <their sum> } | { status: "none" }
40
+ export function parseTotals(output) {
41
+ try {
42
+ if (typeof output !== "string") return { status: "none" };
43
+ const summaries = [];
44
+ for (const line of stripAnsi(output).split(/\r?\n/)) {
45
+ const m = TESTS_LINE_RE.exec(line);
46
+ if (!m) continue;
47
+ const parsed = parseSummaryValue(m[1]);
48
+ if (parsed) summaries.push(parsed);
49
+ }
50
+ if (summaries.length === 0) return { status: "none" };
51
+ return { status: "ok", summaries, totals: sumTotals(summaries) };
52
+ } catch {
53
+ return { status: "none" };
54
+ }
55
+ }
56
+
57
+ // Field-wise sum of a list of { total, skipped, todo }.
58
+ export function sumTotals(summaries) {
59
+ const out = { total: 0, skipped: 0, todo: 0 };
60
+ for (const s of summaries) {
61
+ out.total += s.total;
62
+ out.skipped += s.skipped;
63
+ out.todo += s.todo;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ // Reads a baseline entry or a recorded parseTotals result in any shape -> a list of valid summaries, or null.
69
+ // A list; an old { total, skipped, todo } (a list of one); a recorded { status: "ok", summaries }; an old
70
+ // recorded { status: "ok", totals } (a list of one); an old recorded { status: "conflict", summaries }.
71
+ // Never throws.
72
+ export function readSummaries(x) {
73
+ try {
74
+ let list;
75
+ if (Array.isArray(x)) list = x;
76
+ else if (x && typeof x === "object") {
77
+ if (x.status === "ok") list = Array.isArray(x.summaries) ? x.summaries : [x.totals];
78
+ else if (x.status === "conflict") list = x.summaries;
79
+ else if (x.status === undefined) list = [x];
80
+ else return null;
81
+ } else return null;
82
+ if (!Array.isArray(list) || list.length === 0) return null;
83
+ const out = list.map(validTotals);
84
+ return out.every((s) => s) ? out : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ const isCount = (n) => Number.isInteger(n) && n >= 0;
91
+
92
+ // A baseline entry usable for comparison, or null.
93
+ export function validTotals(t) {
94
+ if (!t || typeof t !== "object") return null;
95
+ if (!isCount(t.total) || !isCount(t.skipped) || !isCount(t.todo)) return null;
96
+ return { total: t.total, skipped: t.skipped, todo: t.todo };
97
+ }
98
+
99
+ // Total dropped, or skipped/todo rose. A rise in total or a drop in skipped/todo is never a problem.
100
+ export function compareTotals(baseline, now) {
101
+ const out = [];
102
+ if (now.total < baseline.total) out.push({ kind: "total", baseline: baseline.total, now: now.total });
103
+ if (now.skipped > baseline.skipped) out.push({ kind: "skipped", baseline: baseline.skipped, now: now.skipped });
104
+ if (now.todo > baseline.todo) out.push({ kind: "todo", baseline: baseline.todo, now: now.todo });
105
+ return out;
106
+ }
107
+
108
+ export function deltaTotals(baseline, now) {
109
+ return { total: now.total - baseline.total, skipped: now.skipped - baseline.skipped, todo: now.todo - baseline.todo };
110
+ }
111
+
112
+ const WAIVER_RE = /^test-count-waiver: total=([+-]?\d+) skipped=([+-]?\d+) todo=([+-]?\d+);(.*)$/;
113
+
114
+ // One line -> { total, skipped, todo, reason } or null. The reason must be non-empty after trimming.
115
+ export function parseWaiver(line) {
116
+ if (typeof line !== "string") return null;
117
+ const m = WAIVER_RE.exec(line.trim());
118
+ if (!m) return null;
119
+ const reason = m[4].trim();
120
+ if (!reason) return null;
121
+ return { total: Number(m[1]) + 0, skipped: Number(m[2]) + 0, todo: Number(m[3]) + 0, reason };
122
+ }
123
+
124
+ // sources: strings, each possibly several lines. Covers only when all three deltas equal the actual ones.
125
+ export function waiverCovers(sources, delta) {
126
+ try {
127
+ if (!Array.isArray(sources)) return false;
128
+ for (const src of sources) {
129
+ if (typeof src !== "string") continue;
130
+ for (const line of src.split(/\r?\n/)) {
131
+ const w = parseWaiver(line);
132
+ if (w && w.total === delta.total && w.skipped === delta.skipped && w.todo === delta.todo) return true;
133
+ }
134
+ }
135
+ } catch {
136
+ // a broken source never covers
137
+ }
138
+ return false;
139
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
+ import { scanText } from "./secret-rules.mjs";
10
11
 
11
12
  export const TRACE_DIR_RELPATH = ".doug/.state/trace";
12
13
 
@@ -28,11 +29,27 @@ function clip(s, n = 120) {
28
29
 
29
30
  // A one-line summary of a tool's input: the command, the file, the pattern, or the description. Never the whole input.
30
31
  // "skill" (card learn-signals) records a Skill tool call's skill name, so trace-derived signals can count
31
- // which skills actually get invoked.
32
- export function detailOf(toolInput) {
32
+ // which skills actually get invoked. Card trace-redact-secrets: the full source string is scanned (scanText, the
33
+ // same secrets config as secret-scan) before clipping; a hit records "[redacted: <rule>]", a scanner error
34
+ // records "[redacted: scan-error]" (fail closed to redacting, never throws). Card trace-redact-reason: redactOrClip
35
+ // is that scan-then-clip, shared by detailOf and the PermissionDenied reason.
36
+ export function redactOrClip(s, secrets = {}) {
37
+ try {
38
+ if (!(secrets && secrets.enabled === false)) {
39
+ const hit = scanText(s, secrets || {});
40
+ if (hit) return "[redacted: " + hit.rule + "]";
41
+ }
42
+ } catch {
43
+ return "[redacted: scan-error]";
44
+ }
45
+ return clip(s);
46
+ }
47
+
48
+ export function detailOf(toolInput, secrets = {}) {
33
49
  if (!toolInput || typeof toolInput !== "object") return null;
34
50
  for (const key of ["command", "file_path", "notebook_path", "pattern", "description", "prompt", "url", "skill"]) {
35
- if (typeof toolInput[key] === "string" && toolInput[key].trim()) return clip(toolInput[key]);
51
+ const s = toolInput[key];
52
+ if (typeof s === "string" && s.trim()) return redactOrClip(s, secrets);
36
53
  }
37
54
  return null;
38
55
  }
@@ -90,9 +107,9 @@ export function usageFromTranscript(file) {
90
107
  // same as every other event here: InstructionsLoaded's `detail` is the loaded file's path and its `reason` the
91
108
  // matcher's load_reason ("session_start", "path_glob_match", ...) - `file_content` is never read off the input,
92
109
  // so it can never end up on the line even by accident. PermissionDenied's `detail` is the denied tool's input
93
- // (via detailOf, same as a Pre/PostToolUse line), `ok` is forced false, and `reason` is the clipped denial_reason.
110
+ // (via detailOf, same as a Pre/PostToolUse line), `ok` is forced false, and `reason` is the denial_reason via redactOrClip (secret-scanned, then clipped).
94
111
  // Every other event's `reason` is null.
95
- export function traceLine(input, { now = new Date(), context = null } = {}) {
112
+ export function traceLine(input, { now = new Date(), context = null, secrets } = {}) {
96
113
  const event = input.hook_event_name || null;
97
114
  const response = input.tool_response ?? input.tool_output ?? null;
98
115
  let ok = null;
@@ -105,10 +122,10 @@ export function traceLine(input, { now = new Date(), context = null } = {}) {
105
122
  let detail = null;
106
123
  let reason = null;
107
124
  if (event === "PreToolUse" || event === "PostToolUse" || event === "PermissionDenied") {
108
- detail = detailOf(input.tool_input);
125
+ detail = detailOf(input.tool_input, secrets);
109
126
  }
110
127
  if (event === "PermissionDenied") {
111
- reason = typeof input.denial_reason === "string" && input.denial_reason.trim() ? clip(input.denial_reason, 120) : null;
128
+ reason = typeof input.denial_reason === "string" && input.denial_reason.trim() ? redactOrClip(input.denial_reason, secrets) : null;
112
129
  } else if (event === "InstructionsLoaded") {
113
130
  detail = typeof input.file_path === "string" && input.file_path ? input.file_path : null;
114
131
  reason = typeof input.load_reason === "string" && input.load_reason ? input.load_reason : null;