@dougharness/cli 1.0.2 → 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.
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +9 -3
- package/dist/board-serve.js.map +1 -1
- package/dist/board.js +10 -2
- package/dist/board.js.map +1 -1
- package/dist/detect/repo.js +14 -1
- package/dist/detect/repo.js.map +1 -1
- package/dist/detect/types.d.ts +2 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +491 -0
- package/dist/doctor.js.map +1 -0
- package/dist/generate/agents.d.ts +7 -0
- package/dist/generate/agents.js +62 -12
- package/dist/generate/agents.js.map +1 -1
- package/dist/generate/claude-md.js +4 -0
- package/dist/generate/claude-md.js.map +1 -1
- package/dist/generate/config.d.ts +8 -0
- package/dist/generate/config.js +4 -1
- package/dist/generate/config.js.map +1 -1
- package/dist/generate/proposal.js +15 -6
- package/dist/generate/proposal.js.map +1 -1
- package/dist/generate/settings.d.ts +2 -0
- package/dist/generate/settings.js +17 -2
- package/dist/generate/settings.js.map +1 -1
- package/dist/init.d.ts +14 -0
- package/dist/init.js +94 -8
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +28 -0
- package/dist/memory-setup.js +323 -0
- package/dist/memory-setup.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
- package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
- package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
- package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +185 -23
- package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
- package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
- package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
- package/node_modules/@dougharness/flow/package.json +2 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
- package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +190 -34
- package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
- package/node_modules/@dougharness/flow/skills/{core-next → doug-hand}/SKILL.md +31 -18
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +8 -9
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
- package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
- package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +551 -113
- package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
- package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
- package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
- package/package.json +3 -3
- package/templates/board-app.js +2 -2
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
- 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
|
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// every number level and passes.
|
|
7
7
|
// - The verified-unchanged skip runs no test command, so there is no output to count there; no count check
|
|
8
8
|
// happens on it. (The pre-commit credit path is counted from the totals the hook records in last-run.json.)
|
|
9
|
-
// -
|
|
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.
|
|
10
12
|
// - Runners other than vitest print no `Tests` summary in this shape: no summary is parsed, no check runs.
|
|
11
13
|
|
|
12
14
|
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
@@ -32,8 +34,9 @@ function parseSummaryValue(value) {
|
|
|
32
34
|
return { total: Number(m[2]), skipped: counts.skipped, todo: counts.todo };
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
// Every `Tests` summary line is
|
|
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" }
|
|
37
40
|
export function parseTotals(output) {
|
|
38
41
|
try {
|
|
39
42
|
if (typeof output !== "string") return { status: "none" };
|
|
@@ -45,15 +48,45 @@ export function parseTotals(output) {
|
|
|
45
48
|
if (parsed) summaries.push(parsed);
|
|
46
49
|
}
|
|
47
50
|
if (summaries.length === 0) return { status: "none" };
|
|
48
|
-
|
|
49
|
-
const same = (s) => s.total === first.total && s.skipped === first.skipped && s.todo === first.todo;
|
|
50
|
-
if (summaries.every(same)) return { status: "ok", totals: { ...first } };
|
|
51
|
-
return { status: "conflict", summaries };
|
|
51
|
+
return { status: "ok", summaries, totals: sumTotals(summaries) };
|
|
52
52
|
} catch {
|
|
53
53
|
return { status: "none" };
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
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
|
+
|
|
57
90
|
const isCount = (n) => Number.isInteger(n) && n >= 0;
|
|
58
91
|
|
|
59
92
|
// A baseline entry usable for comparison, or null.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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() ?
|
|
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;
|