@dougharness/cli 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +9 -3
- package/dist/board-serve.js.map +1 -1
- package/dist/board.js +10 -2
- package/dist/board.js.map +1 -1
- package/dist/detect/repo.js +14 -1
- package/dist/detect/repo.js.map +1 -1
- package/dist/detect/types.d.ts +2 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +491 -0
- package/dist/doctor.js.map +1 -0
- package/dist/generate/agents.d.ts +7 -0
- package/dist/generate/agents.js +62 -12
- package/dist/generate/agents.js.map +1 -1
- package/dist/generate/claude-md.js +4 -0
- package/dist/generate/claude-md.js.map +1 -1
- package/dist/generate/config.d.ts +8 -0
- package/dist/generate/config.js +4 -1
- package/dist/generate/config.js.map +1 -1
- package/dist/generate/proposal.js +15 -6
- package/dist/generate/proposal.js.map +1 -1
- package/dist/generate/settings.d.ts +2 -0
- package/dist/generate/settings.js +17 -2
- package/dist/generate/settings.js.map +1 -1
- package/dist/init.d.ts +14 -0
- package/dist/init.js +94 -8
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +28 -0
- package/dist/memory-setup.js +323 -0
- package/dist/memory-setup.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
- package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
- package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
- package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +185 -23
- package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
- package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
- package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
- package/node_modules/@dougharness/flow/package.json +2 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
- package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +190 -34
- package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
- package/node_modules/@dougharness/flow/skills/{core-next → doug-hand}/SKILL.md +31 -18
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +8 -9
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
- package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
- package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +551 -113
- package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
- package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
- package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
- package/package.json +3 -3
- package/templates/board-app.js +2 -2
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
|
@@ -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,15 @@ 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 { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
|
|
87
97
|
|
|
88
98
|
// Only the last this many characters of last_assistant_message are ever scanned for a partial claim
|
|
89
99
|
// (review, scan cost): a structured result lives at the end of a worker's message, and the balanced-span
|
|
@@ -92,6 +102,11 @@ import { failingTestFilesFrom, classifyFinalBlock, appendBlockLine } from "../li
|
|
|
92
102
|
// that large. Bounding the input bounds the cost regardless of how long the message is.
|
|
93
103
|
const PARTIAL_SCAN_TAIL_CHARS = 32 * 1024;
|
|
94
104
|
|
|
105
|
+
// Card ci-n4b-signal-rerun: a shell that forks reports a child's death by signal as exit 128 + signal number
|
|
106
|
+
// (POSIX shell convention, signals 1..64), so a status in this range is load-suspect like a signal kill.
|
|
107
|
+
const SIGNAL_EXIT_MIN = 129;
|
|
108
|
+
const SIGNAL_EXIT_MAX = 192;
|
|
109
|
+
|
|
95
110
|
// Every balanced {...} span that starts at some "{" in `text`, matched independently from that position
|
|
96
111
|
// (review MAJOR 1): a stray unmatched "{" earlier in the prose, or an odd number of quote characters
|
|
97
112
|
// before it, only ever breaks a scan that tracks nesting from the start of the whole string. Starting a
|
|
@@ -297,6 +312,15 @@ function stripLeadingDotSlash(f) {
|
|
|
297
312
|
// otherwise `{ covered: false, reasonLine }` — one line, no newline, saying why.
|
|
298
313
|
function redByDesignCoverage(claimFiles, commandFailures, changed, dir) {
|
|
299
314
|
const changedSet = new Set(Array.isArray(changed) ? changed : []);
|
|
315
|
+
for (const cf of commandFailures) {
|
|
316
|
+
const bad = nonTestTscPathsFrom(cf.output, dir);
|
|
317
|
+
if (bad.length > 0) {
|
|
318
|
+
return {
|
|
319
|
+
covered: false,
|
|
320
|
+
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.`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
300
324
|
const namedByCommand = commandFailures.map((cf) => ({ command: cf.command, files: failingTestFilesFrom(cf.output, dir) }));
|
|
301
325
|
const noFile = namedByCommand.filter((c) => c.files.length === 0);
|
|
302
326
|
if (noFile.length > 0) {
|
|
@@ -387,6 +411,50 @@ const CHANGE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
|
387
411
|
// succeeded, so any Pre or PostToolUse line naming one counts as a change whatever its ok.
|
|
388
412
|
const SHELL_TOOLS = new Set(["Bash", "Monitor"]);
|
|
389
413
|
|
|
414
|
+
// The change rule shared by agentMadeNoChanges (at an agent's stop) and the in-flight filter (card
|
|
415
|
+
// stop-gate-defer-read-only-quiet, `countingInFlight` below): true when any line of agent `id` is a Pre or
|
|
416
|
+
// PostToolUse line whose tool is in SHELL_TOOLS (whatever ok), or a PostToolUse line whose tool is in
|
|
417
|
+
// CHANGE_TOOLS and whose ok is not exactly false.
|
|
418
|
+
function traceShowsChange(lines, id) {
|
|
419
|
+
return lines
|
|
420
|
+
.filter((l) => l.agent === id)
|
|
421
|
+
.some((l) => {
|
|
422
|
+
if (SHELL_TOOLS.has(l.tool)) return true;
|
|
423
|
+
return l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false;
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Agent types that write by design (card stop-gate-defer-read-only-quiet): one of these in flight defers the
|
|
428
|
+
// main Stop even when its trace so far shows no change (a coder that has only read so far still has a
|
|
429
|
+
// tester's red tests on disk). Exact, case-sensitive match.
|
|
430
|
+
const WRITING_AGENT_TYPES = new Set(["tester", "coder", "implementer", "general-purpose", "doug-flow:implementer", "doug-flow:lead", "claude"]);
|
|
431
|
+
|
|
432
|
+
// The in-flight agents that count toward the deferral (card stop-gate-defer-read-only-quiet): a type in
|
|
433
|
+
// READ_ONLY_AGENT_TYPES never counts; a type in WRITING_AGENT_TYPES always counts; any other type counts only
|
|
434
|
+
// when its trace so far shows a change (traceShowsChange). Never throws: any failure keeps the agent (the
|
|
435
|
+
// conservative answer, as today's deferral).
|
|
436
|
+
function countingInFlight(dir, cfg, sessionId, inFlight) {
|
|
437
|
+
try {
|
|
438
|
+
let lines = null;
|
|
439
|
+
try {
|
|
440
|
+
lines = cfg.trace && cfg.trace.enabled !== false ? readTrace(tracePath(dir, sessionId)) : null;
|
|
441
|
+
} catch {
|
|
442
|
+
lines = null;
|
|
443
|
+
}
|
|
444
|
+
return inFlight.filter((a) => {
|
|
445
|
+
try {
|
|
446
|
+
if (READ_ONLY_AGENT_TYPES.has(a.type)) return false;
|
|
447
|
+
if (WRITING_AGENT_TYPES.has(a.type)) return true;
|
|
448
|
+
return !lines || traceShowsChange(lines, a.id);
|
|
449
|
+
} catch {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
} catch {
|
|
454
|
+
return inFlight;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
390
458
|
// True when this SubagentStop's own agent made no change during its life (card
|
|
391
459
|
// stop-gate-read-only-subagent-skip), so the caller can skip the verification commands and evidence check for
|
|
392
460
|
// it. Never throws (a hook reader must never throw): any failure here reads as false, the conservative answer
|
|
@@ -408,12 +476,7 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
|
408
476
|
const lines = readTrace(file);
|
|
409
477
|
const started = lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id);
|
|
410
478
|
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;
|
|
479
|
+
return !traceShowsChange(lines, subagent.id);
|
|
417
480
|
}
|
|
418
481
|
}
|
|
419
482
|
}
|
|
@@ -423,6 +486,161 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
|
423
486
|
}
|
|
424
487
|
}
|
|
425
488
|
|
|
489
|
+
// card gate-skip-readonly-researchers: true when this SubagentStop's agent changed nothing in the working tree
|
|
490
|
+
// since it started: the trace hook recorded a start fingerprint on SubagentStart (lib/baseline.mjs
|
|
491
|
+
// readAgentStart) and it equals the current one (same HEAD, same dirty set, same identities), and the agent's
|
|
492
|
+
// 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
|
|
493
|
+
// agentMadeNoChanges counts as a change. No record, no git, any failure -> false (run the gate).
|
|
494
|
+
function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFingerprint) {
|
|
495
|
+
try {
|
|
496
|
+
if (!subagent || !currentFingerprint) return false;
|
|
497
|
+
if (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path))) return false;
|
|
498
|
+
// An agent whose own trace shows a successful Edit/Write-type call did change something (an edit that was
|
|
499
|
+
// reverted, or a no-op, leaves the tree equal): never skip it on the fingerprint alone.
|
|
500
|
+
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
501
|
+
const file = tracePath(dir, sessionId);
|
|
502
|
+
if (existsSync(file) && readTrace(file).some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false)) return false;
|
|
503
|
+
}
|
|
504
|
+
const start = readAgentStart(dir, sessionId, subagent.id);
|
|
505
|
+
return sameTree(start, currentFingerprint);
|
|
506
|
+
} catch {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// card worktree-agent-gate, card worktree-gate-mixed-tree-agent: the trees a SubagentStop verifies, in
|
|
512
|
+
// first-touched order. A worktree-isolated subagent edits under <dir>/.claude/worktrees/<wt>/ (input.cwd inside
|
|
513
|
+
// it, or its own successful Edit-type trace lines carry absolute paths there); a path inside <dir> but outside
|
|
514
|
+
// .claude/worktrees names the main checkout; any other path names nothing. The set is the cwd's worktree plus
|
|
515
|
+
// every tree its trace paths name (a worktree that no longer exists as a directory is skipped). Empty, a main
|
|
516
|
+
// Stop, or any failure resolves to [dir]. Only the verification target varies per tree: state, trace,
|
|
517
|
+
// gate.lock, the ledger, and evidence stay under `dir`. Never throws.
|
|
518
|
+
function verifyDirs(dir, cfg, sessionId, input, subagent) {
|
|
519
|
+
try {
|
|
520
|
+
if (!subagent) return [dir];
|
|
521
|
+
const base = join(dir, ".claude/worktrees");
|
|
522
|
+
const wtName = (p) => {
|
|
523
|
+
if (typeof p !== "string" || !isAbsolute(p)) return null;
|
|
524
|
+
const rel = relative(base, resolve(p));
|
|
525
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
526
|
+
return rel.split(sep)[0] || null;
|
|
527
|
+
};
|
|
528
|
+
const inMain = (p) => {
|
|
529
|
+
if (typeof p !== "string" || !isAbsolute(p)) return false;
|
|
530
|
+
const rel = relative(dir, resolve(p));
|
|
531
|
+
return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
|
|
532
|
+
};
|
|
533
|
+
const trees = [];
|
|
534
|
+
const add = (t) => {
|
|
535
|
+
if (!trees.includes(t)) trees.push(t);
|
|
536
|
+
};
|
|
537
|
+
const addWt = (name) => {
|
|
538
|
+
const wt = join(base, name);
|
|
539
|
+
try {
|
|
540
|
+
if (statSync(wt).isDirectory()) add(wt);
|
|
541
|
+
} catch {
|
|
542
|
+
// a worktree that is gone adds nothing
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const cwdName = wtName(input && input.cwd);
|
|
546
|
+
if (cwdName) addWt(cwdName);
|
|
547
|
+
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
548
|
+
const file = tracePath(dir, sessionId);
|
|
549
|
+
if (existsSync(file)) {
|
|
550
|
+
for (const l of readTrace(file)) {
|
|
551
|
+
if (l.agent !== subagent.id || l.event !== "PostToolUse" || !CHANGE_TOOLS.has(l.tool) || l.ok === false) continue;
|
|
552
|
+
const name = wtName(l.detail);
|
|
553
|
+
if (name) addWt(name);
|
|
554
|
+
else if (inMain(l.detail)) add(dir);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return trees.length ? trees : [dir];
|
|
559
|
+
} catch {
|
|
560
|
+
return [dir];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// card worktree-gate-mixed-tree-agent: the single verification target, for a gate process that was handed one
|
|
565
|
+
// (DOUG_STOP_GATE_TREE, set by runPerTree below). Honoured only for a SubagentStop and only when it is `dir`
|
|
566
|
+
// itself or an existing directory directly under <dir>/.claude/worktrees (realpaths compared, so a symlink
|
|
567
|
+
// cannot escape); anything else is ignored as if unset. Never throws.
|
|
568
|
+
function forcedTree(dir, subagent) {
|
|
569
|
+
try {
|
|
570
|
+
const t = process.env.DOUG_STOP_GATE_TREE;
|
|
571
|
+
if (!subagent || !t || !statSync(t).isDirectory()) return null;
|
|
572
|
+
const real = realpathSync(t);
|
|
573
|
+
if (real === realpathSync(dir)) return t;
|
|
574
|
+
return dirname(real) === realpathSync(join(dir, ".claude/worktrees")) ? t : null;
|
|
575
|
+
} catch {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// A child gate process (handed a valid tree and the child flag) leaves SubagentStop decision lines to its parent.
|
|
581
|
+
function isTreeChild(dir, subagent) {
|
|
582
|
+
return !!forcedTree(dir, subagent) && process.env.DOUG_STOP_GATE_CHILD === "1";
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// card worktree-gate-mixed-tree-agent: a SubagentStop whose agent touched several trees. Every exit of the gate
|
|
586
|
+
// is process.exit, so each tree's verification runs as this same script in a child process (DOUG_STOP_GATE_TREE,
|
|
587
|
+
// the parent's deadline in DOUG_STOP_GATE_DEADLINE, DOUG_STOP_GATE_CHILD so the child writes no decision line).
|
|
588
|
+
// The first child that blocks decides (its reason prefixed with the failing tree). A child that gives no verdict
|
|
589
|
+
// (killed, timed out, non-zero exit, unparseable output) is a block naming the tree, never an allow; its process
|
|
590
|
+
// group is killed. `record` writes the one combined decision line.
|
|
591
|
+
function runPerTree(input, trees, dir, deadline, record) {
|
|
592
|
+
const labelOf = (tree) => {
|
|
593
|
+
const rel = relative(dir, tree) || ".";
|
|
594
|
+
return rel === "." ? "the main checkout" : rel;
|
|
595
|
+
};
|
|
596
|
+
const fail = (tree, why) => {
|
|
597
|
+
record("block");
|
|
598
|
+
return blockStop(`[doug] Verification failed in ${labelOf(tree)} (${tree}):\n${why}`);
|
|
599
|
+
};
|
|
600
|
+
let last = "";
|
|
601
|
+
for (const tree of trees) {
|
|
602
|
+
let r = null;
|
|
603
|
+
try {
|
|
604
|
+
r = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], {
|
|
605
|
+
input: JSON.stringify(input),
|
|
606
|
+
env: { ...process.env, DOUG_STOP_GATE_TREE: tree, DOUG_STOP_GATE_DEADLINE: String(deadline), DOUG_STOP_GATE_CHILD: "1" },
|
|
607
|
+
encoding: "utf8",
|
|
608
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
609
|
+
detached: true,
|
|
610
|
+
timeout: Math.max(1000, deadline - Date.now()) + 2000,
|
|
611
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
612
|
+
});
|
|
613
|
+
} catch (e) {
|
|
614
|
+
return fail(tree, `the verification process could not start (${e && e.message ? e.message : e}).`);
|
|
615
|
+
}
|
|
616
|
+
if (r.error || r.signal || r.status !== 0) {
|
|
617
|
+
if (r.pid) {
|
|
618
|
+
try {
|
|
619
|
+
process.kill(-r.pid, "SIGKILL");
|
|
620
|
+
} catch {
|
|
621
|
+
// the group is already gone
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const why = r.error ? String(r.error.code || r.error.message) : r.signal ? `killed by ${r.signal}` : `exit status ${r.status}`;
|
|
625
|
+
return fail(tree, `the verification process gave no verdict (${why}).`);
|
|
626
|
+
}
|
|
627
|
+
const out = typeof r.stdout === "string" ? r.stdout.trim() : "";
|
|
628
|
+
let parsed = null;
|
|
629
|
+
if (out) {
|
|
630
|
+
try {
|
|
631
|
+
parsed = JSON.parse(out);
|
|
632
|
+
} catch {
|
|
633
|
+
return fail(tree, "the verification process gave no verdict (unparseable output).");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (parsed && parsed.decision === "block") return fail(tree, parsed.reason);
|
|
637
|
+
if (out) last = out;
|
|
638
|
+
}
|
|
639
|
+
record("allow");
|
|
640
|
+
if (last) process.stdout.write(last + "\n");
|
|
641
|
+
return allow();
|
|
642
|
+
}
|
|
643
|
+
|
|
426
644
|
// card stop-gate-skips-orphan-subagent-stops: true when a SubagentStop's own event carries none of the three
|
|
427
645
|
// signals of a real subagent of this session (docs/research/gate-output-names-failures.md Q1: Claude Code
|
|
428
646
|
// fires SubagentStop for agents that never had a SubagentStart, carry no agent_type, and have no readable
|
|
@@ -544,7 +762,7 @@ function waiverSources(dir, baselineHead) {
|
|
|
544
762
|
}
|
|
545
763
|
|
|
546
764
|
// The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
|
|
547
|
-
// block reasons, `parsed` the per-command
|
|
765
|
+
// block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
|
|
548
766
|
// record). A missing or unparseable summary, or no baseline, is a trace line and no check.
|
|
549
767
|
function countCheck(dir, cfg, state, input, ran) {
|
|
550
768
|
const problems = [];
|
|
@@ -565,24 +783,28 @@ function countCheck(dir, cfg, state, input, ran) {
|
|
|
565
783
|
for (const { command, stdout, result } of ran) {
|
|
566
784
|
try {
|
|
567
785
|
const p = result !== undefined ? result : parseTotals(stdout);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
continue;
|
|
571
|
-
}
|
|
572
|
-
if (p.status !== "ok") {
|
|
786
|
+
const summaries = p.status === "ok" ? readSummaries(p) : null;
|
|
787
|
+
if (!summaries) {
|
|
573
788
|
note(`count check did not run for \`${command}\`: no test summary in its output`);
|
|
574
789
|
continue;
|
|
575
790
|
}
|
|
576
|
-
parsed[command] = p.totals;
|
|
577
791
|
const all = state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
|
|
578
|
-
const
|
|
579
|
-
if (!
|
|
580
|
-
|
|
792
|
+
const baseList = all ? readSummaries(all[command]) : null;
|
|
793
|
+
if (!baseList) {
|
|
794
|
+
parsed[command] = summaries;
|
|
795
|
+
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("; ")})`);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (baseList.length !== summaries.length) {
|
|
799
|
+
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.`);
|
|
581
800
|
continue;
|
|
582
801
|
}
|
|
583
|
-
|
|
802
|
+
parsed[command] = summaries;
|
|
803
|
+
const base = sumTotals(baseList);
|
|
804
|
+
const now = sumTotals(summaries);
|
|
805
|
+
const found = compareTotals(base, now);
|
|
584
806
|
if (found.length === 0) continue;
|
|
585
|
-
const delta = deltaTotals(base,
|
|
807
|
+
const delta = deltaTotals(base, now);
|
|
586
808
|
if (waiverCovers(waiverSources(dir, state.baselineHead), delta)) continue;
|
|
587
809
|
const lines = found.map((f) => `${f.kind} ${f.kind === "total" ? "dropped" : "rose"} from ${f.baseline} (baseline) to ${f.now} (now)`);
|
|
588
810
|
problems.push(
|
|
@@ -613,16 +835,6 @@ function writeLastGreen(dir, parsed) {
|
|
|
613
835
|
}
|
|
614
836
|
}
|
|
615
837
|
|
|
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
838
|
// card stop-gate-credits-pre-commit: `git rev-parse HEAD^{tree}`, the tree sha of the current commit — what
|
|
627
839
|
// the pre-commit hook's own record (.doug/.state/pre-commit/last-run.json) compares its `tree` field against.
|
|
628
840
|
// Null on any failure (no git, no HEAD yet, ...): a reader must never throw.
|
|
@@ -639,17 +851,36 @@ function headTreeSha(dir) {
|
|
|
639
851
|
// card ratchet-pre-commit-credit: a recorded parseTotals result, normalised, or null when it is not readable.
|
|
640
852
|
function readableRecordedTotals(t) {
|
|
641
853
|
try {
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
854
|
+
if (t && typeof t === "object" && t.status === "none") return { status: "none" };
|
|
855
|
+
const summaries = readSummaries(t);
|
|
856
|
+
return summaries ? { status: "ok", summaries, totals: sumTotals(summaries) } : null;
|
|
857
|
+
} catch {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// card verified-tree-record: a verified-tree record (lib/verified-tree.mjs) of `tree` (the working tree now), same
|
|
863
|
+
// Node and command set, credits the commands instead of re-running them. Same return shape as preCommitCredit:
|
|
864
|
+
// one `{ name, command, ok: true, credited: "verified-tree", recorded? }` per resolveCommands entry in config
|
|
865
|
+
// order. A command with a baseline in state.testTotals and no readable recorded totals returns null (the commands
|
|
866
|
+
// run), the same rule as preCommitCredit. Never throws: any failure is null, and null runs the commands.
|
|
867
|
+
function treeCredit(dir, cfg, state, tree) {
|
|
868
|
+
try {
|
|
869
|
+
if (!tree) return null;
|
|
870
|
+
const record = readRecord(dir);
|
|
871
|
+
if (!record) return null;
|
|
872
|
+
const configured = resolveCommands(cfg);
|
|
873
|
+
if (configured.length === 0) return null;
|
|
874
|
+
if (!creditable(dir, cfg, record, tree, configured.map((c) => c.command)).ok) return null;
|
|
875
|
+
const all = state && state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
|
|
876
|
+
const out = [];
|
|
877
|
+
for (const { name, command } of configured) {
|
|
878
|
+
const recorded = readableRecordedTotals(record.totals[command]);
|
|
879
|
+
const hasBaseline = all ? readSummaries(all[command]) !== null : false;
|
|
880
|
+
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
881
|
+
out.push({ name, command, ok: true, credited: "verified-tree", ...(recorded ? { recorded } : {}) });
|
|
651
882
|
}
|
|
652
|
-
return
|
|
883
|
+
return out;
|
|
653
884
|
} catch {
|
|
654
885
|
return null;
|
|
655
886
|
}
|
|
@@ -657,7 +888,7 @@ function readableRecordedTotals(t) {
|
|
|
657
888
|
|
|
658
889
|
// card stop-gate-credits-pre-commit: the pre-commit hook (.githooks/pre-commit) writes a machine-readable
|
|
659
890
|
// 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`
|
|
891
|
+
// 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
892
|
// once stopGate.ignoreChangedPaths paths are removed) and that record is a passing run, against HEAD's own
|
|
662
893
|
// tree, of exactly today's configured commands in order, the Stop gate credits it instead of re-running the
|
|
663
894
|
// same commands a second time — returning `{ name, command, ok: true, credited: "pre-commit" }` per command,
|
|
@@ -689,14 +920,14 @@ function preCommitCredit(dir, cfg, changed, state) {
|
|
|
689
920
|
if (!record.commands[i] || record.commands[i].command !== configured[i].command) return null;
|
|
690
921
|
}
|
|
691
922
|
// 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 (
|
|
923
|
+
// record carries readable totals for it (a list of valid summaries, or an old ok or conflict shape);
|
|
693
924
|
// otherwise null and the commands really run. `recorded` is the parseTotals-shaped result for the count check.
|
|
694
925
|
const all = state && state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
|
|
695
926
|
const out = [];
|
|
696
927
|
for (let i = 0; i < configured.length; i++) {
|
|
697
928
|
const { name, command } = configured[i];
|
|
698
929
|
const recorded = readableRecordedTotals(record.commands[i].totals);
|
|
699
|
-
const hasBaseline = all ?
|
|
930
|
+
const hasBaseline = all ? readSummaries(all[command]) !== null : false;
|
|
700
931
|
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
701
932
|
out.push({ name, command, ok: true, credited: "pre-commit", ...(recorded ? { recorded } : {}) });
|
|
702
933
|
}
|
|
@@ -706,39 +937,6 @@ function preCommitCredit(dir, cfg, changed, state) {
|
|
|
706
937
|
}
|
|
707
938
|
}
|
|
708
939
|
|
|
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
940
|
// card stop-gate-process-storm, case 2 (second tester pass, reviewer-found gap): a verified-unchanged skip
|
|
743
941
|
// is a green gate in every way that matters, not a bare allow — it resets the same counters an ordinary
|
|
744
942
|
// green gate resets, and on the main Stop it still surfaces the context-window notice, exactly like the
|
|
@@ -754,7 +952,7 @@ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
|
|
|
754
952
|
// exits (F4); the `decision` a SubagentStop's own line now always carries folds in here, alongside its
|
|
755
953
|
// `reason`, rather than a second line being written for the same event. Only for a SubagentStop (subagent
|
|
756
954
|
// set) — a main Stop's own trace line here is not an agent-stop decision.
|
|
757
|
-
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
955
|
+
if (cfg.trace && cfg.trace.enabled !== false && !isTreeChild(dir, subagent)) {
|
|
758
956
|
appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: input.hook_event_name, reason: "verified-unchanged", ...(subagent ? { decision: "allow" } : {}) });
|
|
759
957
|
}
|
|
760
958
|
if (subagent) {
|
|
@@ -838,14 +1036,17 @@ function resolveHookTimeoutSec(cfg) {
|
|
|
838
1036
|
// discarded), less a margin so the gate's own block reaches Claude Code before that happens. gateStart
|
|
839
1037
|
// is this process's own start time, not the moment this function runs, so the budget covers everything
|
|
840
1038
|
// this invocation has done so far (config/state loads, earlier checks), not just what follows.
|
|
841
|
-
function gateBudget(cfg) {
|
|
1039
|
+
function gateBudget(cfg, inherited) {
|
|
842
1040
|
const hookTimeoutSec = resolveHookTimeoutSec(cfg);
|
|
843
1041
|
const hookTimeoutMs = hookTimeoutSec * 1000;
|
|
844
1042
|
const marginMs = Math.min(30000, hookTimeoutMs / 10);
|
|
845
1043
|
// process.uptime() is fractional seconds; runCommand's timeoutMs must be an unsigned integer (Node's
|
|
846
1044
|
// spawnSync throws otherwise), so every value derived from this deadline is rounded down.
|
|
847
1045
|
const gateStart = Date.now() - Math.floor(process.uptime() * 1000);
|
|
848
|
-
|
|
1046
|
+
const own = Math.floor(gateStart + hookTimeoutMs - marginMs);
|
|
1047
|
+
// card worktree-gate-mixed-tree-agent: a per-tree child never outlives its parent's deadline.
|
|
1048
|
+
const deadline = typeof inherited === "number" && Number.isFinite(inherited) && inherited <= own ? inherited : own;
|
|
1049
|
+
return { hookTimeoutSec, hookTimeoutMs, deadline };
|
|
849
1050
|
}
|
|
850
1051
|
|
|
851
1052
|
// A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
|
|
@@ -884,6 +1085,8 @@ function normalizedDeferrals(state) {
|
|
|
884
1085
|
return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
|
|
885
1086
|
}
|
|
886
1087
|
|
|
1088
|
+
const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
|
|
1089
|
+
|
|
887
1090
|
runHook("stop-gate", async (input) => {
|
|
888
1091
|
if (input.stop_hook_active) return allow();
|
|
889
1092
|
const dir = projectDir(input);
|
|
@@ -894,11 +1097,30 @@ runHook("stop-gate", async (input) => {
|
|
|
894
1097
|
// main Stops the counter (R1) is tracking — reset right here, after loadState, so every SubagentStop exit
|
|
895
1098
|
// below that saves state (including the one that reloads state again mid-lock-wait, R6 note there) persists
|
|
896
1099
|
// the reset.
|
|
897
|
-
if (subagent)
|
|
1100
|
+
if (subagent) {
|
|
1101
|
+
state.deferrals = 0;
|
|
1102
|
+
delete state.lastDeferral;
|
|
1103
|
+
}
|
|
898
1104
|
if (!subagent) state.turns += 1;
|
|
899
1105
|
const maxBlocks = cfg.stopGate.maxBlocks ?? 3;
|
|
900
1106
|
const blocksSoFar = subagent ? (state.agentBlocks && state.agentBlocks[subagent.id]) || 0 : state.stopBlocks;
|
|
901
1107
|
const who = subagent ? `Subagent ${subagent.type} (${subagent.id})` : null;
|
|
1108
|
+
// card worktree-gate-mixed-tree-agent: an agent that touched several trees is verified in each, one gate process per tree.
|
|
1109
|
+
const forced = forcedTree(dir, subagent);
|
|
1110
|
+
const treeChild = isTreeChild(dir, subagent);
|
|
1111
|
+
if (subagent && !forced) {
|
|
1112
|
+
const trees = verifyDirs(dir, cfg, input.session_id, input, subagent);
|
|
1113
|
+
if (trees.length > 1) {
|
|
1114
|
+
const record = (decision) => {
|
|
1115
|
+
try {
|
|
1116
|
+
if (cfg.trace && cfg.trace.enabled !== false) appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "SubagentStop", decision, reason: null });
|
|
1117
|
+
} catch {
|
|
1118
|
+
// never block the real exit on a trace failure
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
return runPerTree(input, trees, dir, gateBudget(cfg).deadline, record);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
902
1124
|
// D1 (card stop-gate-defers-while-subagent-in-flight): one choke point for the `decision` every SubagentStop
|
|
903
1125
|
// exit's own trace line now carries, so no exit (present or future) can miss it — except the
|
|
904
1126
|
// `stop_hook_active` early return above, which runs before `subagent` exists and so writes no decision line
|
|
@@ -913,7 +1135,7 @@ runHook("stop-gate", async (input) => {
|
|
|
913
1135
|
// tests_red_by_design, its `detail`) into this same line instead of a second one being written. Wrapped so a
|
|
914
1136
|
// trace failure never blocks the real exit (a hook reader must never throw).
|
|
915
1137
|
function traceSubagentDecision(decision, traceReason, extra) {
|
|
916
|
-
if (!subagent) return;
|
|
1138
|
+
if (!subagent || treeChild) return;
|
|
917
1139
|
try {
|
|
918
1140
|
if (!cfg.trace || cfg.trace.enabled === false) return;
|
|
919
1141
|
appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "SubagentStop", decision, reason: traceReason ?? null, ...(extra || {}) });
|
|
@@ -956,7 +1178,7 @@ runHook("stop-gate", async (input) => {
|
|
|
956
1178
|
};
|
|
957
1179
|
// Card stop-gate-budget-under-hook-timeout: the whole gate (wait plus commands) must fit under the
|
|
958
1180
|
// 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);
|
|
1181
|
+
const { hookTimeoutSec, hookTimeoutMs, deadline: budgetDeadline } = gateBudget(cfg, forced ? Number(process.env.DOUG_STOP_GATE_DEADLINE) : NaN);
|
|
960
1182
|
|
|
961
1183
|
// card stop-gate-skips-orphan-subagent-stops: checked before the partial-claim block, the tree scans, the
|
|
962
1184
|
// deferral logic, gate.lock, skipVerification, and runVerification — an orphan SubagentStop is not a real
|
|
@@ -1042,6 +1264,9 @@ runHook("stop-gate", async (input) => {
|
|
|
1042
1264
|
|
|
1043
1265
|
const edited = state.editedFiles.length > 0;
|
|
1044
1266
|
const changed = changedFiles(dir);
|
|
1267
|
+
// card worktree-agent-gate: the verification target (the agent's worktree, else `dir`) and its changed paths.
|
|
1268
|
+
const vdir = forced || verifyDirs(dir, cfg, input.session_id, input, subagent)[0];
|
|
1269
|
+
const vchanged = vdir === dir ? changed : changedFiles(vdir);
|
|
1045
1270
|
// Snapshot whatever was already dirty so it is never mistaken for this session's own work (card
|
|
1046
1271
|
// stop-gate-waiting-on-subagents). Ideally this happens on SessionStart (scripts/session-start-baseline.mjs),
|
|
1047
1272
|
// before the session can have dirtied anything itself; this is the fallback for a session where that
|
|
@@ -1119,6 +1344,58 @@ runHook("stop-gate", async (input) => {
|
|
|
1119
1344
|
(state.baselineSource === "session-start" ? sessionMadeChanges : edited || (Array.isArray(changed) && changed.length > 0)) ||
|
|
1120
1345
|
(Array.isArray(committed) && committed.length > 0) ||
|
|
1121
1346
|
walkedProposalHits.length > 0;
|
|
1347
|
+
// card stop-gate-runs-only-on-code-turns: a main Stop whose turn changed no code skips verification. The turn-start
|
|
1348
|
+
// record (scripts/turn-start.mjs, UserPromptSubmit) must equal the current tree once preCommit.noTestPaths paths are
|
|
1349
|
+
// dropped from both (same HEAD, same code paths, same identities), and the trace must show no successful main-agent
|
|
1350
|
+
// Edit-type call since the record (an edit reverted by content leaves the tree equal). Placed here, at the
|
|
1351
|
+
// onlyIfEdited skip and ahead of it, because the protected-path, proposal-path and plan-scope scans below are about
|
|
1352
|
+
// files a turn changed and a read-only turn changed none; a missing, malformed or stale record, no git, or any throw
|
|
1353
|
+
// reads as "cannot tell" and falls through to today's path. Never for a SubagentStop.
|
|
1354
|
+
let readOnlyTurn = null;
|
|
1355
|
+
if (!subagent) {
|
|
1356
|
+
let readOnly = null;
|
|
1357
|
+
try {
|
|
1358
|
+
const start = readTurnStart(dir, input.session_id);
|
|
1359
|
+
const status = turnTreeStatus(cfg, start, treeFingerprint(dir, cfg, changed));
|
|
1360
|
+
if (status && status.same) {
|
|
1361
|
+
let editedThisTurn = false;
|
|
1362
|
+
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
1363
|
+
const file = tracePath(dir, input.session_id);
|
|
1364
|
+
if (existsSync(file)) {
|
|
1365
|
+
const since = typeof start.at === "number" ? start.at : 0;
|
|
1366
|
+
editedThisTurn = readTrace(file).some((l) => !l.agent && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false && !(Date.parse(l.t) < since));
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (!editedThisTurn) readOnly = status;
|
|
1370
|
+
}
|
|
1371
|
+
} catch {
|
|
1372
|
+
readOnly = null;
|
|
1373
|
+
}
|
|
1374
|
+
// Only an UNFILTERED match (nothing at all changed) exits here. When only noTestPaths paths moved (a Bash write
|
|
1375
|
+
// to a board or docs file), the scans below still run, so such a file cannot escape the plan-scope and
|
|
1376
|
+
// proposal-path checks; only the verification commands are skipped (skipReason "read-only-turn").
|
|
1377
|
+
if (readOnly && !readOnly.exact) {
|
|
1378
|
+
readOnlyTurn = readOnly;
|
|
1379
|
+
} else if (readOnly) {
|
|
1380
|
+
// The tree-problem scans (protected, proposal, plan scope) still run on the exact path: a file already
|
|
1381
|
+
// dirty before the record is not this turn's, but it must not be hidden from them. Any problem, or a throw,
|
|
1382
|
+
// falls through to the normal path, which finds and blocks on it.
|
|
1383
|
+
let treeClean = false;
|
|
1384
|
+
try {
|
|
1385
|
+
treeClean = scanTreeProblems().problems.length === 0;
|
|
1386
|
+
} catch {
|
|
1387
|
+
treeClean = false;
|
|
1388
|
+
}
|
|
1389
|
+
if (!treeClean) readOnly = null;
|
|
1390
|
+
}
|
|
1391
|
+
if (readOnly && readOnly.exact) {
|
|
1392
|
+
const notice = contextNotice(dir, cfg, state, input.session_id, "skip");
|
|
1393
|
+
saveState(dir, input.session_id, state);
|
|
1394
|
+
const line = readOnly.dirty ? READ_ONLY_LINE : null;
|
|
1395
|
+
const msg = [line, notice].filter(Boolean).join("\n\n");
|
|
1396
|
+
return msg ? systemMessage(msg) : allowDecided();
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1122
1399
|
if (cfg.stopGate.onlyIfEdited && !anyChange) {
|
|
1123
1400
|
const notice = subagent ? null : contextNotice(dir, cfg, state, input.session_id, "skip");
|
|
1124
1401
|
saveState(dir, input.session_id, state);
|
|
@@ -1128,7 +1405,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1128
1405
|
// card stop-gate-process-storm, case 2: a tree already verified by an earlier green gate this session
|
|
1129
1406
|
// needs no recheck at all — placed ahead of maxBlocks and every problems check below, since a red gate
|
|
1130
1407
|
// never records `verified` and there is nothing left here to recheck when nothing has changed since.
|
|
1131
|
-
const currentFingerprint = treeFingerprint(
|
|
1408
|
+
const currentFingerprint = treeFingerprint(vdir, cfg, vchanged);
|
|
1132
1409
|
if (sameTree(state.verified, currentFingerprint)) {
|
|
1133
1410
|
return verifiedUnchangedAllow(dir, cfg, state, input, subagent);
|
|
1134
1411
|
}
|
|
@@ -1213,7 +1490,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1213
1490
|
// (that block is real and counts as today); only with no such problem does this Stop defer, skipping
|
|
1214
1491
|
// gate.lock, the verification commands, the evidence check, state.verified, and the blocks.jsonl ledger
|
|
1215
1492
|
// 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
|
|
1493
|
+
// SubagentStop (subagentsInFlight then returns `[]`) runs the full gate as today, unless that lead turn is read-only
|
|
1494
|
+
// (card stop-gate-runs-only-on-code-turns), which skips verification. state.lastGate is left
|
|
1217
1495
|
// untouched — this Stop verified nothing, so there is nothing honest to record there.
|
|
1218
1496
|
// R1/R3 (card stop-gate-deferral-cap): state.deferrals counts consecutive deferred main Stops; unbounded,
|
|
1219
1497
|
// a dead or forged subagent (the module header's "Known limit") would defer forever. deferralCapNote is
|
|
@@ -1222,7 +1500,13 @@ runHook("stop-gate", async (input) => {
|
|
|
1222
1500
|
// falls through to ends up returning. The cap only ever widens when the full verification runs (R7): it
|
|
1223
1501
|
// never lets a Stop skip verification that would otherwise have run.
|
|
1224
1502
|
let deferralCapNote = null;
|
|
1225
|
-
|
|
1503
|
+
// Card stop-gate-defer-read-only-quiet: only agents that can change the tree count (countingInFlight: not a
|
|
1504
|
+
// read-only type, and a non-writing type only once its trace shows a change). With none left this Stop runs
|
|
1505
|
+
// the ordinary path exactly as with no agent in flight. A repeat deferral (same counting set and same tree
|
|
1506
|
+
// fingerprint as the last one, state.lastDeferral) is silent: no systemMessage, but it still counts toward
|
|
1507
|
+
// stopGate.maxDeferrals. state.lastDeferral is cleared wherever state.deferrals resets to 0, so the first
|
|
1508
|
+
// deferral of a streak always speaks.
|
|
1509
|
+
const inFlight = subagent ? [] : countingInFlight(dir, cfg, input.session_id, subagentsInFlight(dir, cfg, input.session_id));
|
|
1226
1510
|
if (inFlight.length > 0) {
|
|
1227
1511
|
const { problems: deferProblems } = scanTreeProblems();
|
|
1228
1512
|
if (deferProblems.length === 0) {
|
|
@@ -1230,10 +1514,21 @@ runHook("stop-gate", async (input) => {
|
|
|
1230
1514
|
const n = normalizedDeferrals(state) + 1;
|
|
1231
1515
|
const names = inFlight.map((a) => agentLabel({ agent: a.id, agentType: a.type })).join(", ");
|
|
1232
1516
|
if (n < maxDeferrals) {
|
|
1517
|
+
const ids = inFlight.map((a) => a.id).sort();
|
|
1518
|
+
const prev = state.lastDeferral;
|
|
1519
|
+
const repeat =
|
|
1520
|
+
!!prev &&
|
|
1521
|
+
typeof prev === "object" &&
|
|
1522
|
+
Array.isArray(prev.ids) &&
|
|
1523
|
+
prev.ids.length === ids.length &&
|
|
1524
|
+
prev.ids.every((id, i) => id === ids[i]) &&
|
|
1525
|
+
sameTree(prev.fingerprint, currentFingerprint);
|
|
1233
1526
|
state.deferrals = n;
|
|
1527
|
+
state.lastDeferral = { ids, fingerprint: currentFingerprint };
|
|
1234
1528
|
saveState(dir, input.session_id, state);
|
|
1529
|
+
if (repeat) return allow();
|
|
1235
1530
|
return systemMessage(
|
|
1236
|
-
`[doug] Verification deferred:
|
|
1531
|
+
`[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
|
|
1237
1532
|
);
|
|
1238
1533
|
}
|
|
1239
1534
|
// R3: the cap is hit on this Stop — do not defer again. Reset the counter (R5) and fall through to
|
|
@@ -1243,22 +1538,54 @@ runHook("stop-gate", async (input) => {
|
|
|
1243
1538
|
// value itself (stopGate.maxDeferrals) is named alongside it so the message stays informative once the
|
|
1244
1539
|
// deferred count is no longer always the cap.
|
|
1245
1540
|
state.deferrals = 0;
|
|
1541
|
+
delete state.lastDeferral;
|
|
1246
1542
|
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
1543
|
} else {
|
|
1248
1544
|
state.deferrals = 0; // R5: a real tree problem — this Stop does not defer
|
|
1545
|
+
delete state.lastDeferral;
|
|
1249
1546
|
}
|
|
1250
1547
|
// A real problem, or the cap was just hit: fall through to the ordinary path below, which finds the
|
|
1251
1548
|
// same problem again inside runVerification and blocks on it exactly as today (deferral only ever
|
|
1252
1549
|
// widens "nothing to check yet", never what counts as a real problem).
|
|
1253
1550
|
} else if (!subagent) {
|
|
1254
1551
|
state.deferrals = 0; // R5: no agent in flight — this Stop runs the gate
|
|
1552
|
+
delete state.lastDeferral;
|
|
1255
1553
|
}
|
|
1256
1554
|
|
|
1257
1555
|
// card stop-gate-read-only-subagent-skip: computed once, ahead of the gate.lock acquire just below, so a
|
|
1258
1556
|
// 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
|
-
|
|
1557
|
+
// timeoutMs on a suite it will never even need is the other half of the 2026-09-16 process storm. The two
|
|
1558
|
+
// agent reasons are only ever true for a SubagentStop; the main Stop skips only for no-test-paths (below).
|
|
1559
|
+
// card gate-skip-readonly-researchers: a second skip reason, "tree-unchanged", for an agent whose tree
|
|
1560
|
+
// fingerprint equals its SubagentStart one even though its trace shows shell calls.
|
|
1561
|
+
// card stop-gate-no-test-paths: a third reason, for both stops: every path changed since the last green
|
|
1562
|
+
// verification is on preCommit.noTestPaths (onlyNoTestPathsChanged fails closed on anything doubtful).
|
|
1563
|
+
// card gate-docs-skip-unless-named: a fourth reason, "no-test-paths-docs", when every changed path is on the
|
|
1564
|
+
// list or a docs .md that no tracked test names; when some tests name it, `targetedFiles` makes the
|
|
1565
|
+
// verification below run only `test:unit <those files>`. Fail closed to the full gate (docs-gate.mjs).
|
|
1566
|
+
let docsDecision = null;
|
|
1567
|
+
const docsRule = () => {
|
|
1568
|
+
try {
|
|
1569
|
+
if (!resolveCommands(cfg).some((c) => c.name === "test:unit")) return null;
|
|
1570
|
+
const moved = movedSinceVerified(vdir, state.verified, vchanged);
|
|
1571
|
+
return moved && moved.size > 0 ? docsGateDecision(vdir, cfg, moved) : null;
|
|
1572
|
+
} catch {
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
|
|
1577
|
+
? "no-agent-changes"
|
|
1578
|
+
: subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
|
|
1579
|
+
? "tree-unchanged"
|
|
1580
|
+
: readOnlyTurn
|
|
1581
|
+
? "read-only-turn"
|
|
1582
|
+
: onlyNoTestPathsChanged(vdir, cfg, state.verified, vchanged)
|
|
1583
|
+
? "no-test-paths"
|
|
1584
|
+
: (docsDecision = docsRule()) && docsDecision.kind === "skip"
|
|
1585
|
+
? "no-test-paths-docs"
|
|
1586
|
+
: null;
|
|
1587
|
+
const skipVerification = skipReason !== null;
|
|
1588
|
+
const targetedFiles = !skipVerification && docsDecision && docsDecision.kind === "targeted" ? docsDecision.files : null;
|
|
1262
1589
|
|
|
1263
1590
|
// card stop-gate-no-change-restop: a stop in the same scope on a tree unchanged since a red block whose
|
|
1264
1591
|
// commands all really ran (state.lastRed, recorded at the final block below) repeats that block without
|
|
@@ -1268,6 +1595,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1268
1595
|
// unreadable, different, or not plainly a command-failure red degrades to running (fail closed to running).
|
|
1269
1596
|
if (!skipVerification) {
|
|
1270
1597
|
let lastRed = null;
|
|
1598
|
+
let redViaNoTestPaths = false; // card stop-gate-board-edit-reuses-red: reused although the tree moved, only on noTestPaths
|
|
1271
1599
|
try {
|
|
1272
1600
|
const rec = state.lastRed;
|
|
1273
1601
|
const scopeNow = subagent ? subagent.id : "main";
|
|
@@ -1280,21 +1608,26 @@ runHook("stop-gate", async (input) => {
|
|
|
1280
1608
|
typeof rec.summary === "string" &&
|
|
1281
1609
|
Array.isArray(rec.commands) &&
|
|
1282
1610
|
rec.commands.every((c) => typeof c === "string") &&
|
|
1283
|
-
|
|
1611
|
+
// card stop-gate-lead-reuses-subagent-red: a main Stop also takes a subagent's record, but only on the exact
|
|
1612
|
+
// same tree (never via the board-only path below); a SubagentStop still takes only its own scope's.
|
|
1613
|
+
(rec.scope === scopeNow || !subagent) &&
|
|
1284
1614
|
rec.commands.length === commandsNow.length &&
|
|
1285
1615
|
rec.commands.every((c, i) => c === commandsNow[i]) &&
|
|
1286
1616
|
rec.fingerprint &&
|
|
1287
1617
|
typeof rec.fingerprint === "object" &&
|
|
1288
1618
|
currentFingerprint &&
|
|
1289
|
-
sameTree(rec.fingerprint, currentFingerprint) &&
|
|
1619
|
+
(sameTree(rec.fingerprint, currentFingerprint) || (rec.scope === scopeNow && (redViaNoTestPaths = onlyNoTestPathsChanged(vdir, cfg, rec.fingerprint, vchanged)))) &&
|
|
1290
1620
|
!(subagent && (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path)))) &&
|
|
1291
|
-
!preCommitCredit(
|
|
1621
|
+
!preCommitCredit(vdir, cfg, vchanged, state) &&
|
|
1622
|
+
!treeCredit(vdir, cfg, state, computeTree(vdir))
|
|
1292
1623
|
) {
|
|
1293
1624
|
lastRed = rec;
|
|
1294
1625
|
}
|
|
1295
1626
|
} catch {
|
|
1296
1627
|
lastRed = null;
|
|
1297
1628
|
}
|
|
1629
|
+
if (!lastRed) redViaNoTestPaths = false;
|
|
1630
|
+
const crossWho = lastRed && lastRed.scope !== (subagent ? subagent.id : "main") ? (typeof lastRed.who === "string" && lastRed.who ? lastRed.who : `Subagent ${lastRed.scope}`) : null;
|
|
1298
1631
|
if (lastRed) {
|
|
1299
1632
|
if (blocksSoFar >= maxBlocks) {
|
|
1300
1633
|
const alreadyStoodDown = markStoodDown("block-cap");
|
|
@@ -1309,12 +1642,19 @@ runHook("stop-gate", async (input) => {
|
|
|
1309
1642
|
const blocks = blocksSoFar + 1;
|
|
1310
1643
|
if (subagent) state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: blocks };
|
|
1311
1644
|
else state.stopBlocks = blocks;
|
|
1312
|
-
|
|
1645
|
+
// one reuse per record on the exact same tree; a board-only reuse keeps the record (user decision
|
|
1646
|
+
// 2026-10-05), so consecutive board-only turns keep reusing the red, bounded by maxBlocks.
|
|
1647
|
+
if (!redViaNoTestPaths) state.lastRed = null;
|
|
1313
1648
|
saveState(dir, input.session_id, state);
|
|
1314
1649
|
const reason =
|
|
1315
1650
|
(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
1651
|
`Do not claim the task is done while this fails.\n\n` +
|
|
1317
|
-
|
|
1652
|
+
(crossWho
|
|
1653
|
+
? `Nothing changed since ${crossWho}'s block, so the commands were not re-run. ${crossWho}'s run failed:`
|
|
1654
|
+
: redViaNoTestPaths
|
|
1655
|
+
? `Only paths on preCommit.noTestPaths changed since the last block, so the commands were not re-run.`
|
|
1656
|
+
: `Nothing changed since the last block, so the commands were not re-run.`) +
|
|
1657
|
+
(crossWho ? `\n\n` : ` The last run's failure:\n\n`) +
|
|
1318
1658
|
[...treeProblems, lastRed.summary].join("\n\n");
|
|
1319
1659
|
const reuseClass = typeof lastRed.class === "string" ? lastRed.class : "real";
|
|
1320
1660
|
const capped = deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason;
|
|
@@ -1418,7 +1758,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1418
1758
|
// to even get here, so it already passed that check point and always intends 0). Unconditional:
|
|
1419
1759
|
// every path that reaches here, subagent or not, means 0.
|
|
1420
1760
|
state.deferrals = 0;
|
|
1421
|
-
|
|
1761
|
+
delete state.lastDeferral;
|
|
1762
|
+
verifiedCandidate = treeFingerprint(vdir, cfg, vchanged);
|
|
1422
1763
|
if (sameTree(state.verified, verifiedCandidate)) {
|
|
1423
1764
|
// Review round 1 minor 5: this exit returns straight from verifiedUnchangedAllow, bypassing the
|
|
1424
1765
|
// *Releasing wrappers (R4) below — release the lock here (a no-op when this gate never took it)
|
|
@@ -1504,14 +1845,21 @@ runHook("stop-gate", async (input) => {
|
|
|
1504
1845
|
// card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
|
|
1505
1846
|
// stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
|
|
1506
1847
|
// red-by-design, state.verified, the block ledger) runs exactly as it would after a real run.
|
|
1507
|
-
|
|
1848
|
+
// card verified-tree-record: treeBefore is the working tree immediately before the first command; a record of
|
|
1849
|
+
// it (any writer: Stop, verify.mjs, land, pre-commit) is tried before preCommitCredit's last-run.json.
|
|
1850
|
+
const treeBefore = skipVerification ? null : computeTree(vdir);
|
|
1851
|
+
const credited = skipVerification ? null : (treeCredit(vdir, cfg, state, treeBefore) ?? preCommitCredit(vdir, cfg, vchanged, state));
|
|
1852
|
+
// card gate-docs-skip-unless-named: a targeted run is never a full run (no record, no ratchet, no state.verified).
|
|
1853
|
+
const targeted = !credited && targetedFiles ? targetedFiles : null;
|
|
1508
1854
|
if (!skipVerification && credited) {
|
|
1509
1855
|
for (const { recorded, ...r } of credited) {
|
|
1510
1856
|
results.push(r);
|
|
1511
1857
|
if (recorded) ranGreen.push({ command: r.command, result: recorded });
|
|
1512
1858
|
}
|
|
1513
1859
|
} else if (!skipVerification) {
|
|
1514
|
-
for (const { name, command } of resolveCommands(cfg)) {
|
|
1860
|
+
for (const { name, command: fullCommand } of resolveCommands(cfg)) {
|
|
1861
|
+
if (targeted && name !== "test:unit") continue; // a targeted run is test:unit alone, no typecheck
|
|
1862
|
+
const command = targeted ? `${fullCommand} ${targeted.join(" ")}` : fullCommand;
|
|
1515
1863
|
// Card stop-gate-budget-under-hook-timeout: a command that cannot even start before the platform
|
|
1516
1864
|
// would kill this hook's own process is dropped rather than let the platform cut it off mid-run,
|
|
1517
1865
|
// silently, with its block message discarded (F1). One still runs with what budget is left, clamped
|
|
@@ -1530,9 +1878,9 @@ runHook("stop-gate", async (input) => {
|
|
|
1530
1878
|
// never bypass the budget: normalizedTimeoutMs floors and clamps up to at least 1ms; unset uses the
|
|
1531
1879
|
// remaining budget outright. The timed-out message below reports this same value.
|
|
1532
1880
|
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 });
|
|
1881
|
+
const r = runCommand(command, { cwd: vdir, timeoutMs: commandTimeoutMs });
|
|
1882
|
+
results.push({ name, command, ok: r.ok, durationMs: r.durationMs, timedOut: r.timedOut, status: r.status, ...(targeted ? { targeted: targeted.length } : {}) });
|
|
1883
|
+
if (r.ok && !targeted) ranGreen.push({ command, stdout: r.stdout });
|
|
1536
1884
|
if (!r.ok) {
|
|
1537
1885
|
const why = r.timedOut ? `timed out after ${commandTimeoutMs} ms` : `exit ${r.status}`;
|
|
1538
1886
|
const output = r.stderr + "\n" + r.stdout;
|
|
@@ -1543,6 +1891,38 @@ runHook("stop-gate", async (input) => {
|
|
|
1543
1891
|
}
|
|
1544
1892
|
}
|
|
1545
1893
|
}
|
|
1894
|
+
// card verified-tree-record: record "these commands exited 0 on this tree" after a real all-green run on a tree
|
|
1895
|
+
// unchanged across it; remove the record on any command failure (fail closed). A credited run never writes.
|
|
1896
|
+
if (!skipVerification && !credited && !targeted) {
|
|
1897
|
+
try {
|
|
1898
|
+
const configured = resolveCommands(cfg);
|
|
1899
|
+
if (configured.length === 0) {
|
|
1900
|
+
// nothing ran, nothing to record
|
|
1901
|
+
} else if (results.length === configured.length && results.every((r) => r.ok)) {
|
|
1902
|
+
const treeAfter = treeBefore ? computeTree(vdir) : null;
|
|
1903
|
+
if (treeBefore && treeAfter === treeBefore) {
|
|
1904
|
+
const totals = {};
|
|
1905
|
+
for (const g of ranGreen) {
|
|
1906
|
+
const parsed = parseTotals(g.stdout);
|
|
1907
|
+
if (parsed.status === "ok") totals[g.command] = parsed;
|
|
1908
|
+
}
|
|
1909
|
+
writeTreeRecord(vdir, {
|
|
1910
|
+
version: 1,
|
|
1911
|
+
tree: treeAfter,
|
|
1912
|
+
node: process.version,
|
|
1913
|
+
at: new Date().toISOString(),
|
|
1914
|
+
by: subagent ? "subagent-stop" : "stop",
|
|
1915
|
+
commands: results.map((r) => ({ name: r.name, command: r.command, exit: 0, durationMs: typeof r.durationMs === "number" ? r.durationMs : 0 })),
|
|
1916
|
+
totals,
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
} else {
|
|
1920
|
+
removeTreeRecord(vdir);
|
|
1921
|
+
}
|
|
1922
|
+
} catch {
|
|
1923
|
+
// never throw from the writer
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1546
1926
|
const postCommandProblems = problems.length;
|
|
1547
1927
|
|
|
1548
1928
|
// 3b. Evidence: the session must have run a test or verify command itself (lib/evidence.mjs), so a session that
|
|
@@ -1564,13 +1944,23 @@ runHook("stop-gate", async (input) => {
|
|
|
1564
1944
|
problems.push(...counted.problems);
|
|
1565
1945
|
const countProblemAdded = counted.problems.length > 0;
|
|
1566
1946
|
|
|
1567
|
-
|
|
1947
|
+
// A main Stop's skip has no decision-tagged exit line like a SubagentStop's (allowReleasing folds the reason
|
|
1948
|
+
// into that); record the reason here so the trace carries it (card stop-gate-no-test-paths).
|
|
1949
|
+
if (skipVerification && !subagent && cfg.trace && cfg.trace.enabled !== false) {
|
|
1950
|
+
try {
|
|
1951
|
+
appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: input.hook_event_name, reason: skipReason });
|
|
1952
|
+
} catch {
|
|
1953
|
+
// never block the real exit on a trace failure
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
state.lastGate = { ok: problems.length === 0, at: Date.now(), results, scope, evidence, ...(subagent ? { subagent } : {}), ...(skipVerification ? { skipped: skipReason } : {}) };
|
|
1568
1957
|
// card stop-gate-process-storm, case 2: a green gate records the tree it just verified, so a later
|
|
1569
1958
|
// Stop/SubagentStop whose tree still matches can skip re-running these commands (the sameTree check
|
|
1570
1959
|
// above). A red gate never reaches here, so it records nothing (goal 2). Never on a no-agent-changes skip
|
|
1571
1960
|
// (card stop-gate-read-only-subagent-skip): nothing was actually verified.
|
|
1572
|
-
|
|
1573
|
-
if (
|
|
1961
|
+
// A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
|
|
1962
|
+
if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
|
|
1963
|
+
if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
|
|
1574
1964
|
if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
|
|
1575
1965
|
if (problems.length === 0 && subagent) {
|
|
1576
1966
|
// A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
|
|
@@ -1578,17 +1968,23 @@ runHook("stop-gate", async (input) => {
|
|
|
1578
1968
|
saveState(dir, input.session_id, state);
|
|
1579
1969
|
// card stop-gate-read-only-subagent-skip: the "no-agent-changes" reason folds into this exit's own
|
|
1580
1970
|
// decision-tagged trace line (D1) rather than a second one being written.
|
|
1581
|
-
return allowReleasing(
|
|
1971
|
+
return allowReleasing(skipReason);
|
|
1582
1972
|
}
|
|
1583
1973
|
if (problems.length === 0) {
|
|
1584
|
-
|
|
1974
|
+
// A read-only-turn skip changed nothing this turn: no stopBlocks reset and no checkpoint commit.
|
|
1975
|
+
if (skipReason !== "read-only-turn") state.stopBlocks = 0;
|
|
1585
1976
|
// 4. Checkpoint on green (off unless checkpoint.enabled): record the working tree now that the
|
|
1586
1977
|
// gate passed with changes present, so a later regression can be undone with git.
|
|
1587
|
-
const ck = checkpoint({ dir, cfg, changed, gateResults: results });
|
|
1978
|
+
const ck = skipReason === "read-only-turn" ? { skipped: "disabled" } : checkpoint({ dir, cfg, changed, gateResults: results });
|
|
1588
1979
|
const silent = ck.skipped === "disabled" || ck.skipped === "no changes";
|
|
1589
1980
|
if (!silent) state.lastCheckpoint = { ...ck, at: Date.now() };
|
|
1590
1981
|
const notice = contextNotice(dir, cfg, state, input.session_id, "verification");
|
|
1591
1982
|
saveState(dir, input.session_id, state);
|
|
1983
|
+
if (skipReason === "read-only-turn") {
|
|
1984
|
+
const line = readOnlyTurn && readOnlyTurn.dirty ? READ_ONLY_LINE : null;
|
|
1985
|
+
const msg = [line, silent ? null : describeCheckpoint(ck), notice].filter(Boolean).join("\n\n");
|
|
1986
|
+
return msg ? systemMessageReleasing(msg) : allowReleasing();
|
|
1987
|
+
}
|
|
1592
1988
|
if (notice) return systemMessageReleasing(silent ? notice : `${describeCheckpoint(ck)}\n\n${notice}`);
|
|
1593
1989
|
return silent ? allowReleasing() : systemMessageReleasing(describeCheckpoint(ck));
|
|
1594
1990
|
}
|
|
@@ -1630,6 +2026,46 @@ runHook("stop-gate", async (input) => {
|
|
|
1630
2026
|
// Card stop-gate-block-ledger: the standing claim, distinct from state.lastGate above (which a
|
|
1631
2027
|
// later gate run replaces wholesale, line ~942) — this is what a later block's classifier reads.
|
|
1632
2028
|
state.redByDesignClaim = { files: coverage.files, at: new Date().toISOString(), agent: subagent.id };
|
|
2029
|
+
// card stop-gate-lead-reuses-subagent-red: the tester just ran every command on this tree; record the
|
|
2030
|
+
// red so the lead's Stop on the same tree reuses it (same conditions as the final-block recorder).
|
|
2031
|
+
try {
|
|
2032
|
+
const configured = resolveCommands(cfg);
|
|
2033
|
+
if (
|
|
2034
|
+
!skipVerification &&
|
|
2035
|
+
!credited &&
|
|
2036
|
+
!targeted &&
|
|
2037
|
+
results.length === configured.length &&
|
|
2038
|
+
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)))) &&
|
|
2039
|
+
verifiedCandidate
|
|
2040
|
+
) {
|
|
2041
|
+
let redClass = "real";
|
|
2042
|
+
try {
|
|
2043
|
+
redClass = classifyFinalBlock({
|
|
2044
|
+
preVerificationProblems,
|
|
2045
|
+
evidenceProblemAdded,
|
|
2046
|
+
commandFailures,
|
|
2047
|
+
postCommandProblems,
|
|
2048
|
+
results,
|
|
2049
|
+
redByDesignClaimFiles: coverage.files,
|
|
2050
|
+
changedAtStart: changed,
|
|
2051
|
+
dir,
|
|
2052
|
+
budgetDeadline,
|
|
2053
|
+
});
|
|
2054
|
+
} catch {
|
|
2055
|
+
redClass = "real";
|
|
2056
|
+
}
|
|
2057
|
+
state.lastRed = {
|
|
2058
|
+
fingerprint: verifiedCandidate,
|
|
2059
|
+
scope: subagent.id,
|
|
2060
|
+
commands: configured.map((c) => c.command),
|
|
2061
|
+
summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
|
|
2062
|
+
class: redClass,
|
|
2063
|
+
who,
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
} catch {
|
|
2067
|
+
state.lastRed = null;
|
|
2068
|
+
}
|
|
1633
2069
|
saveState(dir, input.session_id, state);
|
|
1634
2070
|
// Card stop-gate-defers-while-subagent-in-flight: the "tests_red_by_design" reason (and its
|
|
1635
2071
|
// covered-files detail) folds into this exit's own decision-tagged trace line (D1) rather than a
|
|
@@ -1678,16 +2114,17 @@ runHook("stop-gate", async (input) => {
|
|
|
1678
2114
|
}
|
|
1679
2115
|
// card stop-gate-no-change-restop: record this red for reuse only when every configured command really
|
|
1680
2116
|
// 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
|
|
2117
|
+
// 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
2118
|
// class so a reuse logs the same class. state.lastRed was cleared above, so every other path leaves none.
|
|
1683
2119
|
try {
|
|
1684
2120
|
const configured = resolveCommands(cfg);
|
|
1685
2121
|
if (
|
|
1686
2122
|
!skipVerification &&
|
|
1687
2123
|
!credited &&
|
|
2124
|
+
!targeted &&
|
|
1688
2125
|
commandFailures.length > 0 &&
|
|
1689
2126
|
results.length === configured.length &&
|
|
1690
|
-
results.every((r) => !r.timedOut && !r.skipped && (r.ok || (typeof r.status === "number" && r.status !== 0))) &&
|
|
2127
|
+
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
2128
|
postCommandProblems - preVerificationProblems === commandFailures.length &&
|
|
1692
2129
|
verifiedCandidate
|
|
1693
2130
|
) {
|
|
@@ -1697,6 +2134,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1697
2134
|
commands: configured.map((c) => c.command),
|
|
1698
2135
|
summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
|
|
1699
2136
|
class: finalClass,
|
|
2137
|
+
who,
|
|
1700
2138
|
};
|
|
1701
2139
|
saveState(dir, input.session_id, state);
|
|
1702
2140
|
}
|