@dougharness/cli 1.0.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,101 @@
1
+ import { isAbsolute, relative, resolve, sep, parse, dirname, basename } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { realpathSync } from "node:fs";
4
+
5
+ // Returns the path relative to the project dir with forward slashes,
6
+ // or null if the path escapes the project.
7
+ export function toProjectRelative(dir, p) {
8
+ if (!p) return null;
9
+ const abs = isAbsolute(p) ? p : resolve(dir, p);
10
+ const rel = relative(dir, abs);
11
+ if (rel.startsWith("..") || isAbsolute(rel)) return null;
12
+ return rel.split(sep).join("/");
13
+ }
14
+
15
+ // Expands a leading "~" or "~/..." against HOME. "~" alone, or "~/" followed by any number of extra leading
16
+ // slashes, resolves under homedir() (the extra slashes are stripped so "~//etc" means "$HOME/etc", never
17
+ // "/etc" — resolve() would otherwise discard the home prefix when the second argument is absolute). Anything
18
+ // else starting with "~" (e.g. "~x", "~root/x") is not tilde-home syntax and is returned unchanged.
19
+ function expandTilde(entry) {
20
+ if (entry === "~") return homedir();
21
+ if (entry.startsWith("~/")) return resolve(homedir(), entry.slice(2).replace(/^[/\\]+/, ""));
22
+ return entry;
23
+ }
24
+
25
+ // True when a resolved path is exactly a filesystem root (e.g. "/" or "C:\").
26
+ function isFilesystemRoot(p) {
27
+ return parse(p).root === p;
28
+ }
29
+
30
+ // Resolves the real path of p by realpath-ing its deepest existing ancestor and re-appending any path
31
+ // segments that do not exist yet (e.g. a Write creating a new file), so containment checks are resistant to
32
+ // symlinks and filesystem case-aliases. Uses realpathSync.native (the OS's own realpath) rather than plain
33
+ // realpathSync: on a case-insensitive, case-preserving volume (APFS's default on macOS) plain
34
+ // realpathSync only resolves symlinks and echoes back whatever case the input used, so it does NOT canonicalize
35
+ // a case-variant alias to the same string — verified directly (fs.realpathSync of a same-directory alias with
36
+ // swapped case returns the swapped-case string unchanged, while fs.realpathSync.native returns the one true
37
+ // on-disk casing for both). Falls back to the lexical, resolved path if realpath cannot be determined for any
38
+ // reason (a missing/inaccessible ancestor all the way to the root) — never throws.
39
+ export function realpathDeepestExisting(p) {
40
+ const resolved = resolve(p);
41
+ const pending = [];
42
+ let current = resolved;
43
+ for (;;) {
44
+ try {
45
+ const real = realpathSync.native(current);
46
+ return pending.length ? resolve(real, ...pending.reverse()) : real;
47
+ } catch {
48
+ const parent = dirname(current);
49
+ if (parent === current) return resolved;
50
+ pending.push(basename(current));
51
+ current = parent;
52
+ }
53
+ }
54
+ }
55
+
56
+ // True when absPath is equal to, or really contained inside, one of allowedOutsidePaths (each entry an
57
+ // absolute or ~-prefixed path naming a file or directory). Used by protect-paths.mjs to let a write outside
58
+ // the project directory through when it is explicitly allowlisted in .doug/config.json's allowedOutsidePaths.
59
+ // Entries that are empty or not strings are ignored rather than thrown on, as is any entry whose REAL path is a
60
+ // filesystem root — never a legitimate allowlist entry, and catastrophic if honored, since "/" as the prefix
61
+ // matches every absolute path and would disable this guard globally. The root check runs after realpath
62
+ // resolution as well as before it, because an entry that is a symlink to "/" resolves lexically to something
63
+ // harmless-looking and only becomes the root once followed. No globs: directory containment on resolved,
64
+ // `..`-free paths is the right primitive for an escape hatch, and is safer than glob matching here. Containment
65
+ // requires a real path-segment boundary (the resolved entry plus a trailing separator, or an exact match) so
66
+ // that an entry "/a/allowed" does not also match a sibling "/a/allowed-other". Both sides are resolved through
67
+ // realpathDeepestExisting so a symlink or filesystem-case alias cannot be used to make a path look contained
68
+ // (or not) when its real target says otherwise.
69
+ export function isAllowedOutside(absPath, allowedOutsidePaths) {
70
+ if (!absPath || !Array.isArray(allowedOutsidePaths)) return false;
71
+ const target = realpathDeepestExisting(absPath);
72
+ for (const entry of allowedOutsidePaths) {
73
+ if (!entry || typeof entry !== "string") continue;
74
+ const resolvedEntry = resolve(expandTilde(entry));
75
+ if (isFilesystemRoot(resolvedEntry)) {
76
+ process.stderr.write(`[doug] allowedOutsidePaths entry ${JSON.stringify(entry)} resolves to the filesystem root; ignoring it.\n`);
77
+ continue;
78
+ }
79
+ const realEntry = realpathDeepestExisting(resolvedEntry);
80
+ // The entry's real path is the root: a symlink (or chain of them) pointing at "/". Same treatment.
81
+ if (isFilesystemRoot(realEntry)) {
82
+ process.stderr.write(`[doug] allowedOutsidePaths entry ${JSON.stringify(entry)} resolves to the filesystem root; ignoring it.\n`);
83
+ continue;
84
+ }
85
+ if (target === realEntry) return true;
86
+ if (target.startsWith(realEntry.endsWith(sep) ? realEntry : realEntry + sep)) return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ // Extracts file paths from a tool_input for Edit/Write/MultiEdit/NotebookEdit.
92
+ export function filePathsFromToolInput(toolInput) {
93
+ if (!toolInput || typeof toolInput !== "object") return [];
94
+ const out = [];
95
+ if (typeof toolInput.file_path === "string") out.push(toolInput.file_path);
96
+ if (typeof toolInput.notebook_path === "string") out.push(toolInput.notebook_path);
97
+ if (Array.isArray(toolInput.edits)) {
98
+ for (const e of toolInput.edits) if (e && typeof e.file_path === "string") out.push(e.file_path);
99
+ }
100
+ return out;
101
+ }
@@ -0,0 +1,201 @@
1
+ // Reader for the applied-proposal ledger, .doug/.state/proposals/applied.jsonl (cards memory-decisions,
2
+ // proposal-ledger-forgeable): one JSON line per successful `learn.mjs apply` to a proposal-only path
3
+ // (docs/decisions/**, .claude/rules/** by default - proposalPaths in .doug/config.json), written by
4
+ // plugins/doug-flow/lib/learn.mjs's applyProposal. Duplicated here rather than imported across the plugin
5
+ // boundary - no workspace dependency exists between doug-gates and doug-flow (lib/learn.mjs's own header
6
+ // comment states the same precedent for its local glob matcher). The line shape is a contract shared with the
7
+ // writer: { target, sha256, diffSha256, at, proposal }, where `proposal` is the diff file's path relative to
8
+ // `dir` (forward slashes) and `diffSha256` is the sha256 of that diff file's bytes. A row's sha256 alone is not
9
+ // enough to trust (card proposal-ledger-forgeable: a hand-written line can carry any sha256 it likes) - see
10
+ // isAppliedContent below for the re-derivation this module runs instead. A row is untrusted, agent-writable
11
+ // data read at Stop time: every field is treated as adversarial input (wrong type, wrong shape, a path that
12
+ // escapes containment), and no exception from checking one row may ever escape isAppliedContent - runHook
13
+ // (plugins/doug-gates/lib/io.mjs) fails a hook open on an uncaught exception, which would skip the whole Stop
14
+ // gate, not just this check (review BLOCKER 1).
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { dirname, join, resolve, sep } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import { spawnSync } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+
21
+ export const PROPOSAL_LEDGER_RELPATH = ".doug/.state/proposals/applied.jsonl";
22
+
23
+ // Duplicated from plugins/doug-flow/lib/learn.mjs's own export of the same constant - no cross-plugin import
24
+ // (see this file's header comment). A genuine ledger line's `proposal` must resolve under here.
25
+ const LEARN_STATE_RELPATH = ".doug/.state/learn";
26
+
27
+ // review MINOR 7 / MINOR 10: each candidate row costs a `git show` and a `git apply` (~30ms); a ledger padded
28
+ // with many matching-but-bogus rows could push a single Stop check past the hook timeout. Only rows whose
29
+ // target and sha256 already match are ever verified (cheap, in-memory), deduped by `proposal` (below) so a
30
+ // pile of rows that all name the same diff costs one verification, not one per row, then capped to the most
31
+ // recently appended this many DISTINCT proposals. This does not close every way to exhaust the cap: an
32
+ // adversary who appends 20 or more rows naming 20 or more genuinely distinct bogus proposal paths can still
33
+ // push a real row out of the window - but that fails closed (a block), never open, so it costs availability,
34
+ // never lets a forgery through.
35
+ const MAX_VERIFIED_ROWS = 20;
36
+
37
+ // Every spawned git call gets a hard ceiling (ms) so a hung or adversarially slow git invocation cannot itself
38
+ // become the way to blow the Stop-gate timeout (review MINOR 7).
39
+ const GIT_TIMEOUT_MS = 5000;
40
+
41
+ // Every well-formed line, tolerant of a missing file or a torn line (a crashed process mid-write) - never fatal.
42
+ export function readAppliedLedger(dir) {
43
+ const file = join(dir, PROPOSAL_LEDGER_RELPATH);
44
+ if (!existsSync(file)) return [];
45
+ let text;
46
+ try {
47
+ text = readFileSync(file, "utf8");
48
+ } catch {
49
+ return [];
50
+ }
51
+ const out = [];
52
+ for (const line of text.split("\n")) {
53
+ if (!line.trim()) continue;
54
+ try {
55
+ const o = JSON.parse(line);
56
+ if (o && typeof o === "object" && o.target) out.push(o);
57
+ } catch {
58
+ // a torn line is skipped, never fatal
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function sha256OfBytes(buf) {
65
+ return createHash("sha256").update(buf).digest("hex");
66
+ }
67
+
68
+ // sha256 of relPath's current bytes under dir, or the sha of empty content when it cannot be read (a deleted or
69
+ // never-created target) - matches applyProposal's own convention for a deleted target.
70
+ function currentSha256(dir, relPath) {
71
+ try {
72
+ return sha256OfBytes(readFileSync(join(dir, relPath)));
73
+ } catch {
74
+ return sha256OfBytes(Buffer.alloc(0));
75
+ }
76
+ }
77
+
78
+ // review MINOR 8: the writer (applyProposal) refuses a diff that touches more than one file, or whose one
79
+ // target differs from what the caller expected - a ledger row must not trust a diff that the writer itself
80
+ // would have refused. Every "+++ b/" AND "--- a/" header is parsed (the union of both sides' file names,
81
+ // mirroring learn.mjs's own parseDiffTargets - duplicated locally, not imported, for the same reason as
82
+ // everything else in this file's header comment); "/dev/null" never matches either pattern, so a creation or
83
+ // deletion diff's placeholder side is naturally excluded. True only when the diff names exactly one path and
84
+ // that path is relPath.
85
+ function diffNamesOnly(diffText, relPath) {
86
+ const targets = new Set();
87
+ for (const m of diffText.matchAll(/^\+\+\+ b\/(.+)$/gm)) targets.add(m[1]);
88
+ for (const m of diffText.matchAll(/^--- a\/(.+)$/gm)) targets.add(m[1]);
89
+ return targets.size === 1 && targets.has(relPath);
90
+ }
91
+
92
+ // The committed base of relPath at `ref` (default HEAD) in dir, as a Buffer, or null when the path is not at
93
+ // that ref (a new file), the ref does not exist at all (a repo with no commits yet, or a base sha git no
94
+ // longer knows), or the git call times out (GIT_TIMEOUT_MS) or fails.
95
+ function readCommittedBase(dir, relPath, ref) {
96
+ const target = typeof ref === "string" && ref ? ref : "HEAD";
97
+ const result = spawnSync("git", ["show", `${target}:${relPath}`], { cwd: dir, timeout: GIT_TIMEOUT_MS });
98
+ if (result.status !== 0) return null;
99
+ return result.stdout;
100
+ }
101
+
102
+ // Re-derivation at one ref (card proposal-ledger-forgeable): write the committed base of `relPath` at
103
+ // `relPath`, read at `ref`, inside a fresh temp dir (omitted entirely when there is no base at that ref, so a
104
+ // `--- /dev/null` new-file diff can still create it - git apply refuses to create a file that already
105
+ // exists), `git apply` the diff there (git apply works as a plain patch tool outside a git repository; cwd is
106
+ // the temp dir, never `dir` itself, so nothing real is ever touched), and compare the resulting bytes (empty
107
+ // if the diff deleted the file) against `currentSha`. Any failure - a `git apply` that does not succeed or
108
+ // times out (GIT_TIMEOUT_MS), or an unexpected exception - means the diff does not reproduce what is on disk
109
+ // now from this particular ref, so this attempt does not satisfy the check. The temp dir is always removed.
110
+ function diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref) {
111
+ let tmp;
112
+ try {
113
+ tmp = mkdtempSync(join(tmpdir(), "doug-ledger-verify-"));
114
+ const committedBase = readCommittedBase(dir, relPath, ref);
115
+ const targetInTmp = join(tmp, relPath);
116
+ if (committedBase !== null) {
117
+ mkdirSync(dirname(targetInTmp), { recursive: true });
118
+ writeFileSync(targetInTmp, committedBase);
119
+ }
120
+ const applied = spawnSync("git", ["apply", diffAbsPath], { cwd: tmp, timeout: GIT_TIMEOUT_MS });
121
+ if (applied.status !== 0) return false;
122
+ let resultBytes;
123
+ try {
124
+ resultBytes = readFileSync(targetInTmp);
125
+ } catch {
126
+ resultBytes = Buffer.alloc(0);
127
+ }
128
+ return sha256OfBytes(resultBytes) === currentSha;
129
+ } catch {
130
+ return false;
131
+ } finally {
132
+ if (tmp) {
133
+ try {
134
+ rmSync(tmp, { recursive: true, force: true });
135
+ } catch {
136
+ // best-effort cleanup; a leftover temp dir is harmless
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ // `base` (card stop-scan-committed-changes) is the session's baseline HEAD when known, tried first: a diff
143
+ // that created `relPath` after that baseline still applies onto "absent" even once the session has since
144
+ // committed `relPath` itself (HEAD would otherwise already equal the current bytes, and git apply refuses to
145
+ // create a file that already exists there). M1 fix: when the base attempt fails, HEAD is also tried before
146
+ // giving up, not only when `base` is null/omitted - a second approved apply on the same target, committed
147
+ // in-session after the first (so its preimage is the first version, not the pre-session base), re-derives
148
+ // correctly onto HEAD even though it cannot re-derive onto the pre-session base at all. Every other check
149
+ // (row shape, containment, diffSha256, diffNamesOnly) is unchanged; this only widens which ref the content
150
+ // re-derivation itself is allowed to succeed against.
151
+ function diffReproducesCurrentContent(dir, relPath, diffAbsPath, currentSha, base) {
152
+ const refs = typeof base === "string" && base && base !== "HEAD" ? [base, "HEAD"] : ["HEAD"];
153
+ return refs.some((ref) => diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref));
154
+ }
155
+
156
+ // One row's full verification, wrapped so that no exception - a malformed field of any type, an unreadable
157
+ // path, a git call throwing - can ever escape (review BLOCKER 1: an uncaught exception here would fail the
158
+ // whole Stop hook open, not just this row). Returns false, never throws.
159
+ function rowIsGenuine(dir, relPath, sha, learnDirAbs, row, base) {
160
+ try {
161
+ if (typeof row.diffSha256 !== "string" || typeof row.proposal !== "string") return false;
162
+ const diffAbsPath = resolve(dir, row.proposal);
163
+ if (diffAbsPath !== learnDirAbs && !diffAbsPath.startsWith(`${learnDirAbs}${sep}`)) return false;
164
+ const diffBuf = readFileSync(diffAbsPath);
165
+ if (sha256OfBytes(diffBuf) !== row.diffSha256) return false;
166
+ if (!diffNamesOnly(diffBuf.toString("utf8"), relPath)) return false;
167
+ return diffReproducesCurrentContent(dir, relPath, diffAbsPath, sha, base);
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ // True when relPath's current content sha256 matches an applied-ledger line for that same target AND that
174
+ // line's diff (a) is a real file under dir/.doug/.state/learn/, (b) has the sha256 the row claims for it, (c)
175
+ // names relPath and no other file, and (d) applied onto the committed base of relPath, reproduces exactly the
176
+ // bytes on disk now (card proposal-ledger-forgeable). Any row missing `diffSha256` or `proposal`, or carrying
177
+ // either as anything but a string - the old, pre-hardening line shape, or a forged/malformed one - never
178
+ // satisfies this: a hand-written sha256 alone is not proof of an approved `learn.mjs apply` any more, it is
179
+ // only what the ledger claims happened. `base` (card stop-scan-committed-changes) is the ref re-derivation
180
+ // tries first, forwarded to diffReproducesCurrentContent - the caller's session baseline HEAD when it has
181
+ // one, so a target the session itself committed since that baseline is still checked against what existed
182
+ // before the session started, not only against HEAD (which, once the target is committed, already equals the
183
+ // current bytes and would make a new-file diff fail to apply there). HEAD is always tried too (M1 review
184
+ // fix) - a second approved apply on the same target, committed in-session after the first, has the first
185
+ // version as its preimage and can only re-derive onto HEAD, not onto the pre-session base. With `base` null
186
+ // or omitted, only HEAD is tried, exactly as before this card.
187
+ export function isAppliedContent(dir, relPath, { base = null } = {}) {
188
+ const sha = currentSha256(dir, relPath);
189
+ const learnDirAbs = resolve(dir, LEARN_STATE_RELPATH);
190
+ const matching = readAppliedLedger(dir).filter((row) => row && row.target === relPath && row.sha256 === sha);
191
+ // review MINOR 10: dedupe by `proposal` before capping to MAX_VERIFIED_ROWS - many rows naming the very same
192
+ // diff (repeated or near-repeated bogus lines) must cost one verification slot, not one per row, or a large
193
+ // enough pile of them could push a genuine row for this same target/sha256 out of the last-N window even
194
+ // though every one of those rows resolves to a single distinct diff file. A Map naturally keeps only the
195
+ // last-seen entry per key, and the ledger is append-only (later == newer), so the newest occurrence of each
196
+ // distinct `proposal` wins.
197
+ const byProposal = new Map();
198
+ for (const row of matching) byProposal.set(row.proposal, row);
199
+ const candidates = [...byProposal.values()].slice(-MAX_VERIFIED_ROWS).reverse(); // newest distinct proposal first
200
+ return candidates.some((row) => rowIsGenuine(dir, relPath, sha, learnDirAbs, row, base));
201
+ }
@@ -0,0 +1,147 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ // card gates-runner-no-color: NO_COLOR, not FORCE_COLOR — vitest's colour library (picocolors/tinyrainbow)
4
+ // treats any FORCE_COLOR key as colour on, even "0", which broke the stop gate's captured output and the
5
+ // block ledger's failing-file parse (same fact as land.mjs's commandEnv, card land-force-color-enables-color).
6
+ export function commandEnv(base = process.env) {
7
+ const env = { ...base, CI: base.CI ?? "1", NO_COLOR: "1" };
8
+ delete env.FORCE_COLOR;
9
+ return env;
10
+ }
11
+
12
+ // Runs a shell command in the project dir with a timeout. Never throws.
13
+ export function runCommand(command, { cwd, timeoutMs = 300000, env = process.env } = {}) {
14
+ const started = Date.now();
15
+ const res = spawnSync(command, {
16
+ cwd,
17
+ shell: true,
18
+ encoding: "utf8",
19
+ timeout: timeoutMs,
20
+ maxBuffer: 16 * 1024 * 1024,
21
+ env: commandEnv(env),
22
+ // 2026-09-16 process storm (card stop-gate-process-storm): detached makes the spawned shell the leader
23
+ // of its own process group, so a timeout can kill that whole group below, not just the direct child.
24
+ detached: true,
25
+ });
26
+ const timedOut = res.error && res.error.code === "ETIMEDOUT";
27
+ // spawnSync's own `timeout` (ETIMEDOUT) and `maxBuffer` (ENOBUFS) both signal only the direct child (the
28
+ // shell); anything that child spawned (e.g. a background test worker) is left running under pid 1 — four
29
+ // concurrent SubagentStops each doing this orphaned ten vitest workers apiece and downed the watchdog three
30
+ // times on 2026-09-16. Killed on ANY res.error with a pid (not just ETIMEDOUT; reviewer-found gap), since a
31
+ // maxBuffer overflow leaves the same orphaned-descendants shape as a timeout does. With `detached: true`
32
+ // above, res.pid is the process-group leader, so signalling the negative pid reaches every descendant still
33
+ // in that group. `timedOut` itself still reports only the ETIMEDOUT case, unchanged.
34
+ if (res.error && res.pid) {
35
+ try {
36
+ process.kill(-res.pid, "SIGKILL");
37
+ } catch {
38
+ // group already gone
39
+ }
40
+ }
41
+ return {
42
+ command,
43
+ ok: !timedOut && res.status === 0,
44
+ status: res.status,
45
+ timedOut: !!timedOut,
46
+ durationMs: Date.now() - started,
47
+ stdout: res.stdout || "",
48
+ stderr: res.stderr || (res.error ? String(res.error.message) : ""),
49
+ };
50
+ }
51
+
52
+ // Keeps the last N lines of output so a block reason stays readable.
53
+ export function tail(text, lines = 30) {
54
+ const arr = String(text).trimEnd().split("\n");
55
+ return arr.slice(-lines).join("\n");
56
+ }
57
+
58
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
59
+ const stripAnsi = (s) => s.replace(ANSI_RE, "");
60
+
61
+ // card gate-output-names-failures: `tail` alone buries a real vitest failure under dozens of passing lines,
62
+ // leaving no failing test name in a block reason (brief F1/F2). Leads with the failure-naming lines (`FAIL`,
63
+ // `×`, `✗`; a `×` line's `→` message right after it) and the final `Test Files`/`Tests` totals, in output
64
+ // order and each kept once, then fills the rest of `maxLines` with the plain tail of whatever wasn't already
65
+ // shown - skipping any OTHER totals-shaped line too (review H10: a `pnpm -r --stream` run prints one
66
+ // `Test Files`/`Tests` pair per package, so without this the tail filler pulls the earlier, per-package
67
+ // pairs back in and the totals show up more than once). No vitest-shaped lines at all -> identical to
68
+ // `tail(text, maxLines)`. When the failure-naming lines alone would overflow `maxLines`, keeps the first
69
+ // `maxLines - 3` of them plus the two totals lines plus one `… <n> more failing tests not shown` line (40
70
+ // total at the default cap) rather than growing past the cap. ANSI escapes are stripped from every kept
71
+ // line. `maxLines < 4` (too small to fit totals plus the overflow line at all) falls back to a plain tail
72
+ // (review m2). Never throws.
73
+ export function failureSummary(output, maxLines = 40) {
74
+ let raw;
75
+ try {
76
+ raw = String(output ?? "").trimEnd();
77
+ } catch {
78
+ // review m1: `output`'s own toString threw; there is nothing safe left to summarise or tail.
79
+ return "";
80
+ }
81
+ if (raw === "") return "";
82
+ if (maxLines < 4) return tail(raw, maxLines);
83
+ try {
84
+ const rawLines = raw.split("\n");
85
+ const cleanLines = rawLines.map(stripAnsi);
86
+ const used = new Set();
87
+ const headIdx = [];
88
+ for (let i = 0; i < cleanLines.length; i++) {
89
+ if (used.has(i)) continue;
90
+ if (!/^\s*(FAIL|×|✗)\s/.test(cleanLines[i])) continue;
91
+ headIdx.push(i);
92
+ used.add(i);
93
+ // a `×` line's `→` message belongs with it, kept whole right after.
94
+ if (/^\s*×\s/.test(cleanLines[i]) && i + 1 < cleanLines.length && !used.has(i + 1) && /^\s*→/.test(cleanLines[i + 1])) {
95
+ headIdx.push(i + 1);
96
+ used.add(i + 1);
97
+ }
98
+ }
99
+ if (headIdx.length === 0) return tail(raw, maxLines);
100
+
101
+ // review MAJOR M-A: a `pnpm -r` run prints one `Test Files`/`Tests` pair per package, so scanning for
102
+ // every line matching the pattern (as before) could add thousands of totals lines and blow the cap wide
103
+ // open. Only the LAST `Test Files` line and the LAST `Tests` line are the overall run's own totals; kept
104
+ // (at most one of each, at most 2 lines total) instead, which also keeps the head budget non-negative
105
+ // for any input size.
106
+ let lastTestFilesIdx = -1;
107
+ let lastTestsIdx = -1;
108
+ for (let i = 0; i < cleanLines.length; i++) {
109
+ if (used.has(i)) continue;
110
+ if (/^\s*Test Files\s/.test(cleanLines[i])) lastTestFilesIdx = i;
111
+ else if (/^\s*Tests\s/.test(cleanLines[i])) lastTestsIdx = i;
112
+ }
113
+ const totalsIdx = [lastTestFilesIdx, lastTestsIdx].filter((i) => i !== -1).sort((a, b) => a - b);
114
+ for (const i of totalsIdx) used.add(i);
115
+
116
+ const headLines = headIdx.map((i) => cleanLines[i]);
117
+ const totalsLines = totalsIdx.map((i) => cleanLines[i]);
118
+
119
+ if (headLines.length + totalsLines.length > maxLines) {
120
+ const keepHeadCount = Math.max(0, maxLines - totalsLines.length - 1);
121
+ const kept = headLines.slice(0, keepHeadCount);
122
+ const dropped = headLines.slice(keepHeadCount);
123
+ const droppedNamed = dropped.filter((l) => /^\s*(FAIL|×|✗)\s/.test(l)).length;
124
+ return [...kept, ...totalsLines, `… ${droppedNamed} more failing tests not shown`].join("\n");
125
+ }
126
+
127
+ const resultLines = [...headLines, ...totalsLines];
128
+ const remaining = maxLines - resultLines.length;
129
+ if (remaining > 0) {
130
+ // review H10: an earlier per-package Test Files/Tests pair is not in `used` (only the LAST pair is
131
+ // kept as the real totals above), but it must still be skipped here, or the tail filler pulls it back
132
+ // in and the totals end up shown more than once.
133
+ const filler = [];
134
+ for (let i = cleanLines.length - 1; i >= 0 && filler.length < remaining; i--) {
135
+ if (used.has(i)) continue;
136
+ if (/^\s*(Test Files|Tests)\s/.test(cleanLines[i])) continue;
137
+ filler.push(cleanLines[i]);
138
+ }
139
+ filler.reverse();
140
+ resultLines.push(...filler);
141
+ }
142
+ return resultLines.join("\n");
143
+ } catch {
144
+ // review m1: `raw` is already a plain, safely-coerced string, so this fallback cannot itself throw.
145
+ return tail(raw, maxLines);
146
+ }
147
+ }
@@ -0,0 +1,117 @@
1
+ // Plan-versus-diff scope: when .doug/plan.json is approved (or done and not yet landed), the union of
2
+ // the files its tasks own is the only thing a session may change.
3
+ // Pure functions plus one reader; the stop gate decides, this file only computes.
4
+
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { globToRegExp, matchAny, normalizePath } from "./glob.mjs";
8
+
9
+ export const PLAN_RELPATH = ".doug/plan.json";
10
+ export const ENFORCED_STATUSES = ["approved", "done"];
11
+ // The flow's own files change during a run and are never out of scope. Per-session hook state too.
12
+ // board.json moves uncommitted with the live board server; it lands in the card's landing commit.
13
+ export const ALWAYS_ALLOWED = [".doug/plan.json", ".doug/anchor.md", ".doug/.state/**", ".doug/board.json"];
14
+
15
+ // Returns { plan, reason }. plan is set only when the file exists, parses, has tasks, and is in an
16
+ // enforced status; reason says why not otherwise ("no-plan", "unreadable", "status:draft", "invalid").
17
+ export function loadScopePlan(dir) {
18
+ const file = join(dir, PLAN_RELPATH);
19
+ if (!existsSync(file)) return { plan: null, reason: "no-plan" };
20
+ let plan;
21
+ try {
22
+ plan = JSON.parse(readFileSync(file, "utf8"));
23
+ } catch (err) {
24
+ return { plan: null, reason: "unreadable", error: String(err.message) };
25
+ }
26
+ if (!plan || typeof plan !== "object" || !Array.isArray(plan.tasks)) return { plan: null, reason: "invalid" };
27
+ if (!ENFORCED_STATUSES.includes(plan.status)) return { plan: null, reason: `status:${plan.status}` };
28
+ // plan.mjs land records `landed`; a landed plan has been merged and no longer holds scope.
29
+ if (plan.landed) return { plan: null, reason: "landed" };
30
+ return { plan, reason: null };
31
+ }
32
+
33
+ const GLOB_CHARS = /[*?{]/;
34
+
35
+ // Does a plan ownership entry cover this path? Exact file, a directory (with or without a trailing
36
+ // slash), or a glob matched against the whole relative path. No basename matching: a task that owns
37
+ // "README.md" does not own "docs/README.md".
38
+ export function ownsPath(owned, file) {
39
+ if (typeof owned !== "string" || typeof file !== "string") return false;
40
+ const o = normalizePath(owned).replace(/\/+$/, "");
41
+ const f = normalizePath(file);
42
+ if (!o) return false;
43
+ if (GLOB_CHARS.test(o)) return globToRegExp(o).test(f);
44
+ return f === o || f.startsWith(o + "/");
45
+ }
46
+
47
+ // Sorted union of every path the plan's tasks own.
48
+ export function planScope(plan) {
49
+ const out = new Set();
50
+ for (const t of plan.tasks || []) for (const f of t.files || []) if (typeof f === "string") out.add(normalizePath(f));
51
+ return [...out].sort();
52
+ }
53
+
54
+ function segments(p) {
55
+ return normalizePath(p).split("/").filter(Boolean);
56
+ }
57
+
58
+ function sharedDepth(a, b) {
59
+ const x = segments(a).slice(0, -1); // directories only; the basename never counts
60
+ const y = segments(b).slice(0, -1);
61
+ let n = 0;
62
+ while (n < x.length && n < y.length && x[n] === y[n]) n++;
63
+ return n;
64
+ }
65
+
66
+ // Tasks whose owned files sit closest to this file in the tree: longest shared directory prefix,
67
+ // ties kept in plan order. Empty when no task owns anything in a directory the file shares.
68
+ export function nearestTasks(plan, file) {
69
+ let best = 0;
70
+ const hits = [];
71
+ for (const t of plan.tasks || []) {
72
+ let depth = 0;
73
+ let via = null;
74
+ for (const f of t.files || []) {
75
+ const d = sharedDepth(f, file);
76
+ if (d > depth || via === null) {
77
+ depth = d;
78
+ via = f;
79
+ }
80
+ }
81
+ if (via === null || depth === 0) continue;
82
+ if (depth > best) {
83
+ best = depth;
84
+ hits.length = 0;
85
+ }
86
+ if (depth === best) hits.push({ id: t.id, via });
87
+ }
88
+ return hits;
89
+ }
90
+
91
+ // Changed files the plan does not cover, after the ignore list and the flow's own files.
92
+ // Each entry: { file, nearest: [{ id, via }] }.
93
+ export function scopeViolations(changed, plan, { ignore = [] } = {}) {
94
+ const owned = planScope(plan);
95
+ const out = [];
96
+ for (const raw of changed) {
97
+ const file = normalizePath(raw);
98
+ if (matchAny(ALWAYS_ALLOWED, file)) continue;
99
+ if (ignore.length && matchAny(ignore, file)) continue;
100
+ if (owned.some((o) => ownsPath(o, file))) continue;
101
+ out.push({ file, nearest: nearestTasks(plan, file) });
102
+ }
103
+ return out;
104
+ }
105
+
106
+ export function describeViolations(violations, plan) {
107
+ const lines = [`Files changed outside the ${plan.status} plan "${plan.title}" (${PLAN_RELPATH}):`];
108
+ for (const v of violations) {
109
+ const near = v.nearest.length ? `nearest task${v.nearest.length > 1 ? "s" : ""}: ${v.nearest.map((n) => `${n.id} (owns ${n.via})`).join(", ")}` : "no task owns anything near it";
110
+ lines.push(` ${v.file} ${near}`);
111
+ }
112
+ lines.push(
113
+ "The plan is the contract. Revert these files, or add them to the owning task's files and have the user re-approve the plan. " +
114
+ "Paths that legitimately change outside any task (lockfiles, generated files) belong in stopGate.ignoreChangedPaths in .doug/config.json.",
115
+ );
116
+ return lines.join("\n");
117
+ }