@dougharness/cli 1.5.0 → 1.6.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/generate/agents.js +4 -0
- package/dist/generate/agents.js.map +1 -1
- package/dist/generate/claude-md.js +1 -1
- package/dist/generate/claude-md.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/coder.md +2 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +2 -0
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +2 -0
- package/node_modules/@dougharness/flow/agents/tester.md +2 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +16 -6
- package/node_modules/@dougharness/flow/lib/code-index.mjs +2 -2
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -2
- package/node_modules/@dougharness/flow/lib/learn.mjs +11 -4
- package/node_modules/@dougharness/flow/lib/memory.mjs +46 -21
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +16 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +5 -1
- package/node_modules/@dougharness/flow/scripts/learn.mjs +3 -1
- package/node_modules/@dougharness/flow/scripts/memory.mjs +218 -9
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +3 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +2 -0
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -3
- package/node_modules/@dougharness/flow/workflows/card-queen.js +16 -6
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +1 -0
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +175 -4
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +1 -1
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +69 -99
- package/node_modules/@dougharness/gates/scripts/verify.mjs +27 -9
- package/package.json +3 -3
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// The gate.lock helpers shared by the Stop gate (scripts/stop-gate.mjs) and verify.mjs (card
|
|
2
2
|
// verify-takes-gate-lock). Moved verbatim from stop-gate.mjs; the Stop gate's behaviour is unchanged.
|
|
3
3
|
|
|
4
|
-
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { loadConfig } from "./config.mjs";
|
|
7
9
|
|
|
8
10
|
// Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
|
|
9
11
|
// at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
|
|
@@ -72,10 +74,10 @@ export function pidIsLive(pid) {
|
|
|
72
74
|
|
|
73
75
|
// Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
|
|
74
76
|
// caller decides what a failure means.
|
|
75
|
-
export function writeGateLock(dir, sessionId) {
|
|
77
|
+
export function writeGateLock(dir, sessionId, pid = process.pid) {
|
|
76
78
|
const p = gateLockPath(dir);
|
|
77
79
|
mkdirSync(dirname(p), { recursive: true });
|
|
78
|
-
writeFileSync(p, JSON.stringify({ pid
|
|
80
|
+
writeFileSync(p, JSON.stringify({ pid, session: sessionId, at: Date.now() }), { flag: "wx" });
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
// Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
|
|
@@ -92,6 +94,48 @@ export function takeOverGateLock(dir, sessionId) {
|
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
// Card gate-load-timeouts: exclusive acquisition for a waiter that saw the lock released, dead or stale. The
|
|
98
|
+
// old path overwrote it, so two waiters leaving the wait loop together both ran their suites (ledger blocks 425
|
|
99
|
+
// and 426). Creates with wx; a stale lock is removed only while the content judged stale is still in place, and
|
|
100
|
+
// EEXIST reports "busy" so the caller keeps waiting. Returns "acquired", "busy" or "unwritable" (the lock cannot
|
|
101
|
+
// be written at all: run unlocked). Never throws.
|
|
102
|
+
export function acquireGateLock(dir, sessionId, hookTimeoutMs, pid = process.pid) {
|
|
103
|
+
try {
|
|
104
|
+
const p = gateLockPath(dir);
|
|
105
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
106
|
+
try {
|
|
107
|
+
writeGateLock(dir, sessionId, pid);
|
|
108
|
+
return "acquired";
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (!err || err.code !== "EEXIST") return "unwritable";
|
|
111
|
+
}
|
|
112
|
+
let raw;
|
|
113
|
+
try {
|
|
114
|
+
raw = readFileSync(p, "utf8");
|
|
115
|
+
} catch {
|
|
116
|
+
continue; // released between the create and the read: try the create again
|
|
117
|
+
}
|
|
118
|
+
let lock = null;
|
|
119
|
+
try {
|
|
120
|
+
const o = JSON.parse(raw);
|
|
121
|
+
lock = o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
|
|
122
|
+
} catch {
|
|
123
|
+
lock = null;
|
|
124
|
+
}
|
|
125
|
+
if (lock && pidIsLive(lock.pid) && !lockStaleByAge(lock, hookTimeoutMs)) return "busy";
|
|
126
|
+
try {
|
|
127
|
+
if (readFileSync(p, "utf8") !== raw) return "busy"; // someone else replaced it meanwhile
|
|
128
|
+
unlinkSync(p);
|
|
129
|
+
} catch {
|
|
130
|
+
// already gone, or not removable: the next create decides
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return "busy";
|
|
134
|
+
} catch {
|
|
135
|
+
return "unwritable";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
95
139
|
// Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
|
|
96
140
|
// Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
|
|
97
141
|
// missing, non-number, or non-positive value reads as the platform's own default, 600.
|
|
@@ -124,3 +168,130 @@ export function normalizedTimeoutMs(cfg) {
|
|
|
124
168
|
export function lockWaitBoundMs(perCommandMs, commandCount) {
|
|
125
169
|
return perCommandMs * Math.max(1, commandCount);
|
|
126
170
|
}
|
|
171
|
+
|
|
172
|
+
// Card gate-load-timeouts: CLI for .githooks/pre-commit, so a commit's suites queue on the same gate.lock as the
|
|
173
|
+
// Stop gate and verify.mjs (ledger blocks 422, 432, 435 had a lock-free pre-commit suite next to a gate's).
|
|
174
|
+
// `node gate-lock.mjs run -- <command> [args...]`, run from the repo top level: waits on a live lock up to
|
|
175
|
+
// stopGate.timeoutMs (default 300000), then runs anyway; a stale lock is taken at once; a lock that cannot be
|
|
176
|
+
// written runs unlocked. Leading NAME=VALUE arguments set the command's env; one remaining argument runs via `sh -c`.
|
|
177
|
+
// Holds the lock while the command runs, releases it, exits with the command's status.
|
|
178
|
+
function isMain() {
|
|
179
|
+
try {
|
|
180
|
+
return !!process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (isMain()) {
|
|
186
|
+
const args = process.argv.slice(2);
|
|
187
|
+
const dir = process.cwd();
|
|
188
|
+
const sub = args[0];
|
|
189
|
+
if (sub === "acquire" || sub === "release") {
|
|
190
|
+
// Card gate-lock-takeover-exclusive: one lock for a whole shell hook, held under the hook shell's own pid
|
|
191
|
+
// (`--pid $$`, alive for the whole hook). `acquire` waits like `run` and exits 0 whether it owns the lock,
|
|
192
|
+
// gave up waiting, or the lock is unwritable (the hook then runs unlocked); a non-zero exit is a runner
|
|
193
|
+
// failure. `release` removes the lock only when it names that pid, and never fails.
|
|
194
|
+
const pidArg = args.indexOf("--pid") === -1 ? NaN : Number(args[args.indexOf("--pid") + 1]);
|
|
195
|
+
if (!Number.isInteger(pidArg) || pidArg <= 0) {
|
|
196
|
+
process.stderr.write(`usage: gate-lock.mjs ${sub} --pid <pid>\n`);
|
|
197
|
+
process.exit(2);
|
|
198
|
+
}
|
|
199
|
+
if (sub === "release") {
|
|
200
|
+
try {
|
|
201
|
+
const lock = readGateLock(dir);
|
|
202
|
+
if (lock && lock.pid === pidArg) unlinkSync(gateLockPath(dir));
|
|
203
|
+
} catch {
|
|
204
|
+
// never fail a release
|
|
205
|
+
}
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
let waitMs = 300000;
|
|
209
|
+
let hookTimeoutMs = 600000;
|
|
210
|
+
try {
|
|
211
|
+
const cfg = loadConfig(dir);
|
|
212
|
+
waitMs = normalizedTimeoutMs(cfg) ?? waitMs;
|
|
213
|
+
hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
|
|
214
|
+
} catch {
|
|
215
|
+
// defaults
|
|
216
|
+
}
|
|
217
|
+
const deadline = Date.now() + waitMs;
|
|
218
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
219
|
+
while (true) {
|
|
220
|
+
const r = acquireGateLock(dir, "pre-commit", hookTimeoutMs, pidArg);
|
|
221
|
+
if (r !== "busy") break;
|
|
222
|
+
if (Date.now() >= deadline) {
|
|
223
|
+
process.stderr.write(`[doug] pre-commit: gate.lock still held after ${waitMs} ms; running anyway.\n`);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
Atomics.wait(nap, 0, 0, 200);
|
|
227
|
+
}
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
const sep = args.indexOf("--");
|
|
231
|
+
const cmd = sep === -1 ? [] : args.slice(sep + 1);
|
|
232
|
+
if (args[0] !== "run" || cmd.length === 0) {
|
|
233
|
+
process.stderr.write("usage: gate-lock.mjs run -- <command> [args...]\n");
|
|
234
|
+
process.exit(2);
|
|
235
|
+
}
|
|
236
|
+
let owned = false;
|
|
237
|
+
const release = () => {
|
|
238
|
+
try {
|
|
239
|
+
if (!owned) return;
|
|
240
|
+
owned = false;
|
|
241
|
+
const lock = readGateLock(dir);
|
|
242
|
+
if (lock && lock.pid === process.pid) unlinkSync(gateLockPath(dir));
|
|
243
|
+
} catch {
|
|
244
|
+
// never throw from a release
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
process.on("exit", release);
|
|
248
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(128));
|
|
249
|
+
try {
|
|
250
|
+
let waitMs = 300000;
|
|
251
|
+
let hookTimeoutMs = 600000;
|
|
252
|
+
try {
|
|
253
|
+
const cfg = loadConfig(dir);
|
|
254
|
+
waitMs = normalizedTimeoutMs(cfg) ?? waitMs;
|
|
255
|
+
hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
|
|
256
|
+
} catch {
|
|
257
|
+
// defaults
|
|
258
|
+
}
|
|
259
|
+
const deadline = Date.now() + waitMs;
|
|
260
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
261
|
+
while (true) {
|
|
262
|
+
const r = acquireGateLock(dir, "pre-commit", hookTimeoutMs);
|
|
263
|
+
if (r === "acquired") {
|
|
264
|
+
owned = true;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
if (r === "unwritable") break;
|
|
268
|
+
if (Date.now() >= deadline) {
|
|
269
|
+
process.stderr.write(`[doug] pre-commit: gate.lock still held after ${waitMs} ms; running ${cmd[0]} anyway.\n`);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
Atomics.wait(nap, 0, 0, 200);
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// run unlocked rather than block a commit on a lock fault
|
|
276
|
+
}
|
|
277
|
+
// Card gate-lock-run-shell-forms: leading NAME=VALUE arguments go into the child's environment, a single
|
|
278
|
+
// remaining argument runs through `sh -c` (a quoted compound command), and several arguments spawn as argv
|
|
279
|
+
// unchanged. A command that cannot be spawned says so on stderr and exits 127 instead of a silent 1.
|
|
280
|
+
const env = { ...process.env };
|
|
281
|
+
let argv = cmd;
|
|
282
|
+
while (argv.length > 1 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0])) {
|
|
283
|
+
const eq = argv[0].indexOf("=");
|
|
284
|
+
env[argv[0].slice(0, eq)] = argv[0].slice(eq + 1);
|
|
285
|
+
argv = argv.slice(1);
|
|
286
|
+
}
|
|
287
|
+
const res =
|
|
288
|
+
argv.length === 1
|
|
289
|
+
? spawnSync("sh", ["-c", argv[0]], { stdio: "inherit", env })
|
|
290
|
+
: spawnSync(argv[0], argv.slice(1), { stdio: "inherit", env });
|
|
291
|
+
release();
|
|
292
|
+
if (res.error) {
|
|
293
|
+
process.stderr.write(`[doug] gate-lock run: cannot run ${argv[0]}: ${res.error.message}\n`);
|
|
294
|
+
process.exit(127);
|
|
295
|
+
}
|
|
296
|
+
process.exit(res.status === null ? 1 : res.status);
|
|
297
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// - Counts cannot see delete-one-add-one: a session that removes a test and adds an unrelated one keeps
|
|
6
6
|
// every number level and passes.
|
|
7
7
|
// - The verified-unchanged skip runs no test command, so there is no output to count there; no count check
|
|
8
|
-
// happens on it. (The
|
|
8
|
+
// happens on it. (The credited path is counted from the totals the verified-tree record carries.)
|
|
9
9
|
// - The baseline is the number of `Tests` summaries a command prints plus their sum (ADR 0013): a session
|
|
10
10
|
// that adds or removes a package changes the number of summaries and blocks with no waiver until a new
|
|
11
11
|
// baseline is taken.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/gates",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|
|
@@ -91,9 +91,9 @@ 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, outputTail, redactText } from "../lib/block-ledger.mjs";
|
|
94
|
+
import { failingTestFilesFrom, tscErrorPaths, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine, outputTail, redactText } from "../lib/block-ledger.mjs";
|
|
95
95
|
import { docsGateDecision } from "../lib/docs-gate.mjs";
|
|
96
|
-
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
96
|
+
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, acquireGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
97
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
|
|
@@ -560,7 +560,10 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
|
|
|
560
560
|
// .claude/worktrees names the main checkout, except <dir>/.doug/.state and anything under it (untracked state,
|
|
561
561
|
// never project code) which names nothing; any other path names nothing. The set is the cwd's worktree plus
|
|
562
562
|
// every tree its trace paths name (a worktree that no longer exists as a directory is skipped). Empty, a main
|
|
563
|
-
// Stop, or any failure resolves to [dir].
|
|
563
|
+
// Stop, or any failure resolves to [dir]. Card queen-seat-stop-gates-worktree: a Bash or Monitor line of the
|
|
564
|
+
// agent (Pre or Post, whatever ok) also names a worktree by any absolute path token in its detail (a seat
|
|
565
|
+
// that edits only through `cd <wt> && ...`); a shell token never names the main checkout.
|
|
566
|
+
// Only the verification target varies per tree: state, trace,
|
|
564
567
|
// gate.lock, the ledger, and evidence stay under `dir`. Never throws.
|
|
565
568
|
function verifyDirs(dir, cfg, sessionId, input, subagent) {
|
|
566
569
|
try {
|
|
@@ -598,7 +601,22 @@ function verifyDirs(dir, cfg, sessionId, input, subagent) {
|
|
|
598
601
|
const file = tracePath(dir, sessionId);
|
|
599
602
|
if (existsSync(file)) {
|
|
600
603
|
for (const l of readTrace(file)) {
|
|
601
|
-
if (l.agent !== subagent.id
|
|
604
|
+
if (l.agent !== subagent.id) continue;
|
|
605
|
+
if (SHELL_TOOLS.has(l.tool) && (l.event === "PreToolUse" || l.event === "PostToolUse")) {
|
|
606
|
+
// card queen-seat-stop-gates-worktree: a shell command names a worktree by an absolute path token;
|
|
607
|
+
// a main-checkout path in it names nothing.
|
|
608
|
+
for (const tok of String(l.detail || "").split(/[\s;&|()<>'"`=]+/)) {
|
|
609
|
+
if (!tok.startsWith("/")) continue;
|
|
610
|
+
// the trace clips a detail at 120 chars with a trailing ellipsis; a clipped token names a worktree
|
|
611
|
+
// only when the name is followed by "/" inside it (else the cut name may be a sibling's prefix)
|
|
612
|
+
const clipped = tok.endsWith("…");
|
|
613
|
+
const t = clipped ? tok.slice(0, -1) : tok;
|
|
614
|
+
const n = wtName(t);
|
|
615
|
+
if (n && (!clipped || t.includes("/" + n + "/"))) addWt(n);
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (l.event !== "PostToolUse" || !CHANGE_TOOLS.has(l.tool) || l.ok === false) continue;
|
|
602
620
|
const name = wtName(l.detail);
|
|
603
621
|
if (name) addWt(name);
|
|
604
622
|
else if (inMain(l.detail)) add(dir);
|
|
@@ -865,14 +883,14 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
|
865
883
|
// logging must never fail the gate
|
|
866
884
|
}
|
|
867
885
|
};
|
|
868
|
-
// Each entry is `{ command, stdout }` (a real run: parsed here) or `{ command, result }` (a credited
|
|
886
|
+
// Each entry is `{ command, stdout }` (a real run: parsed here) or `{ command, result }` (a credited verified-tree
|
|
869
887
|
// run: the parseTotals-shaped result the hook recorded). Both take the identical path below.
|
|
870
888
|
for (const { command, stdout, result } of ran) {
|
|
871
889
|
try {
|
|
872
890
|
const p = result !== undefined ? result : parseTotals(stdout);
|
|
873
891
|
const summaries = p.status === "ok" ? readSummaries(p) : null;
|
|
874
892
|
if (!summaries) {
|
|
875
|
-
note(`count check did not run for \`${command}\`: no test summary in its output`);
|
|
893
|
+
note(`count check did not run for \`${redactText(command, cfg.secrets)}\`: no test summary in its output`);
|
|
876
894
|
continue;
|
|
877
895
|
}
|
|
878
896
|
const baseline = baselineFor(command);
|
|
@@ -880,11 +898,11 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
|
880
898
|
const baseList = all ? readSummaries(all[command]) : null;
|
|
881
899
|
if (!baseList) {
|
|
882
900
|
parsed[command] = summaries;
|
|
883
|
-
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("; ")})`);
|
|
901
|
+
note(`count check did not run for \`${redactText(command, cfg.secrets)}\`: 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("; ")})`);
|
|
884
902
|
continue;
|
|
885
903
|
}
|
|
886
904
|
if (baseList.length !== summaries.length) {
|
|
887
|
-
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.`);
|
|
905
|
+
problems.push(`\`${redactText(command, cfg.secrets)}\` 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.`);
|
|
888
906
|
continue;
|
|
889
907
|
}
|
|
890
908
|
parsed[command] = summaries;
|
|
@@ -896,10 +914,10 @@ function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
|
896
914
|
if (waiverCovers(waiverSources(dir, state.baselineHead), delta)) continue;
|
|
897
915
|
const lines = found.map((f) => `${f.kind} ${f.kind === "total" ? "dropped" : "rose"} from ${f.baseline} (baseline) to ${f.now} (now)`);
|
|
898
916
|
problems.push(
|
|
899
|
-
`\`${command}\` test count regressed: ${lines.join("; ")}. Deleting or skipping tests does not make a change green. Restore them, or waive it with a line \`test-count-waiver: total=${delta.total} skipped=${delta.skipped} todo=${delta.todo}; <reason>\` in the commit message or ${WAIVER_FILE_RELPATH}.`,
|
|
917
|
+
`\`${redactText(command, cfg.secrets)}\` test count regressed: ${lines.join("; ")}. Deleting or skipping tests does not make a change green. Restore them, or waive it with a line \`test-count-waiver: total=${delta.total} skipped=${delta.skipped} todo=${delta.todo}; <reason>\` in the commit message or ${WAIVER_FILE_RELPATH}.`,
|
|
900
918
|
);
|
|
901
919
|
} catch {
|
|
902
|
-
note(`count check did not run for \`${command}\`: error while checking`);
|
|
920
|
+
note(`count check did not run for \`${redactText(command, cfg.secrets)}\`: error while checking`);
|
|
903
921
|
}
|
|
904
922
|
}
|
|
905
923
|
return { problems, parsed };
|
|
@@ -923,19 +941,6 @@ function writeLastGreen(dir, parsed) {
|
|
|
923
941
|
}
|
|
924
942
|
}
|
|
925
943
|
|
|
926
|
-
// card stop-gate-credits-pre-commit: `git rev-parse HEAD^{tree}`, the tree sha of the current commit — what
|
|
927
|
-
// the pre-commit hook's own record (.doug/.state/pre-commit/last-run.json) compares its `tree` field against.
|
|
928
|
-
// Null on any failure (no git, no HEAD yet, ...): a reader must never throw.
|
|
929
|
-
function headTreeSha(dir) {
|
|
930
|
-
try {
|
|
931
|
-
const out = execFileSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
|
|
932
|
-
const sha = out.trim();
|
|
933
|
-
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
934
|
-
} catch {
|
|
935
|
-
return null;
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
944
|
// card ratchet-pre-commit-credit: a recorded parseTotals result, normalised, or null when it is not readable.
|
|
940
945
|
function readableRecordedTotals(t) {
|
|
941
946
|
try {
|
|
@@ -948,10 +953,10 @@ function readableRecordedTotals(t) {
|
|
|
948
953
|
}
|
|
949
954
|
|
|
950
955
|
// card verified-tree-record: a verified-tree record (lib/verified-tree.mjs) of `tree` (the working tree now), same
|
|
951
|
-
// Node and command set, credits the commands instead of re-running them.
|
|
956
|
+
// Node and command set, credits the commands instead of re-running them. Returns
|
|
952
957
|
// one `{ name, command, ok: true, credited: "verified-tree", recorded? }` per resolveCommands entry in config
|
|
953
958
|
// order. A command with a baseline in state.testTotals and no readable recorded totals returns null (the commands
|
|
954
|
-
// run)
|
|
959
|
+
// run). Never throws: any failure is null, and null runs the commands.
|
|
955
960
|
function treeCredit(dir, cfg, state, tree, mainDir) {
|
|
956
961
|
try {
|
|
957
962
|
if (!tree) return null;
|
|
@@ -977,59 +982,6 @@ function treeCredit(dir, cfg, state, tree, mainDir) {
|
|
|
977
982
|
}
|
|
978
983
|
}
|
|
979
984
|
|
|
980
|
-
// card stop-gate-credits-pre-commit: the pre-commit hook (.githooks/pre-commit) writes a machine-readable
|
|
981
|
-
// record of its own run to .doug/.state/pre-commit/last-run.json (version, tree, ok, at, commands: [{ name,
|
|
982
|
-
// 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`
|
|
983
|
-
// once stopGate.ignoreChangedPaths paths are removed) and that record is a passing run, against HEAD's own
|
|
984
|
-
// tree, of exactly today's configured commands in order, the Stop gate credits it instead of re-running the
|
|
985
|
-
// same commands a second time — returning `{ name, command, ok: true, credited: "pre-commit" }` per command,
|
|
986
|
-
// same length and order as resolveCommands(cfg). Returns null (run as today) on a missing, unparsable,
|
|
987
|
-
// wrong-shape, failing, stale-tree, or mismatched-commands record, or a dirty tree — fail closed to running,
|
|
988
|
-
// never to passing. A reader must never throw: every path here is wrapped, so a corrupt record never crashes
|
|
989
|
-
// the hook (`runHook` fails open on a throw, which here would mean passing without running — forbidden).
|
|
990
|
-
function preCommitCredit(dir, cfg, changed, state, mainDir) {
|
|
991
|
-
try {
|
|
992
|
-
if (!Array.isArray(changed)) return null;
|
|
993
|
-
const ignore = cfg.stopGate.ignoreChangedPaths || [];
|
|
994
|
-
const remaining = changed.filter((f) => !matchAny(ignore, f));
|
|
995
|
-
if (remaining.length > 0) return null;
|
|
996
|
-
const raw = readFileSync(join(dir, ".doug/.state/pre-commit/last-run.json"), "utf8");
|
|
997
|
-
const record = JSON.parse(raw);
|
|
998
|
-
if (!record || typeof record !== "object") return null;
|
|
999
|
-
if (record.version !== 1) return null;
|
|
1000
|
-
if (record.ok !== true) return null;
|
|
1001
|
-
if (typeof record.tree !== "string") return null;
|
|
1002
|
-
if (!Array.isArray(record.commands)) return null;
|
|
1003
|
-
const head = headTreeSha(dir);
|
|
1004
|
-
if (!head || record.tree !== head) return null;
|
|
1005
|
-
for (const c of record.commands) {
|
|
1006
|
-
if (!c || typeof c !== "object" || c.exit !== 0) return null;
|
|
1007
|
-
}
|
|
1008
|
-
const configured = resolveCommands(cfg);
|
|
1009
|
-
if (record.commands.length !== configured.length) return null;
|
|
1010
|
-
for (let i = 0; i < configured.length; i++) {
|
|
1011
|
-
if (!record.commands[i] || record.commands[i].command !== configured[i].command) return null;
|
|
1012
|
-
}
|
|
1013
|
-
// card ratchet-pre-commit-credit: a command with a baseline in state.testTotals is credited only when the
|
|
1014
|
-
// record carries readable totals for it (a list of valid summaries, or an old ok or conflict shape);
|
|
1015
|
-
// otherwise null and the commands really run. `recorded` is the parseTotals-shaped result for the count check.
|
|
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);
|
|
1018
|
-
const out = [];
|
|
1019
|
-
for (let i = 0; i < configured.length; i++) {
|
|
1020
|
-
const { name, command } = configured[i];
|
|
1021
|
-
const recorded = readableRecordedTotals(record.commands[i].totals);
|
|
1022
|
-
const base = baselineFor(command);
|
|
1023
|
-
const hasBaseline = base ? readSummaries(base[command]) !== null : false;
|
|
1024
|
-
if (hasBaseline && (!recorded || recorded.status === "none")) return null;
|
|
1025
|
-
out.push({ name, command, ok: true, credited: "pre-commit", ...(recorded ? { recorded } : {}) });
|
|
1026
|
-
}
|
|
1027
|
-
return out;
|
|
1028
|
-
} catch {
|
|
1029
|
-
return null;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
985
|
// card stop-gate-process-storm, case 2 (second tester pass, reviewer-found gap): a verified-unchanged skip
|
|
1034
986
|
// is a green gate in every way that matters, not a bare allow — it resets the same counters an ordinary
|
|
1035
987
|
// green gate resets, and on the main Stop it still surfaces the context-window notice, exactly like the
|
|
@@ -1735,7 +1687,6 @@ runHook("stop-gate", async (input) => {
|
|
|
1735
1687
|
currentFingerprint &&
|
|
1736
1688
|
(sameTree(rec.fingerprint, currentFingerprint) || (rec.scope === scopeNow && (redViaNoTestPaths = onlyNoTestPathsChanged(vdir, cfg, rec.fingerprint, vchanged)))) &&
|
|
1737
1689
|
!(subagent && (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path)))) &&
|
|
1738
|
-
!preCommitCredit(vdir, cfg, vchanged, state, dir) &&
|
|
1739
1690
|
!treeCredit(vdir, cfg, state, computeTree(vdir), dir)
|
|
1740
1691
|
) {
|
|
1741
1692
|
lastRed = rec;
|
|
@@ -1779,6 +1730,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1779
1730
|
failures: Array.isArray(lastRed.failures) ? lastRed.failures : [],
|
|
1780
1731
|
cwd: typeof lastRed.cwd === "string" ? lastRed.cwd : null,
|
|
1781
1732
|
tree: typeof lastRed.tree === "string" ? lastRed.tree : null,
|
|
1733
|
+
files: Array.isArray(lastRed.files) ? lastRed.files : [],
|
|
1782
1734
|
};
|
|
1783
1735
|
logBlock(reason, reuseClass, reuseRecord);
|
|
1784
1736
|
traceMainBlock(reuseRecord);
|
|
@@ -1818,13 +1770,17 @@ runHook("stop-gate", async (input) => {
|
|
|
1818
1770
|
lockOwned = false;
|
|
1819
1771
|
} else {
|
|
1820
1772
|
const lock = readGateLock(dir);
|
|
1821
|
-
|
|
1773
|
+
// Card gate-lock-takeover-exclusive: a stale-on-arrival lock is acquired exclusively; "busy" (a rival just
|
|
1774
|
+
// took it) enters the wait branch below like a live holder, "unwritable" runs unlocked.
|
|
1775
|
+
let arrivalWait = !(!lock || !pidIsLive(lock.pid) || lockStaleByAge(lock, hookTimeoutMs));
|
|
1776
|
+
if (!arrivalWait) {
|
|
1822
1777
|
// Stale (a dead pid, a lock file that fails to parse, or one older than the hook-timeout budget —
|
|
1823
|
-
// card stop-gate-budget-under-hook-timeout: no legitimate holder can still be running that long)
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
}
|
|
1778
|
+
// card stop-gate-budget-under-hook-timeout: no legitimate holder can still be running that long).
|
|
1779
|
+
const got = acquireGateLock(dir, input.session_id, hookTimeoutMs);
|
|
1780
|
+
if (got === "acquired") lockOwned = true;
|
|
1781
|
+
else if (got === "busy") arrivalWait = true;
|
|
1782
|
+
}
|
|
1783
|
+
if (arrivalWait) {
|
|
1828
1784
|
// The bound scales with the number of configured commands (reviewer-found gap): a holder legitimately
|
|
1829
1785
|
// runs each of resolveCommands(cfg) with its own timeoutMs budget, one after another, so a flat
|
|
1830
1786
|
// timeoutMs would give up on — and duplicate — a holder still legitimately partway through its own
|
|
@@ -1839,11 +1795,22 @@ runHook("stop-gate", async (input) => {
|
|
|
1839
1795
|
// taking the lock over, since taking over and running past the platform's own timeout would just get
|
|
1840
1796
|
// this gate killed mid-command too (an ungated stop, its block message discarded).
|
|
1841
1797
|
let refusedLock = null;
|
|
1798
|
+
let acquired = false;
|
|
1842
1799
|
while (true) {
|
|
1843
1800
|
const live = readGateLock(dir);
|
|
1844
|
-
if (!live || !pidIsLive(live.pid) || lockStaleByAge(live, hookTimeoutMs))
|
|
1801
|
+
if (!live || !pidIsLive(live.pid) || lockStaleByAge(live, hookTimeoutMs)) {
|
|
1802
|
+
// Released, died, or stale by age. Card gate-load-timeouts: take it exclusively, so two waiters
|
|
1803
|
+
// leaving this loop together do not both run their suites (ledger blocks 425 and 426). A lost race
|
|
1804
|
+
// goes back to waiting, still bounded by the two deadlines below.
|
|
1805
|
+
const got = acquireGateLock(dir, input.session_id, hookTimeoutMs);
|
|
1806
|
+
if (got !== "busy") {
|
|
1807
|
+
acquired = got === "acquired";
|
|
1808
|
+
lockOwned = acquired;
|
|
1809
|
+
break;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1845
1812
|
if (Date.now() >= budgetDeadline) {
|
|
1846
|
-
refusedLock = live;
|
|
1813
|
+
refusedLock = readGateLock(dir) || live || {};
|
|
1847
1814
|
break;
|
|
1848
1815
|
}
|
|
1849
1816
|
if (Date.now() >= waitDeadline) break; // bounded: never hang the gate on someone else's lock
|
|
@@ -1891,8 +1858,10 @@ runHook("stop-gate", async (input) => {
|
|
|
1891
1858
|
return verifiedUnchangedAllow(dir, cfg, state, input, subagent, deferralCapNote);
|
|
1892
1859
|
}
|
|
1893
1860
|
// Still different, or the wait hit its bound with the lock still held: take it over and run.
|
|
1894
|
-
|
|
1895
|
-
|
|
1861
|
+
if (!acquired) {
|
|
1862
|
+
takeOverGateLock(dir, input.session_id);
|
|
1863
|
+
lockOwned = true;
|
|
1864
|
+
}
|
|
1896
1865
|
}
|
|
1897
1866
|
}
|
|
1898
1867
|
}
|
|
@@ -1971,20 +1940,21 @@ runHook("stop-gate", async (input) => {
|
|
|
1971
1940
|
const r = failedRuns[i] || {};
|
|
1972
1941
|
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
1942
|
});
|
|
1974
|
-
|
|
1943
|
+
// card memory-failures-wiring: the sorted, unique, repo-relative paths the failed commands' output names.
|
|
1944
|
+
const files = [...new Set(commandFailures.flatMap((cf) => [...failingTestFilesFrom(cf.output, vdir), ...tscErrorPaths(cf.output, vdir)]))].sort();
|
|
1945
|
+
return { failures, cwd: vdir, tree: treeBefore ?? null, files };
|
|
1975
1946
|
} catch {
|
|
1976
|
-
return { failures: [], cwd: vdir, tree: null }; // the line is written without the failures; the decision is unchanged
|
|
1947
|
+
return { failures: [], cwd: vdir, tree: null, files: [] }; // the line is written without the failures; the decision is unchanged
|
|
1977
1948
|
}
|
|
1978
1949
|
};
|
|
1979
1950
|
const failedRuns = []; // parallel to commandFailures: { status, timedOut } of each failed run (card block-ledger-failing-output)
|
|
1980
|
-
const ranGreen = []; // count check input: { command, stdout } of each command that really ran and exited 0, plus { command, result } of each credited
|
|
1981
|
-
// card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
|
|
1982
|
-
// stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
|
|
1983
|
-
// red-by-design, state.verified, the block ledger) runs exactly as it would after a real run.
|
|
1951
|
+
const ranGreen = []; // count check input: { command, stdout } of each command that really ran and exited 0, plus { command, result } of each credited verified-tree command whose recorded totals are readable
|
|
1984
1952
|
// card verified-tree-record: treeBefore is the working tree immediately before the first command; a record of
|
|
1985
|
-
// it (any writer: Stop, verify.mjs, land, pre-commit)
|
|
1953
|
+
// it (any writer: Stop, verify.mjs, land, pre-commit) for this exact tree and command set stands in for
|
|
1954
|
+
// actually running them. Every other check below (evidence, red-by-design, state.verified, the block ledger)
|
|
1955
|
+
// runs exactly as it would after a real run.
|
|
1986
1956
|
const treeBefore = skipVerification ? null : computeTree(vdir);
|
|
1987
|
-
const credited = skipVerification ? null :
|
|
1957
|
+
const credited = skipVerification ? null : treeCredit(vdir, cfg, state, treeBefore, dir);
|
|
1988
1958
|
// card gate-docs-skip-unless-named: a targeted run is never a full run (no record, no ratchet, no state.verified).
|
|
1989
1959
|
const targeted = !credited && targetedFiles ? targetedFiles : null;
|
|
1990
1960
|
if (!skipVerification && credited) {
|
|
@@ -2004,7 +1974,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2004
1974
|
if (remaining <= 0) {
|
|
2005
1975
|
results.push({ name, command, ok: false, skipped: "budget" });
|
|
2006
1976
|
problems.push(
|
|
2007
|
-
`\`${command}\` not run: the stop gate's budget (hook timeout ${hookTimeoutSec} s) was used up before it could start.`,
|
|
1977
|
+
`\`${redactText(command, cfg.secrets)}\` not run: the stop gate's budget (hook timeout ${hookTimeoutSec} s) was used up before it could start.`,
|
|
2008
1978
|
);
|
|
2009
1979
|
continue;
|
|
2010
1980
|
}
|
|
@@ -2073,7 +2043,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2073
2043
|
let evidence = null;
|
|
2074
2044
|
if (!skipVerification && cfg.stopGate.requireEvidence !== false && sessionMadeChanges) {
|
|
2075
2045
|
evidence = verificationEvidence(state.commands || [], cfg);
|
|
2076
|
-
if (evidence.ran.length === 0) problems.push(describeMissingEvidence(evidence.looked, results.every((r) => r.ok)));
|
|
2046
|
+
if (evidence.ran.length === 0) problems.push(describeMissingEvidence(evidence.looked.map((c) => redactText(c, cfg.secrets)), results.every((r) => r.ok)));
|
|
2077
2047
|
}
|
|
2078
2048
|
const evidenceProblemAdded = problems.length > postCommandProblems;
|
|
2079
2049
|
|
|
@@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process";
|
|
|
10
10
|
import { unlinkSync, realpathSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { loadConfig } from "../lib/config.mjs";
|
|
13
|
-
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
13
|
+
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, acquireGateLock, 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
16
|
import { computeTree, readRecord, writeRecord, removeRecord, creditable, exitCreditUsable, resolveCommands } from "../lib/verified-tree.mjs";
|
|
@@ -155,17 +155,33 @@ function main() {
|
|
|
155
155
|
const hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
|
|
156
156
|
const stale = (l) => !l || !pidIsLive(l.pid) || lockStaleByAge(l, hookTimeoutMs);
|
|
157
157
|
const held = readGateLock(dir);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
// Card gate-lock-takeover-exclusive: a lock read as stale on arrival is acquired exclusively; "busy" (a rival
|
|
159
|
+
// just took it) is waited on like a live holder, never overwritten; "unwritable" runs unlocked.
|
|
160
|
+
let needWait = !stale(held);
|
|
161
|
+
if (!needWait) {
|
|
162
|
+
const got = acquireGateLock(dir, o.by || "verify", hookTimeoutMs);
|
|
163
|
+
if (got === "acquired") lockOwned = true;
|
|
164
|
+
else if (got === "busy") needWait = true;
|
|
165
|
+
}
|
|
166
|
+
if (needWait) {
|
|
162
167
|
const waitStart = Date.now();
|
|
163
168
|
const deadline = waitStart + waitBoundMs;
|
|
164
169
|
let live = held;
|
|
170
|
+
let acquired = false;
|
|
165
171
|
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
166
172
|
while (true) {
|
|
167
173
|
live = readGateLock(dir);
|
|
168
|
-
if (stale(live)
|
|
174
|
+
if (stale(live)) {
|
|
175
|
+
// Card gate-load-timeouts: take a released or stale lock exclusively (two waiters must not both run);
|
|
176
|
+
// a lost race goes back to waiting, bounded by the deadline.
|
|
177
|
+
const got = acquireGateLock(dir, o.by || "verify", hookTimeoutMs);
|
|
178
|
+
if (got !== "busy") {
|
|
179
|
+
acquired = got === "acquired";
|
|
180
|
+
lockOwned = acquired;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() >= deadline) break;
|
|
169
185
|
Atomics.wait(nap, 0, 0, 200);
|
|
170
186
|
}
|
|
171
187
|
lockWaitMs = Date.now() - waitStart;
|
|
@@ -173,9 +189,11 @@ function main() {
|
|
|
173
189
|
treeBefore = computeTree(cwd);
|
|
174
190
|
const again = tryCredit();
|
|
175
191
|
if (again !== null) return again;
|
|
176
|
-
if (!
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
if (!acquired) {
|
|
193
|
+
if (!stale(live)) process.stderr.write(`[doug] verify: gate.lock still held by pid ${live.pid} after ${waitBoundMs} ms; running the commands anyway.\n`);
|
|
194
|
+
takeOverGateLock(dir, o.by || "verify");
|
|
195
|
+
lockOwned = true;
|
|
196
|
+
}
|
|
179
197
|
}
|
|
180
198
|
}
|
|
181
199
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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.6.0",
|
|
27
|
+
"@dougharness/gates": "1.6.0"
|
|
28
28
|
},
|
|
29
29
|
"bundleDependencies": [
|
|
30
30
|
"@dougharness/flow",
|