@dougharness/cli 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +10 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +5 -4
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +33 -8
- package/dist/board.js.map +1 -1
- package/dist/memory-setup.js +5 -2
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/lib/board.mjs +17 -1
- package/node_modules/@dougharness/flow/lib/cost.mjs +29 -6
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +3 -1
- package/node_modules/@dougharness/flow/lib/land.mjs +6 -1
- package/node_modules/@dougharness/flow/lib/memory.mjs +49 -22
- package/node_modules/@dougharness/flow/lib/plan.mjs +2 -1
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +35 -14
- package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/memory.mjs +10 -6
- package/node_modules/@dougharness/flow/scripts/plan.mjs +22 -0
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +4 -4
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +8 -6
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +11 -9
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +26 -14
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +10 -87
- package/node_modules/@dougharness/gates/scripts/verify.mjs +58 -3
- package/package.json +4 -4
- package/templates/board-app.js +2 -1
|
@@ -93,6 +93,7 @@ import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
|
|
|
93
93
|
import { parseTotals, readSummaries, sumTotals, compareTotals, deltaTotals, waiverCovers } from "../lib/test-totals.mjs";
|
|
94
94
|
import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine } 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
97
|
import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
|
|
97
98
|
|
|
98
99
|
// Only the last this many characters of last_assistant_message are ever scanned for a partial claim
|
|
@@ -511,7 +512,8 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
|
|
|
511
512
|
// card worktree-agent-gate, card worktree-gate-mixed-tree-agent: the trees a SubagentStop verifies, in
|
|
512
513
|
// first-touched order. A worktree-isolated subagent edits under <dir>/.claude/worktrees/<wt>/ (input.cwd inside
|
|
513
514
|
// it, or its own successful Edit-type trace lines carry absolute paths there); a path inside <dir> but outside
|
|
514
|
-
// .claude/worktrees names the main checkout
|
|
515
|
+
// .claude/worktrees names the main checkout, except <dir>/.doug/.state and anything under it (untracked state,
|
|
516
|
+
// never project code) which names nothing; any other path names nothing. The set is the cwd's worktree plus
|
|
515
517
|
// every tree its trace paths name (a worktree that no longer exists as a directory is skipped). Empty, a main
|
|
516
518
|
// Stop, or any failure resolves to [dir]. Only the verification target varies per tree: state, trace,
|
|
517
519
|
// gate.lock, the ledger, and evidence stay under `dir`. Never throws.
|
|
@@ -527,8 +529,11 @@ function verifyDirs(dir, cfg, sessionId, input, subagent) {
|
|
|
527
529
|
};
|
|
528
530
|
const inMain = (p) => {
|
|
529
531
|
if (typeof p !== "string" || !isAbsolute(p)) return false;
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
+
const abs = resolve(p);
|
|
533
|
+
const rel = relative(dir, abs);
|
|
534
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
|
|
535
|
+
const st = relative(join(dir, ".doug/.state"), abs);
|
|
536
|
+
return !(st === "" || (!st.startsWith("..") && !isAbsolute(st)));
|
|
532
537
|
};
|
|
533
538
|
const trees = [];
|
|
534
539
|
const add = (t) => {
|
|
@@ -967,70 +972,6 @@ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
|
|
|
967
972
|
return notice ? systemMessage(notice) : allow();
|
|
968
973
|
}
|
|
969
974
|
|
|
970
|
-
// Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
|
|
971
|
-
// at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
|
|
972
|
-
// 2026-09-16 process storm). `.doug/.state/gate.lock` holds `{ pid, session, at }` while a gate's commands
|
|
973
|
-
// run.
|
|
974
|
-
const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
|
|
975
|
-
function gateLockPath(dir) {
|
|
976
|
-
return join(dir, GATE_LOCK_RELPATH);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
|
|
980
|
-
// content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
|
|
981
|
-
// caller treats exactly like a stale one.
|
|
982
|
-
function readGateLock(dir) {
|
|
983
|
-
try {
|
|
984
|
-
const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
|
|
985
|
-
// A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
|
|
986
|
-
// group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
|
|
987
|
-
// throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
|
|
988
|
-
return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
|
|
989
|
-
} catch {
|
|
990
|
-
return null;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
function pidIsLive(pid) {
|
|
995
|
-
try {
|
|
996
|
-
process.kill(pid, 0);
|
|
997
|
-
return true;
|
|
998
|
-
} catch (err) {
|
|
999
|
-
// EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
|
|
1000
|
-
// that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
|
|
1001
|
-
// anything else) reads as dead.
|
|
1002
|
-
return err && err.code === "EPERM";
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
|
|
1007
|
-
// caller decides what a failure means.
|
|
1008
|
-
function writeGateLock(dir, sessionId) {
|
|
1009
|
-
mkdirSync(join(dir, ".doug/.state"), { recursive: true });
|
|
1010
|
-
writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
|
|
1014
|
-
// simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
|
|
1015
|
-
// it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
|
|
1016
|
-
// does not hold).
|
|
1017
|
-
function takeOverGateLock(dir, sessionId) {
|
|
1018
|
-
try {
|
|
1019
|
-
mkdirSync(join(dir, ".doug/.state"), { recursive: true });
|
|
1020
|
-
writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
|
|
1021
|
-
} catch {
|
|
1022
|
-
// best-effort takeover only
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
|
|
1027
|
-
// Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
|
|
1028
|
-
// missing, non-number, or non-positive value reads as the platform's own default, 600.
|
|
1029
|
-
function resolveHookTimeoutSec(cfg) {
|
|
1030
|
-
const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
|
|
1031
|
-
return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
975
|
// Computed once per gate run: the wall-clock instant past which the platform itself would cancel this
|
|
1035
976
|
// hook (F1 — a timed-out hook is killed, fails open, and its output, including a block message, is
|
|
1036
977
|
// discarded), less a margin so the gate's own block reaches Claude Code before that happens. gateStart
|
|
@@ -1049,26 +990,8 @@ function gateBudget(cfg, inherited) {
|
|
|
1049
990
|
return { hookTimeoutSec, hookTimeoutMs, deadline };
|
|
1050
991
|
}
|
|
1051
992
|
|
|
1052
|
-
// A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
|
|
1053
|
-
// legitimate holder can still be running that long, since the platform itself would have killed its hook
|
|
1054
|
-
// by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
|
|
1055
|
-
function lockStaleByAge(lock, hookTimeoutMs) {
|
|
1056
|
-
return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// Card stop-gate-timeout-normalise: cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout
|
|
1060
|
-
// and the wait bound below, so the two never disagree on what a garbage or fractional value means. A finite
|
|
1061
|
-
// number above 0 floors to an integer, clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a
|
|
1062
|
-
// timeout of 0 as none at all, letting a command run past the budget unclamped; 2.5 must not reach spawnSync
|
|
1063
|
-
// un-floored — it throws ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else
|
|
1064
|
-
// (non-number, NaN, Infinity, 0, negative, null) is "unset" (null), read by each call site as its own default.
|
|
1065
|
-
function normalizedTimeoutMs(cfg) {
|
|
1066
|
-
const t = cfg.stopGate.timeoutMs;
|
|
1067
|
-
return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
993
|
// Card stop-gate-deferral-cap (R2): cfg.stopGate.maxDeferrals normalised the same shape as
|
|
1071
|
-
// normalizedTimeoutMs
|
|
994
|
+
// normalizedTimeoutMs (lib/gate-lock.mjs) — a hook reader must never throw (F6/F10), so a finite number >= 1 is floored
|
|
1072
995
|
// and used; anything else (missing, 0, negative, NaN, a string, null, an object) reads as the default, 10.
|
|
1073
996
|
function normalizedMaxDeferrals(cfg) {
|
|
1074
997
|
const m = cfg && cfg.stopGate && cfg.stopGate.maxDeferrals;
|
|
@@ -1709,7 +1632,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1709
1632
|
// timeoutMs no longer defaults to a flat 300000ms here — the budget deadline below is the real bound
|
|
1710
1633
|
// either way, so an unset value waits only for what remains of it, unmultiplied.
|
|
1711
1634
|
const normalizedTimeout = normalizedTimeoutMs(cfg);
|
|
1712
|
-
const waitBoundMs = normalizedTimeout !== null ? normalizedTimeout
|
|
1635
|
+
const waitBoundMs = normalizedTimeout !== null ? lockWaitBoundMs(normalizedTimeout, resolveCommands(cfg).length) : budgetDeadline - Date.now();
|
|
1713
1636
|
const waitDeadline = Date.now() + waitBoundMs;
|
|
1714
1637
|
// Card stop-gate-budget-under-hook-timeout: waitDeadline alone can outrun the platform's own hook
|
|
1715
1638
|
// timeout (F1), so a waiter also stops at budgetDeadline — but there it refuses the stop instead of
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
// node .doug/hooks/scripts/verify.mjs [--no-credit] [--json] [--dir <project>] [--cwd <tree dir>] [--timeout-ms <n>]
|
|
5
5
|
// [--record-dir <dir>] [--credit-dir <dir>]... [--by <writer>] [--command <cmd>]...
|
|
6
6
|
// Exit: 0 all green or credited; 1 any command failed or timed out; 2 usage error or no commands.
|
|
7
|
-
// No count ratchet and no scans here (no session baseline); the Stop gate does those. Takes
|
|
7
|
+
// No count ratchet and no scans here (no session baseline); the Stop gate does those. Takes the Stop gate's .doug/.state/gate.lock 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
11
|
import { resolve } from "node:path";
|
|
11
12
|
import { loadConfig } from "../lib/config.mjs";
|
|
13
|
+
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
12
14
|
import { runCommand, tail } from "../lib/run.mjs";
|
|
13
15
|
import { parseTotals, readSummaries, sumTotals } from "../lib/test-totals.mjs";
|
|
14
16
|
import { computeTree, readRecord, writeRecord, removeRecord, creditable, resolveCommands } from "../lib/verified-tree.mjs";
|
|
@@ -75,8 +77,10 @@ function main() {
|
|
|
75
77
|
process.stderr.write("[doug] verify: no commands (stopGate.commands is empty and no --command given)\n");
|
|
76
78
|
return 2;
|
|
77
79
|
}
|
|
78
|
-
|
|
80
|
+
let treeBefore = computeTree(cwd);
|
|
79
81
|
|
|
82
|
+
// Credits a verified-tree record of the same tree when one exists; returns the exit code, or null.
|
|
83
|
+
const tryCredit = () => {
|
|
80
84
|
if (!o.noCredit && treeBefore) {
|
|
81
85
|
const wanted = commands.map((c) => c.command);
|
|
82
86
|
let record = null;
|
|
@@ -102,8 +106,59 @@ function main() {
|
|
|
102
106
|
return 0;
|
|
103
107
|
}
|
|
104
108
|
}
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
111
|
+
const first = tryCredit();
|
|
112
|
+
if (first !== null) return first;
|
|
105
113
|
|
|
106
|
-
const timeoutMs = o.timeoutMs ? o.timeoutMs :
|
|
114
|
+
const timeoutMs = o.timeoutMs ? o.timeoutMs : (cfg.stopGate && normalizedTimeoutMs(cfg)) ?? 600000;
|
|
115
|
+
const waitBoundMs = lockWaitBoundMs(timeoutMs, commands.length);
|
|
116
|
+
|
|
117
|
+
// Card verify-takes-gate-lock: one full suite at a time per checkout, the same gate.lock the Stop gate takes
|
|
118
|
+
// (lib/gate-lock.mjs). A live holder is waited on for up to timeoutMs x command count (the Stop gate's bound), then the record is re-checked (the
|
|
119
|
+
// holder's green run may have just written it); a lock still held after the bound is taken over and the
|
|
120
|
+
// commands run. Released only by the exit handler below, so every return path (green, red, throw) frees it.
|
|
121
|
+
let lockOwned = false;
|
|
122
|
+
process.on("exit", () => {
|
|
123
|
+
try {
|
|
124
|
+
if (!lockOwned) return;
|
|
125
|
+
const lock = readGateLock(dir);
|
|
126
|
+
if (lock && lock.pid === process.pid) unlinkSync(gateLockPath(dir));
|
|
127
|
+
} catch {
|
|
128
|
+
// never throw from an exit handler
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
writeGateLock(dir, o.by || "verify");
|
|
133
|
+
lockOwned = true;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (!err || err.code !== "EEXIST") {
|
|
136
|
+
lockOwned = false; // an unwritable .doug/.state: run unlocked rather than hang
|
|
137
|
+
} else {
|
|
138
|
+
const hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
|
|
139
|
+
const stale = (l) => !l || !pidIsLive(l.pid) || lockStaleByAge(l, hookTimeoutMs);
|
|
140
|
+
const held = readGateLock(dir);
|
|
141
|
+
if (stale(held)) {
|
|
142
|
+
takeOverGateLock(dir, o.by || "verify");
|
|
143
|
+
lockOwned = true;
|
|
144
|
+
} else {
|
|
145
|
+
const deadline = Date.now() + waitBoundMs;
|
|
146
|
+
let live = held;
|
|
147
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
148
|
+
while (true) {
|
|
149
|
+
live = readGateLock(dir);
|
|
150
|
+
if (stale(live) || Date.now() >= deadline) break;
|
|
151
|
+
Atomics.wait(nap, 0, 0, 200);
|
|
152
|
+
}
|
|
153
|
+
treeBefore = computeTree(cwd);
|
|
154
|
+
const again = tryCredit();
|
|
155
|
+
if (again !== null) return again;
|
|
156
|
+
if (!stale(live)) process.stderr.write(`[doug] verify: gate.lock still held by pid ${live.pid} after ${waitBoundMs} ms; running the commands anyway.\n`);
|
|
157
|
+
takeOverGateLock(dir, o.by || "verify");
|
|
158
|
+
lockOwned = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
107
162
|
const results = [];
|
|
108
163
|
const recCommands = [];
|
|
109
164
|
const totals = {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "doug: tailored, approval-gated harness install for Claude Code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
-
"doug": "
|
|
16
|
+
"doug": "dist/bin.js"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"node": ">=22.16"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@dougharness/flow": "1.1.
|
|
27
|
-
"@dougharness/gates": "1.1.
|
|
26
|
+
"@dougharness/flow": "1.1.1",
|
|
27
|
+
"@dougharness/gates": "1.1.1"
|
|
28
28
|
},
|
|
29
29
|
"bundleDependencies": [
|
|
30
30
|
"@dougharness/flow",
|
package/templates/board-app.js
CHANGED
|
@@ -339,7 +339,8 @@ function app() {
|
|
|
339
339
|
});
|
|
340
340
|
if (res.status === 200) {
|
|
341
341
|
board = await res.json();
|
|
342
|
-
|
|
342
|
+
const warning = res.headers.get("x-doug-warning");
|
|
343
|
+
say("Saved to " + config.record + "." + (warning ? " " + warning : ""));
|
|
343
344
|
} else {
|
|
344
345
|
let message = "The move was rejected (" + res.status + ").";
|
|
345
346
|
try { const body = await res.json(); if (body && body.error) message = body.error; } catch (_) {}
|