@dougharness/cli 1.1.1 → 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 (50) hide show
  1. package/dist/board-serve.js +40 -6
  2. package/dist/board-serve.js.map +1 -1
  3. package/dist/init.js +1 -1
  4. package/dist/init.js.map +1 -1
  5. package/dist/memory-setup.d.ts +7 -0
  6. package/dist/memory-setup.js +70 -46
  7. package/dist/memory-setup.js.map +1 -1
  8. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  9. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  10. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  11. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  12. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  13. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  14. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  15. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  16. package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
  17. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  18. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  19. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  20. package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
  21. package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
  22. package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
  23. package/node_modules/@dougharness/flow/lib/memory.mjs +48 -12
  24. package/node_modules/@dougharness/flow/lib/plan.mjs +32 -4
  25. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  26. package/node_modules/@dougharness/flow/package.json +1 -1
  27. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  28. package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
  29. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +20 -9
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +100 -30
  32. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  33. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
  34. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  35. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  36. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
  37. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  38. package/node_modules/@dougharness/flow/workflows/doug-implement.js +7 -7
  39. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  40. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  41. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
  42. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
  43. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  44. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  45. package/node_modules/@dougharness/gates/package.json +1 -1
  46. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  47. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +138 -10
  48. package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
  49. package/package.json +3 -3
  50. package/templates/board-app.js +46 -0
@@ -84,7 +84,7 @@ function taskShape(task) {
84
84
  function adversaryRequired(task) {
85
85
  return !!adversary && taskShape(task) !== 'S'
86
86
  }
87
- const INTEGRATION_WORKTREE = '.claude/worktrees/doug-integration'
87
+ const INTEGRATION_WORKTREE = plan.integrationWorktree || '.claude/worktrees/doug-integration'
88
88
 
89
89
  // What an adversarial reviewer may call a blocker. The same text is in the two adversary agent files and in
90
90
  // codex-review's own prompt; change all four together.
@@ -1432,14 +1432,14 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1432
1432
  `They are local branches of this repository (each was made in its own worktree); no fetch is needed.`,
1433
1433
  ``,
1434
1434
  `Steps:`,
1435
- `1. From the repository root, find or create the integration worktree at .claude/worktrees/doug-integration:`,
1435
+ `1. From the repository root, find or create the integration worktree at ${INTEGRATION_WORKTREE}:`,
1436
1436
  ...(firstIntegration
1437
1437
  ? [
1438
- ` this is the run's first integration; the worktree may be a leftover of an earlier run and is never reused as is: if it exists, git worktree remove --force .claude/worktrees/doug-integration (then git worktree prune).`,
1439
- ` In every case create it fresh: git worktree add -B ${integrationBranch} .claude/worktrees/doug-integration ${baseBranch}`,
1438
+ ` this is the run's first integration; the worktree may be a leftover of an earlier run and is never reused as is: if it exists, git worktree remove --force ${INTEGRATION_WORKTREE} (then git worktree prune).`,
1439
+ ` In every case create it fresh: git worktree add -B ${integrationBranch} ${INTEGRATION_WORKTREE} ${baseBranch}`,
1440
1440
  ]
1441
1441
  : [
1442
- ` if it does not exist: git worktree add -B ${integrationBranch} .claude/worktrees/doug-integration ${baseBranch}`,
1442
+ ` if it does not exist: git worktree add -B ${integrationBranch} ${INTEGRATION_WORKTREE} ${baseBranch}`,
1443
1443
  ` if it exists (a previous level made it): use it as is; it already has ${integrationBranch} checked out.`,
1444
1444
  ]),
1445
1445
  ` Do every following git and verify command inside that worktree (git -C or cd into it).`,
@@ -1449,7 +1449,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1449
1449
  ...(plan.verify.length
1450
1450
  ? [
1451
1451
  ` When .doug/hooks/scripts/verify.mjs exists, run them from the repository root (not inside the worktree) as one command, so a tree already verified is credited instead of re-run:`,
1452
- ` node .doug/hooks/scripts/verify.mjs --json --by integrate --cwd .claude/worktrees/doug-integration --record-dir . ${worktrees.map(w => `--credit-dir ${shellQuote(w)}`).join(' ')} ${plan.verify.map(v => `--command ${shellQuote(v)}`).join(' ')}`,
1452
+ ` node .doug/hooks/scripts/verify.mjs --json --by integrate --cwd ${INTEGRATION_WORKTREE} --record-dir . ${worktrees.map(w => `--credit-dir ${shellQuote(w)}`).join(' ')} ${plan.verify.map(v => `--command ${shellQuote(v)}`).join(' ')}`,
1453
1453
  ` Copy each entry of its JSON results[] into verify as { command, ok, outputTail }, and add credited: "verified-tree" to each when the JSON's credited is not null. When verify.mjs does not exist, run the commands one by one as above.`,
1454
1454
  ]
1455
1455
  : []),
@@ -1469,7 +1469,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
1469
1469
  ]
1470
1470
  : []),
1471
1471
  `4. If every merge succeeded, remove the merged task worktrees to leave the repository tidy: git worktree remove --force <path> for each of ${worktrees.join(', ')}. Keep their branches.`,
1472
- isLastLevel && !keepWorktree ? `5. This is the last level. Also remove the integration worktree (git worktree remove --force .claude/worktrees/doug-integration); the branch ${integrationBranch} keeps the result.` : isLastLevel ? `5. This is the last level, but keep the integration worktree: the adversary reviews the merged size-S tasks there, and plan.mjs land reuses and removes it.` : `5. Keep the integration worktree; the next level merges into it.`,
1472
+ isLastLevel && !keepWorktree ? `5. This is the last level. Also remove the integration worktree (git worktree remove --force ${INTEGRATION_WORKTREE}); the branch ${integrationBranch} keeps the result.` : isLastLevel ? `5. This is the last level, but keep the integration worktree: the adversary reviews the merged size-S tasks there, and plan.mjs land reuses and removes it.` : `5. Keep the integration worktree; the next level merges into it.`,
1473
1473
  isLastLevel
1474
1474
  ? `6. ok=true only if all merges succeeded, all verify commands passed, and every acceptance command exited 0. Do not push. Do not check out anything in the main working tree.`
1475
1475
  : `6. ok=true only if all merges succeeded and all verify commands passed. Do not push. Do not check out anything in the main working tree.`,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doug-gates",
3
3
  "displayName": "Doug Gates",
4
- "version": "1.1.1",
4
+ "version": "1.2.0",
5
5
  "description": "Deterministic guardrails for Claude Code: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, a verification gate on Stop and on SubagentStop, a snapshot of the plan and session into the anchor before compaction and re-anchoring after it, and a run trace of subagents and tool calls. Configured by .doug/config.json.",
6
6
  "author": {
7
7
  "name": "Doug contributors"
@@ -23,6 +23,28 @@ export function readPlanSummary(dir) {
23
23
  }
24
24
  }
25
25
 
26
+ // Every active (enforced, not landed) per-card plan under .doug/plans/, as readPlanSummary shapes them.
27
+ export function readPlanSummaries(dir) {
28
+ const out = [];
29
+ try {
30
+ const d = join(dir, ".doug/plans");
31
+ if (!existsSync(d)) return out;
32
+ for (const name of readdirSync(d).filter((f) => f.endsWith(".json")).sort()) {
33
+ try {
34
+ const p = JSON.parse(readFileSync(join(d, name), "utf8"));
35
+ if (!p || typeof p !== "object" || !Array.isArray(p.tasks) || p.landed) continue;
36
+ if (p.status !== "approved" && p.status !== "done") continue;
37
+ out.push({ title: p.title || "(untitled)", status: p.status, card: typeof p.card === "string" ? p.card : null, tasks: p.tasks.map((t) => ({ id: t.id, files: Array.isArray(t.files) ? t.files : [] })) });
38
+ } catch {
39
+ // an unreadable per-card plan is skipped
40
+ }
41
+ }
42
+ } catch {
43
+ return out;
44
+ }
45
+ return out;
46
+ }
47
+
26
48
  // Short HEAD sha and current branch, tolerant of a non-repo or a git failure (moved from stop-gate.mjs; used by
27
49
  // both the Stop gate's own handoff and the PreCompact snapshot).
28
50
  export function gitHead(dir) {
@@ -55,7 +77,7 @@ export function readDecisions(dir) {
55
77
  // plan/decisions/edited-files lines (card precompact-keeps-handoff, review MAJOR): those grow unbounded with a
56
78
  // session's edited-file count, and SessionStart's re-injection caps the whole anchor at 4000 characters, so a few
57
79
  // short handoff lines must be near the top to survive that cap on a long session.
58
- export function snapshotSection({ plan, decisions, state, now, handoff }) {
80
+ export function snapshotSection({ plan, plans, decisions, state, now, handoff }) {
59
81
  const lines = [`## Compaction snapshot (doug, ${now.toISOString()})`];
60
82
  if (handoff) {
61
83
  lines.push("");
@@ -70,9 +92,12 @@ export function snapshotSection({ plan, decisions, state, now, handoff }) {
70
92
  if (typeof handoff.contextPct === "number") lines.push(`Context: ${handoff.contextPct}%`);
71
93
  }
72
94
  lines.push("");
73
- if (plan) {
74
- lines.push(`Plan: ${plan.title} [${plan.status}]${plan.card ? ` (card ${plan.card})` : ""}`);
75
- for (const t of plan.tasks) lines.push(`- ${t.id} owns ${t.files.length ? t.files.join(", ") : "(no files)"}`);
95
+ const shown = plan ? [plan] : plans || [];
96
+ if (shown.length) {
97
+ for (const pl of shown) {
98
+ lines.push(`Plan: ${pl.title} [${pl.status}]${pl.card ? ` (card ${pl.card})` : ""}`);
99
+ for (const t of pl.tasks) lines.push(`- ${t.id} owns ${t.files.length ? t.files.join(", ") : "(no files)"}`);
100
+ }
76
101
  } else lines.push("Plan: none in .doug/plan.json");
77
102
  lines.push("");
78
103
  lines.push(decisions.length ? `Decisions (docs/decisions): ${decisions.join(", ")}` : "Decisions: none under docs/decisions");
@@ -13,8 +13,8 @@ const PM_INSTALL_VERBS = {
13
13
  // "|"/"&"/"|&"/newline, and the body of every unquoted-or-double-quoted "$(...)" or backtick substitution
14
14
  // really executes, so its pieces are split out too, recursively (research Q1.5; design decided by the lead,
15
15
  // point 3). Falls back to today's quote-blind split - checking MORE than a real shell would, never less - when
16
- // the scanner hits an unterminated quote or substitution (a heredoc body looks the same to it, and heredocs are
17
- // out of scope - point 4) or throws for any other reason (a hook reader must never throw - CLAUDE.md Gotchas).
16
+ // the scanner hits an unterminated quote or substitution (a heredoc body looks the same to it; only the
17
+ // package-manager rule strips heredoc bodies - stripHeredocBodies, card bash-rules-heredoc-body) or throws for any other reason (a hook reader must never throw - CLAUDE.md Gotchas).
18
18
  // A bare "&" (backgrounds the command before it, redirection forms excepted) and bash's "|&" (a pipe connector,
19
19
  // treated the same as a bare "|") joined this set in review round 5 (third review) - see scanTop's comment.
20
20
  const ALL_SEPS = new Set(["&&", "||", ";", "|", "&", "|&", "\n"]);
@@ -155,8 +155,8 @@ function words(cmd) {
155
155
  // itself sits inside (a stack, not a boolean: scanParenBody below starts its own body scan fresh in the "top"
156
156
  // state). "$((...))" is read as a substitution too (its body is "(...)" verbatim) - over-checking is fine.
157
157
  //
158
- // Known limits, stated as limits, not bugs: a word-initial "#" comment and a heredoc body are not recognised
159
- // (over-report only, never under-report - research Q1.7); zsh's RC_QUOTES, bash 5.x, and dash's lack of
158
+ // Known limits, stated as limits, not bugs: a word-initial "#" comment and a heredoc body are not recognised by this scanner
159
+ // (the package-manager rule alone strips heredoc bodies first - stripHeredocBodies; over-report only, never under-report - research Q1.7); zsh's RC_QUOTES, bash 5.x, and dash's lack of
160
160
  // $'...' are unverified or divergent, and the bash/zsh reading is the one this scanner takes; a "(...)" subshell
161
161
  // or "{ ...; }" group is not unwrapped (unchanged from today).
162
162
  //
@@ -1060,7 +1060,7 @@ function pipeStages(group) {
1060
1060
  // group's stages at "|" or "|&" (pipeStages above) - and, design point 5, recursion into every substitution
1061
1061
  // body's own groups too, so a curl|sh chain hidden inside a "$(...)" or backtick is still caught. Because
1062
1062
  // scanTop finds a substitution wherever it sits - inside an ordinary quoted argument or inside what looks like
1063
- // inert text such as a heredoc body (heredocs carry no state of their own here, a known limit) - a "$(... curl
1063
+ // inert text such as a heredoc body (this check keeps no heredoc state, a known limit) - a "$(... curl
1064
1064
  // ... | sh)" written inside either is still denied even where a real shell would just treat it as literal
1065
1065
  // text: an over-denial on the safe side, not a miss. Falls back to the old quote-blind group split (unchanged -
1066
1066
  // review round 5 did not extend this fallback regex to "&"/"|&", see the coder's report) on an unterminated
@@ -1485,6 +1485,184 @@ export function commandForms(cmd, depth = 0) {
1485
1485
  }
1486
1486
  }
1487
1487
 
1488
+ // Card bash-rules-heredoc-body: the body of a heredoc (<<WORD, <<'WORD', <<"WORD", <<-WORD, up to the terminator
1489
+ // line) is data fed to a command's stdin, not commands, so the package-manager rule must not match prose inside
1490
+ // it (a research note quoting the npm CLI). stripHeredocBodies returns the command with those bodies removed;
1491
+ // evaluateBash feeds it to the package-manager rule ONLY - every other rule still sees the original text
1492
+ // (checks more, never less). Its own small quote-aware walk (not scanTop: a body with an apostrophe would
1493
+ // leave scanTop in an unterminated quote and send it to the quote-blind fallback). A body is kept, i.e. still
1494
+ // checked, when: its terminator line never appears (unterminated); ANY pipeline stage anywhere in the whole
1495
+ // command is a shell or source/"." (a shell reading the heredoc runs it as a script), or any group ends in a
1496
+ // trailing "|" (the receiving stage is on a later line): then nothing is stripped; or the delimiter is unquoted
1497
+ // and the body holds "$(" or a backtick (an unquoted heredoc expands substitutions, which run).
1498
+ // Known limits (accepted, not caught): a heredoc written to a file later run (cat > s.sh <<EOF ... bash s.sh),
1499
+ // xargs ... sh -c, tee >(sh), while read l; do $l; done <<EOF, ssh/docker exec (remote). "<<<" is a
1500
+ // here-string, not a heredoc; an operator inside a quote, a substitution, or a word-initial "#" comment is not
1501
+ // one either. Never throws: any error returns the command unchanged (today's behaviour).
1502
+ function heredocReadsAsScript(stage) {
1503
+ // Compared on the raw stage and on the stage with quotes and backslashes removed ("sh", s\h): conservative.
1504
+ for (const text of [stage, stage.replace(/["'\\]/g, "")]) {
1505
+ if (matchesShellStage(text)) return true;
1506
+ const r = resolveVerb(words(text), 0);
1507
+ const verb = r.w[r.i];
1508
+ if (verb === "source" || verb === ".") return true;
1509
+ }
1510
+ return false;
1511
+ }
1512
+
1513
+ // Whole-command rule (round 2): ANY pipeline stage anywhere in the command that reads as a shell script consumer,
1514
+ // or any group ending in a trailing "|", keeps every body. A per-operator or per-group relation misses a heredoc
1515
+ // inside a compound (if/for/while/case/subshell/brace group) whose closing keyword is piped to a shell.
1516
+ function commandHasShellConsumer(str) {
1517
+ const cut = cutOnSeparators(str, GROUP_SEPS);
1518
+ const groups = cut ? cut.pieces : str.split(/\s*(?:&&|\|\||;)\s*|\n/).map((s) => s.trim()).filter(Boolean);
1519
+ for (const group of groups) {
1520
+ if (/\|&?\s*$/.test(group)) return true; // the receiving stage is on a later line: unknown, so conservative
1521
+ if (pipeStages(group).some((st) => heredocReadsAsScript(st))) return true;
1522
+ }
1523
+ return false;
1524
+ }
1525
+
1526
+ export function stripHeredocBodies(command) {
1527
+ const str = String(command);
1528
+ try {
1529
+ if (!str.includes("<<")) return str;
1530
+ if (commandHasShellConsumer(str)) return str;
1531
+ const removals = [];
1532
+ let pending = [];
1533
+ let state = "top";
1534
+ let i = 0;
1535
+ while (i < str.length) {
1536
+ const c = str[i];
1537
+ if (state === "squote") { if (c === "'") state = "top"; i += 1; continue; }
1538
+ if (state === "dquote") {
1539
+ if (c === "\\" && isDquoteEscapable(str[i + 1])) { i += 2; continue; }
1540
+ if (c === '"') { state = "top"; i += 1; continue; }
1541
+ if (c === "$" && str[i + 1] === "(") {
1542
+ const close = scanParenBody(str, i + 1);
1543
+ if (close < 0) return str;
1544
+ i = close + 1;
1545
+ continue;
1546
+ }
1547
+ if (c === "`") {
1548
+ const close = scanBacktickBody(str, i);
1549
+ if (close < 0) return str;
1550
+ i = close + 1;
1551
+ continue;
1552
+ }
1553
+ i += 1;
1554
+ continue;
1555
+ }
1556
+ if (state === "ansic") {
1557
+ if (c === "\\") { i += 2; continue; }
1558
+ if (c === "'") state = "top";
1559
+ i += 1;
1560
+ continue;
1561
+ }
1562
+ // top
1563
+ if (c === "\\") { i += 2; continue; }
1564
+ if (c === "'") { state = "squote"; i += 1; continue; }
1565
+ if (c === '"') { state = "dquote"; i += 1; continue; }
1566
+ if (c === "$" && str[i + 1] === "'") { state = "ansic"; i += 2; continue; }
1567
+ if (c === "$" && str[i + 1] === '"') { state = "dquote"; i += 2; continue; }
1568
+ if (c === "$" && str[i + 1] === "(") {
1569
+ const close = scanParenBody(str, i + 1);
1570
+ if (close < 0) return str;
1571
+ i = close + 1;
1572
+ continue;
1573
+ }
1574
+ if (c === "`") {
1575
+ const close = scanBacktickBody(str, i);
1576
+ if (close < 0) return str;
1577
+ i = close + 1;
1578
+ continue;
1579
+ }
1580
+ if (c === "#" && (i === 0 || /[\s;&|()]/.test(str[i - 1]))) {
1581
+ while (i < str.length && str[i] !== "\n") i += 1;
1582
+ continue;
1583
+ }
1584
+ if (c === "<" && str[i + 1] === "<") {
1585
+ if (str[i + 2] === "<") { i += 3; continue; } // here-string
1586
+ let j = i + 2;
1587
+ const dash = str[j] === "-";
1588
+ if (dash) j += 1;
1589
+ while (str[j] === " " || str[j] === "\t") j += 1;
1590
+ let word = "";
1591
+ let quoted = false;
1592
+ let dollarQuoted = false;
1593
+ while (j < str.length && !/[\s;&|<>()]/.test(str[j])) {
1594
+ const ch = str[j];
1595
+ if (ch === "\\") { quoted = true; word += str[j + 1] ?? ""; j += 2; continue; }
1596
+ if (ch === "$" && (str[j + 1] === "'" || str[j + 1] === '"')) { dollarQuoted = true; j += 1; continue; }
1597
+ if (ch === "'" || ch === '"') {
1598
+ const close = str.indexOf(ch, j + 1);
1599
+ if (close < 0) return str;
1600
+ quoted = true;
1601
+ word += str.slice(j + 1, close);
1602
+ j = close + 1;
1603
+ continue;
1604
+ }
1605
+ word += ch;
1606
+ j += 1;
1607
+ }
1608
+ if (word) pending.push({ word, dash, quoted, shellFed: dollarQuoted || word[0] === "$" });
1609
+ i = Math.max(j, i + 2);
1610
+ continue;
1611
+ }
1612
+ const sep =
1613
+ (c === "&" && str[i + 1] === "&") || (c === "|" && str[i + 1] === "|") ? 2
1614
+ : c === ";" ? 1
1615
+ : c === "&" && str[i + 1] !== ">" && str[i - 1] !== ">" && str[i - 1] !== "<" && str[i - 1] !== "|" ? 1
1616
+ : 0;
1617
+ if (sep) {
1618
+ i += sep;
1619
+ continue;
1620
+ }
1621
+ if (c === "\n") {
1622
+ const ops = pending;
1623
+ pending = [];
1624
+ let pos = i + 1;
1625
+ let lastEnd = -1;
1626
+ for (const op of ops) {
1627
+ const bodyStart = pos;
1628
+ let found = -1;
1629
+ while (pos <= str.length) {
1630
+ const nl = str.indexOf("\n", pos);
1631
+ const lineEnd = nl < 0 ? str.length : nl;
1632
+ const line = str.slice(pos, lineEnd);
1633
+ if ((op.dash ? line.replace(/^\t+/, "") : line) === op.word) { found = lineEnd; break; }
1634
+ if (lineEnd >= str.length) break;
1635
+ pos = lineEnd + 1;
1636
+ }
1637
+ if (found < 0) return removals.length ? applyRemovals(str, removals) : str; // unterminated: stop here
1638
+ const body = str.slice(bodyStart, found);
1639
+ const expands = !op.quoted && /\$\(|`/.test(body);
1640
+ if (!op.shellFed && !expands) removals.push([bodyStart, found]);
1641
+ lastEnd = found;
1642
+ pos = found + 1;
1643
+ }
1644
+ if (lastEnd >= 0) { i = lastEnd; continue; }
1645
+ i += 1;
1646
+ continue;
1647
+ }
1648
+ i += 1;
1649
+ }
1650
+ return removals.length ? applyRemovals(str, removals) : str;
1651
+ } catch {
1652
+ return str;
1653
+ }
1654
+ }
1655
+
1656
+ function applyRemovals(str, removals) {
1657
+ let out = "";
1658
+ let at = 0;
1659
+ for (const [a, b] of removals) {
1660
+ out += str.slice(at, a);
1661
+ at = b;
1662
+ }
1663
+ return out + str.slice(at);
1664
+ }
1665
+
1488
1666
  export function checkPackageManager(cmd, expected) {
1489
1667
  if (!expected) return null;
1490
1668
  const m = /^(npm|pnpm|yarn|bun)\s*(.*)$/.exec(cmd);
@@ -1506,6 +1684,8 @@ export function evaluateBash(command, { config, currentBranch }) {
1506
1684
  const pipeReason = checkPipeToShell(command);
1507
1685
  if (pipeReason) reasons.push(pipeReason);
1508
1686
  }
1687
+ const pmCommand = config.bash.packageManagerGuard ? stripHeredocBodies(command) : command;
1688
+ const pmSeparate = pmCommand !== command;
1509
1689
  for (const cmd of splitCommands(command)) {
1510
1690
  // Card bash-rules-prefixed-commands: run the five per-command checks (each anchored on the start of the
1511
1691
  // string it's given) on every form commandForms resolves for this split command - cmd itself, a prefix
@@ -1521,9 +1701,19 @@ export function evaluateBash(command, { config, currentBranch }) {
1521
1701
  checks.push(checkResetHardOnProtected(form, config.bash.denyForcePushTo, currentBranch));
1522
1702
  }
1523
1703
  if (config.bash.denyDestructive) checks.push(checkDestructive(form));
1524
- if (config.bash.packageManagerGuard) checks.push(checkPackageManager(form, config.packageManager));
1704
+ if (config.bash.packageManagerGuard && !pmSeparate) checks.push(checkPackageManager(form, config.packageManager));
1525
1705
  for (const r of checks) if (r && !reasons.includes(r)) reasons.push(r);
1526
1706
  }
1527
1707
  }
1708
+ // Card bash-rules-heredoc-body: with a data heredoc body present, the package-manager rule reads the command
1709
+ // with those bodies stripped (stripHeredocBodies); the other rules above already ran on the original text.
1710
+ if (pmSeparate) {
1711
+ for (const cmd of splitCommands(pmCommand)) {
1712
+ for (const form of commandForms(cmd)) {
1713
+ const r = checkPackageManager(form, config.packageManager);
1714
+ if (r && !reasons.includes(r)) reasons.push(r);
1715
+ }
1716
+ }
1717
+ }
1528
1718
  return reasons;
1529
1719
  }
@@ -1,15 +1,45 @@
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 { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { join } from "node:path";
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";
6
7
 
7
8
  // Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
8
9
  // at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
9
- // 2026-09-16 process storm). `.doug/.state/gate.lock` holds `{ pid, session, at }` while a gate's commands
10
- // run.
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.
11
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();
12
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
+ }
13
43
  return join(dir, GATE_LOCK_RELPATH);
14
44
  }
15
45
 
@@ -43,8 +73,9 @@ export function pidIsLive(pid) {
43
73
  // Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
44
74
  // caller decides what a failure means.
45
75
  export function writeGateLock(dir, sessionId) {
46
- mkdirSync(join(dir, ".doug/.state"), { recursive: true });
47
- writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
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" });
48
79
  }
49
80
 
50
81
  // Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
@@ -53,8 +84,9 @@ export function writeGateLock(dir, sessionId) {
53
84
  // does not hold).
54
85
  export function takeOverGateLock(dir, sessionId) {
55
86
  try {
56
- mkdirSync(join(dir, ".doug/.state"), { recursive: true });
57
- writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
87
+ const p = gateLockPath(dir);
88
+ mkdirSync(dirname(p), { recursive: true });
89
+ writeFileSync(p, JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
58
90
  } catch {
59
91
  // best-effort takeover only
60
92
  }
@@ -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.1",
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(),