@dougharness/cli 1.0.2 → 1.1.1

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 (88) hide show
  1. package/dist/bin.js +18 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +14 -7
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +42 -9
  7. package/dist/board.js.map +1 -1
  8. package/dist/detect/repo.js +14 -1
  9. package/dist/detect/repo.js.map +1 -1
  10. package/dist/detect/types.d.ts +2 -0
  11. package/dist/doctor.d.ts +46 -0
  12. package/dist/doctor.js +491 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/generate/agents.d.ts +7 -0
  15. package/dist/generate/agents.js +62 -12
  16. package/dist/generate/agents.js.map +1 -1
  17. package/dist/generate/claude-md.js +4 -0
  18. package/dist/generate/claude-md.js.map +1 -1
  19. package/dist/generate/config.d.ts +8 -0
  20. package/dist/generate/config.js +4 -1
  21. package/dist/generate/config.js.map +1 -1
  22. package/dist/generate/proposal.js +15 -6
  23. package/dist/generate/proposal.js.map +1 -1
  24. package/dist/generate/settings.d.ts +2 -0
  25. package/dist/generate/settings.js +17 -2
  26. package/dist/generate/settings.js.map +1 -1
  27. package/dist/init.d.ts +14 -0
  28. package/dist/init.js +94 -8
  29. package/dist/init.js.map +1 -1
  30. package/dist/memory-setup.d.ts +28 -0
  31. package/dist/memory-setup.js +326 -0
  32. package/dist/memory-setup.js.map +1 -0
  33. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  34. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  35. package/node_modules/@dougharness/flow/agents/planner.md +3 -3
  36. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  37. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  38. package/node_modules/@dougharness/flow/lib/board.mjs +171 -13
  39. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  40. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  41. package/node_modules/@dougharness/flow/lib/cost.mjs +155 -7
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
  43. package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  45. package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
  46. package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
  47. package/node_modules/@dougharness/flow/package.json +2 -1
  48. package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
  49. package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
  50. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  51. package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
  52. package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
  53. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  54. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  55. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  56. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
  57. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
  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/bash-rules.mjs +21 -14
  67. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  68. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  69. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  70. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  71. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  72. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
  73. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  74. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  75. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  76. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  77. package/node_modules/@dougharness/gates/package.json +1 -1
  78. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  79. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +558 -197
  80. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  81. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  82. package/node_modules/@dougharness/gates/scripts/verify.mjs +201 -0
  83. package/package.json +4 -4
  84. package/templates/board-app.js +4 -3
  85. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  86. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  87. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  88. 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
+ }
@@ -726,8 +726,8 @@ function skipPrefixOptions(w, i, argFlags, onArgFlag) {
726
726
  // exactly as before. For the separate-token form the value is already its own token, so this returns { next }
727
727
  // pointing at it and lets matchesShellWords's loop continue from there. For an attached form (whose value can't
728
728
  // be pulled back out as its own token) there is no token to point { next } at, so this returns { splice: value,
729
- // next } instead: the caller replaces everything up to `next` with the single token `value` and resumes its own
730
- // loop at index 0 - a rebuild, not a recursive call, so nested attached values (env -Senv -Senv ... sh) cost no
729
+ // next } instead: the caller overwrites the attached token's slot (next - 1) with the single token `value` and resumes its own
730
+ // loop at that slot - a linear overwrite, not a recursive call, so nested attached values (env -Senv -Senv ... sh) cost no
731
731
  // call-stack depth no matter how many there are. Otherwise (the value is missing, empty, or itself option-
732
732
  // shaped) it isn't the command, so env keeps parsing options from there: for the separate-token form the walk
733
733
  // re-examines that same value token as the next option (advancing by 1, not 2, past just the flag), and for an
@@ -892,10 +892,13 @@ function redirWordSpan(tok) {
892
892
  // Walks a stage's already-split words from index i, resolving prefixes (env assignments, sudo, env, nice,
893
893
  // time, nohup, exec, command, busybox) until it lands on the candidate verb. Split out of matchesShellStage so
894
894
  // scanEnvOptions can hand an -S/--split-string value back into this same walk when that value qualifies as
895
- // env's command. That handoff is a rebuild of `w`, not a recursive call (see the "splice" case below): every
896
- // handoff replaces one or more consumed tokens with the single value token, so `w` strictly shrinks by at least
897
- // one token each time, and the loop below runs no deeper than one stack frame no matter how many wrappers are
898
- // nested (env -Senv -Senv ... sh).
895
+ // env's command. That handoff is an in-place overwrite of one slot of a single copy of `w`, not a recursive call (see the "splice" case below): every
896
+ // handoff replaces one or more consumed tokens with the single value token, so the walk's unread tail strictly
897
+ // shrinks by at least one token each time, and the loop below runs no deeper than one stack frame no matter how
898
+ // many wrappers are nested (env -Senv -Senv ... sh). The handoff is linear (card bash-rules-env-splice-quadratic):
899
+ // the value overwrites the attached token's slot in a single copy of `w` and the walk resumes there, instead of
900
+ // rebuilding the array per handoff (quadratic: 25 s at 80000 nestings). No token cap is applied: a cap would deny
901
+ // commands that are allowed today, and the linear walk removes the timeout risk that a cap would guard.
899
902
  // Card bash-rules-prefixed-commands: also shared with commandForms, which needs the resolved word array and
900
903
  // verb index (not just a shell/not-shell verdict), so the walk itself no longer decides an outcome - it returns
901
904
  // { w, i } once it lands on a candidate verb (i may be w.length when nothing followed the prefixes), or
@@ -920,7 +923,7 @@ function redirWordSpan(tok) {
920
923
  // still reads the RAW token, unchanged - a quoted FLAG is out of this card's scope, and dequoting it too
921
924
  // would risk changing which flag a cluster or an attached form is read as, for no fixture that needs it.
922
925
  // The returned `{w, i}` itself is unaffected by this: `w` is still the array the caller passed in (or
923
- // resolveVerb's own env-splice rebuild, unrelated to this round), and `i` is just an index into it - the
926
+ // resolveVerb's own single copy after an env splice, unrelated to this round), and `i` is just an index into it - the
924
927
  // caller (isShellVerb by way of commandForms/matchesShellWords) dequotes `w[i]` itself before comparing it
925
928
  // against a shell name, the same way this loop now does for a prefix name.
926
929
  //
@@ -936,6 +939,7 @@ function redirWordSpan(tok) {
936
939
  // ...) reads the prefix/verb token's text again - everything after it operates on the RAW words that follow,
937
940
  // unaffected by which reading recognised the name in front of them.
938
941
  function resolveVerb(w, i) {
942
+ const original = w;
939
943
  while (i < w.length) {
940
944
  const tok = w[i];
941
945
  const span = redirWordSpan(tok);
@@ -970,11 +974,14 @@ function resolveVerb(w, i) {
970
974
  if (isBase("env")) {
971
975
  const result = scanEnvOptions(w, i + 1);
972
976
  if ("splice" in result) {
973
- // An attached -S/--split-string value qualified as env's command: rebuild the word array with that
974
- // value as the new first word, followed by whatever came after it, and resume this same loop at 0 -
975
- // no recursive call, so this is safe no matter how many attached values are nested.
976
- w = [result.splice, ...w.slice(result.next)];
977
- i = 0;
977
+ // An attached -S/--split-string value qualified as env's command: the value becomes the walk's next word.
978
+ // It overwrites the slot of the attached token just before result.next (copying w once, on the first
979
+ // splice, so the caller's array is never mutated) and the walk resumes AT that slot - the same words,
980
+ // from the same start, the old rebuilt array [value, ...rest] held at its index 0. Each handoff consumes at
981
+ // least one token and costs O(1), so n nested values cost O(n), and no recursion either.
982
+ if (w === original) w = original.slice();
983
+ i = result.next - 1;
984
+ w[i] = result.splice;
978
985
  continue;
979
986
  }
980
987
  i = result.next; // separate-token -S/--split-string value, or no qualifying value: continue the walk
@@ -1335,8 +1342,8 @@ function dequoteScript(word, reading = "bash") {
1335
1342
  // behind a resolvable prefix (sudo, an env assignment, env, nice, time, nohup, exec, command, busybox) or a
1336
1343
  // shell's -c string. Always includes cmd itself. When resolveVerb's walk consumed or rebuilt anything - i > 0
1337
1344
  // (an ordinary prefix was skipped) or the returned word array isn't the one passed in (an attached env
1338
- // -S/--split-string value spliced in a new array and reset i to 0 on it, so i > 0 alone missed a denied command
1339
- // sitting right behind it, e.g. env -Sgit reset --hard) - also includes the resolved word array from the verb
1345
+ // -S/--split-string value was spliced into a single copy of the array, overwriting the attached token's slot;
1346
+ // the walk resumes there, so i stays >= 1 after a splice) - also includes the resolved word array from the verb
1340
1347
  // onward. When that verb's basename is a shell, findScriptCandidates (above) locates 0, 1, or 2 candidate -c
1341
1348
  // script words; each candidate is dequoted (dequoteScript) - the ONLY word this function dequotes - twice, once
1342
1349
  // per shell reading (review round 3: bash and zsh decode an unrecognised ANSI-C escape differently, see
@@ -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.
@@ -0,0 +1,94 @@
1
+ // The gate.lock helpers shared by the Stop gate (scripts/stop-gate.mjs) and verify.mjs (card
2
+ // verify-takes-gate-lock). Moved verbatim from stop-gate.mjs; the Stop gate's behaviour is unchanged.
3
+
4
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ // Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
8
+ // at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
9
+ // 2026-09-16 process storm). `.doug/.state/gate.lock` holds `{ pid, session, at }` while a gate's commands
10
+ // run.
11
+ const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
12
+ export function gateLockPath(dir) {
13
+ return join(dir, GATE_LOCK_RELPATH);
14
+ }
15
+
16
+ // Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
17
+ // content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
18
+ // caller treats exactly like a stale one.
19
+ export function readGateLock(dir) {
20
+ try {
21
+ const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
22
+ // A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
23
+ // group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
24
+ // throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
25
+ return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function pidIsLive(pid) {
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ } catch (err) {
36
+ // EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
37
+ // that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
38
+ // anything else) reads as dead.
39
+ return err && err.code === "EPERM";
40
+ }
41
+ }
42
+
43
+ // Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
44
+ // caller decides what a failure means.
45
+ export function writeGateLock(dir, sessionId) {
46
+ mkdirSync(join(dir, ".doug/.state"), { recursive: true });
47
+ writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
48
+ }
49
+
50
+ // Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
51
+ // simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
52
+ // it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
53
+ // does not hold).
54
+ export function takeOverGateLock(dir, sessionId) {
55
+ try {
56
+ mkdirSync(join(dir, ".doug/.state"), { recursive: true });
57
+ writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
58
+ } catch {
59
+ // best-effort takeover only
60
+ }
61
+ }
62
+
63
+ // Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
64
+ // Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
65
+ // missing, non-number, or non-positive value reads as the platform's own default, 600.
66
+ export function resolveHookTimeoutSec(cfg) {
67
+ const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
68
+ return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
69
+ }
70
+
71
+ // A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
72
+ // legitimate holder can still be running that long, since the platform itself would have killed its hook
73
+ // by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
74
+ export function lockStaleByAge(lock, hookTimeoutMs) {
75
+ return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
76
+ }
77
+
78
+ // Card stop-gate-timeout-normalise (moved here by card verify-lock-wait-bound so verify.mjs shares it):
79
+ // cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout and the wait bound, so the two
80
+ // never disagree on what a garbage or fractional value means. A finite number above 0 floors to an integer,
81
+ // clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a timeout of 0 as none at all,
82
+ // letting a command run past the budget unclamped; 2.5 must not reach spawnSync un-floored — it throws
83
+ // ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else (non-number, NaN, Infinity,
84
+ // 0, negative, null) is "unset" (null), read by each call site as its own default.
85
+ export function normalizedTimeoutMs(cfg) {
86
+ const t = cfg.stopGate.timeoutMs;
87
+ return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
88
+ }
89
+
90
+ // How long a gate waits on a live gate.lock: the per-command timeout times the command count, the longest a
91
+ // holder legitimately runs.
92
+ export function lockWaitBoundMs(perCommandMs, commandCount) {
93
+ return perCommandMs * Math.max(1, commandCount);
94
+ }
@@ -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