@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.
Files changed (37) hide show
  1. package/dist/bin.js +10 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +5 -4
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +33 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/memory-setup.js +5 -2
  9. package/dist/memory-setup.js.map +1 -1
  10. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  11. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  12. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  13. package/node_modules/@dougharness/flow/lib/board.mjs +17 -1
  14. package/node_modules/@dougharness/flow/lib/cost.mjs +29 -6
  15. package/node_modules/@dougharness/flow/lib/embeddings.mjs +3 -1
  16. package/node_modules/@dougharness/flow/lib/land.mjs +6 -1
  17. package/node_modules/@dougharness/flow/lib/memory.mjs +49 -22
  18. package/node_modules/@dougharness/flow/lib/plan.mjs +2 -1
  19. package/node_modules/@dougharness/flow/package.json +1 -1
  20. package/node_modules/@dougharness/flow/scripts/board.mjs +35 -14
  21. package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
  22. package/node_modules/@dougharness/flow/scripts/memory.mjs +10 -6
  23. package/node_modules/@dougharness/flow/scripts/plan.mjs +22 -0
  24. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +4 -4
  25. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +8 -6
  26. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  27. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
  28. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +11 -9
  29. package/node_modules/@dougharness/flow/workflows/doug-implement.js +26 -14
  30. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  31. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
  32. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
  33. package/node_modules/@dougharness/gates/package.json +1 -1
  34. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +10 -87
  35. package/node_modules/@dougharness/gates/scripts/verify.mjs +58 -3
  36. package/package.json +4 -4
  37. 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; any other path names nothing. The set is the cwd's worktree plus
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 rel = relative(dir, resolve(p));
531
- return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
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 above — a hook reader must never throw (F6/F10), so a finite number >= 1 is floored
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 * Math.max(1, resolveCommands(cfg).length) : budgetDeadline - Date.now();
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 no gate.lock.
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
- const treeBefore = computeTree(cwd);
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 : Number.isInteger(cfg.stopGate && cfg.stopGate.timeoutMs) && cfg.stopGate.timeoutMs > 0 ? cfg.stopGate.timeoutMs : 600000;
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.0",
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": "./dist/bin.js"
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.0",
27
- "@dougharness/gates": "1.1.0"
26
+ "@dougharness/flow": "1.1.1",
27
+ "@dougharness/gates": "1.1.1"
28
28
  },
29
29
  "bundleDependencies": [
30
30
  "@dougharness/flow",
@@ -339,7 +339,8 @@ function app() {
339
339
  });
340
340
  if (res.status === 200) {
341
341
  board = await res.json();
342
- say("Saved to " + config.record + ".");
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 (_) {}