@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
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
// checkpoint commit or tag (lib/checkpoint.mjs). Honors stop_hook_active and a per-session block
|
|
9
9
|
// cap so it can never loop forever.
|
|
10
10
|
//
|
|
11
|
+
// card stop-gate-runs-only-on-code-turns: a main Stop verifies only when the finishing turn changed code: it skips when
|
|
12
|
+
// the tree still equals the UserPromptSubmit turn-start fingerprint (scripts/turn-start.mjs) and no main-agent Edit was
|
|
13
|
+
// traced since, noting unverified changes left by other agents; a missing record verifies as today. When only
|
|
14
|
+
// preCommit.noTestPaths paths moved, the protected-path, proposal-path and plan-scope scans still run and only the
|
|
15
|
+
// verification commands are skipped.
|
|
16
|
+
//
|
|
11
17
|
// card stop-gate-read-only-subagent-skip: a SubagentStop whose own agent made no change skips the
|
|
12
18
|
// verification commands (step 3) and the evidence check (step 3b) entirely, and never takes or waits for
|
|
13
19
|
// gate.lock — the protected-path, proposal-path, and scope scans still run. The run trace, when it can vouch
|
|
@@ -21,7 +27,8 @@
|
|
|
21
27
|
// session in flight defers instead of verifying: it still runs the cheap tree-state scans (protected paths,
|
|
22
28
|
// proposal paths, plan scope — steps 1, 1b, 2) and blocks on a real hit exactly as before, but skips the
|
|
23
29
|
// verification commands, the evidence check, gate.lock, state.verified, and the blocks.jsonl ledger, and
|
|
24
|
-
// returns one systemMessage naming the agents still running
|
|
30
|
+
// returns one systemMessage naming the agents still running (card stop-gate-defer-read-only-quiet: only agents that can change the tree count, and a repeat deferral is silent; the full verification then follows at the first
|
|
31
|
+
// Stop after they stop, except that a read-only lead turn skips it: card stop-gate-runs-only-on-code-turns). "In flight" (subagentsInFlight below) is read off
|
|
25
32
|
// this session's run trace: an agent has stopped when some SubagentStop line for it after its last
|
|
26
33
|
// SubagentStart carries `decision: "allow"` (rule A), or when more of those lines carry no `decision` field at
|
|
27
34
|
// all than carry `decision: "block"` (rule B) — a decision-less line is the trace hook's own SubagentStop line
|
|
@@ -66,9 +73,10 @@
|
|
|
66
73
|
// Fails closed exactly like subagentsInFlight: with trace.enabled false, or the trace file missing or
|
|
67
74
|
// unreadable, there is no evidence either way, so the gate runs as today.
|
|
68
75
|
|
|
69
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
|
|
70
|
-
import { execFileSync } from "node:child_process";
|
|
71
|
-
import {
|
|
76
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
|
|
77
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
78
|
+
import { fileURLToPath } from "node:url";
|
|
79
|
+
import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
72
80
|
import { runHook, allow, blockStop, systemMessage } from "../lib/io.mjs";
|
|
73
81
|
import { loadConfig, projectDir } from "../lib/config.mjs";
|
|
74
82
|
import { loadState, saveState } from "../lib/state.mjs";
|
|
@@ -77,13 +85,16 @@ import { runCommand, failureSummary } from "../lib/run.mjs";
|
|
|
77
85
|
import { loadScopePlan, scopeViolations, describeViolations, PLAN_RELPATH } from "../lib/scope.mjs";
|
|
78
86
|
import { checkpoint, describeCheckpoint } from "../lib/checkpoint.mjs";
|
|
79
87
|
import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.mjs";
|
|
80
|
-
import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity } from "../lib/baseline.mjs";
|
|
88
|
+
import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
|
|
81
89
|
import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
|
|
82
90
|
import { isAppliedContent } from "../lib/proposals.mjs";
|
|
83
91
|
import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection } from "../lib/anchor.mjs";
|
|
84
92
|
import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
|
|
85
|
-
import { parseTotals,
|
|
86
|
-
import { failingTestFilesFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
|
|
93
|
+
import { parseTotals, readSummaries, sumTotals, compareTotals, deltaTotals, waiverCovers } from "../lib/test-totals.mjs";
|
|
94
|
+
import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
|
|
95
|
+
import { docsGateDecision } from "../lib/docs-gate.mjs";
|
|
96
|
+
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
97
|
+
import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
|
|
87
98
|
|
|
88
99
|
// Only the last this many characters of last_assistant_message are ever scanned for a partial claim
|
|
89
100
|
// (review, scan cost): a structured result lives at the end of a worker's message, and the balanced-span
|
|
@@ -92,6 +103,11 @@ import { failingTestFilesFrom, classifyFinalBlock, appendBlockLine } from "../li
|
|
|
92
103
|
// that large. Bounding the input bounds the cost regardless of how long the message is.
|
|
93
104
|
const PARTIAL_SCAN_TAIL_CHARS = 32 * 1024;
|
|
94
105
|
|
|
106
|
+
// Card ci-n4b-signal-rerun: a shell that forks reports a child's death by signal as exit 128 + signal number
|
|
107
|
+
// (POSIX shell convention, signals 1..64), so a status in this range is load-suspect like a signal kill.
|
|
108
|
+
const SIGNAL_EXIT_MIN = 129;
|
|
109
|
+
const SIGNAL_EXIT_MAX = 192;
|
|
110
|
+
|
|
95
111
|
// Every balanced {...} span that starts at some "{" in `text`, matched independently from that position
|
|
96
112
|
// (review MAJOR 1): a stray unmatched "{" earlier in the prose, or an odd number of quote characters
|
|
97
113
|
// before it, only ever breaks a scan that tracks nesting from the start of the whole string. Starting a
|
|
@@ -297,6 +313,15 @@ function stripLeadingDotSlash(f) {
|
|
|
297
313
|
// otherwise `{ covered: false, reasonLine }` — one line, no newline, saying why.
|
|
298
314
|
function redByDesignCoverage(claimFiles, commandFailures, changed, dir) {
|
|
299
315
|
const changedSet = new Set(Array.isArray(changed) ? changed : []);
|
|
316
|
+
for (const cf of commandFailures) {
|
|
317
|
+
const bad = nonTestTscPathsFrom(cf.output, dir);
|
|
318
|
+
if (bad.length > 0) {
|
|
319
|
+
return {
|
|
320
|
+
covered: false,
|
|
321
|
+
reasonLine: `The tests_red_by_design claim does not cover this: \`${cf.command}\` reported a type error in ${bad.join(", ")}, which is not a test file.`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
300
325
|
const namedByCommand = commandFailures.map((cf) => ({ command: cf.command, files: failingTestFilesFrom(cf.output, dir) }));
|
|
301
326
|
const noFile = namedByCommand.filter((c) => c.files.length === 0);
|
|
302
327
|
if (noFile.length > 0) {
|
|
@@ -387,6 +412,50 @@ const CHANGE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
|
387
412
|
// succeeded, so any Pre or PostToolUse line naming one counts as a change whatever its ok.
|
|
388
413
|
const SHELL_TOOLS = new Set(["Bash", "Monitor"]);
|
|
389
414
|
|
|
415
|
+
// The change rule shared by agentMadeNoChanges (at an agent's stop) and the in-flight filter (card
|
|
416
|
+
// stop-gate-defer-read-only-quiet, `countingInFlight` below): true when any line of agent `id` is a Pre or
|
|
417
|
+
// PostToolUse line whose tool is in SHELL_TOOLS (whatever ok), or a PostToolUse line whose tool is in
|
|
418
|
+
// CHANGE_TOOLS and whose ok is not exactly false.
|
|
419
|
+
function traceShowsChange(lines, id) {
|
|
420
|
+
return lines
|
|
421
|
+
.filter((l) => l.agent === id)
|
|
422
|
+
.some((l) => {
|
|
423
|
+
if (SHELL_TOOLS.has(l.tool)) return true;
|
|
424
|
+
return l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Agent types that write by design (card stop-gate-defer-read-only-quiet): one of these in flight defers the
|
|
429
|
+
// main Stop even when its trace so far shows no change (a coder that has only read so far still has a
|
|
430
|
+
// tester's red tests on disk). Exact, case-sensitive match.
|
|
431
|
+
const WRITING_AGENT_TYPES = new Set(["tester", "coder", "implementer", "general-purpose", "doug-flow:implementer", "doug-flow:lead", "claude"]);
|
|
432
|
+
|
|
433
|
+
// The in-flight agents that count toward the deferral (card stop-gate-defer-read-only-quiet): a type in
|
|
434
|
+
// READ_ONLY_AGENT_TYPES never counts; a type in WRITING_AGENT_TYPES always counts; any other type counts only
|
|
435
|
+
// when its trace so far shows a change (traceShowsChange). Never throws: any failure keeps the agent (the
|
|
436
|
+
// conservative answer, as today's deferral).
|
|
437
|
+
function countingInFlight(dir, cfg, sessionId, inFlight) {
|
|
438
|
+
try {
|
|
439
|
+
let lines = null;
|
|
440
|
+
try {
|
|
441
|
+
lines = cfg.trace && cfg.trace.enabled !== false ? readTrace(tracePath(dir, sessionId)) : null;
|
|
442
|
+
} catch {
|
|
443
|
+
lines = null;
|
|
444
|
+
}
|
|
445
|
+
return inFlight.filter((a) => {
|
|
446
|
+
try {
|
|
447
|
+
if (READ_ONLY_AGENT_TYPES.has(a.type)) return false;
|
|
448
|
+
if (WRITING_AGENT_TYPES.has(a.type)) return true;
|
|
449
|
+
return !lines || traceShowsChange(lines, a.id);
|
|
450
|
+
} catch {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
} catch {
|
|
455
|
+
return inFlight;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
390
459
|
// True when this SubagentStop's own agent made no change during its life (card
|
|
391
460
|
// stop-gate-read-only-subagent-skip), so the caller can skip the verification commands and evidence check for
|
|
392
461
|
// it. Never throws (a hook reader must never throw): any failure here reads as false, the conservative answer
|
|
@@ -408,12 +477,7 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
|
408
477
|
const lines = readTrace(file);
|
|
409
478
|
const started = lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id);
|
|
410
479
|
if (started) {
|
|
411
|
-
|
|
412
|
-
const changed = mine.some((l) => {
|
|
413
|
-
if (SHELL_TOOLS.has(l.tool)) return true;
|
|
414
|
-
return l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false;
|
|
415
|
-
});
|
|
416
|
-
return !changed;
|
|
480
|
+
return !traceShowsChange(lines, subagent.id);
|
|
417
481
|
}
|
|
418
482
|
}
|
|
419
483
|
}
|
|
@@ -423,6 +487,165 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
|
423
487
|
}
|
|
424
488
|
}
|
|
425
489
|
|
|
490
|
+
// card gate-skip-readonly-researchers: true when this SubagentStop's agent changed nothing in the working tree
|
|
491
|
+
// since it started: the trace hook recorded a start fingerprint on SubagentStart (lib/baseline.mjs
|
|
492
|
+
// readAgentStart) and it equals the current one (same HEAD, same dirty set, same identities), and the agent's
|
|
493
|
+
// final message carries no tests_red_by_design claim, and its trace shows no successful Edit-type call. Catches read-only agents that run Bash, which
|
|
494
|
+
// agentMadeNoChanges counts as a change. No record, no git, any failure -> false (run the gate).
|
|
495
|
+
function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFingerprint) {
|
|
496
|
+
try {
|
|
497
|
+
if (!subagent || !currentFingerprint) return false;
|
|
498
|
+
if (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path))) return false;
|
|
499
|
+
// An agent whose own trace shows a successful Edit/Write-type call did change something (an edit that was
|
|
500
|
+
// reverted, or a no-op, leaves the tree equal): never skip it on the fingerprint alone.
|
|
501
|
+
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
502
|
+
const file = tracePath(dir, sessionId);
|
|
503
|
+
if (existsSync(file) && readTrace(file).some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false)) return false;
|
|
504
|
+
}
|
|
505
|
+
const start = readAgentStart(dir, sessionId, subagent.id);
|
|
506
|
+
return sameTree(start, currentFingerprint);
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// card worktree-agent-gate, card worktree-gate-mixed-tree-agent: the trees a SubagentStop verifies, in
|
|
513
|
+
// first-touched order. A worktree-isolated subagent edits under <dir>/.claude/worktrees/<wt>/ (input.cwd inside
|
|
514
|
+
// it, or its own successful Edit-type trace lines carry absolute paths there); a path inside <dir> but outside
|
|
515
|
+
// .claude/worktrees names the main checkout, except <dir>/.doug/.state and anything under it (untracked state,
|
|
516
|
+
// never project code) which names nothing; any other path names nothing. The set is the cwd's worktree plus
|
|
517
|
+
// every tree its trace paths name (a worktree that no longer exists as a directory is skipped). Empty, a main
|
|
518
|
+
// Stop, or any failure resolves to [dir]. Only the verification target varies per tree: state, trace,
|
|
519
|
+
// gate.lock, the ledger, and evidence stay under `dir`. Never throws.
|
|
520
|
+
function verifyDirs(dir, cfg, sessionId, input, subagent) {
|
|
521
|
+
try {
|
|
522
|
+
if (!subagent) return [dir];
|
|
523
|
+
const base = join(dir, ".claude/worktrees");
|
|
524
|
+
const wtName = (p) => {
|
|
525
|
+
if (typeof p !== "string" || !isAbsolute(p)) return null;
|
|
526
|
+
const rel = relative(base, resolve(p));
|
|
527
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
528
|
+
return rel.split(sep)[0] || null;
|
|
529
|
+
};
|
|
530
|
+
const inMain = (p) => {
|
|
531
|
+
if (typeof p !== "string" || !isAbsolute(p)) return false;
|
|
532
|
+
const abs = resolve(p);
|
|
533
|
+
const rel = relative(dir, abs);
|
|
534
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
|
|
535
|
+
const st = relative(join(dir, ".doug/.state"), abs);
|
|
536
|
+
return !(st === "" || (!st.startsWith("..") && !isAbsolute(st)));
|
|
537
|
+
};
|
|
538
|
+
const trees = [];
|
|
539
|
+
const add = (t) => {
|
|
540
|
+
if (!trees.includes(t)) trees.push(t);
|
|
541
|
+
};
|
|
542
|
+
const addWt = (name) => {
|
|
543
|
+
const wt = join(base, name);
|
|
544
|
+
try {
|
|
545
|
+
if (statSync(wt).isDirectory()) add(wt);
|
|
546
|
+
} catch {
|
|
547
|
+
// a worktree that is gone adds nothing
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
const cwdName = wtName(input && input.cwd);
|
|
551
|
+
if (cwdName) addWt(cwdName);
|
|
552
|
+
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
553
|
+
const file = tracePath(dir, sessionId);
|
|
554
|
+
if (existsSync(file)) {
|
|
555
|
+
for (const l of readTrace(file)) {
|
|
556
|
+
if (l.agent !== subagent.id || l.event !== "PostToolUse" || !CHANGE_TOOLS.has(l.tool) || l.ok === false) continue;
|
|
557
|
+
const name = wtName(l.detail);
|
|
558
|
+
if (name) addWt(name);
|
|
559
|
+
else if (inMain(l.detail)) add(dir);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return trees.length ? trees : [dir];
|
|
564
|
+
} catch {
|
|
565
|
+
return [dir];
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// card worktree-gate-mixed-tree-agent: the single verification target, for a gate process that was handed one
|
|
570
|
+
// (DOUG_STOP_GATE_TREE, set by runPerTree below). Honoured only for a SubagentStop and only when it is `dir`
|
|
571
|
+
// itself or an existing directory directly under <dir>/.claude/worktrees (realpaths compared, so a symlink
|
|
572
|
+
// cannot escape); anything else is ignored as if unset. Never throws.
|
|
573
|
+
function forcedTree(dir, subagent) {
|
|
574
|
+
try {
|
|
575
|
+
const t = process.env.DOUG_STOP_GATE_TREE;
|
|
576
|
+
if (!subagent || !t || !statSync(t).isDirectory()) return null;
|
|
577
|
+
const real = realpathSync(t);
|
|
578
|
+
if (real === realpathSync(dir)) return t;
|
|
579
|
+
return dirname(real) === realpathSync(join(dir, ".claude/worktrees")) ? t : null;
|
|
580
|
+
} catch {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// A child gate process (handed a valid tree and the child flag) leaves SubagentStop decision lines to its parent.
|
|
586
|
+
function isTreeChild(dir, subagent) {
|
|
587
|
+
return !!forcedTree(dir, subagent) && process.env.DOUG_STOP_GATE_CHILD === "1";
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// card worktree-gate-mixed-tree-agent: a SubagentStop whose agent touched several trees. Every exit of the gate
|
|
591
|
+
// is process.exit, so each tree's verification runs as this same script in a child process (DOUG_STOP_GATE_TREE,
|
|
592
|
+
// the parent's deadline in DOUG_STOP_GATE_DEADLINE, DOUG_STOP_GATE_CHILD so the child writes no decision line).
|
|
593
|
+
// The first child that blocks decides (its reason prefixed with the failing tree). A child that gives no verdict
|
|
594
|
+
// (killed, timed out, non-zero exit, unparseable output) is a block naming the tree, never an allow; its process
|
|
595
|
+
// group is killed. `record` writes the one combined decision line.
|
|
596
|
+
function runPerTree(input, trees, dir, deadline, record) {
|
|
597
|
+
const labelOf = (tree) => {
|
|
598
|
+
const rel = relative(dir, tree) || ".";
|
|
599
|
+
return rel === "." ? "the main checkout" : rel;
|
|
600
|
+
};
|
|
601
|
+
const fail = (tree, why) => {
|
|
602
|
+
record("block");
|
|
603
|
+
return blockStop(`[doug] Verification failed in ${labelOf(tree)} (${tree}):\n${why}`);
|
|
604
|
+
};
|
|
605
|
+
let last = "";
|
|
606
|
+
for (const tree of trees) {
|
|
607
|
+
let r = null;
|
|
608
|
+
try {
|
|
609
|
+
r = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], {
|
|
610
|
+
input: JSON.stringify(input),
|
|
611
|
+
env: { ...process.env, DOUG_STOP_GATE_TREE: tree, DOUG_STOP_GATE_DEADLINE: String(deadline), DOUG_STOP_GATE_CHILD: "1" },
|
|
612
|
+
encoding: "utf8",
|
|
613
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
614
|
+
detached: true,
|
|
615
|
+
timeout: Math.max(1000, deadline - Date.now()) + 2000,
|
|
616
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
617
|
+
});
|
|
618
|
+
} catch (e) {
|
|
619
|
+
return fail(tree, `the verification process could not start (${e && e.message ? e.message : e}).`);
|
|
620
|
+
}
|
|
621
|
+
if (r.error || r.signal || r.status !== 0) {
|
|
622
|
+
if (r.pid) {
|
|
623
|
+
try {
|
|
624
|
+
process.kill(-r.pid, "SIGKILL");
|
|
625
|
+
} catch {
|
|
626
|
+
// the group is already gone
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const why = r.error ? String(r.error.code || r.error.message) : r.signal ? `killed by ${r.signal}` : `exit status ${r.status}`;
|
|
630
|
+
return fail(tree, `the verification process gave no verdict (${why}).`);
|
|
631
|
+
}
|
|
632
|
+
const out = typeof r.stdout === "string" ? r.stdout.trim() : "";
|
|
633
|
+
let parsed = null;
|
|
634
|
+
if (out) {
|
|
635
|
+
try {
|
|
636
|
+
parsed = JSON.parse(out);
|
|
637
|
+
} catch {
|
|
638
|
+
return fail(tree, "the verification process gave no verdict (unparseable output).");
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (parsed && parsed.decision === "block") return fail(tree, parsed.reason);
|
|
642
|
+
if (out) last = out;
|
|
643
|
+
}
|
|
644
|
+
record("allow");
|
|
645
|
+
if (last) process.stdout.write(last + "\n");
|
|
646
|
+
return allow();
|
|
647
|
+
}
|
|
648
|
+
|
|
426
649
|
// card stop-gate-skips-orphan-subagent-stops: true when a SubagentStop's own event carries none of the three
|
|
427
650
|
// signals of a real subagent of this session (docs/research/gate-output-names-failures.md Q1: Claude Code
|
|
428
651
|
// fires SubagentStop for agents that never had a SubagentStart, carry no agent_type, and have no readable
|
|
@@ -544,7 +767,7 @@ function waiverSources(dir, baselineHead) {
|
|
|
544
767
|
}
|
|
545
768
|
|
|
546
769
|
// The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
|
|
547
|
-
// block reasons, `parsed` the per-command
|
|
770
|
+
// block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
|
|
548
771
|
// record). A missing or unparseable summary, or no baseline, is a trace line and no check.
|
|
549
772
|
function countCheck(dir, cfg, state, input, ran) {
|
|
550
773
|
const problems = [];
|
|
@@ -565,24 +788,28 @@ function countCheck(dir, cfg, state, input, ran) {
|
|
|
565
788
|
for (const { command, stdout, result } of ran) {
|
|
566
789
|
try {
|
|
567
790
|
const p = result !== undefined ? result : parseTotals(stdout);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
continue;
|
|
571
|
-
}
|
|
572
|
-
if (p.status !== "ok") {
|
|
791
|
+
const summaries = p.status === "ok" ? readSummaries(p) : null;
|
|
792
|
+
if (!summaries) {
|
|
573
793
|
note(`count check did not run for \`${command}\`: no test summary in its output`);
|
|
574
794
|
continue;
|
|
575
795
|
}
|
|
576
|
-
parsed[command] = p.totals;
|
|
577
796
|
const all = state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
|
|
578
|
-
const
|
|
579
|
-
if (!
|
|
580
|
-
|
|
797
|
+
const baseList = all ? readSummaries(all[command]) : null;
|
|
798
|
+
if (!baseList) {
|
|
799
|
+
parsed[command] = summaries;
|
|
800
|
+
note(`count check did not run for \`${command}\`: no baseline totals for it (${summaries.length} test summar${summaries.length === 1 ? "y" : "ies"} printed: ${summaries.map((s) => `total ${s.total}, skipped ${s.skipped}, todo ${s.todo}`).join("; ")})`);
|
|
581
801
|
continue;
|
|
582
802
|
}
|
|
583
|
-
|
|
803
|
+
if (baseList.length !== summaries.length) {
|
|
804
|
+
problems.push(`\`${command}\` printed ${summaries.length} test summar${summaries.length === 1 ? "y" : "ies"}; the baseline has ${baseList.length}. The test count cannot be trusted. A waiver does not cover this; take a new baseline if a package was added or removed.`);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
parsed[command] = summaries;
|
|
808
|
+
const base = sumTotals(baseList);
|
|
809
|
+
const now = sumTotals(summaries);
|
|
810
|
+
const found = compareTotals(base, now);
|
|
584
811
|
if (found.length === 0) continue;
|
|
585
|
-
const delta = deltaTotals(base,
|
|
812
|
+
const delta = deltaTotals(base, now);
|
|
586
813
|
if (waiverCovers(waiverSources(dir, state.baselineHead), delta)) continue;
|
|
587
814
|
const lines = found.map((f) => `${f.kind} ${f.kind === "total" ? "dropped" : "rose"} from ${f.baseline} (baseline) to ${f.now} (now)`);
|
|
588
815
|
problems.push(
|
|
@@ -613,16 +840,6 @@ function writeLastGreen(dir, parsed) {
|
|
|
613
840
|
}
|
|
614
841
|
}
|
|
615
842
|
|
|
616
|
-
function resolveCommands(cfg) {
|
|
617
|
-
const out = [];
|
|
618
|
-
for (const entry of cfg.stopGate.commands || []) {
|
|
619
|
-
if (typeof entry !== "string") continue;
|
|
620
|
-
const named = cfg.commands && cfg.commands[entry];
|
|
621
|
-
out.push({ name: entry, command: typeof named === "string" ? named : entry });
|
|
622
|
-
}
|
|
623
|
-
return out;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
843
|
// card stop-gate-credits-pre-commit: `git rev-parse HEAD^{tree}`, the tree sha of the current commit — what
|
|
627
844
|
// the pre-commit hook's own record (.doug/.state/pre-commit/last-run.json) compares its `tree` field against.
|
|
628
845
|
// Null on any failure (no git, no HEAD yet, ...): a reader must never throw.
|
|
@@ -639,17 +856,36 @@ function headTreeSha(dir) {
|
|
|
639
856
|
// card ratchet-pre-commit-credit: a recorded parseTotals result, normalised, or null when it is not readable.
|
|
640
857
|
function readableRecordedTotals(t) {
|
|
641
858
|
try {
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
859
|
+
if (t && typeof t === "object" && t.status === "none") return { status: "none" };
|
|
860
|
+
const summaries = readSummaries(t);
|
|
861
|
+
return summaries ? { status: "ok", summaries, totals: sumTotals(summaries) } : null;
|
|
862
|
+
} catch {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// card verified-tree-record: a verified-tree record (lib/verified-tree.mjs) of `tree` (the working tree now), same
|
|
868
|
+
// Node and command set, credits the commands instead of re-running them. Same return shape as preCommitCredit:
|
|
869
|
+
// one `{ name, command, ok: true, credited: "verified-tree", recorded? }` per resolveCommands entry in config
|
|
870
|
+
// order. A command with a baseline in state.testTotals and no readable recorded totals returns null (the commands
|
|
871
|
+
// run), the same rule as preCommitCredit. Never throws: any failure is null, and null runs the commands.
|
|
872
|
+
function treeCredit(dir, cfg, state, tree) {
|
|
873
|
+
try {
|
|
874
|
+
if (!tree) return null;
|
|
875
|
+
const record = readRecord(dir);
|
|
876
|
+
if (!record) return null;
|
|
877
|
+
const configured = resolveCommands(cfg);
|
|
878
|
+
if (configured.length === 0) return null;
|
|
879
|
+
if (!creditable(dir, cfg, record, tree, configured.map((c) => c.command)).ok) return null;
|
|
880
|
+
const all = state && state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
|
|
881
|
+
const out = [];
|
|
882
|
+
for (const { name, command } of configured) {
|
|
883
|
+
const recorded = readableRecordedTotals(record.totals[command]);
|
|
884
|
+
const hasBaseline = all ? readSummaries(all[command]) !== null : false;
|
|
885
|
+
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
886
|
+
out.push({ name, command, ok: true, credited: "verified-tree", ...(recorded ? { recorded } : {}) });
|
|
651
887
|
}
|
|
652
|
-
return
|
|
888
|
+
return out;
|
|
653
889
|
} catch {
|
|
654
890
|
return null;
|
|
655
891
|
}
|
|
@@ -657,7 +893,7 @@ function readableRecordedTotals(t) {
|
|
|
657
893
|
|
|
658
894
|
// card stop-gate-credits-pre-commit: the pre-commit hook (.githooks/pre-commit) writes a machine-readable
|
|
659
895
|
// record of its own run to .doug/.state/pre-commit/last-run.json (version, tree, ok, at, commands: [{ name,
|
|
660
|
-
// command, exit, durationMs, totals? }], totals being the hook's parseTotals result for that command; a command with a baseline in state.testTotals is credited only if its totals are readable ok or conflict, else the commands run). When the working tree has no changes beyond HEAD (nothing left in `changed`
|
|
896
|
+
// command, exit, durationMs, totals? }], totals being the hook's parseTotals result for that command; a command with a baseline in state.testTotals is credited only if its totals are readable (a list of summaries, or an old ok or conflict shape), else the commands run). When the working tree has no changes beyond HEAD (nothing left in `changed`
|
|
661
897
|
// once stopGate.ignoreChangedPaths paths are removed) and that record is a passing run, against HEAD's own
|
|
662
898
|
// tree, of exactly today's configured commands in order, the Stop gate credits it instead of re-running the
|
|
663
899
|
// same commands a second time — returning `{ name, command, ok: true, credited: "pre-commit" }` per command,
|
|
@@ -689,14 +925,14 @@ function preCommitCredit(dir, cfg, changed, state) {
|
|
|
689
925
|
if (!record.commands[i] || record.commands[i].command !== configured[i].command) return null;
|
|
690
926
|
}
|
|
691
927
|
// card ratchet-pre-commit-credit: a command with a baseline in state.testTotals is credited only when the
|
|
692
|
-
// record carries readable totals for it (
|
|
928
|
+
// record carries readable totals for it (a list of valid summaries, or an old ok or conflict shape);
|
|
693
929
|
// otherwise null and the commands really run. `recorded` is the parseTotals-shaped result for the count check.
|
|
694
930
|
const all = state && state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
|
|
695
931
|
const out = [];
|
|
696
932
|
for (let i = 0; i < configured.length; i++) {
|
|
697
933
|
const { name, command } = configured[i];
|
|
698
934
|
const recorded = readableRecordedTotals(record.commands[i].totals);
|
|
699
|
-
const hasBaseline = all ?
|
|
935
|
+
const hasBaseline = all ? readSummaries(all[command]) !== null : false;
|
|
700
936
|
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
701
937
|
out.push({ name, command, ok: true, credited: "pre-commit", ...(recorded ? { recorded } : {}) });
|
|
702
938
|
}
|
|
@@ -706,39 +942,6 @@ function preCommitCredit(dir, cfg, changed, state) {
|
|
|
706
942
|
}
|
|
707
943
|
}
|
|
708
944
|
|
|
709
|
-
// The verified-tree fingerprint (card stop-gate-process-storm, goal 2): the HEAD sha plus the content
|
|
710
|
-
// identity of every dirty path and every proposal-path file on disk, at the moment it is computed. Null
|
|
711
|
-
// when `changed` is not the array shape changedFiles returns (no git, or git unavailable) — there is
|
|
712
|
-
// nothing to fingerprint reliably, so the tree can never be judged "unchanged" on this path.
|
|
713
|
-
function treeFingerprint(dir, cfg, changed) {
|
|
714
|
-
if (!Array.isArray(changed)) return null;
|
|
715
|
-
const walked = walkProposalPathFiles(dir, cfg.proposalPaths);
|
|
716
|
-
const paths = [...new Set([...changed, ...walked])];
|
|
717
|
-
// `at` (state.mjs's comment for state.verified) is when this fingerprint was taken; sameTree below never
|
|
718
|
-
// reads it, so recomputing it on the after-wait path (a fresh Date.now()) never affects equality.
|
|
719
|
-
return { head: headAt(dir), tree: captureBaseline(dir, paths), at: Date.now() };
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// Whether two treeFingerprint results describe the same tree: both present, the same HEAD, the same set of
|
|
723
|
-
// paths, and an identical identity for each. Never throws (a hook reader must never throw): a malformed
|
|
724
|
-
// fingerprint — e.g. a hand-edited state.verified — just compares unequal rather than blocking the gate.
|
|
725
|
-
function sameTree(a, b) {
|
|
726
|
-
try {
|
|
727
|
-
if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
|
|
728
|
-
if (a.head !== b.head) return false;
|
|
729
|
-
const at = a.tree && typeof a.tree === "object" ? a.tree : null;
|
|
730
|
-
const bt = b.tree && typeof b.tree === "object" ? b.tree : null;
|
|
731
|
-
if (!at || !bt) return false;
|
|
732
|
-
const ak = Object.keys(at).sort();
|
|
733
|
-
const bk = Object.keys(bt).sort();
|
|
734
|
-
if (ak.length !== bk.length) return false;
|
|
735
|
-
for (let i = 0; i < ak.length; i++) if (ak[i] !== bk[i]) return false;
|
|
736
|
-
return ak.every((k) => at[k] === bt[k]);
|
|
737
|
-
} catch {
|
|
738
|
-
return false;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
945
|
// card stop-gate-process-storm, case 2 (second tester pass, reviewer-found gap): a verified-unchanged skip
|
|
743
946
|
// is a green gate in every way that matters, not a bare allow — it resets the same counters an ordinary
|
|
744
947
|
// green gate resets, and on the main Stop it still surfaces the context-window notice, exactly like the
|
|
@@ -754,7 +957,7 @@ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
|
|
|
754
957
|
// exits (F4); the `decision` a SubagentStop's own line now always carries folds in here, alongside its
|
|
755
958
|
// `reason`, rather than a second line being written for the same event. Only for a SubagentStop (subagent
|
|
756
959
|
// set) — a main Stop's own trace line here is not an agent-stop decision.
|
|
757
|
-
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
960
|
+
if (cfg.trace && cfg.trace.enabled !== false && !isTreeChild(dir, subagent)) {
|
|
758
961
|
appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: input.hook_event_name, reason: "verified-unchanged", ...(subagent ? { decision: "allow" } : {}) });
|
|
759
962
|
}
|
|
760
963
|
if (subagent) {
|
|
@@ -769,105 +972,26 @@ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
|
|
|
769
972
|
return notice ? systemMessage(notice) : allow();
|
|
770
973
|
}
|
|
771
974
|
|
|
772
|
-
// Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
|
|
773
|
-
// at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
|
|
774
|
-
// 2026-09-16 process storm). `.doug/.state/gate.lock` holds `{ pid, session, at }` while a gate's commands
|
|
775
|
-
// run.
|
|
776
|
-
const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
|
|
777
|
-
function gateLockPath(dir) {
|
|
778
|
-
return join(dir, GATE_LOCK_RELPATH);
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
|
|
782
|
-
// content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
|
|
783
|
-
// caller treats exactly like a stale one.
|
|
784
|
-
function readGateLock(dir) {
|
|
785
|
-
try {
|
|
786
|
-
const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
|
|
787
|
-
// A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
|
|
788
|
-
// group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
|
|
789
|
-
// throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
|
|
790
|
-
return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
|
|
791
|
-
} catch {
|
|
792
|
-
return null;
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function pidIsLive(pid) {
|
|
797
|
-
try {
|
|
798
|
-
process.kill(pid, 0);
|
|
799
|
-
return true;
|
|
800
|
-
} catch (err) {
|
|
801
|
-
// EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
|
|
802
|
-
// that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
|
|
803
|
-
// anything else) reads as dead.
|
|
804
|
-
return err && err.code === "EPERM";
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
|
|
809
|
-
// caller decides what a failure means.
|
|
810
|
-
function writeGateLock(dir, sessionId) {
|
|
811
|
-
mkdirSync(join(dir, ".doug/.state"), { recursive: true });
|
|
812
|
-
writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
|
|
816
|
-
// simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
|
|
817
|
-
// it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
|
|
818
|
-
// does not hold).
|
|
819
|
-
function takeOverGateLock(dir, sessionId) {
|
|
820
|
-
try {
|
|
821
|
-
mkdirSync(join(dir, ".doug/.state"), { recursive: true });
|
|
822
|
-
writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
|
|
823
|
-
} catch {
|
|
824
|
-
// best-effort takeover only
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
|
|
829
|
-
// Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
|
|
830
|
-
// missing, non-number, or non-positive value reads as the platform's own default, 600.
|
|
831
|
-
function resolveHookTimeoutSec(cfg) {
|
|
832
|
-
const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
|
|
833
|
-
return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
975
|
// Computed once per gate run: the wall-clock instant past which the platform itself would cancel this
|
|
837
976
|
// hook (F1 — a timed-out hook is killed, fails open, and its output, including a block message, is
|
|
838
977
|
// discarded), less a margin so the gate's own block reaches Claude Code before that happens. gateStart
|
|
839
978
|
// is this process's own start time, not the moment this function runs, so the budget covers everything
|
|
840
979
|
// this invocation has done so far (config/state loads, earlier checks), not just what follows.
|
|
841
|
-
function gateBudget(cfg) {
|
|
980
|
+
function gateBudget(cfg, inherited) {
|
|
842
981
|
const hookTimeoutSec = resolveHookTimeoutSec(cfg);
|
|
843
982
|
const hookTimeoutMs = hookTimeoutSec * 1000;
|
|
844
983
|
const marginMs = Math.min(30000, hookTimeoutMs / 10);
|
|
845
984
|
// process.uptime() is fractional seconds; runCommand's timeoutMs must be an unsigned integer (Node's
|
|
846
985
|
// spawnSync throws otherwise), so every value derived from this deadline is rounded down.
|
|
847
986
|
const gateStart = Date.now() - Math.floor(process.uptime() * 1000);
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
// legitimate holder can still be running that long, since the platform itself would have killed its hook
|
|
853
|
-
// by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
|
|
854
|
-
function lockStaleByAge(lock, hookTimeoutMs) {
|
|
855
|
-
return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// Card stop-gate-timeout-normalise: cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout
|
|
859
|
-
// and the wait bound below, so the two never disagree on what a garbage or fractional value means. A finite
|
|
860
|
-
// number above 0 floors to an integer, clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a
|
|
861
|
-
// timeout of 0 as none at all, letting a command run past the budget unclamped; 2.5 must not reach spawnSync
|
|
862
|
-
// un-floored — it throws ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else
|
|
863
|
-
// (non-number, NaN, Infinity, 0, negative, null) is "unset" (null), read by each call site as its own default.
|
|
864
|
-
function normalizedTimeoutMs(cfg) {
|
|
865
|
-
const t = cfg.stopGate.timeoutMs;
|
|
866
|
-
return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
|
|
987
|
+
const own = Math.floor(gateStart + hookTimeoutMs - marginMs);
|
|
988
|
+
// card worktree-gate-mixed-tree-agent: a per-tree child never outlives its parent's deadline.
|
|
989
|
+
const deadline = typeof inherited === "number" && Number.isFinite(inherited) && inherited <= own ? inherited : own;
|
|
990
|
+
return { hookTimeoutSec, hookTimeoutMs, deadline };
|
|
867
991
|
}
|
|
868
992
|
|
|
869
993
|
// Card stop-gate-deferral-cap (R2): cfg.stopGate.maxDeferrals normalised the same shape as
|
|
870
|
-
// normalizedTimeoutMs
|
|
994
|
+
// normalizedTimeoutMs (lib/gate-lock.mjs) — a hook reader must never throw (F6/F10), so a finite number >= 1 is floored
|
|
871
995
|
// and used; anything else (missing, 0, negative, NaN, a string, null, an object) reads as the default, 10.
|
|
872
996
|
function normalizedMaxDeferrals(cfg) {
|
|
873
997
|
const m = cfg && cfg.stopGate && cfg.stopGate.maxDeferrals;
|
|
@@ -884,6 +1008,8 @@ function normalizedDeferrals(state) {
|
|
|
884
1008
|
return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
|
|
885
1009
|
}
|
|
886
1010
|
|
|
1011
|
+
const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
|
|
1012
|
+
|
|
887
1013
|
runHook("stop-gate", async (input) => {
|
|
888
1014
|
if (input.stop_hook_active) return allow();
|
|
889
1015
|
const dir = projectDir(input);
|
|
@@ -894,11 +1020,30 @@ runHook("stop-gate", async (input) => {
|
|
|
894
1020
|
// main Stops the counter (R1) is tracking — reset right here, after loadState, so every SubagentStop exit
|
|
895
1021
|
// below that saves state (including the one that reloads state again mid-lock-wait, R6 note there) persists
|
|
896
1022
|
// the reset.
|
|
897
|
-
if (subagent)
|
|
1023
|
+
if (subagent) {
|
|
1024
|
+
state.deferrals = 0;
|
|
1025
|
+
delete state.lastDeferral;
|
|
1026
|
+
}
|
|
898
1027
|
if (!subagent) state.turns += 1;
|
|
899
1028
|
const maxBlocks = cfg.stopGate.maxBlocks ?? 3;
|
|
900
1029
|
const blocksSoFar = subagent ? (state.agentBlocks && state.agentBlocks[subagent.id]) || 0 : state.stopBlocks;
|
|
901
1030
|
const who = subagent ? `Subagent ${subagent.type} (${subagent.id})` : null;
|
|
1031
|
+
// card worktree-gate-mixed-tree-agent: an agent that touched several trees is verified in each, one gate process per tree.
|
|
1032
|
+
const forced = forcedTree(dir, subagent);
|
|
1033
|
+
const treeChild = isTreeChild(dir, subagent);
|
|
1034
|
+
if (subagent && !forced) {
|
|
1035
|
+
const trees = verifyDirs(dir, cfg, input.session_id, input, subagent);
|
|
1036
|
+
if (trees.length > 1) {
|
|
1037
|
+
const record = (decision) => {
|
|
1038
|
+
try {
|
|
1039
|
+
if (cfg.trace && cfg.trace.enabled !== false) appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "SubagentStop", decision, reason: null });
|
|
1040
|
+
} catch {
|
|
1041
|
+
// never block the real exit on a trace failure
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
return runPerTree(input, trees, dir, gateBudget(cfg).deadline, record);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
902
1047
|
// D1 (card stop-gate-defers-while-subagent-in-flight): one choke point for the `decision` every SubagentStop
|
|
903
1048
|
// exit's own trace line now carries, so no exit (present or future) can miss it — except the
|
|
904
1049
|
// `stop_hook_active` early return above, which runs before `subagent` exists and so writes no decision line
|
|
@@ -913,7 +1058,7 @@ runHook("stop-gate", async (input) => {
|
|
|
913
1058
|
// tests_red_by_design, its `detail`) into this same line instead of a second one being written. Wrapped so a
|
|
914
1059
|
// trace failure never blocks the real exit (a hook reader must never throw).
|
|
915
1060
|
function traceSubagentDecision(decision, traceReason, extra) {
|
|
916
|
-
if (!subagent) return;
|
|
1061
|
+
if (!subagent || treeChild) return;
|
|
917
1062
|
try {
|
|
918
1063
|
if (!cfg.trace || cfg.trace.enabled === false) return;
|
|
919
1064
|
appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "SubagentStop", decision, reason: traceReason ?? null, ...(extra || {}) });
|
|
@@ -956,7 +1101,7 @@ runHook("stop-gate", async (input) => {
|
|
|
956
1101
|
};
|
|
957
1102
|
// Card stop-gate-budget-under-hook-timeout: the whole gate (wait plus commands) must fit under the
|
|
958
1103
|
// platform's own hook timeout. Computed once so the wait loop and the command loop share one clock.
|
|
959
|
-
const { hookTimeoutSec, hookTimeoutMs, deadline: budgetDeadline } = gateBudget(cfg);
|
|
1104
|
+
const { hookTimeoutSec, hookTimeoutMs, deadline: budgetDeadline } = gateBudget(cfg, forced ? Number(process.env.DOUG_STOP_GATE_DEADLINE) : NaN);
|
|
960
1105
|
|
|
961
1106
|
// card stop-gate-skips-orphan-subagent-stops: checked before the partial-claim block, the tree scans, the
|
|
962
1107
|
// deferral logic, gate.lock, skipVerification, and runVerification — an orphan SubagentStop is not a real
|
|
@@ -1042,6 +1187,9 @@ runHook("stop-gate", async (input) => {
|
|
|
1042
1187
|
|
|
1043
1188
|
const edited = state.editedFiles.length > 0;
|
|
1044
1189
|
const changed = changedFiles(dir);
|
|
1190
|
+
// card worktree-agent-gate: the verification target (the agent's worktree, else `dir`) and its changed paths.
|
|
1191
|
+
const vdir = forced || verifyDirs(dir, cfg, input.session_id, input, subagent)[0];
|
|
1192
|
+
const vchanged = vdir === dir ? changed : changedFiles(vdir);
|
|
1045
1193
|
// Snapshot whatever was already dirty so it is never mistaken for this session's own work (card
|
|
1046
1194
|
// stop-gate-waiting-on-subagents). Ideally this happens on SessionStart (scripts/session-start-baseline.mjs),
|
|
1047
1195
|
// before the session can have dirtied anything itself; this is the fallback for a session where that
|
|
@@ -1119,6 +1267,58 @@ runHook("stop-gate", async (input) => {
|
|
|
1119
1267
|
(state.baselineSource === "session-start" ? sessionMadeChanges : edited || (Array.isArray(changed) && changed.length > 0)) ||
|
|
1120
1268
|
(Array.isArray(committed) && committed.length > 0) ||
|
|
1121
1269
|
walkedProposalHits.length > 0;
|
|
1270
|
+
// card stop-gate-runs-only-on-code-turns: a main Stop whose turn changed no code skips verification. The turn-start
|
|
1271
|
+
// record (scripts/turn-start.mjs, UserPromptSubmit) must equal the current tree once preCommit.noTestPaths paths are
|
|
1272
|
+
// dropped from both (same HEAD, same code paths, same identities), and the trace must show no successful main-agent
|
|
1273
|
+
// Edit-type call since the record (an edit reverted by content leaves the tree equal). Placed here, at the
|
|
1274
|
+
// onlyIfEdited skip and ahead of it, because the protected-path, proposal-path and plan-scope scans below are about
|
|
1275
|
+
// files a turn changed and a read-only turn changed none; a missing, malformed or stale record, no git, or any throw
|
|
1276
|
+
// reads as "cannot tell" and falls through to today's path. Never for a SubagentStop.
|
|
1277
|
+
let readOnlyTurn = null;
|
|
1278
|
+
if (!subagent) {
|
|
1279
|
+
let readOnly = null;
|
|
1280
|
+
try {
|
|
1281
|
+
const start = readTurnStart(dir, input.session_id);
|
|
1282
|
+
const status = turnTreeStatus(cfg, start, treeFingerprint(dir, cfg, changed));
|
|
1283
|
+
if (status && status.same) {
|
|
1284
|
+
let editedThisTurn = false;
|
|
1285
|
+
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
1286
|
+
const file = tracePath(dir, input.session_id);
|
|
1287
|
+
if (existsSync(file)) {
|
|
1288
|
+
const since = typeof start.at === "number" ? start.at : 0;
|
|
1289
|
+
editedThisTurn = readTrace(file).some((l) => !l.agent && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false && !(Date.parse(l.t) < since));
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (!editedThisTurn) readOnly = status;
|
|
1293
|
+
}
|
|
1294
|
+
} catch {
|
|
1295
|
+
readOnly = null;
|
|
1296
|
+
}
|
|
1297
|
+
// Only an UNFILTERED match (nothing at all changed) exits here. When only noTestPaths paths moved (a Bash write
|
|
1298
|
+
// to a board or docs file), the scans below still run, so such a file cannot escape the plan-scope and
|
|
1299
|
+
// proposal-path checks; only the verification commands are skipped (skipReason "read-only-turn").
|
|
1300
|
+
if (readOnly && !readOnly.exact) {
|
|
1301
|
+
readOnlyTurn = readOnly;
|
|
1302
|
+
} else if (readOnly) {
|
|
1303
|
+
// The tree-problem scans (protected, proposal, plan scope) still run on the exact path: a file already
|
|
1304
|
+
// dirty before the record is not this turn's, but it must not be hidden from them. Any problem, or a throw,
|
|
1305
|
+
// falls through to the normal path, which finds and blocks on it.
|
|
1306
|
+
let treeClean = false;
|
|
1307
|
+
try {
|
|
1308
|
+
treeClean = scanTreeProblems().problems.length === 0;
|
|
1309
|
+
} catch {
|
|
1310
|
+
treeClean = false;
|
|
1311
|
+
}
|
|
1312
|
+
if (!treeClean) readOnly = null;
|
|
1313
|
+
}
|
|
1314
|
+
if (readOnly && readOnly.exact) {
|
|
1315
|
+
const notice = contextNotice(dir, cfg, state, input.session_id, "skip");
|
|
1316
|
+
saveState(dir, input.session_id, state);
|
|
1317
|
+
const line = readOnly.dirty ? READ_ONLY_LINE : null;
|
|
1318
|
+
const msg = [line, notice].filter(Boolean).join("\n\n");
|
|
1319
|
+
return msg ? systemMessage(msg) : allowDecided();
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1122
1322
|
if (cfg.stopGate.onlyIfEdited && !anyChange) {
|
|
1123
1323
|
const notice = subagent ? null : contextNotice(dir, cfg, state, input.session_id, "skip");
|
|
1124
1324
|
saveState(dir, input.session_id, state);
|
|
@@ -1128,7 +1328,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1128
1328
|
// card stop-gate-process-storm, case 2: a tree already verified by an earlier green gate this session
|
|
1129
1329
|
// needs no recheck at all — placed ahead of maxBlocks and every problems check below, since a red gate
|
|
1130
1330
|
// never records `verified` and there is nothing left here to recheck when nothing has changed since.
|
|
1131
|
-
const currentFingerprint = treeFingerprint(
|
|
1331
|
+
const currentFingerprint = treeFingerprint(vdir, cfg, vchanged);
|
|
1132
1332
|
if (sameTree(state.verified, currentFingerprint)) {
|
|
1133
1333
|
return verifiedUnchangedAllow(dir, cfg, state, input, subagent);
|
|
1134
1334
|
}
|
|
@@ -1213,7 +1413,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1213
1413
|
// (that block is real and counts as today); only with no such problem does this Stop defer, skipping
|
|
1214
1414
|
// gate.lock, the verification commands, the evidence check, state.verified, and the blocks.jsonl ledger
|
|
1215
1415
|
// entirely, and touching neither state.stopBlocks nor state.agentBlocks. The next Stop after the last
|
|
1216
|
-
// SubagentStop (subagentsInFlight then returns `[]`) runs the full gate as today
|
|
1416
|
+
// SubagentStop (subagentsInFlight then returns `[]`) runs the full gate as today, unless that lead turn is read-only
|
|
1417
|
+
// (card stop-gate-runs-only-on-code-turns), which skips verification. state.lastGate is left
|
|
1217
1418
|
// untouched — this Stop verified nothing, so there is nothing honest to record there.
|
|
1218
1419
|
// R1/R3 (card stop-gate-deferral-cap): state.deferrals counts consecutive deferred main Stops; unbounded,
|
|
1219
1420
|
// a dead or forged subagent (the module header's "Known limit") would defer forever. deferralCapNote is
|
|
@@ -1222,7 +1423,13 @@ runHook("stop-gate", async (input) => {
|
|
|
1222
1423
|
// falls through to ends up returning. The cap only ever widens when the full verification runs (R7): it
|
|
1223
1424
|
// never lets a Stop skip verification that would otherwise have run.
|
|
1224
1425
|
let deferralCapNote = null;
|
|
1225
|
-
|
|
1426
|
+
// Card stop-gate-defer-read-only-quiet: only agents that can change the tree count (countingInFlight: not a
|
|
1427
|
+
// read-only type, and a non-writing type only once its trace shows a change). With none left this Stop runs
|
|
1428
|
+
// the ordinary path exactly as with no agent in flight. A repeat deferral (same counting set and same tree
|
|
1429
|
+
// fingerprint as the last one, state.lastDeferral) is silent: no systemMessage, but it still counts toward
|
|
1430
|
+
// stopGate.maxDeferrals. state.lastDeferral is cleared wherever state.deferrals resets to 0, so the first
|
|
1431
|
+
// deferral of a streak always speaks.
|
|
1432
|
+
const inFlight = subagent ? [] : countingInFlight(dir, cfg, input.session_id, subagentsInFlight(dir, cfg, input.session_id));
|
|
1226
1433
|
if (inFlight.length > 0) {
|
|
1227
1434
|
const { problems: deferProblems } = scanTreeProblems();
|
|
1228
1435
|
if (deferProblems.length === 0) {
|
|
@@ -1230,10 +1437,21 @@ runHook("stop-gate", async (input) => {
|
|
|
1230
1437
|
const n = normalizedDeferrals(state) + 1;
|
|
1231
1438
|
const names = inFlight.map((a) => agentLabel({ agent: a.id, agentType: a.type })).join(", ");
|
|
1232
1439
|
if (n < maxDeferrals) {
|
|
1440
|
+
const ids = inFlight.map((a) => a.id).sort();
|
|
1441
|
+
const prev = state.lastDeferral;
|
|
1442
|
+
const repeat =
|
|
1443
|
+
!!prev &&
|
|
1444
|
+
typeof prev === "object" &&
|
|
1445
|
+
Array.isArray(prev.ids) &&
|
|
1446
|
+
prev.ids.length === ids.length &&
|
|
1447
|
+
prev.ids.every((id, i) => id === ids[i]) &&
|
|
1448
|
+
sameTree(prev.fingerprint, currentFingerprint);
|
|
1233
1449
|
state.deferrals = n;
|
|
1450
|
+
state.lastDeferral = { ids, fingerprint: currentFingerprint };
|
|
1234
1451
|
saveState(dir, input.session_id, state);
|
|
1452
|
+
if (repeat) return allow();
|
|
1235
1453
|
return systemMessage(
|
|
1236
|
-
`[doug] Verification deferred:
|
|
1454
|
+
`[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
|
|
1237
1455
|
);
|
|
1238
1456
|
}
|
|
1239
1457
|
// R3: the cap is hit on this Stop — do not defer again. Reset the counter (R5) and fall through to
|
|
@@ -1243,22 +1461,54 @@ runHook("stop-gate", async (input) => {
|
|
|
1243
1461
|
// value itself (stopGate.maxDeferrals) is named alongside it so the message stays informative once the
|
|
1244
1462
|
// deferred count is no longer always the cap.
|
|
1245
1463
|
state.deferrals = 0;
|
|
1464
|
+
delete state.lastDeferral;
|
|
1246
1465
|
deferralCapNote = `[doug] Deferral cap reached: ${n - 1} consecutive Stops deferred (stopGate.maxDeferrals ${maxDeferrals}) while subagents of this session were still in flight (${names}); running the full verification anyway.`;
|
|
1247
1466
|
} else {
|
|
1248
1467
|
state.deferrals = 0; // R5: a real tree problem — this Stop does not defer
|
|
1468
|
+
delete state.lastDeferral;
|
|
1249
1469
|
}
|
|
1250
1470
|
// A real problem, or the cap was just hit: fall through to the ordinary path below, which finds the
|
|
1251
1471
|
// same problem again inside runVerification and blocks on it exactly as today (deferral only ever
|
|
1252
1472
|
// widens "nothing to check yet", never what counts as a real problem).
|
|
1253
1473
|
} else if (!subagent) {
|
|
1254
1474
|
state.deferrals = 0; // R5: no agent in flight — this Stop runs the gate
|
|
1475
|
+
delete state.lastDeferral;
|
|
1255
1476
|
}
|
|
1256
1477
|
|
|
1257
1478
|
// card stop-gate-read-only-subagent-skip: computed once, ahead of the gate.lock acquire just below, so a
|
|
1258
1479
|
// SubagentStop whose own agent made no change never takes or waits for the lock at all — waiting up to
|
|
1259
|
-
// timeoutMs on a suite it will never even need is the other half of the 2026-09-16 process storm.
|
|
1260
|
-
// true for a SubagentStop; the main Stop
|
|
1261
|
-
|
|
1480
|
+
// timeoutMs on a suite it will never even need is the other half of the 2026-09-16 process storm. The two
|
|
1481
|
+
// agent reasons are only ever true for a SubagentStop; the main Stop skips only for no-test-paths (below).
|
|
1482
|
+
// card gate-skip-readonly-researchers: a second skip reason, "tree-unchanged", for an agent whose tree
|
|
1483
|
+
// fingerprint equals its SubagentStart one even though its trace shows shell calls.
|
|
1484
|
+
// card stop-gate-no-test-paths: a third reason, for both stops: every path changed since the last green
|
|
1485
|
+
// verification is on preCommit.noTestPaths (onlyNoTestPathsChanged fails closed on anything doubtful).
|
|
1486
|
+
// card gate-docs-skip-unless-named: a fourth reason, "no-test-paths-docs", when every changed path is on the
|
|
1487
|
+
// list or a docs .md that no tracked test names; when some tests name it, `targetedFiles` makes the
|
|
1488
|
+
// verification below run only `test:unit <those files>`. Fail closed to the full gate (docs-gate.mjs).
|
|
1489
|
+
let docsDecision = null;
|
|
1490
|
+
const docsRule = () => {
|
|
1491
|
+
try {
|
|
1492
|
+
if (!resolveCommands(cfg).some((c) => c.name === "test:unit")) return null;
|
|
1493
|
+
const moved = movedSinceVerified(vdir, state.verified, vchanged);
|
|
1494
|
+
return moved && moved.size > 0 ? docsGateDecision(vdir, cfg, moved) : null;
|
|
1495
|
+
} catch {
|
|
1496
|
+
return null;
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
|
|
1500
|
+
? "no-agent-changes"
|
|
1501
|
+
: subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
|
|
1502
|
+
? "tree-unchanged"
|
|
1503
|
+
: readOnlyTurn
|
|
1504
|
+
? "read-only-turn"
|
|
1505
|
+
: onlyNoTestPathsChanged(vdir, cfg, state.verified, vchanged)
|
|
1506
|
+
? "no-test-paths"
|
|
1507
|
+
: (docsDecision = docsRule()) && docsDecision.kind === "skip"
|
|
1508
|
+
? "no-test-paths-docs"
|
|
1509
|
+
: null;
|
|
1510
|
+
const skipVerification = skipReason !== null;
|
|
1511
|
+
const targetedFiles = !skipVerification && docsDecision && docsDecision.kind === "targeted" ? docsDecision.files : null;
|
|
1262
1512
|
|
|
1263
1513
|
// card stop-gate-no-change-restop: a stop in the same scope on a tree unchanged since a red block whose
|
|
1264
1514
|
// commands all really ran (state.lastRed, recorded at the final block below) repeats that block without
|
|
@@ -1268,6 +1518,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1268
1518
|
// unreadable, different, or not plainly a command-failure red degrades to running (fail closed to running).
|
|
1269
1519
|
if (!skipVerification) {
|
|
1270
1520
|
let lastRed = null;
|
|
1521
|
+
let redViaNoTestPaths = false; // card stop-gate-board-edit-reuses-red: reused although the tree moved, only on noTestPaths
|
|
1271
1522
|
try {
|
|
1272
1523
|
const rec = state.lastRed;
|
|
1273
1524
|
const scopeNow = subagent ? subagent.id : "main";
|
|
@@ -1280,21 +1531,26 @@ runHook("stop-gate", async (input) => {
|
|
|
1280
1531
|
typeof rec.summary === "string" &&
|
|
1281
1532
|
Array.isArray(rec.commands) &&
|
|
1282
1533
|
rec.commands.every((c) => typeof c === "string") &&
|
|
1283
|
-
|
|
1534
|
+
// card stop-gate-lead-reuses-subagent-red: a main Stop also takes a subagent's record, but only on the exact
|
|
1535
|
+
// same tree (never via the board-only path below); a SubagentStop still takes only its own scope's.
|
|
1536
|
+
(rec.scope === scopeNow || !subagent) &&
|
|
1284
1537
|
rec.commands.length === commandsNow.length &&
|
|
1285
1538
|
rec.commands.every((c, i) => c === commandsNow[i]) &&
|
|
1286
1539
|
rec.fingerprint &&
|
|
1287
1540
|
typeof rec.fingerprint === "object" &&
|
|
1288
1541
|
currentFingerprint &&
|
|
1289
|
-
sameTree(rec.fingerprint, currentFingerprint) &&
|
|
1542
|
+
(sameTree(rec.fingerprint, currentFingerprint) || (rec.scope === scopeNow && (redViaNoTestPaths = onlyNoTestPathsChanged(vdir, cfg, rec.fingerprint, vchanged)))) &&
|
|
1290
1543
|
!(subagent && (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path)))) &&
|
|
1291
|
-
!preCommitCredit(
|
|
1544
|
+
!preCommitCredit(vdir, cfg, vchanged, state) &&
|
|
1545
|
+
!treeCredit(vdir, cfg, state, computeTree(vdir))
|
|
1292
1546
|
) {
|
|
1293
1547
|
lastRed = rec;
|
|
1294
1548
|
}
|
|
1295
1549
|
} catch {
|
|
1296
1550
|
lastRed = null;
|
|
1297
1551
|
}
|
|
1552
|
+
if (!lastRed) redViaNoTestPaths = false;
|
|
1553
|
+
const crossWho = lastRed && lastRed.scope !== (subagent ? subagent.id : "main") ? (typeof lastRed.who === "string" && lastRed.who ? lastRed.who : `Subagent ${lastRed.scope}`) : null;
|
|
1298
1554
|
if (lastRed) {
|
|
1299
1555
|
if (blocksSoFar >= maxBlocks) {
|
|
1300
1556
|
const alreadyStoodDown = markStoodDown("block-cap");
|
|
@@ -1309,12 +1565,19 @@ runHook("stop-gate", async (input) => {
|
|
|
1309
1565
|
const blocks = blocksSoFar + 1;
|
|
1310
1566
|
if (subagent) state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
|
|
1311
1567
|
else state.stopBlocks = blocks;
|
|
1312
|
-
|
|
1568
|
+
// one reuse per record on the exact same tree; a board-only reuse keeps the record (user decision
|
|
1569
|
+
// 2026-10-05), so consecutive board-only turns keep reusing the red, bounded by maxBlocks.
|
|
1570
|
+
if (!redViaNoTestPaths) state.lastRed = null;
|
|
1313
1571
|
saveState(dir, input.session_id, state);
|
|
1314
1572
|
const reason =
|
|
1315
1573
|
(who ? `[doug] ${who} cannot report done: verification failed (${blocks}/${maxBlocks}). Fix the following before finishing the task. ` : `[doug] Verification failed (${blocks}/${maxBlocks}). Fix the following before finishing. `) +
|
|
1316
1574
|
`Do not claim the task is done while this fails.\n\n` +
|
|
1317
|
-
|
|
1575
|
+
(crossWho
|
|
1576
|
+
? `Nothing changed since ${crossWho}'s block, so the commands were not re-run. ${crossWho}'s run failed:`
|
|
1577
|
+
: redViaNoTestPaths
|
|
1578
|
+
? `Only paths on preCommit.noTestPaths changed since the last block, so the commands were not re-run.`
|
|
1579
|
+
: `Nothing changed since the last block, so the commands were not re-run.`) +
|
|
1580
|
+
(crossWho ? `\n\n` : ` The last run's failure:\n\n`) +
|
|
1318
1581
|
[...treeProblems, lastRed.summary].join("\n\n");
|
|
1319
1582
|
const reuseClass = typeof lastRed.class === "string" ? lastRed.class : "real";
|
|
1320
1583
|
const capped = deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason;
|
|
@@ -1369,7 +1632,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1369
1632
|
// timeoutMs no longer defaults to a flat 300000ms here — the budget deadline below is the real bound
|
|
1370
1633
|
// either way, so an unset value waits only for what remains of it, unmultiplied.
|
|
1371
1634
|
const normalizedTimeout = normalizedTimeoutMs(cfg);
|
|
1372
|
-
const waitBoundMs = normalizedTimeout !== null ? normalizedTimeout
|
|
1635
|
+
const waitBoundMs = normalizedTimeout !== null ? lockWaitBoundMs(normalizedTimeout, resolveCommands(cfg).length) : budgetDeadline - Date.now();
|
|
1373
1636
|
const waitDeadline = Date.now() + waitBoundMs;
|
|
1374
1637
|
// Card stop-gate-budget-under-hook-timeout: waitDeadline alone can outrun the platform's own hook
|
|
1375
1638
|
// timeout (F1), so a waiter also stops at budgetDeadline — but there it refuses the stop instead of
|
|
@@ -1418,7 +1681,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1418
1681
|
// to even get here, so it already passed that check point and always intends 0). Unconditional:
|
|
1419
1682
|
// every path that reaches here, subagent or not, means 0.
|
|
1420
1683
|
state.deferrals = 0;
|
|
1421
|
-
|
|
1684
|
+
delete state.lastDeferral;
|
|
1685
|
+
verifiedCandidate = treeFingerprint(vdir, cfg, vchanged);
|
|
1422
1686
|
if (sameTree(state.verified, verifiedCandidate)) {
|
|
1423
1687
|
// Review round 1 minor 5: this exit returns straight from verifiedUnchangedAllow, bypassing the
|
|
1424
1688
|
// *Releasing wrappers (R4) below — release the lock here (a no-op when this gate never took it)
|
|
@@ -1504,14 +1768,21 @@ runHook("stop-gate", async (input) => {
|
|
|
1504
1768
|
// card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
|
|
1505
1769
|
// stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
|
|
1506
1770
|
// red-by-design, state.verified, the block ledger) runs exactly as it would after a real run.
|
|
1507
|
-
|
|
1771
|
+
// card verified-tree-record: treeBefore is the working tree immediately before the first command; a record of
|
|
1772
|
+
// it (any writer: Stop, verify.mjs, land, pre-commit) is tried before preCommitCredit's last-run.json.
|
|
1773
|
+
const treeBefore = skipVerification ? null : computeTree(vdir);
|
|
1774
|
+
const credited = skipVerification ? null : (treeCredit(vdir, cfg, state, treeBefore) ?? preCommitCredit(vdir, cfg, vchanged, state));
|
|
1775
|
+
// card gate-docs-skip-unless-named: a targeted run is never a full run (no record, no ratchet, no state.verified).
|
|
1776
|
+
const targeted = !credited && targetedFiles ? targetedFiles : null;
|
|
1508
1777
|
if (!skipVerification && credited) {
|
|
1509
1778
|
for (const { recorded, ...r } of credited) {
|
|
1510
1779
|
results.push(r);
|
|
1511
1780
|
if (recorded) ranGreen.push({ command: r.command, result: recorded });
|
|
1512
1781
|
}
|
|
1513
1782
|
} else if (!skipVerification) {
|
|
1514
|
-
for (const { name, command } of resolveCommands(cfg)) {
|
|
1783
|
+
for (const { name, command: fullCommand } of resolveCommands(cfg)) {
|
|
1784
|
+
if (targeted && name !== "test:unit") continue; // a targeted run is test:unit alone, no typecheck
|
|
1785
|
+
const command = targeted ? `${fullCommand} ${targeted.join(" ")}` : fullCommand;
|
|
1515
1786
|
// Card stop-gate-budget-under-hook-timeout: a command that cannot even start before the platform
|
|
1516
1787
|
// would kill this hook's own process is dropped rather than let the platform cut it off mid-run,
|
|
1517
1788
|
// silently, with its block message discarded (F1). One still runs with what budget is left, clamped
|
|
@@ -1530,9 +1801,9 @@ runHook("stop-gate", async (input) => {
|
|
|
1530
1801
|
// never bypass the budget: normalizedTimeoutMs floors and clamps up to at least 1ms; unset uses the
|
|
1531
1802
|
// remaining budget outright. The timed-out message below reports this same value.
|
|
1532
1803
|
const commandTimeoutMs = Math.min(normalizedTimeoutMs(cfg) ?? remaining, remaining);
|
|
1533
|
-
const r = runCommand(command, { cwd:
|
|
1534
|
-
results.push({ name, command, ok: r.ok, durationMs: r.durationMs, timedOut: r.timedOut, status: r.status });
|
|
1535
|
-
if (r.ok) ranGreen.push({ command, stdout: r.stdout });
|
|
1804
|
+
const r = runCommand(command, { cwd: vdir, timeoutMs: commandTimeoutMs });
|
|
1805
|
+
results.push({ name, command, ok: r.ok, durationMs: r.durationMs, timedOut: r.timedOut, status: r.status, ...(targeted ? { targeted: targeted.length } : {}) });
|
|
1806
|
+
if (r.ok && !targeted) ranGreen.push({ command, stdout: r.stdout });
|
|
1536
1807
|
if (!r.ok) {
|
|
1537
1808
|
const why = r.timedOut ? `timed out after ${commandTimeoutMs} ms` : `exit ${r.status}`;
|
|
1538
1809
|
const output = r.stderr + "\n" + r.stdout;
|
|
@@ -1543,6 +1814,38 @@ runHook("stop-gate", async (input) => {
|
|
|
1543
1814
|
}
|
|
1544
1815
|
}
|
|
1545
1816
|
}
|
|
1817
|
+
// card verified-tree-record: record "these commands exited 0 on this tree" after a real all-green run on a tree
|
|
1818
|
+
// unchanged across it; remove the record on any command failure (fail closed). A credited run never writes.
|
|
1819
|
+
if (!skipVerification && !credited && !targeted) {
|
|
1820
|
+
try {
|
|
1821
|
+
const configured = resolveCommands(cfg);
|
|
1822
|
+
if (configured.length === 0) {
|
|
1823
|
+
// nothing ran, nothing to record
|
|
1824
|
+
} else if (results.length === configured.length && results.every((r) => r.ok)) {
|
|
1825
|
+
const treeAfter = treeBefore ? computeTree(vdir) : null;
|
|
1826
|
+
if (treeBefore && treeAfter === treeBefore) {
|
|
1827
|
+
const totals = {};
|
|
1828
|
+
for (const g of ranGreen) {
|
|
1829
|
+
const parsed = parseTotals(g.stdout);
|
|
1830
|
+
if (parsed.status === "ok") totals[g.command] = parsed;
|
|
1831
|
+
}
|
|
1832
|
+
writeTreeRecord(vdir, {
|
|
1833
|
+
version: 1,
|
|
1834
|
+
tree: treeAfter,
|
|
1835
|
+
node: process.version,
|
|
1836
|
+
at: new Date().toISOString(),
|
|
1837
|
+
by: subagent ? "subagent-stop" : "stop",
|
|
1838
|
+
commands: results.map((r) => ({ name: r.name, command: r.command, exit: 0, durationMs: typeof r.durationMs === "number" ? r.durationMs : 0 })),
|
|
1839
|
+
totals,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
} else {
|
|
1843
|
+
removeTreeRecord(vdir);
|
|
1844
|
+
}
|
|
1845
|
+
} catch {
|
|
1846
|
+
// never throw from the writer
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1546
1849
|
const postCommandProblems = problems.length;
|
|
1547
1850
|
|
|
1548
1851
|
// 3b. Evidence: the session must have run a test or verify command itself (lib/evidence.mjs), so a session that
|
|
@@ -1564,13 +1867,23 @@ runHook("stop-gate", async (input) => {
|
|
|
1564
1867
|
problems.push(...counted.problems);
|
|
1565
1868
|
const countProblemAdded = counted.problems.length > 0;
|
|
1566
1869
|
|
|
1567
|
-
|
|
1870
|
+
// A main Stop's skip has no decision-tagged exit line like a SubagentStop's (allowReleasing folds the reason
|
|
1871
|
+
// into that); record the reason here so the trace carries it (card stop-gate-no-test-paths).
|
|
1872
|
+
if (skipVerification && !subagent && cfg.trace && cfg.trace.enabled !== false) {
|
|
1873
|
+
try {
|
|
1874
|
+
appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: input.hook_event_name, reason: skipReason });
|
|
1875
|
+
} catch {
|
|
1876
|
+
// never block the real exit on a trace failure
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
state.lastGate = { ok: problems.length === 0, at: Date.now(), results, scope, evidence, ...(subagent ? { subagent } : {}), ...(skipVerification ? { skipped: skipReason } : {}) };
|
|
1568
1880
|
// card stop-gate-process-storm, case 2: a green gate records the tree it just verified, so a later
|
|
1569
1881
|
// Stop/SubagentStop whose tree still matches can skip re-running these commands (the sameTree check
|
|
1570
1882
|
// above). A red gate never reaches here, so it records nothing (goal 2). Never on a no-agent-changes skip
|
|
1571
1883
|
// (card stop-gate-read-only-subagent-skip): nothing was actually verified.
|
|
1572
|
-
|
|
1573
|
-
if (
|
|
1884
|
+
// A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
|
|
1885
|
+
if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
|
|
1886
|
+
if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
|
|
1574
1887
|
if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
|
|
1575
1888
|
if (problems.length === 0 && subagent) {
|
|
1576
1889
|
// A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
|
|
@@ -1578,17 +1891,23 @@ runHook("stop-gate", async (input) => {
|
|
|
1578
1891
|
saveState(dir, input.session_id, state);
|
|
1579
1892
|
// card stop-gate-read-only-subagent-skip: the "no-agent-changes" reason folds into this exit's own
|
|
1580
1893
|
// decision-tagged trace line (D1) rather than a second one being written.
|
|
1581
|
-
return allowReleasing(
|
|
1894
|
+
return allowReleasing(skipReason);
|
|
1582
1895
|
}
|
|
1583
1896
|
if (problems.length === 0) {
|
|
1584
|
-
|
|
1897
|
+
// A read-only-turn skip changed nothing this turn: no stopBlocks reset and no checkpoint commit.
|
|
1898
|
+
if (skipReason !== "read-only-turn") state.stopBlocks = 0;
|
|
1585
1899
|
// 4. Checkpoint on green (off unless checkpoint.enabled): record the working tree now that the
|
|
1586
1900
|
// gate passed with changes present, so a later regression can be undone with git.
|
|
1587
|
-
const ck = checkpoint({ dir, cfg, changed, gateResults: results });
|
|
1901
|
+
const ck = skipReason === "read-only-turn" ? { skipped: "disabled" } : checkpoint({ dir, cfg, changed, gateResults: results });
|
|
1588
1902
|
const silent = ck.skipped === "disabled" || ck.skipped === "no changes";
|
|
1589
1903
|
if (!silent) state.lastCheckpoint = { ...ck, at: Date.now() };
|
|
1590
1904
|
const notice = contextNotice(dir, cfg, state, input.session_id, "verification");
|
|
1591
1905
|
saveState(dir, input.session_id, state);
|
|
1906
|
+
if (skipReason === "read-only-turn") {
|
|
1907
|
+
const line = readOnlyTurn && readOnlyTurn.dirty ? READ_ONLY_LINE : null;
|
|
1908
|
+
const msg = [line, silent ? null : describeCheckpoint(ck), notice].filter(Boolean).join("\n\n");
|
|
1909
|
+
return msg ? systemMessageReleasing(msg) : allowReleasing();
|
|
1910
|
+
}
|
|
1592
1911
|
if (notice) return systemMessageReleasing(silent ? notice : `${describeCheckpoint(ck)}\n\n${notice}`);
|
|
1593
1912
|
return silent ? allowReleasing() : systemMessageReleasing(describeCheckpoint(ck));
|
|
1594
1913
|
}
|
|
@@ -1630,6 +1949,46 @@ runHook("stop-gate", async (input) => {
|
|
|
1630
1949
|
// Card stop-gate-block-ledger: the standing claim, distinct from state.lastGate above (which a
|
|
1631
1950
|
// later gate run replaces wholesale, line ~942) — this is what a later block's classifier reads.
|
|
1632
1951
|
state.redByDesignClaim = { files: coverage.files, at: new Date().toISOString(), agent: subagent.id };
|
|
1952
|
+
// card stop-gate-lead-reuses-subagent-red: the tester just ran every command on this tree; record the
|
|
1953
|
+
// red so the lead's Stop on the same tree reuses it (same conditions as the final-block recorder).
|
|
1954
|
+
try {
|
|
1955
|
+
const configured = resolveCommands(cfg);
|
|
1956
|
+
if (
|
|
1957
|
+
!skipVerification &&
|
|
1958
|
+
!credited &&
|
|
1959
|
+
!targeted &&
|
|
1960
|
+
results.length === configured.length &&
|
|
1961
|
+
results.every((r) => !r.timedOut && !r.skipped && (r.ok || (typeof r.status === "number" && r.status !== 0 && !(r.status >= SIGNAL_EXIT_MIN && r.status <= SIGNAL_EXIT_MAX)))) &&
|
|
1962
|
+
verifiedCandidate
|
|
1963
|
+
) {
|
|
1964
|
+
let redClass = "real";
|
|
1965
|
+
try {
|
|
1966
|
+
redClass = classifyFinalBlock({
|
|
1967
|
+
preVerificationProblems,
|
|
1968
|
+
evidenceProblemAdded,
|
|
1969
|
+
commandFailures,
|
|
1970
|
+
postCommandProblems,
|
|
1971
|
+
results,
|
|
1972
|
+
redByDesignClaimFiles: coverage.files,
|
|
1973
|
+
changedAtStart: changed,
|
|
1974
|
+
dir,
|
|
1975
|
+
budgetDeadline,
|
|
1976
|
+
});
|
|
1977
|
+
} catch {
|
|
1978
|
+
redClass = "real";
|
|
1979
|
+
}
|
|
1980
|
+
state.lastRed = {
|
|
1981
|
+
fingerprint: verifiedCandidate,
|
|
1982
|
+
scope: subagent.id,
|
|
1983
|
+
commands: configured.map((c) => c.command),
|
|
1984
|
+
summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
|
|
1985
|
+
class: redClass,
|
|
1986
|
+
who,
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
} catch {
|
|
1990
|
+
state.lastRed = null;
|
|
1991
|
+
}
|
|
1633
1992
|
saveState(dir, input.session_id, state);
|
|
1634
1993
|
// Card stop-gate-defers-while-subagent-in-flight: the "tests_red_by_design" reason (and its
|
|
1635
1994
|
// covered-files detail) folds into this exit's own decision-tagged trace line (D1) rather than a
|
|
@@ -1678,16 +2037,17 @@ runHook("stop-gate", async (input) => {
|
|
|
1678
2037
|
}
|
|
1679
2038
|
// card stop-gate-no-change-restop: record this red for reuse only when every configured command really
|
|
1680
2039
|
// ran (not credited, none dropped for budget or timed out) and at least one failed, every failure by a
|
|
1681
|
-
// numeric non-zero exit (a signal kill has status null
|
|
2040
|
+
// numeric non-zero exit outside 129..192 (a signal kill has status null, a shell-reported one 128+signal; both load-suspect). Stored with the block's ledger
|
|
1682
2041
|
// class so a reuse logs the same class. state.lastRed was cleared above, so every other path leaves none.
|
|
1683
2042
|
try {
|
|
1684
2043
|
const configured = resolveCommands(cfg);
|
|
1685
2044
|
if (
|
|
1686
2045
|
!skipVerification &&
|
|
1687
2046
|
!credited &&
|
|
2047
|
+
!targeted &&
|
|
1688
2048
|
commandFailures.length > 0 &&
|
|
1689
2049
|
results.length === configured.length &&
|
|
1690
|
-
results.every((r) => !r.timedOut && !r.skipped && (r.ok || (typeof r.status === "number" && r.status !== 0))) &&
|
|
2050
|
+
results.every((r) => !r.timedOut && !r.skipped && (r.ok || (typeof r.status === "number" && r.status !== 0 && !(r.status >= SIGNAL_EXIT_MIN && r.status <= SIGNAL_EXIT_MAX)))) &&
|
|
1691
2051
|
postCommandProblems - preVerificationProblems === commandFailures.length &&
|
|
1692
2052
|
verifiedCandidate
|
|
1693
2053
|
) {
|
|
@@ -1697,6 +2057,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1697
2057
|
commands: configured.map((c) => c.command),
|
|
1698
2058
|
summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
|
|
1699
2059
|
class: finalClass,
|
|
2060
|
+
who,
|
|
1700
2061
|
};
|
|
1701
2062
|
saveState(dir, input.session_id, state);
|
|
1702
2063
|
}
|