@dougharness/cli 1.4.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/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/lib/board.mjs +9 -9
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +44 -5
- 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 +1380 -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 +162 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +38 -14
- package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
- package/node_modules/@dougharness/flow/scripts/memory.mjs +658 -36
- package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +6 -2
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +2 -0
- 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/run-report/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/card-queen.js +44 -5
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +5 -3
- 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 +48 -33
- package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
- package/package.json +3 -3
|
@@ -17,6 +17,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rename
|
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
19
|
import { pathToFileURL } from "node:url";
|
|
20
|
+
import { readSummaries } from "./test-totals.mjs";
|
|
20
21
|
|
|
21
22
|
export const RECORD_RELPATH = ".doug/.state/verified-tree.json";
|
|
22
23
|
export const EXCLUDED = [".doug/.state", ".claude/worktrees", ".doug/anchor.md"];
|
|
@@ -108,6 +109,37 @@ export function validRecord(r) {
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// Test totals depend on the environment (a worktree without a built dist skips tests), not only the tree. A record
|
|
113
|
+
// stamps `ranIn` (realpath of the directory its commands ran in); its totals count as evidence only for that directory.
|
|
114
|
+
// Never throws: any failure, or a record with no ranIn (legacy), is false (the commands run).
|
|
115
|
+
export function totalsUsable(record, dir) {
|
|
116
|
+
try {
|
|
117
|
+
return !!record && typeof record.ranIn === "string" && realpathSync(record.ranIn) === realpathSync(dir);
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A record from another checkout (or a legacy one with no ranIn) credits exit codes only when none of its recorded
|
|
124
|
+
// totals reports skipped tests: a dist-less worktree skips tests that could fail here. Absent or unreadable entries
|
|
125
|
+
// do not count. Never throws: any failure is false.
|
|
126
|
+
export function exitCreditUsable(record, dir) {
|
|
127
|
+
try {
|
|
128
|
+
if (!record || typeof record !== "object") return false;
|
|
129
|
+
if (totalsUsable(record, dir)) return true;
|
|
130
|
+
const totals = record.totals;
|
|
131
|
+
if (!totals || typeof totals !== "object") return true;
|
|
132
|
+
for (const entry of Object.values(totals)) {
|
|
133
|
+
const sums = readSummaries(entry);
|
|
134
|
+
if (!sums) continue;
|
|
135
|
+
if (sums.reduce((n, s) => n + (s.skipped || 0), 0) > 0) return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
111
143
|
export function readRecord(dir) {
|
|
112
144
|
try {
|
|
113
145
|
const r = JSON.parse(readFileSync(join(dir, RECORD_RELPATH), "utf8"));
|
|
@@ -214,6 +246,7 @@ if (isMain()) {
|
|
|
214
246
|
node: process.version,
|
|
215
247
|
at: new Date().toISOString(),
|
|
216
248
|
by: "pre-commit",
|
|
249
|
+
ranIn: realpathSync(dir),
|
|
217
250
|
commands: commands.map((cm) => ({ name: cm.name, command: cm.command, exit: cm.exit, durationMs: cm.durationMs })),
|
|
218
251
|
totals,
|
|
219
252
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/gates",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Deterministic Claude Code hooks: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, stop gate, re-anchor after compaction.",
|
|
6
6
|
"type": "module",
|
|
@@ -94,7 +94,7 @@ import { parseTotals, readSummaries, sumTotals, compareTotals, deltaTotals, waiv
|
|
|
94
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))];
|
|
@@ -510,7 +510,7 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
|
510
510
|
// card stop-gate-worktree-agent-fingerprint: the start record is a fingerprint of the main checkout (the trace
|
|
511
511
|
// hook runs there), so for a worktree verification dir it is compared like with like instead: unchanged only if
|
|
512
512
|
// the record exists, every verification dir has no changed files, and every one's HEAD carries no commit of its
|
|
513
|
-
// 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
|
|
514
514
|
// Code branches a fresh isolation worktree from the remote default branch, which local main may be ahead of or
|
|
515
515
|
// behind). Any git failure reads as changed. A verification set that mixes a worktree with `dir` reads as
|
|
516
516
|
// changed. A main-checkout agent keeps the fingerprint comparison.
|
|
@@ -539,12 +539,7 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
|
|
|
539
539
|
if (!start || typeof start !== "object" || !start.head) return false;
|
|
540
540
|
const dirs = forced ? [vdir] : verifyDirs(dir, cfg, sessionId, input, subagent);
|
|
541
541
|
if (!dirs.includes(vdir) || dirs.includes(dir)) return false;
|
|
542
|
-
|
|
543
|
-
try {
|
|
544
|
-
remoteHead = execFileSync("git", ["rev-parse", "--verify", "-q", "refs/remotes/origin/HEAD^{commit}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim() || null;
|
|
545
|
-
} catch {
|
|
546
|
-
remoteHead = null;
|
|
547
|
-
}
|
|
542
|
+
const remoteHead = typeof start.originHead === "string" && start.originHead ? start.originHead : null;
|
|
548
543
|
return dirs.every((d) => {
|
|
549
544
|
const h = headAt(d);
|
|
550
545
|
if (!h) return false;
|
|
@@ -827,22 +822,36 @@ function readLastGreen(dir) {
|
|
|
827
822
|
}
|
|
828
823
|
}
|
|
829
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
|
+
|
|
830
848
|
// The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
|
|
831
849
|
// block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
|
|
832
850
|
// record). A missing or unparseable summary, or no baseline, is a trace line and no check.
|
|
833
851
|
function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
834
852
|
// card worktree-count-baseline: a worktree run compares against its own earlier green runs, never main's record
|
|
835
853
|
// (a worktree can lack the dist build main has, so its skipped count differs). No record yet means no check.
|
|
836
|
-
const
|
|
837
|
-
// card ratchet-stale-session-baseline: a main-checkout run compares against the main tree's live last-green
|
|
838
|
-
// record too (per command), not the session-start copy; the copy is the fallback when the record has no
|
|
839
|
-
// readable entry for the command, so a deleted record does not remove the check.
|
|
840
|
-
const worktreeBaseline = inWorktree ? readLastGreen(vdir) : null;
|
|
841
|
-
const mainRecord = inWorktree ? null : readLastGreen(dir);
|
|
842
|
-
const baselineFor = (command) => {
|
|
843
|
-
if (inWorktree) return worktreeBaseline;
|
|
844
|
-
return mainRecord && readSummaries(mainRecord[command]) ? mainRecord : state.testTotals;
|
|
845
|
-
};
|
|
854
|
+
const baselineFor = countBaselineFor(dir, state, vdir);
|
|
846
855
|
const problems = [];
|
|
847
856
|
const parsed = {};
|
|
848
857
|
// The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
|
|
@@ -943,7 +952,7 @@ function readableRecordedTotals(t) {
|
|
|
943
952
|
// one `{ name, command, ok: true, credited: "verified-tree", recorded? }` per resolveCommands entry in config
|
|
944
953
|
// order. A command with a baseline in state.testTotals and no readable recorded totals returns null (the commands
|
|
945
954
|
// run), the same rule as preCommitCredit. Never throws: any failure is null, and null runs the commands.
|
|
946
|
-
function treeCredit(dir, cfg, state, tree) {
|
|
955
|
+
function treeCredit(dir, cfg, state, tree, mainDir) {
|
|
947
956
|
try {
|
|
948
957
|
if (!tree) return null;
|
|
949
958
|
const record = readRecord(dir);
|
|
@@ -951,11 +960,14 @@ function treeCredit(dir, cfg, state, tree) {
|
|
|
951
960
|
const configured = resolveCommands(cfg);
|
|
952
961
|
if (configured.length === 0) return null;
|
|
953
962
|
if (!creditable(dir, cfg, record, tree, configured.map((c) => c.command)).ok) return null;
|
|
954
|
-
|
|
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);
|
|
955
966
|
const out = [];
|
|
956
967
|
for (const { name, command } of configured) {
|
|
957
|
-
const recorded = readableRecordedTotals(record.totals[command]);
|
|
958
|
-
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;
|
|
959
971
|
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
960
972
|
out.push({ name, command, ok: true, credited: "verified-tree", ...(recorded ? { recorded } : {}) });
|
|
961
973
|
}
|
|
@@ -975,7 +987,7 @@ function treeCredit(dir, cfg, state, tree) {
|
|
|
975
987
|
// wrong-shape, failing, stale-tree, or mismatched-commands record, or a dirty tree — fail closed to running,
|
|
976
988
|
// never to passing. A reader must never throw: every path here is wrapped, so a corrupt record never crashes
|
|
977
989
|
// the hook (`runHook` fails open on a throw, which here would mean passing without running — forbidden).
|
|
978
|
-
function preCommitCredit(dir, cfg, changed, state) {
|
|
990
|
+
function preCommitCredit(dir, cfg, changed, state, mainDir) {
|
|
979
991
|
try {
|
|
980
992
|
if (!Array.isArray(changed)) return null;
|
|
981
993
|
const ignore = cfg.stopGate.ignoreChangedPaths || [];
|
|
@@ -1001,12 +1013,14 @@ function preCommitCredit(dir, cfg, changed, state) {
|
|
|
1001
1013
|
// card ratchet-pre-commit-credit: a command with a baseline in state.testTotals is credited only when the
|
|
1002
1014
|
// record carries readable totals for it (a list of valid summaries, or an old ok or conflict shape);
|
|
1003
1015
|
// otherwise null and the commands really run. `recorded` is the parseTotals-shaped result for the count check.
|
|
1004
|
-
|
|
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);
|
|
1005
1018
|
const out = [];
|
|
1006
1019
|
for (let i = 0; i < configured.length; i++) {
|
|
1007
1020
|
const { name, command } = configured[i];
|
|
1008
1021
|
const recorded = readableRecordedTotals(record.commands[i].totals);
|
|
1009
|
-
const
|
|
1022
|
+
const base = baselineFor(command);
|
|
1023
|
+
const hasBaseline = base ? readSummaries(base[command]) !== null : false;
|
|
1010
1024
|
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
1011
1025
|
out.push({ name, command, ok: true, credited: "pre-commit", ...(recorded ? { recorded } : {}) });
|
|
1012
1026
|
}
|
|
@@ -1721,8 +1735,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1721
1735
|
currentFingerprint &&
|
|
1722
1736
|
(sameTree(rec.fingerprint, currentFingerprint) || (rec.scope === scopeNow && (redViaNoTestPaths = onlyNoTestPathsChanged(vdir, cfg, rec.fingerprint, vchanged)))) &&
|
|
1723
1737
|
!(subagent && (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path)))) &&
|
|
1724
|
-
!preCommitCredit(vdir, cfg, vchanged, state) &&
|
|
1725
|
-
!treeCredit(vdir, cfg, state, computeTree(vdir))
|
|
1738
|
+
!preCommitCredit(vdir, cfg, vchanged, state, dir) &&
|
|
1739
|
+
!treeCredit(vdir, cfg, state, computeTree(vdir), dir)
|
|
1726
1740
|
) {
|
|
1727
1741
|
lastRed = rec;
|
|
1728
1742
|
}
|
|
@@ -1970,7 +1984,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1970
1984
|
// card verified-tree-record: treeBefore is the working tree immediately before the first command; a record of
|
|
1971
1985
|
// it (any writer: Stop, verify.mjs, land, pre-commit) is tried before preCommitCredit's last-run.json.
|
|
1972
1986
|
const treeBefore = skipVerification ? null : computeTree(vdir);
|
|
1973
|
-
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));
|
|
1974
1988
|
// card gate-docs-skip-unless-named: a targeted run is never a full run (no record, no ratchet, no state.verified).
|
|
1975
1989
|
const targeted = !credited && targetedFiles ? targetedFiles : null;
|
|
1976
1990
|
if (!skipVerification && credited) {
|
|
@@ -2008,7 +2022,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2008
2022
|
const output = r.stderr + "\n" + r.stdout;
|
|
2009
2023
|
// card gate-output-names-failures: the block reason leads with the failing test names (R1/R2);
|
|
2010
2024
|
// commandFailures below keeps the untruncated output for the tests_red_by_design coverage check.
|
|
2011
|
-
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)}`);
|
|
2012
2026
|
commandFailures.push({ command, output });
|
|
2013
2027
|
failedRuns.push({ status: r.status, timedOut: r.timedOut });
|
|
2014
2028
|
}
|
|
@@ -2035,6 +2049,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2035
2049
|
node: process.version,
|
|
2036
2050
|
at: new Date().toISOString(),
|
|
2037
2051
|
by: subagent ? "subagent-stop" : "stop",
|
|
2052
|
+
ranIn: realpathSync(vdir),
|
|
2038
2053
|
commands: results.map((r) => ({ name: r.name, command: r.command, exit: 0, durationMs: typeof r.durationMs === "number" ? r.durationMs : 0 })),
|
|
2039
2054
|
totals,
|
|
2040
2055
|
});
|
|
@@ -2150,7 +2165,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2150
2165
|
// (none of them a dropped command) closes that gap.
|
|
2151
2166
|
postCommandProblems - preVerificationProblems === commandFailures.length
|
|
2152
2167
|
) {
|
|
2153
|
-
const coverage = redByDesignCoverage(claimFiles, commandFailures, changed, dir);
|
|
2168
|
+
const coverage = redByDesignCoverage(claimFiles, commandFailures, changed, dir, cfg.secrets);
|
|
2154
2169
|
if (coverage.covered) {
|
|
2155
2170
|
// Same as a subagent's green gate: counter resets, no checkpoint, no turn.
|
|
2156
2171
|
state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
|
|
@@ -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",
|