@dougharness/cli 1.3.0 → 1.5.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/ablate.js +2 -2
- package/dist/ablate.js.map +1 -1
- package/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/board-page.d.ts +5 -1
- package/dist/board-page.js +19 -5
- package/dist/board-page.js.map +1 -1
- package/dist/board-serve.js +34 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +160 -13
- package/dist/board.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -0
- package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
- package/node_modules/@dougharness/flow/lib/board.mjs +375 -30
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +237 -24
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
- package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
- package/node_modules/@dougharness/flow/lib/memory.mjs +1403 -44
- package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +355 -13
- package/node_modules/@dougharness/flow/scripts/board.mjs +160 -27
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
- package/node_modules/@dougharness/flow/scripts/memory.mjs +668 -39
- package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +7 -3
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +11 -2
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +3 -3
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +55 -37
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +32 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +45 -5
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +130 -42
- package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
- package/package.json +3 -3
- package/templates/board-app.js +240 -30
|
@@ -85,16 +85,16 @@ import { runCommand, failureSummary } from "../lib/run.mjs";
|
|
|
85
85
|
import { loadScopePlan, scopeViolations, describeViolations, PLAN_RELPATH } from "../lib/scope.mjs";
|
|
86
86
|
import { checkpoint, describeCheckpoint } from "../lib/checkpoint.mjs";
|
|
87
87
|
import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.mjs";
|
|
88
|
-
import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
|
|
88
|
+
import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, turnTreeStatusHeadMoved, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
|
|
89
89
|
import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
|
|
90
90
|
import { isAppliedContent } from "../lib/proposals.mjs";
|
|
91
91
|
import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, readPlanSummaries, snapshotSection } from "../lib/anchor.mjs";
|
|
92
92
|
import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
|
|
93
93
|
import { parseTotals, readSummaries, sumTotals, compareTotals, deltaTotals, waiverCovers } from "../lib/test-totals.mjs";
|
|
94
|
-
import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
|
|
94
|
+
import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine, outputTail, redactText } from "../lib/block-ledger.mjs";
|
|
95
95
|
import { docsGateDecision } from "../lib/docs-gate.mjs";
|
|
96
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";
|
|
97
|
+
import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable, totalsUsable, exitCreditUsable } from "../lib/verified-tree.mjs";
|
|
98
98
|
|
|
99
99
|
// Only the last this many characters of last_assistant_message are ever scanned for a partial claim
|
|
100
100
|
// (review, scan cost): a structured result lives at the end of a worker's message, and the balanced-span
|
|
@@ -311,14 +311,14 @@ function stripLeadingDotSlash(f) {
|
|
|
311
311
|
// failed command's output names at least one test file, every named file is in the claim, and every
|
|
312
312
|
// claimed file is changed or untracked in the working tree (`changed`, from changedFiles(dir) above);
|
|
313
313
|
// otherwise `{ covered: false, reasonLine }` — one line, no newline, saying why.
|
|
314
|
-
function redByDesignCoverage(claimFiles, commandFailures, changed, dir) {
|
|
314
|
+
function redByDesignCoverage(claimFiles, commandFailures, changed, dir, secrets) {
|
|
315
315
|
const changedSet = new Set(Array.isArray(changed) ? changed : []);
|
|
316
316
|
for (const cf of commandFailures) {
|
|
317
317
|
const bad = nonTestTscPathsFrom(cf.output, dir);
|
|
318
318
|
if (bad.length > 0) {
|
|
319
319
|
return {
|
|
320
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.`,
|
|
321
|
+
reasonLine: `The tests_red_by_design claim does not cover this: \`${redactText(cf.command, secrets)}\` reported a type error in ${bad.join(", ")}, which is not a test file.`,
|
|
322
322
|
};
|
|
323
323
|
}
|
|
324
324
|
}
|
|
@@ -327,7 +327,7 @@ function redByDesignCoverage(claimFiles, commandFailures, changed, dir) {
|
|
|
327
327
|
if (noFile.length > 0) {
|
|
328
328
|
return {
|
|
329
329
|
covered: false,
|
|
330
|
-
reasonLine: `The tests_red_by_design claim does not cover this: ${noFile.map((c) => `\`${c.command}\``).join(", ")} named no test file in its output.`,
|
|
330
|
+
reasonLine: `The tests_red_by_design claim does not cover this: ${noFile.map((c) => `\`${redactText(c.command, secrets)}\``).join(", ")} named no test file in its output.`,
|
|
331
331
|
};
|
|
332
332
|
}
|
|
333
333
|
const allNamed = [...new Set(namedByCommand.flatMap((c) => c.files))];
|
|
@@ -468,6 +468,21 @@ function countingInFlight(dir, cfg, sessionId, inFlight) {
|
|
|
468
468
|
// and whose ok is not exactly false (a null ok counts as success — conservative). No change found -> true.
|
|
469
469
|
//
|
|
470
470
|
// Without trace evidence, true only when subagent.type is in READ_ONLY_AGENT_TYPES.
|
|
471
|
+
// card ratchet-stale-session-baseline: true only when the trace vouches for the agent's whole life (its
|
|
472
|
+
// SubagentStart line is there) and shows no successful Edit-type PostToolUse of its own. Bash does not count.
|
|
473
|
+
function agentMadeNoEditCalls(dir, cfg, sessionId, subagent) {
|
|
474
|
+
try {
|
|
475
|
+
if (!subagent || !cfg.trace || cfg.trace.enabled === false) return false;
|
|
476
|
+
const file = tracePath(dir, sessionId);
|
|
477
|
+
if (!existsSync(file)) return false;
|
|
478
|
+
const lines = readTrace(file);
|
|
479
|
+
if (!lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id)) return false;
|
|
480
|
+
return !lines.some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false);
|
|
481
|
+
} catch {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
471
486
|
function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
472
487
|
try {
|
|
473
488
|
if (!subagent) return false;
|
|
@@ -495,7 +510,7 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
|
495
510
|
// card stop-gate-worktree-agent-fingerprint: the start record is a fingerprint of the main checkout (the trace
|
|
496
511
|
// hook runs there), so for a worktree verification dir it is compared like with like instead: unchanged only if
|
|
497
512
|
// the record exists, every verification dir has no changed files, and every one's HEAD carries no commit of its
|
|
498
|
-
// own: it equals or is an ancestor of the record's HEAD, or of the
|
|
513
|
+
// own: it equals or is an ancestor of the record's HEAD, or of the origin/HEAD sha the record holds, taken at start, never the live ref (Claude
|
|
499
514
|
// Code branches a fresh isolation worktree from the remote default branch, which local main may be ahead of or
|
|
500
515
|
// behind). Any git failure reads as changed. A verification set that mixes a worktree with `dir` reads as
|
|
501
516
|
// changed. A main-checkout agent keeps the fingerprint comparison.
|
|
@@ -524,12 +539,7 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
|
|
|
524
539
|
if (!start || typeof start !== "object" || !start.head) return false;
|
|
525
540
|
const dirs = forced ? [vdir] : verifyDirs(dir, cfg, sessionId, input, subagent);
|
|
526
541
|
if (!dirs.includes(vdir) || dirs.includes(dir)) return false;
|
|
527
|
-
|
|
528
|
-
try {
|
|
529
|
-
remoteHead = execFileSync("git", ["rev-parse", "--verify", "-q", "refs/remotes/origin/HEAD^{commit}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim() || null;
|
|
530
|
-
} catch {
|
|
531
|
-
remoteHead = null;
|
|
532
|
-
}
|
|
542
|
+
const remoteHead = typeof start.originHead === "string" && start.originHead ? start.originHead : null;
|
|
533
543
|
return dirs.every((d) => {
|
|
534
544
|
const h = headAt(d);
|
|
535
545
|
if (!h) return false;
|
|
@@ -812,14 +822,36 @@ function readLastGreen(dir) {
|
|
|
812
822
|
}
|
|
813
823
|
}
|
|
814
824
|
|
|
825
|
+
// card ratchet-credit-reads-record: one baseline for the count check and both credit paths. In a worktree
|
|
826
|
+
// (vdir !== dir) it is the worktree's own last-green record. On the main checkout (card
|
|
827
|
+
// ratchet-stale-session-baseline) it is the main last-green record when that has a readable entry for the
|
|
828
|
+
// command, else the session-start copy state.testTotals. Returns (command) => baseline object or null. Never throws.
|
|
829
|
+
function countBaselineFor(dir, state, vdir) {
|
|
830
|
+
try {
|
|
831
|
+
const inWorktree = typeof vdir === "string" && vdir !== dir;
|
|
832
|
+
const worktreeBaseline = inWorktree ? readLastGreen(vdir) : null;
|
|
833
|
+
const mainRecord = inWorktree ? null : readLastGreen(dir);
|
|
834
|
+
const fallback = state && state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
|
|
835
|
+
return (command) => {
|
|
836
|
+
try {
|
|
837
|
+
if (inWorktree) return worktreeBaseline;
|
|
838
|
+
return mainRecord && readSummaries(mainRecord[command]) ? mainRecord : fallback;
|
|
839
|
+
} catch {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
} catch {
|
|
844
|
+
return () => null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
815
848
|
// The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
|
|
816
849
|
// block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
|
|
817
850
|
// record). A missing or unparseable summary, or no baseline, is a trace line and no check.
|
|
818
851
|
function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
819
852
|
// card worktree-count-baseline: a worktree run compares against its own earlier green runs, never main's record
|
|
820
853
|
// (a worktree can lack the dist build main has, so its skipped count differs). No record yet means no check.
|
|
821
|
-
const
|
|
822
|
-
const baseline = inWorktree ? readLastGreen(vdir) : state.testTotals;
|
|
854
|
+
const baselineFor = countBaselineFor(dir, state, vdir);
|
|
823
855
|
const problems = [];
|
|
824
856
|
const parsed = {};
|
|
825
857
|
// The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
|
|
@@ -843,6 +875,7 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
|
843
875
|
note(`count check did not run for \`${command}\`: no test summary in its output`);
|
|
844
876
|
continue;
|
|
845
877
|
}
|
|
878
|
+
const baseline = baselineFor(command);
|
|
846
879
|
const all = baseline && typeof baseline === "object" ? baseline : null;
|
|
847
880
|
const baseList = all ? readSummaries(all[command]) : null;
|
|
848
881
|
if (!baseList) {
|
|
@@ -919,7 +952,7 @@ function readableRecordedTotals(t) {
|
|
|
919
952
|
// one `{ name, command, ok: true, credited: "verified-tree", recorded? }` per resolveCommands entry in config
|
|
920
953
|
// order. A command with a baseline in state.testTotals and no readable recorded totals returns null (the commands
|
|
921
954
|
// run), the same rule as preCommitCredit. Never throws: any failure is null, and null runs the commands.
|
|
922
|
-
function treeCredit(dir, cfg, state, tree) {
|
|
955
|
+
function treeCredit(dir, cfg, state, tree, mainDir) {
|
|
923
956
|
try {
|
|
924
957
|
if (!tree) return null;
|
|
925
958
|
const record = readRecord(dir);
|
|
@@ -927,11 +960,14 @@ function treeCredit(dir, cfg, state, tree) {
|
|
|
927
960
|
const configured = resolveCommands(cfg);
|
|
928
961
|
if (configured.length === 0) return null;
|
|
929
962
|
if (!creditable(dir, cfg, record, tree, configured.map((c) => c.command)).ok) return null;
|
|
930
|
-
|
|
963
|
+
if (!exitCreditUsable(record, dir)) return null;
|
|
964
|
+
// dir is the verify dir (a worktree for a worktree agent); mainDir the main checkout, as countCheck's dir.
|
|
965
|
+
const baselineFor = countBaselineFor(mainDir ?? dir, state, dir);
|
|
931
966
|
const out = [];
|
|
932
967
|
for (const { name, command } of configured) {
|
|
933
|
-
const recorded = readableRecordedTotals(record.totals[command]);
|
|
934
|
-
const
|
|
968
|
+
const recorded = totalsUsable(record, dir) ? readableRecordedTotals(record.totals[command]) : null;
|
|
969
|
+
const base = baselineFor(command);
|
|
970
|
+
const hasBaseline = base ? readSummaries(base[command]) !== null : false;
|
|
935
971
|
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
936
972
|
out.push({ name, command, ok: true, credited: "verified-tree", ...(recorded ? { recorded } : {}) });
|
|
937
973
|
}
|
|
@@ -951,7 +987,7 @@ function treeCredit(dir, cfg, state, tree) {
|
|
|
951
987
|
// wrong-shape, failing, stale-tree, or mismatched-commands record, or a dirty tree — fail closed to running,
|
|
952
988
|
// never to passing. A reader must never throw: every path here is wrapped, so a corrupt record never crashes
|
|
953
989
|
// the hook (`runHook` fails open on a throw, which here would mean passing without running — forbidden).
|
|
954
|
-
function preCommitCredit(dir, cfg, changed, state) {
|
|
990
|
+
function preCommitCredit(dir, cfg, changed, state, mainDir) {
|
|
955
991
|
try {
|
|
956
992
|
if (!Array.isArray(changed)) return null;
|
|
957
993
|
const ignore = cfg.stopGate.ignoreChangedPaths || [];
|
|
@@ -977,12 +1013,14 @@ function preCommitCredit(dir, cfg, changed, state) {
|
|
|
977
1013
|
// card ratchet-pre-commit-credit: a command with a baseline in state.testTotals is credited only when the
|
|
978
1014
|
// record carries readable totals for it (a list of valid summaries, or an old ok or conflict shape);
|
|
979
1015
|
// otherwise null and the commands really run. `recorded` is the parseTotals-shaped result for the count check.
|
|
980
|
-
|
|
1016
|
+
// dir is the verify dir (a worktree for a worktree agent); mainDir the main checkout, as countCheck's dir.
|
|
1017
|
+
const baselineFor = countBaselineFor(mainDir ?? dir, state, dir);
|
|
981
1018
|
const out = [];
|
|
982
1019
|
for (let i = 0; i < configured.length; i++) {
|
|
983
1020
|
const { name, command } = configured[i];
|
|
984
1021
|
const recorded = readableRecordedTotals(record.commands[i].totals);
|
|
985
|
-
const
|
|
1022
|
+
const base = baselineFor(command);
|
|
1023
|
+
const hasBaseline = base ? readSummaries(base[command]) !== null : false;
|
|
986
1024
|
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
987
1025
|
out.push({ name, command, ok: true, credited: "pre-commit", ...(recorded ? { recorded } : {}) });
|
|
988
1026
|
}
|
|
@@ -1209,8 +1247,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1209
1247
|
traceSubagentDecision("allow", traceReason, extra);
|
|
1210
1248
|
return allow();
|
|
1211
1249
|
};
|
|
1212
|
-
const blockStopDecided = (reason) => {
|
|
1213
|
-
traceSubagentDecision("block", null);
|
|
1250
|
+
const blockStopDecided = (reason, extra) => {
|
|
1251
|
+
traceSubagentDecision("block", null, extra);
|
|
1214
1252
|
return blockStop(reason);
|
|
1215
1253
|
};
|
|
1216
1254
|
const systemMessageDecided = (message, traceReason, extra) => {
|
|
@@ -1220,8 +1258,19 @@ runHook("stop-gate", async (input) => {
|
|
|
1220
1258
|
// Card stop-gate-block-ledger: one ledger line per block/stand-down below, written synchronously right
|
|
1221
1259
|
// before the blockStop/systemMessage call it belongs to (those call process.exit(0) themselves, so
|
|
1222
1260
|
// nothing queued after them ever runs). appendBlockLine never throws.
|
|
1223
|
-
const logBlock = (reason, cls) =>
|
|
1224
|
-
appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id });
|
|
1261
|
+
const logBlock = (reason, cls, record) =>
|
|
1262
|
+
appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id, record });
|
|
1263
|
+
// card block-ledger-failing-output: a main Stop verification block has no decision trace line of its own;
|
|
1264
|
+
// append one carrying the record. A SubagentStop's line comes from traceSubagentDecision. Never throws.
|
|
1265
|
+
const traceMainBlock = (record) => {
|
|
1266
|
+
if (subagent) return;
|
|
1267
|
+
try {
|
|
1268
|
+
if (!cfg.trace || cfg.trace.enabled === false) return;
|
|
1269
|
+
appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "Stop", decision: "block", reason: null, ...record });
|
|
1270
|
+
} catch {
|
|
1271
|
+
// never block the real exit on a trace failure
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1225
1274
|
// Card block-ledger-stood-down-repeats: a stood-down line is written at most once per session per
|
|
1226
1275
|
// cause, so a session standing down at its cap does not append a fresh identical line on every later
|
|
1227
1276
|
// Stop/SubagentStop past it. Cause key: "turn-budget" (lead only), or "block-cap:<lead|agent id>" — the
|
|
@@ -1418,7 +1467,10 @@ runHook("stop-gate", async (input) => {
|
|
|
1418
1467
|
let readOnly = null;
|
|
1419
1468
|
try {
|
|
1420
1469
|
const start = readTurnStart(dir, input.session_id);
|
|
1421
|
-
const
|
|
1470
|
+
const currentFp = treeFingerprint(dir, cfg, changed);
|
|
1471
|
+
let status = turnTreeStatus(cfg, start, currentFp);
|
|
1472
|
+
// A turn that only committed noTestPaths files moved HEAD but changed no code (card stop-gate-read-only-turn-commits).
|
|
1473
|
+
if (status && !status.same) status = turnTreeStatusHeadMoved(dir, cfg, start, currentFp) || status;
|
|
1422
1474
|
if (status && status.same) {
|
|
1423
1475
|
let editedThisTurn = false;
|
|
1424
1476
|
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
@@ -1683,8 +1735,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1683
1735
|
currentFingerprint &&
|
|
1684
1736
|
(sameTree(rec.fingerprint, currentFingerprint) || (rec.scope === scopeNow && (redViaNoTestPaths = onlyNoTestPathsChanged(vdir, cfg, rec.fingerprint, vchanged)))) &&
|
|
1685
1737
|
!(subagent && (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path)))) &&
|
|
1686
|
-
!preCommitCredit(vdir, cfg, vchanged, state) &&
|
|
1687
|
-
!treeCredit(vdir, cfg, state, computeTree(vdir))
|
|
1738
|
+
!preCommitCredit(vdir, cfg, vchanged, state, dir) &&
|
|
1739
|
+
!treeCredit(vdir, cfg, state, computeTree(vdir), dir)
|
|
1688
1740
|
) {
|
|
1689
1741
|
lastRed = rec;
|
|
1690
1742
|
}
|
|
@@ -1723,8 +1775,14 @@ runHook("stop-gate", async (input) => {
|
|
|
1723
1775
|
[...treeProblems, lastRed.summary].join("\n\n");
|
|
1724
1776
|
const reuseClass = typeof lastRed.class === "string" ? lastRed.class : "real";
|
|
1725
1777
|
const capped = deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason;
|
|
1726
|
-
|
|
1727
|
-
|
|
1778
|
+
const reuseRecord = {
|
|
1779
|
+
failures: Array.isArray(lastRed.failures) ? lastRed.failures : [],
|
|
1780
|
+
cwd: typeof lastRed.cwd === "string" ? lastRed.cwd : null,
|
|
1781
|
+
tree: typeof lastRed.tree === "string" ? lastRed.tree : null,
|
|
1782
|
+
};
|
|
1783
|
+
logBlock(reason, reuseClass, reuseRecord);
|
|
1784
|
+
traceMainBlock(reuseRecord);
|
|
1785
|
+
return blockStopDecided(capped, reuseRecord);
|
|
1728
1786
|
}
|
|
1729
1787
|
}
|
|
1730
1788
|
|
|
@@ -1869,9 +1927,9 @@ runHook("stop-gate", async (input) => {
|
|
|
1869
1927
|
if (deferralCapNote && !subagent) return systemMessageDecided(deferralCapNote, traceReason, extra);
|
|
1870
1928
|
return allowDecided(traceReason, extra);
|
|
1871
1929
|
};
|
|
1872
|
-
const blockStopReleasing = (reason) => {
|
|
1930
|
+
const blockStopReleasing = (reason, extra) => {
|
|
1873
1931
|
releaseLock();
|
|
1874
|
-
return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason);
|
|
1932
|
+
return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason, extra);
|
|
1875
1933
|
};
|
|
1876
1934
|
const systemMessageReleasing = (message, traceReason, extra) => {
|
|
1877
1935
|
releaseLock();
|
|
@@ -1906,6 +1964,19 @@ runHook("stop-gate", async (input) => {
|
|
|
1906
1964
|
// One entry per failed command, `{ command, output }` (stderr + stdout, untruncated), for the
|
|
1907
1965
|
// tests_red_by_design coverage check below (design point 2, card subagent-stop-gate-tester-red).
|
|
1908
1966
|
const commandFailures = [];
|
|
1967
|
+
// card block-ledger-failing-output: the failing commands, the directory and the tree this block verified.
|
|
1968
|
+
const buildBlockRecord = () => {
|
|
1969
|
+
try {
|
|
1970
|
+
const failures = commandFailures.map((cf, i) => {
|
|
1971
|
+
const r = failedRuns[i] || {};
|
|
1972
|
+
return { command: redactText(cf.command, cfg.secrets), exit: typeof r.status === "number" ? r.status : null, timedOut: !!r.timedOut, tail: outputTail(cf.output, cfg.secrets) };
|
|
1973
|
+
});
|
|
1974
|
+
return { failures, cwd: vdir, tree: treeBefore ?? null };
|
|
1975
|
+
} catch {
|
|
1976
|
+
return { failures: [], cwd: vdir, tree: null }; // the line is written without the failures; the decision is unchanged
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
const failedRuns = []; // parallel to commandFailures: { status, timedOut } of each failed run (card block-ledger-failing-output)
|
|
1909
1980
|
const ranGreen = []; // count check input: { command, stdout } of each command that really ran and exited 0, plus { command, result } of each credited pre-commit command whose recorded totals are readable
|
|
1910
1981
|
// card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
|
|
1911
1982
|
// stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
|
|
@@ -1913,7 +1984,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1913
1984
|
// card verified-tree-record: treeBefore is the working tree immediately before the first command; a record of
|
|
1914
1985
|
// it (any writer: Stop, verify.mjs, land, pre-commit) is tried before preCommitCredit's last-run.json.
|
|
1915
1986
|
const treeBefore = skipVerification ? null : computeTree(vdir);
|
|
1916
|
-
const credited = skipVerification ? null : (treeCredit(vdir, cfg, state, treeBefore) ?? preCommitCredit(vdir, cfg, vchanged, state));
|
|
1987
|
+
const credited = skipVerification ? null : (treeCredit(vdir, cfg, state, treeBefore, dir) ?? preCommitCredit(vdir, cfg, vchanged, state, dir));
|
|
1917
1988
|
// card gate-docs-skip-unless-named: a targeted run is never a full run (no record, no ratchet, no state.verified).
|
|
1918
1989
|
const targeted = !credited && targetedFiles ? targetedFiles : null;
|
|
1919
1990
|
if (!skipVerification && credited) {
|
|
@@ -1951,8 +2022,9 @@ runHook("stop-gate", async (input) => {
|
|
|
1951
2022
|
const output = r.stderr + "\n" + r.stdout;
|
|
1952
2023
|
// card gate-output-names-failures: the block reason leads with the failing test names (R1/R2);
|
|
1953
2024
|
// commandFailures below keeps the untruncated output for the tests_red_by_design coverage check.
|
|
1954
|
-
problems.push(`\`${command}\` failed (${why}):\n${failureSummary(output, 40)}`);
|
|
2025
|
+
problems.push(`\`${redactText(command, cfg.secrets)}\` failed (${why}):\n${redactText(failureSummary(output, 40), cfg.secrets)}`);
|
|
1955
2026
|
commandFailures.push({ command, output });
|
|
2027
|
+
failedRuns.push({ status: r.status, timedOut: r.timedOut });
|
|
1956
2028
|
}
|
|
1957
2029
|
}
|
|
1958
2030
|
}
|
|
@@ -1977,6 +2049,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1977
2049
|
node: process.version,
|
|
1978
2050
|
at: new Date().toISOString(),
|
|
1979
2051
|
by: subagent ? "subagent-stop" : "stop",
|
|
2052
|
+
ranIn: realpathSync(vdir),
|
|
1980
2053
|
commands: results.map((r) => ({ name: r.name, command: r.command, exit: 0, durationMs: typeof r.durationMs === "number" ? r.durationMs : 0 })),
|
|
1981
2054
|
totals,
|
|
1982
2055
|
});
|
|
@@ -2006,8 +2079,17 @@ runHook("stop-gate", async (input) => {
|
|
|
2006
2079
|
|
|
2007
2080
|
// 3c. Test-count ratchet (card test-count-ratchet): its own kind of problem, not a failed command.
|
|
2008
2081
|
const counted = countCheck(dir, cfg, state, input, ranGreen, vdir);
|
|
2009
|
-
|
|
2010
|
-
|
|
2082
|
+
// card ratchet-stale-session-baseline: a seat with no Edit-type call is never blocked by a count change it did
|
|
2083
|
+
// not cause. Its count problems become one trace note, and that stop writes neither the record nor state.verified.
|
|
2084
|
+
const countExempt = counted.problems.length > 0 && !!subagent && agentMadeNoEditCalls(dir, cfg, input.session_id, subagent);
|
|
2085
|
+
if (countExempt) {
|
|
2086
|
+
try {
|
|
2087
|
+
if (cfg.trace && cfg.trace.enabled !== false) appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "CountCheck", reason: `count change not blocking a seat that made no Edit-type call: ${counted.problems[0].split("\n")[0].slice(0, 300)}` });
|
|
2088
|
+
} catch {
|
|
2089
|
+
// logging must never fail the gate
|
|
2090
|
+
}
|
|
2091
|
+
} else problems.push(...counted.problems);
|
|
2092
|
+
const countProblemAdded = !countExempt && counted.problems.length > 0;
|
|
2011
2093
|
|
|
2012
2094
|
// A main Stop's skip has no decision-tagged exit line like a SubagentStop's (allowReleasing folds the reason
|
|
2013
2095
|
// into that); record the reason here so the trace carries it (card stop-gate-no-test-paths).
|
|
@@ -2025,8 +2107,8 @@ runHook("stop-gate", async (input) => {
|
|
|
2025
2107
|
// (card stop-gate-read-only-subagent-skip): nothing was actually verified.
|
|
2026
2108
|
// A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
|
|
2027
2109
|
if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
|
|
2028
|
-
if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
|
|
2029
|
-
if (problems.length === 0 && !skipVerification) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
|
|
2110
|
+
if (problems.length === 0 && !skipVerification && !targeted && !countExempt) state.verified = verifiedCandidate;
|
|
2111
|
+
if (problems.length === 0 && !skipVerification && !countExempt) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
|
|
2030
2112
|
if (problems.length === 0 && subagent) {
|
|
2031
2113
|
// A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
|
|
2032
2114
|
state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
|
|
@@ -2083,7 +2165,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2083
2165
|
// (none of them a dropped command) closes that gap.
|
|
2084
2166
|
postCommandProblems - preVerificationProblems === commandFailures.length
|
|
2085
2167
|
) {
|
|
2086
|
-
const coverage = redByDesignCoverage(claimFiles, commandFailures, changed, dir);
|
|
2168
|
+
const coverage = redByDesignCoverage(claimFiles, commandFailures, changed, dir, cfg.secrets);
|
|
2087
2169
|
if (coverage.covered) {
|
|
2088
2170
|
// Same as a subagent's green gate: counter resets, no checkpoint, no turn.
|
|
2089
2171
|
state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
|
|
@@ -2126,6 +2208,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2126
2208
|
summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
|
|
2127
2209
|
class: redClass,
|
|
2128
2210
|
who,
|
|
2211
|
+
...buildBlockRecord(),
|
|
2129
2212
|
};
|
|
2130
2213
|
}
|
|
2131
2214
|
} catch {
|
|
@@ -2164,6 +2247,8 @@ runHook("stop-gate", async (input) => {
|
|
|
2164
2247
|
let finalClass = "real";
|
|
2165
2248
|
try {
|
|
2166
2249
|
finalClass = classifyFinalBlock({
|
|
2250
|
+
countProblems: countExempt ? 0 : counted.problems.length,
|
|
2251
|
+
totalProblems: problems.length,
|
|
2167
2252
|
preVerificationProblems,
|
|
2168
2253
|
evidenceProblemAdded,
|
|
2169
2254
|
commandFailures,
|
|
@@ -2177,6 +2262,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2177
2262
|
} catch {
|
|
2178
2263
|
finalClass = "real";
|
|
2179
2264
|
}
|
|
2265
|
+
const blockRecord = buildBlockRecord();
|
|
2180
2266
|
// card stop-gate-no-change-restop: record this red for reuse only when every configured command really
|
|
2181
2267
|
// ran (not credited, none dropped for budget or timed out) and at least one failed, every failure by a
|
|
2182
2268
|
// 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
|
|
@@ -2200,14 +2286,16 @@ runHook("stop-gate", async (input) => {
|
|
|
2200
2286
|
summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
|
|
2201
2287
|
class: finalClass,
|
|
2202
2288
|
who,
|
|
2289
|
+
...blockRecord,
|
|
2203
2290
|
};
|
|
2204
2291
|
saveState(dir, input.session_id, state);
|
|
2205
2292
|
}
|
|
2206
2293
|
} catch {
|
|
2207
2294
|
state.lastRed = null;
|
|
2208
2295
|
}
|
|
2209
|
-
logBlock(reason, finalClass);
|
|
2210
|
-
|
|
2296
|
+
logBlock(reason, finalClass, blockRecord);
|
|
2297
|
+
traceMainBlock(blockRecord);
|
|
2298
|
+
return blockStopReleasing(reason, blockRecord);
|
|
2211
2299
|
};
|
|
2212
2300
|
|
|
2213
2301
|
return runVerification();
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
// No count ratchet and no scans here (no session baseline); the Stop gate does those. Takes the Stop gate's gate.lock (under the git common dir, shared by every worktree) around the commands (card verify-takes-gate-lock).
|
|
8
8
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
|
-
import { unlinkSync } from "node:fs";
|
|
10
|
+
import { unlinkSync, realpathSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { loadConfig } from "../lib/config.mjs";
|
|
13
13
|
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
14
14
|
import { runCommand, tail } from "../lib/run.mjs";
|
|
15
15
|
import { parseTotals, readSummaries, sumTotals } from "../lib/test-totals.mjs";
|
|
16
|
-
import { computeTree, readRecord, writeRecord, removeRecord, creditable, resolveCommands } from "../lib/verified-tree.mjs";
|
|
16
|
+
import { computeTree, readRecord, writeRecord, removeRecord, creditable, exitCreditUsable, resolveCommands } from "../lib/verified-tree.mjs";
|
|
17
17
|
|
|
18
18
|
function parseArgs(argv) {
|
|
19
19
|
const o = { noCredit: false, json: false, commands: [], creditDirs: [] };
|
|
@@ -51,6 +51,20 @@ function toplevel() {
|
|
|
51
51
|
return process.cwd();
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// True when dir is a main checkout (git dir equals the common dir), or when that cannot be told (fail closed). Never throws.
|
|
55
|
+
function isMainCheckout(dir) {
|
|
56
|
+
try {
|
|
57
|
+
const env = {};
|
|
58
|
+
for (const [k, v] of Object.entries(process.env)) if (!k.startsWith("GIT_")) env[k] = v;
|
|
59
|
+
const r = spawnSync("git", ["rev-parse", "--git-dir", "--git-common-dir"], { cwd: dir, encoding: "utf8", env, timeout: 10000 });
|
|
60
|
+
const [a, b] = r.status === 0 ? r.stdout.trim().split("\n") : [];
|
|
61
|
+
if (!a || !b) return true;
|
|
62
|
+
return realpathSync(resolve(dir, a)) === realpathSync(resolve(dir, b));
|
|
63
|
+
} catch {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
function passed(totals) {
|
|
55
69
|
const s = totals && totals.status === "ok" ? readSummaries(totals) : null;
|
|
56
70
|
if (!s) return null;
|
|
@@ -86,9 +100,11 @@ function main() {
|
|
|
86
100
|
const wanted = commands.map((c) => c.command);
|
|
87
101
|
let record = null;
|
|
88
102
|
let fromOther = false;
|
|
103
|
+
// Card verify-credit-foreign-skipped: in a main checkout a record from another checkout that skipped tests is not evidence.
|
|
104
|
+
const mainCwd = isMainCheckout(cwd);
|
|
89
105
|
for (const [i, d] of [recordDir, ...o.creditDirs.map((c) => resolve(c))].entries()) {
|
|
90
106
|
const rec = readRecord(d);
|
|
91
|
-
if (rec && creditable(cwd, cfg, rec, treeBefore, wanted).ok) {
|
|
107
|
+
if (rec && creditable(cwd, cfg, rec, treeBefore, wanted).ok && (!mainCwd || exitCreditUsable(rec, cwd))) {
|
|
92
108
|
record = rec;
|
|
93
109
|
fromOther = i > 0;
|
|
94
110
|
break;
|
|
@@ -185,7 +201,7 @@ function main() {
|
|
|
185
201
|
if (ok) {
|
|
186
202
|
const treeAfter = computeTree(cwd);
|
|
187
203
|
if (treeBefore && treeAfter && treeBefore === treeAfter) {
|
|
188
|
-
writeRecord(recordDir, { version: 1, tree: treeAfter, node: process.version, at: new Date().toISOString(), by: o.by || "verify", commands: recCommands, totals });
|
|
204
|
+
writeRecord(recordDir, { version: 1, tree: treeAfter, node: process.version, at: new Date().toISOString(), by: o.by || "verify", ranIn: realpathSync(cwd), commands: recCommands, totals });
|
|
189
205
|
}
|
|
190
206
|
} else {
|
|
191
207
|
removeRecord(recordDir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "doug: tailored, approval-gated harness install for Claude Code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"node": ">=22.16"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@dougharness/flow": "1.
|
|
27
|
-
"@dougharness/gates": "1.
|
|
26
|
+
"@dougharness/flow": "1.5.0",
|
|
27
|
+
"@dougharness/gates": "1.5.0"
|
|
28
28
|
},
|
|
29
29
|
"bundleDependencies": [
|
|
30
30
|
"@dougharness/flow",
|