@dougharness/cli 1.4.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.
Files changed (44) hide show
  1. package/dist/ablate.js +2 -2
  2. package/dist/ablate.js.map +1 -1
  3. package/dist/generate/agents.js +4 -0
  4. package/dist/generate/agents.js.map +1 -1
  5. package/dist/generate/claude-md.js +1 -1
  6. package/dist/generate/claude-md.js.map +1 -1
  7. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  8. package/node_modules/@dougharness/flow/agents/coder.md +2 -0
  9. package/node_modules/@dougharness/flow/agents/implementer.md +2 -0
  10. package/node_modules/@dougharness/flow/agents/planner.md +2 -1
  11. package/node_modules/@dougharness/flow/agents/reviewer.md +2 -0
  12. package/node_modules/@dougharness/flow/agents/tester.md +2 -0
  13. package/node_modules/@dougharness/flow/lib/board.mjs +9 -9
  14. package/node_modules/@dougharness/flow/lib/card-loop.mjs +54 -5
  15. package/node_modules/@dougharness/flow/lib/code-index.mjs +2 -2
  16. package/node_modules/@dougharness/flow/lib/coordinator.mjs +4 -2
  17. package/node_modules/@dougharness/flow/lib/decisions.mjs +3 -5
  18. package/node_modules/@dougharness/flow/lib/learn.mjs +105 -57
  19. package/node_modules/@dougharness/flow/lib/memory.mjs +1418 -57
  20. package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
  21. package/node_modules/@dougharness/flow/package.json +1 -1
  22. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +178 -1
  23. package/node_modules/@dougharness/flow/scripts/board.mjs +43 -15
  24. package/node_modules/@dougharness/flow/scripts/learn.mjs +4 -2
  25. package/node_modules/@dougharness/flow/scripts/memory.mjs +870 -39
  26. package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
  27. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  28. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  29. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +4 -0
  30. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
  31. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  32. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -3
  33. package/node_modules/@dougharness/flow/workflows/card-queen.js +54 -5
  34. package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
  35. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  36. package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
  37. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +6 -3
  38. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +175 -4
  39. package/node_modules/@dougharness/gates/lib/test-totals.mjs +1 -1
  40. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
  41. package/node_modules/@dougharness/gates/package.json +1 -1
  42. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +110 -125
  43. package/node_modules/@dougharness/gates/scripts/verify.mjs +47 -13
  44. package/package.json +3 -3
@@ -258,6 +258,15 @@ export function writeAgentStart(dir, cfg, sessionId, agentId) {
258
258
  try {
259
259
  const fp = treeFingerprint(dir, cfg, changedFiles(dir));
260
260
  if (!fp) return;
261
+ // card worktree-skip-origin-at-start: origin/HEAD's sha at start (null when unset), for the worktree skip.
262
+ let originHead = null;
263
+ try {
264
+ const o = execFileSync("git", ["rev-parse", "--verify", "-q", "refs/remotes/origin/HEAD^{commit}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
265
+ if (/^[0-9a-f]{7,64}$/.test(o)) originHead = o;
266
+ } catch {
267
+ originHead = null;
268
+ }
269
+ fp.originHead = originHead;
261
270
  const file = agentStartFile(dir, sessionId, agentId);
262
271
  mkdirSync(join(file, ".."), { recursive: true });
263
272
  writeFileSync(file, JSON.stringify(fp));
@@ -110,13 +110,15 @@ export function failingTestFilesFrom(output, dir) {
110
110
  // them is in the standing tests_red_by_design claim (`redByDesignClaimFiles`).
111
111
  // 2. 'load-timeout' — at least one command timed out or was dropped for budget, and no failed command's
112
112
  // captured output names a failing test file.
113
- // 3. 'race' — the working tree (`changedFiles(dir)`, as a set) differs now from `changedAtStart`. Either
113
+ // 3. 'test-count' — the test-count ratchet is the block's only problem (`countProblems > 0` and it equals
114
+ // `totalProblems`).
115
+ // 4. 'race' — the working tree (`changedFiles(dir)`, as a set) differs now from `changedAtStart`. Either
114
116
  // side being unknown (null: no git, or git unavailable) is never a race. Skipped entirely once
115
117
  // `budgetDeadline` (the gate's own hook-timeout deadline, gateBudget() in stop-gate.mjs) has already
116
118
  // passed (review pass 2, item 4): a second `git status` here is exactly the kind of extra work that
117
119
  // could push a slow gate past the platform's own hook timeout, discarding its block message (F1) — a
118
120
  // deadline already blown is not worth spending on a label, so this falls through to 'real' instead.
119
- // 4. 'real' — otherwise.
121
+ // 5. 'real' — otherwise.
120
122
  //
121
123
  // Never throws (review pass 2, item 2 MAJOR): this call sits between the gate's decision to block and the
122
124
  // blockStop call that carries it out (stop-gate.mjs), so an unguarded throw here would fail the *whole gate*
@@ -141,10 +143,10 @@ export function classifyFinalBlock({ countProblems, totalProblems, preVerificati
141
143
  if (allNamed.every((f) => claimSet.has(f))) return "expected-red";
142
144
  }
143
145
 
144
- // card block-ledger-failing-output: the test-count ratchet is the block's only problem.
145
146
  const timedOutOrDropped = (Array.isArray(results) ? results : []).some((r) => r.timedOut || r.skipped === "budget");
146
147
  if (timedOutOrDropped && allNamed.length === 0) return "load-timeout";
147
148
 
149
+ // card block-ledger-failing-output: the test-count ratchet is the block's only problem.
148
150
  if (typeof countProblems === "number" && countProblems > 0 && totalProblems === countProblems) return "test-count";
149
151
 
150
152
  if (Array.isArray(changedAtStart) && !(typeof budgetDeadline === "number" && Date.now() >= budgetDeadline)) {
@@ -213,6 +215,7 @@ export function appendBlockLine(dir, { hook, agent, reason, class: cls, session,
213
215
  class: cls,
214
216
  session: session ?? null,
215
217
  ...plain,
218
+ files: Array.isArray(plain.files) ? plain.files : [],
216
219
  }) + "\n";
217
220
  mkdirSync(join(dir, ".doug/.state"), { recursive: true });
218
221
  appendFileSync(join(dir, BLOCKS_RELPATH), line);
@@ -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: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
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 pre-commit credit path is counted from the totals the hook records in last-run.json.)
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.
@@ -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.4.0",
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",