@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.
- package/dist/bin.js +18 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +14 -7
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +42 -9
- 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 +326 -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/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +3 -3
- 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 +171 -13
- 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 +155 -7
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
- package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
- package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
- package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
- package/node_modules/@dougharness/flow/package.json +2 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
- package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
- package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
- 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/bash-rules.mjs +21 -14
- 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/gate-lock.mjs +94 -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 +558 -197
- 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 +201 -0
- package/package.json +4 -4
- package/templates/board-app.js +4 -3
- 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/core-next/SKILL.md +0 -92
- 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
|
|
730
|
-
// loop at
|
|
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
|
|
896
|
-
// handoff replaces one or more consumed tokens with the single value token, so
|
|
897
|
-
// one token each time, and the loop below runs no deeper than one stack frame no matter how
|
|
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
|
|
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:
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
|
|
977
|
-
|
|
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
|
|
1339
|
-
//
|
|
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
|
|