@dougharness/cli 1.1.0 → 1.2.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 (59) hide show
  1. package/dist/bin.js +10 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +45 -10
  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/init.js +1 -1
  9. package/dist/init.js.map +1 -1
  10. package/dist/memory-setup.d.ts +7 -0
  11. package/dist/memory-setup.js +75 -48
  12. package/dist/memory-setup.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  14. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  16. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  17. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  18. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  19. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  20. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  21. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  22. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  23. package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
  24. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  25. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  26. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  27. package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
  28. package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
  29. package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
  30. package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
  31. package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
  32. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  33. package/node_modules/@dougharness/flow/package.json +1 -1
  34. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  35. package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
  36. package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
  37. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  38. package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
  39. package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
  40. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
  41. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
  42. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  43. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  44. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
  45. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
  46. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  47. package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
  48. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  49. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  50. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
  51. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
  52. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  53. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  54. package/node_modules/@dougharness/gates/package.json +1 -1
  55. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  56. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
  57. package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
  58. package/package.json +4 -4
  59. package/templates/board-app.js +48 -1
@@ -0,0 +1,126 @@
1
+ // The gate.lock helpers shared by the Stop gate (scripts/stop-gate.mjs) and verify.mjs (card
2
+ // verify-takes-gate-lock). Moved verbatim from stop-gate.mjs; the Stop gate's behaviour is unchanged.
3
+
4
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { execFileSync } from "node:child_process";
6
+ import { dirname, join, resolve } from "node:path";
7
+
8
+ // Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
9
+ // at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
10
+ // 2026-09-16 process storm). gate.lock holds `{ pid, session, at }` while a gate's commands run. Card
11
+ // gate-lock-shared (ADR 0015): it lives at `<git common dir>/doug/gate.lock`, a path every worktree of the
12
+ // repo shares, so gates in parallel worktrees queue instead of running full suites concurrently. It falls
13
+ // back to the per-checkout `.doug/.state/gate.lock` when git is unavailable, `dir` is not in a repo, or the
14
+ // common dir is not writable; resolution never throws.
15
+ const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
16
+ // Memoized per dir for the life of the process (a fallback result too): one gate run must see one path,
17
+ // or a poll that fell back under load would read no lock and take over a live holder's shared lock.
18
+ const lockPathMemo = new Map();
19
+ export function gateLockPath(dir) {
20
+ if (lockPathMemo.has(dir)) return lockPathMemo.get(dir);
21
+ const p = resolveGateLockPath(dir);
22
+ lockPathMemo.set(dir, p);
23
+ return p;
24
+ }
25
+ function resolveGateLockPath(dir) {
26
+ try {
27
+ const out = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
28
+ cwd: dir,
29
+ encoding: "utf8",
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ timeout: 5000,
32
+ }).trim();
33
+ if (out) {
34
+ const common = resolve(dir, out);
35
+ const lockDir = join(common, "doug");
36
+ accessSync(common, constants.W_OK);
37
+ if (existsSync(lockDir)) accessSync(lockDir, constants.W_OK);
38
+ return join(lockDir, "gate.lock");
39
+ }
40
+ } catch {
41
+ // fall through to the per-checkout path
42
+ }
43
+ return join(dir, GATE_LOCK_RELPATH);
44
+ }
45
+
46
+ // Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
47
+ // content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
48
+ // caller treats exactly like a stale one.
49
+ export function readGateLock(dir) {
50
+ try {
51
+ const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
52
+ // A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
53
+ // group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
54
+ // throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
55
+ return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function pidIsLive(pid) {
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ } catch (err) {
66
+ // EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
67
+ // that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
68
+ // anything else) reads as dead.
69
+ return err && err.code === "EPERM";
70
+ }
71
+ }
72
+
73
+ // Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
74
+ // caller decides what a failure means.
75
+ export function writeGateLock(dir, sessionId) {
76
+ const p = gateLockPath(dir);
77
+ mkdirSync(dirname(p), { recursive: true });
78
+ writeFileSync(p, JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
79
+ }
80
+
81
+ // Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
82
+ // simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
83
+ // it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
84
+ // does not hold).
85
+ export function takeOverGateLock(dir, sessionId) {
86
+ try {
87
+ const p = gateLockPath(dir);
88
+ mkdirSync(dirname(p), { recursive: true });
89
+ writeFileSync(p, JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
90
+ } catch {
91
+ // best-effort takeover only
92
+ }
93
+ }
94
+
95
+ // Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
96
+ // Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
97
+ // missing, non-number, or non-positive value reads as the platform's own default, 600.
98
+ export function resolveHookTimeoutSec(cfg) {
99
+ const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
100
+ return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
101
+ }
102
+
103
+ // A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
104
+ // legitimate holder can still be running that long, since the platform itself would have killed its hook
105
+ // by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
106
+ export function lockStaleByAge(lock, hookTimeoutMs) {
107
+ return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
108
+ }
109
+
110
+ // Card stop-gate-timeout-normalise (moved here by card verify-lock-wait-bound so verify.mjs shares it):
111
+ // cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout and the wait bound, so the two
112
+ // never disagree on what a garbage or fractional value means. A finite number above 0 floors to an integer,
113
+ // clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a timeout of 0 as none at all,
114
+ // letting a command run past the budget unclamped; 2.5 must not reach spawnSync un-floored — it throws
115
+ // ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else (non-number, NaN, Infinity,
116
+ // 0, negative, null) is "unset" (null), read by each call site as its own default.
117
+ export function normalizedTimeoutMs(cfg) {
118
+ const t = cfg.stopGate.timeoutMs;
119
+ return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
120
+ }
121
+
122
+ // How long a gate waits on a live gate.lock: the per-command timeout times the command count, the longest a
123
+ // holder legitimately runs.
124
+ export function lockWaitBoundMs(perCommandMs, commandCount) {
125
+ return perCommandMs * Math.max(1, commandCount);
126
+ }
@@ -37,6 +37,11 @@ const MAX_VERIFIED_ROWS = 20;
37
37
  // Every spawned git call gets a hard ceiling (ms) so a hung or adversarially slow git invocation cannot itself
38
38
  // become the way to blow the Stop-gate timeout (review MINOR 7).
39
39
  const GIT_TIMEOUT_MS = 5000;
40
+ // Most commits (newest first) that touched a target since the session baseline whose parent is tried as a
41
+ // re-derivation ref (card ledger-two-committed-applies): a bound, so a long session cannot make the walk costly.
42
+ const MAX_PARENT_REFS = 10;
43
+ // A git commit sha, abbreviated or full (same shape lib/baseline.mjs accepts for baselineHead).
44
+ const GIT_SHA_RE = /^[0-9a-f]{7,64}$/;
40
45
 
41
46
  // Every well-formed line, tolerant of a missing file or a torn line (a crashed process mid-write) - never fatal.
42
47
  export function readAppliedLedger(dir) {
@@ -139,18 +144,48 @@ function diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref) {
139
144
  }
140
145
  }
141
146
 
147
+ // The parents of the commits since `base` that touched relPath, newest first, capped at MAX_PARENT_REFS: each
148
+ // `<commit>^` holds relPath as it was just before that commit, which is where an in-session apply's preimage
149
+ // lives once a later apply is also committed (card ledger-two-committed-applies). Empty when `base` is not a
150
+ // usable sha, or the git call fails or times out (GIT_TIMEOUT_MS); never throws.
151
+ function parentRefsSince(dir, relPath, base) {
152
+ try {
153
+ const listed = spawnSync("git", ["rev-list", `--max-count=${MAX_PARENT_REFS}`, `${base}..HEAD`, "--", relPath], {
154
+ cwd: dir,
155
+ timeout: GIT_TIMEOUT_MS,
156
+ encoding: "utf8",
157
+ });
158
+ if (listed.status !== 0 || typeof listed.stdout !== "string") return [];
159
+ return listed.stdout
160
+ .split("\n")
161
+ .map((line) => line.trim())
162
+ .filter((line) => /^[0-9a-f]{7,64}$/.test(line))
163
+ .map((sha) => `${sha}^`);
164
+ } catch {
165
+ return [];
166
+ }
167
+ }
168
+
142
169
  // `base` (card stop-scan-committed-changes) is the session's baseline HEAD when known, tried first: a diff
143
170
  // that created `relPath` after that baseline still applies onto "absent" even once the session has since
144
171
  // committed `relPath` itself (HEAD would otherwise already equal the current bytes, and git apply refuses to
145
172
  // create a file that already exists there). M1 fix: when the base attempt fails, HEAD is also tried before
146
173
  // giving up, not only when `base` is null/omitted - a second approved apply on the same target, committed
147
174
  // in-session after the first (so its preimage is the first version, not the pre-session base), re-derives
148
- // correctly onto HEAD even though it cannot re-derive onto the pre-session base at all. Every other check
175
+ // correctly onto HEAD even though it cannot re-derive onto the pre-session base at all. Card
176
+ // ledger-two-committed-applies: once two or more applies on one target are committed in-session, the earlier
177
+ // ones' preimage is neither the base nor HEAD; when both fail and `base` is known, the parent of each commit
178
+ // since `base` that touched `relPath` (parentRefsSince, newest first, capped) is tried too. Every other check
149
179
  // (row shape, containment, diffSha256, diffNamesOnly) is unchanged; this only widens which ref the content
150
180
  // re-derivation itself is allowed to succeed against.
151
181
  function diffReproducesCurrentContent(dir, relPath, diffAbsPath, currentSha, base) {
152
- const refs = typeof base === "string" && base && base !== "HEAD" ? [base, "HEAD"] : ["HEAD"];
153
- return refs.some((ref) => diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref));
182
+ // `base` is agent-writable state (state.baselineHead): only a hex sha ever reaches git, so a value such as
183
+ // "--output=<path>" is dropped here and can neither be read as an option nor name a revision.
184
+ const hasBase = typeof base === "string" && GIT_SHA_RE.test(base);
185
+ const refs = hasBase ? [base, "HEAD"] : ["HEAD"];
186
+ if (refs.some((ref) => diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref))) return true;
187
+ if (!hasBase) return false;
188
+ return parentRefsSince(dir, relPath, base).some((ref) => diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref));
154
189
  }
155
190
 
156
191
  // One row's full verification, wrapped so that no exception - a malformed field of any type, an unreadable
@@ -182,8 +217,9 @@ function rowIsGenuine(dir, relPath, sha, learnDirAbs, row, base) {
182
217
  // before the session started, not only against HEAD (which, once the target is committed, already equals the
183
218
  // current bytes and would make a new-file diff fail to apply there). HEAD is always tried too (M1 review
184
219
  // fix) - a second approved apply on the same target, committed in-session after the first, has the first
185
- // version as its preimage and can only re-derive onto HEAD, not onto the pre-session base. With `base` null
186
- // or omitted, only HEAD is tried, exactly as before this card.
220
+ // version as its preimage and can only re-derive onto HEAD, not onto the pre-session base. With several applies
221
+ // committed in-session (card ledger-two-committed-applies), the parent of each commit since `base` that touched
222
+ // the target is tried as well. With `base` null or omitted, only HEAD is tried, exactly as before this card.
187
223
  export function isAppliedContent(dir, relPath, { base = null } = {}) {
188
224
  const sha = currentSha256(dir, relPath);
189
225
  const learnDirAbs = resolve(dir, LEARN_STATE_RELPATH);
@@ -2,7 +2,7 @@
2
2
  // the files its tasks own is the only thing a session may change.
3
3
  // Pure functions plus one reader; the stop gate decides, this file only computes.
4
4
 
5
- import { readFileSync, existsSync } from "node:fs";
5
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { globToRegExp, matchAny, normalizePath } from "./glob.mjs";
8
8
 
@@ -10,18 +10,56 @@ export const PLAN_RELPATH = ".doug/plan.json";
10
10
  export const ENFORCED_STATUSES = ["approved", "done"];
11
11
  // The flow's own files change during a run and are never out of scope. Per-session hook state too.
12
12
  // board.json moves uncommitted with the live board server; it lands in the card's landing commit.
13
- export const ALWAYS_ALLOWED = [".doug/plan.json", ".doug/anchor.md", ".doug/.state/**", ".doug/board.json"];
13
+ export const ALWAYS_ALLOWED = [".doug/plan.json", ".doug/plans/**", ".doug/anchor.md", ".doug/.state/**", ".doug/board.json"];
14
14
 
15
15
  // Returns { plan, reason }. plan is set only when the file exists, parses, has tasks, and is in an
16
16
  // enforced status; reason says why not otherwise ("no-plan", "unreadable", "status:draft", "invalid").
17
17
  export function loadScopePlan(dir) {
18
+ const single = loadSinglePlan(dir);
19
+ if (single.plan) return single;
20
+ // card parallel-card-queens: with no enforced .doug/plan.json, the enforced, not-landed per-card plans
21
+ // under .doug/plans/ hold scope together.
22
+ const cards = loadCardPlans(dir);
23
+ if (cards.plan || cards.reason === "unreadable") return cards;
24
+ return single;
25
+ }
26
+
27
+ // Union of every approved/done, not-landed .doug/plans/*.json. Never throws: an unreadable file is skipped,
28
+ // and reported as reason "unreadable" only when it leaves no plan holding scope.
29
+ function loadCardPlans(dir) {
30
+ let names = [];
31
+ try {
32
+ names = readdirSync(join(dir, ".doug/plans")).filter((f) => f.endsWith(".json")).sort();
33
+ } catch {
34
+ return { plan: null, reason: "no-plan" };
35
+ }
36
+ const plans = [];
37
+ for (const name of names) {
38
+ try {
39
+ const p = JSON.parse(readFileSync(join(dir, ".doug/plans", name), "utf8"));
40
+ if (!p || typeof p !== "object" || !Array.isArray(p.tasks)) continue;
41
+ if (!ENFORCED_STATUSES.includes(p.status) || p.landed) continue;
42
+ plans.push(p);
43
+ } catch (err) {
44
+ // fail open: any unreadable per-card file means the union is unknown, so scope is not checked
45
+ return { plan: null, reason: "unreadable", error: String(err && err.message), file: `.doug/plans/${name}` };
46
+ }
47
+ }
48
+ if (!plans.length) return { plan: null, reason: "no-plan" };
49
+ const status = plans.some((p) => p.status === "approved") ? "approved" : "done";
50
+ const cards = plans.map((p) => p.card || p.title || "?");
51
+ const title = `per-card plans (${cards.join(", ")})`;
52
+ return { plan: { version: 1, title, status, perCard: cards, tasks: plans.flatMap((p) => p.tasks) }, reason: null };
53
+ }
54
+
55
+ function loadSinglePlan(dir) {
18
56
  const file = join(dir, PLAN_RELPATH);
19
57
  if (!existsSync(file)) return { plan: null, reason: "no-plan" };
20
58
  let plan;
21
59
  try {
22
60
  plan = JSON.parse(readFileSync(file, "utf8"));
23
61
  } catch (err) {
24
- return { plan: null, reason: "unreadable", error: String(err.message) };
62
+ return { plan: null, reason: "unreadable", error: String(err.message), file: PLAN_RELPATH };
25
63
  }
26
64
  if (!plan || typeof plan !== "object" || !Array.isArray(plan.tasks)) return { plan: null, reason: "invalid" };
27
65
  if (!ENFORCED_STATUSES.includes(plan.status)) return { plan: null, reason: `status:${plan.status}` };
@@ -104,7 +142,8 @@ export function scopeViolations(changed, plan, { ignore = [] } = {}) {
104
142
  }
105
143
 
106
144
  export function describeViolations(violations, plan) {
107
- const lines = [`Files changed outside the ${plan.status} plan "${plan.title}" (${PLAN_RELPATH}):`];
145
+ const source = Array.isArray(plan.perCard) ? `per-card plans ${plan.perCard.join(", ")} (.doug/plans/)` : `plan "${plan.title}" (${PLAN_RELPATH})`;
146
+ const lines = [`Files changed outside the ${plan.status} ${source}:`];
108
147
  for (const v of violations) {
109
148
  const near = v.nearest.length ? `nearest task${v.nearest.length > 1 ? "s" : ""}: ${v.nearest.map((n) => `${n.id} (owns ${n.via})`).join(", ")}` : "no task owns anything near it";
110
149
  lines.push(` ${v.file} ${near}`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/gates",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",
@@ -19,7 +19,7 @@ import { join } from "node:path";
19
19
  import { runHook, allow, addContext, systemMessage } from "../lib/io.mjs";
20
20
  import { loadConfig, projectDir } from "../lib/config.mjs";
21
21
  import { loadState, saveState } from "../lib/state.mjs";
22
- import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection, splitSnapshot } from "../lib/anchor.mjs";
22
+ import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, readPlanSummaries, snapshotSection, splitSnapshot } from "../lib/anchor.mjs";
23
23
 
24
24
  runHook("reanchor", async (input) => {
25
25
  const dir = projectDir(input);
@@ -30,6 +30,7 @@ runHook("reanchor", async (input) => {
30
30
  const state = loadState(dir, input.session_id);
31
31
  const section = snapshotSection({
32
32
  plan: readPlanSummary(dir),
33
+ plans: readPlanSummaries(dir),
33
34
  decisions: readDecisions(dir),
34
35
  state,
35
36
  now: new Date(),