@dougharness/cli 1.1.1 → 1.3.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 (57) 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/implementer.md +1 -1
  11. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  12. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  13. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  14. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  16. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  17. package/node_modules/@dougharness/flow/hooks/register.tsx +94 -0
  18. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  19. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  20. package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
  21. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  22. package/node_modules/@dougharness/flow/lib/card-loop.mjs +778 -0
  23. package/node_modules/@dougharness/flow/lib/coordinator.mjs +393 -0
  24. package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
  25. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  26. package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
  27. package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
  28. package/node_modules/@dougharness/flow/lib/memory.mjs +406 -76
  29. package/node_modules/@dougharness/flow/lib/plan.mjs +33 -5
  30. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  31. package/node_modules/@dougharness/flow/package.json +1 -1
  32. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +440 -0
  33. package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
  34. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  35. package/node_modules/@dougharness/flow/scripts/memory.mjs +33 -10
  36. package/node_modules/@dougharness/flow/scripts/plan.mjs +104 -30
  37. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  38. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
  39. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  40. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  41. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  42. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
  43. package/node_modules/@dougharness/flow/workflows/card-queen.js +846 -0
  44. package/node_modules/@dougharness/flow/workflows/doug-implement.js +63 -23
  45. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  46. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  47. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
  48. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
  49. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  50. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  51. package/node_modules/@dougharness/gates/package.json +1 -1
  52. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  53. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  54. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +156 -14
  55. package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
  56. package/package.json +3 -3
  57. package/templates/board-app.js +46 -0
@@ -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.3.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(),
@@ -27,9 +27,11 @@
27
27
  // Loopback exemption (card research-cap-localhost, whole-command grammar, fix pass 5): a local model probe is not
28
28
  // web research, so it is neither counted nor denied. The exemption applies only when the entire raw command
29
29
  // string, before any splitting, is ONE simple fetch (isLoopbackFetch):
30
- // 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick, a
31
- // backslash or !; an unterminated quote is not exempt. Outside quotes only space, tab, [A-Za-z0-9._:/=@%+,-]
32
- // and a redirect's > are allowed, so & | ; ( ) { } [ ] * ? ~ # $ ` \ ! < and a newline are never exempt.
30
+ // 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick or !,
31
+ // and a backslash only as \" or \\ (a literal char; any other backslash is not exempt); an unterminated quote
32
+ // is not exempt. Outside quotes only space, tab, [A-Za-z0-9._:/=@%+,-], a redirect's >, the exact word 2>&1
33
+ // and a backslash-newline continuation (removed, no word break) are allowed, so & | ; ( ) { } [ ] * ? ~ # $
34
+ // ` \ ! < and a bare newline are otherwise never exempt (2>&1>x, a&b, 2>&1; are not the exact word).
33
35
  // 2. The first word is exactly curl or wget: no path, wrapper, assignment or other case.
34
36
  // 3. Every other word is an allowlisted option (value-less -s -S -f -L -i -v -g or --silent and similar,
35
37
  // combinable in one short token; value-taking -o -H -d -X -w -m for curl, -O -T and --header for wget,
@@ -41,11 +43,18 @@
41
43
  // exactly /dev/null or does not start with /dev/ (bash opens /dev/tcp and /dev/udp as sockets).
42
44
  // A command outside the grammar is not an error: it is counted by bashFetchVerbs exactly as at HEAD, one count
43
45
  // per split command whose first resolved fetch form is curl or wget. Only Bash curl/wget is affected; WebFetch
44
- // and WebSearch always count. Anything doubtful counts: a compound line, a pipe, a wrapper, a mixed local and
45
- // remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
46
+ // and WebSearch always count.
46
47
  // Accepted limits of the exemption: an env proxy (https_proxy=http://evil.com in the environment, not on the
47
48
  // command) routes the request out; -L, and wget by default, follow a redirect a local server can point at a
48
49
  // remote host; a ~/.curlrc or ~/.wgetrc can add a URL or a proxy.
50
+ // Anything doubtful counts: a compound line, a pipe that is not a safe pipe consumer (below), a wrapper, a mixed
51
+ // local and remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
52
+ //
53
+ // Pipe consumers (card research-cap-loopback-post): the same exemption holds for a pipeline of unquoted single "|"
54
+ // (never ||, |& or an empty segment) whose first segment is a loopback fetch unchanged and whose every later
55
+ // segment is, with no redirect, head/tail with only a count (-c N, -n N, -cN, -nN, -N) or exactly
56
+ // python3|python -m json.tool. Anything else (jq, sh, curl, python -c) counts as at HEAD. Each counted Bash
57
+ // counter line also carries hosts, the URL hosts of that split command, so a miss is diagnosable.
49
58
  //
50
59
  // Known limit: several calls issued in one message can each read the same count before any of them appends, so
51
60
  // a single burst can push an agent's count past the cap by the number of calls it issues at once.
@@ -305,14 +314,29 @@ function tokenize(text) {
305
314
  gt = false;
306
315
  open = false;
307
316
  };
308
- for (const ch of text) {
317
+ for (let i = 0; i < text.length; i += 1) {
318
+ const ch = text[i];
309
319
  if (quote) {
320
+ if (quote === '"' && ch === "\\") {
321
+ // Only \" and \\ are escapes inside double quotes; both give a literal char. Any other backslash
322
+ // (\$, \`, \!, backslash-newline) leaves the grammar.
323
+ const next = text[i + 1];
324
+ if (next !== '"' && next !== "\\") return null;
325
+ raw += ch + next;
326
+ val += next;
327
+ i += 1;
328
+ continue;
329
+ }
310
330
  raw += ch;
311
331
  if (ch === quote) quote = "";
312
- else if (quote === '"' && "$`\\!".includes(ch)) return null;
332
+ else if (quote === '"' && "$`!".includes(ch)) return null;
313
333
  else val += ch;
314
334
  } else if (ch === " " || ch === "\t") end();
315
- else {
335
+ else if (ch === "\\" && text[i + 1] === "\n") i += 1; // a line continuation: removed, no word break
336
+ else if (!open && text.startsWith("2>&1", i) && (i + 4 === text.length || " \t".includes(text[i + 4]))) {
337
+ words.push({ raw: "2>&1", val: "2>&1", gt: false, dup: true }); // the exact word only, never a prefix
338
+ i += 3;
339
+ } else {
316
340
  open = true;
317
341
  raw += ch;
318
342
  if (ch === "'" || ch === '"') quote = ch;
@@ -337,7 +361,8 @@ function isLoopbackFetch(command) {
337
361
  let urls = 0;
338
362
  let wantValue = false;
339
363
  for (let k = 1; k < words.length; k += 1) {
340
- const { raw, val, gt } = words[k];
364
+ const { raw, val, gt, dup } = words[k];
365
+ if (dup) continue;
341
366
  if (gt) {
342
367
  const m = REDIRECT.exec(raw);
343
368
  if (!m) return false;
@@ -368,19 +393,98 @@ function isLoopbackFetch(command) {
368
393
  return urls > 0;
369
394
  }
370
395
 
371
- // Returns one verb ("curl" or "wget") per split command of a Bash command line that resolves to a fetch;
372
- // non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
396
+ // Splits a raw command on unquoted single "|" (card research-cap-loopback-post). Null when the text has a "||", a
397
+ // "|&", an empty segment, or no pipe at all, so only a plain pipeline is ever split.
398
+ function splitPipeline(command) {
399
+ const text = String(command);
400
+ const segs = [];
401
+ let quote = "";
402
+ let cur = "";
403
+ for (let i = 0; i < text.length; i += 1) {
404
+ const ch = text[i];
405
+ if (quote) {
406
+ if (quote === '"' && ch === "\\" && i + 1 < text.length) {
407
+ cur += ch + text[i + 1];
408
+ i += 1;
409
+ continue;
410
+ }
411
+ if (ch === quote) quote = "";
412
+ cur += ch;
413
+ } else if (ch === "'" || ch === '"') {
414
+ quote = ch;
415
+ cur += ch;
416
+ } else if (ch === "|") {
417
+ if (text[i + 1] === "|" || text[i + 1] === "&" || cur.trim() === "") return null;
418
+ segs.push(cur);
419
+ cur = "";
420
+ } else cur += ch;
421
+ }
422
+ if (quote || cur.trim() === "" || segs.length === 0) return null;
423
+ segs.push(cur);
424
+ return segs;
425
+ }
426
+
427
+ // One pipe consumer that cannot fetch: head or tail with only a count, or python[3] -m json.tool. No redirect.
428
+ function isSafeConsumer(segment) {
429
+ const words = tokenize(segment.trim());
430
+ if (!words || words.length === 0 || words.some((w) => w.gt || w.raw !== w.val)) return false;
431
+ const v = words.map((w) => w.val);
432
+ if (v[0] === "head" || v[0] === "tail") {
433
+ const rest = v.slice(1);
434
+ if (rest.length === 0) return false;
435
+ if (rest.length === 1) return /^-(?:[cn]?\d+)$/.test(rest[0]);
436
+ return rest.length === 2 && /^-[cn]$/.test(rest[0]) && /^\d+$/.test(rest[1]);
437
+ }
438
+ return (v[0] === "python3" || v[0] === "python") && v.length === 3 && v[1] === "-m" && v[2] === "json.tool";
439
+ }
440
+
441
+ // True when the whole command is one loopback fetch, or one followed by safe consumers (see the header).
442
+ function isLoopbackCommand(command) {
443
+ if (isLoopbackFetch(command)) return true;
444
+ const segs = splitPipeline(command);
445
+ return !!segs && isLoopbackFetch(segs[0]) && segs.slice(1).every(isSafeConsumer);
446
+ }
447
+
448
+ // URL hosts of one split command, lower-cased, unique, in order (card research-cap-loopback-post). Diagnostic
449
+ // only: never throws and never returns the command text.
450
+ function urlHosts(cmd) {
451
+ const hosts = [];
452
+ try {
453
+ for (const w of String(cmd).match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) {
454
+ const word = w.replace(/["']/g, "");
455
+ if (word === "" || word.startsWith("-")) continue;
456
+ let host = "";
457
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(word);
458
+ if (scheme) {
459
+ const auth = scheme[1].slice(scheme[1].lastIndexOf("@") + 1);
460
+ const m = /^(\[[^\]]*\]|[^:]*)/.exec(auth);
461
+ host = m ? m[1] : "";
462
+ } else {
463
+ const m = /^(\[::1\]|[a-z0-9.-]+)(:\d+)?([/?#]|$)/i.exec(word);
464
+ if (m && (/^(?:localhost|127\.0\.0\.1|\[::1\])$/i.test(m[1]) || m[2] || m[3])) host = m[1];
465
+ }
466
+ host = host.toLowerCase();
467
+ if (host && !hosts.includes(host)) hosts.push(host);
468
+ }
469
+ } catch {
470
+ // diagnostic only
471
+ }
472
+ return hosts;
473
+ }
474
+
475
+ // Returns one {verb, hosts} per split command of a Bash command line that resolves to a fetch (verb "curl" or
476
+ // "wget"); non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
373
477
  // fetches. commandForms runs on each split command's original text with its quotes intact (it does its own
374
478
  // quote parsing for a shell -c script), never on stripped or reconstructed text. A command that is one simple
375
- // loopback fetch (isLoopbackFetch) is not a fetch to count.
479
+ // loopback fetch, alone or piped to a safe consumer (isLoopbackCommand), is not a fetch to count.
376
480
  function bashFetchVerbs(command) {
377
- if (isLoopbackFetch(command)) return [];
481
+ if (isLoopbackCommand(command)) return [];
378
482
  const verbs = [];
379
483
  for (const cmd of splitTopLevel(command)) {
380
484
  for (const form of commandForms(cmd)) {
381
485
  const verb = firstWordBasename(form);
382
486
  if (FETCH_VERBS.has(verb)) {
383
- verbs.push(verb);
487
+ verbs.push({ verb, hosts: urlHosts(cmd) });
384
488
  break;
385
489
  }
386
490
  }
@@ -401,7 +505,7 @@ runHook("research-cap", async (input) => {
401
505
  if (input.tool_name === "Bash") {
402
506
  const verbs = bashFetchVerbs(input.tool_input && input.tool_input.command);
403
507
  if (verbs.length === 0) return allow(); // an ordinary Bash command is never counted or denied here
404
- entries = verbs.map((verb) => ({ tool: "Bash", verb }));
508
+ entries = verbs.map(({ verb, hosts }) => ({ tool: "Bash", verb, hosts }));
405
509
  } else {
406
510
  entries = [{ tool: input.tool_name }];
407
511
  }
@@ -73,11 +73,11 @@
73
73
  // Fails closed exactly like subagentsInFlight: with trace.enabled false, or the trace file missing or
74
74
  // unreadable, there is no evidence either way, so the gate runs as today.
75
75
 
76
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
76
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
77
77
  import { execFileSync, spawnSync } from "node:child_process";
78
78
  import { fileURLToPath } from "node:url";
79
79
  import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
80
- import { runHook, allow, blockStop, systemMessage } from "../lib/io.mjs";
80
+ import { runHook, allow as allowRaw, blockStop, systemMessage as systemMessageRaw } from "../lib/io.mjs";
81
81
  import { loadConfig, projectDir } from "../lib/config.mjs";
82
82
  import { loadState, saveState } from "../lib/state.mjs";
83
83
  import { matchAny } from "../lib/glob.mjs";
@@ -88,7 +88,7 @@ import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.m
88
88
  import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
89
89
  import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
90
90
  import { isAppliedContent } from "../lib/proposals.mjs";
91
- import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection } from "../lib/anchor.mjs";
91
+ import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, readPlanSummaries, snapshotSection } from "../lib/anchor.mjs";
92
92
  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";
@@ -492,7 +492,24 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
492
492
  // readAgentStart) and it equals the current one (same HEAD, same dirty set, same identities), and the agent's
493
493
  // final message carries no tests_red_by_design claim, and its trace shows no successful Edit-type call. Catches read-only agents that run Bash, which
494
494
  // agentMadeNoChanges counts as a change. No record, no git, any failure -> false (run the gate).
495
- function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFingerprint) {
495
+ // card stop-gate-worktree-agent-fingerprint: the start record is a fingerprint of the main checkout (the trace
496
+ // hook runs there), so for a worktree verification dir it is compared like with like instead: unchanged only if
497
+ // the record exists, every verification dir has no changed files, and every one's HEAD carries no commit of its
498
+ // own: it equals or is an ancestor of the record's HEAD, or of the main checkout's refs/remotes/origin/HEAD (Claude
499
+ // Code branches a fresh isolation worktree from the remote default branch, which local main may be ahead of or
500
+ // behind). Any git failure reads as changed. A verification set that mixes a worktree with `dir` reads as
501
+ // changed. A main-checkout agent keeps the fingerprint comparison.
502
+ function isAncestorIn(dir, a, b) {
503
+ try {
504
+ if (!/^[0-9a-f]{7,64}$/.test(String(a)) || !/^[0-9a-f]{7,64}$/.test(String(b))) return false;
505
+ execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: dir, stdio: "ignore", timeout: 5000 });
506
+ return true;
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
511
+
512
+ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFingerprint, vdir = dir, forced = null) {
496
513
  try {
497
514
  if (!subagent || !currentFingerprint) return false;
498
515
  if (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path))) return false;
@@ -503,6 +520,24 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
503
520
  if (existsSync(file) && readTrace(file).some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false)) return false;
504
521
  }
505
522
  const start = readAgentStart(dir, sessionId, subagent.id);
523
+ if (vdir !== dir) {
524
+ if (!start || typeof start !== "object" || !start.head) return false;
525
+ const dirs = forced ? [vdir] : verifyDirs(dir, cfg, sessionId, input, subagent);
526
+ if (!dirs.includes(vdir) || dirs.includes(dir)) return false;
527
+ let remoteHead;
528
+ try {
529
+ remoteHead = execFileSync("git", ["rev-parse", "--verify", "-q", "refs/remotes/origin/HEAD^{commit}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim() || null;
530
+ } catch {
531
+ remoteHead = null;
532
+ }
533
+ return dirs.every((d) => {
534
+ const h = headAt(d);
535
+ if (!h) return false;
536
+ if (!isAncestorIn(dir, h, start.head) && !(remoteHead && isAncestorIn(dir, h, remoteHead))) return false;
537
+ const c = changedFiles(d);
538
+ return Array.isArray(c) && c.length === 0;
539
+ });
540
+ }
506
541
  return sameTree(start, currentFingerprint);
507
542
  } catch {
508
543
  return false;
@@ -721,6 +756,7 @@ function contextNotice(dir, cfg, state, sessionId, boundary) {
721
756
  const anchorFile = join(dir, ".doug/anchor.md");
722
757
  const section = snapshotSection({
723
758
  plan: readPlanSummary(dir),
759
+ plans: readPlanSummaries(dir),
724
760
  decisions: readDecisions(dir),
725
761
  state,
726
762
  now: new Date(),
@@ -766,10 +802,24 @@ function waiverSources(dir, baselineHead) {
766
802
  return sources;
767
803
  }
768
804
 
805
+ // A tree's own last-green record, read live; null when absent or unreadable (card worktree-count-baseline).
806
+ function readLastGreen(dir) {
807
+ try {
808
+ const parsed = JSON.parse(readFileSync(join(dir, LAST_GREEN_RELPATH), "utf8"));
809
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
810
+ } catch {
811
+ return null;
812
+ }
813
+ }
814
+
769
815
  // The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
770
816
  // block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
771
817
  // record). A missing or unparseable summary, or no baseline, is a trace line and no check.
772
- function countCheck(dir, cfg, state, input, ran) {
818
+ function countCheck(dir, cfg, state, input, ran, vdir) {
819
+ // card worktree-count-baseline: a worktree run compares against its own earlier green runs, never main's record
820
+ // (a worktree can lack the dist build main has, so its skipped count differs). No record yet means no check.
821
+ const inWorktree = typeof vdir === "string" && vdir !== dir;
822
+ const baseline = inWorktree ? readLastGreen(vdir) : state.testTotals;
773
823
  const problems = [];
774
824
  const parsed = {};
775
825
  // The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
@@ -793,7 +843,7 @@ function countCheck(dir, cfg, state, input, ran) {
793
843
  note(`count check did not run for \`${command}\`: no test summary in its output`);
794
844
  continue;
795
845
  }
796
- const all = state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
846
+ const all = baseline && typeof baseline === "object" ? baseline : null;
797
847
  const baseList = all ? readSummaries(all[command]) : null;
798
848
  if (!baseList) {
799
849
  parsed[command] = summaries;
@@ -1008,10 +1058,99 @@ function normalizedDeferrals(state) {
1008
1058
  return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
1009
1059
  }
1010
1060
 
1061
+ // Card landing-block-enforced: board.mjs record/summary leave .doug/.state/reports/<id>/landing-block.txt and an
1062
+ // empty landing-block.pending (move <id> done re-arms it). A main Stop that would otherwise allow checks each
1063
+ // pending block against the lead's final message and blocks, once, until every line of it is there verbatim. The
1064
+ // marker is deleted the first time it is checked, so the block happens at most once per landing. Every exit that
1065
+ // lets a main Stop end plainly goes through allow() below. The systemMessage exits (the block-cap and turn-budget
1066
+ // stand-downs, the deferral notice, the repeat-deferral exit) return their text unchanged and never block (card
1067
+ // landing-block-shown-on-deferral): systemMessage() below first sweeps quietly, deleting a marker whose lines are
1068
+ // all in the final message and leaving any other, so a block printed on a deferral stop is not demanded again at
1069
+ // a later plain stop. A red gate (blockStop) keeps its own reason. A marker holds the session that landed (empty =
1070
+ // any); another session's marker is left untouched. Fails open: any throw, an unreadable block
1071
+ // file, or a final message that is not a string allows with one stderr line.
1072
+ let landingInput = null;
1073
+ // The pending markers this session owns, as [id, root] pairs; null when there is no reports directory.
1074
+ function ownedLandingMarkers(input) {
1075
+ const root = join(projectDir(input), ".doug/.state/reports");
1076
+ if (!existsSync(root)) return { root, pending: [] };
1077
+ const mine = (id) => {
1078
+ try {
1079
+ const owner = readFileSync(join(root, id, "landing-block.pending"), "utf8").trim();
1080
+ return owner === "" || owner === input.session_id;
1081
+ } catch {
1082
+ return false;
1083
+ }
1084
+ };
1085
+ return { root, pending: readdirSync(root).filter((id) => existsSync(join(root, id, "landing-block.pending")) && mine(id)).sort() };
1086
+ }
1087
+ function landingBlockLines(root, id) {
1088
+ return readFileSync(join(root, id, "landing-block.txt"), "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
1089
+ }
1090
+ function landingBlockReason(input) {
1091
+ try {
1092
+ const { root, pending } = ownedLandingMarkers(input);
1093
+ if (!pending.length) return null;
1094
+ const message = input.last_assistant_message;
1095
+ if (typeof message !== "string") {
1096
+ // The markers stay, so a later Stop with a readable final message still checks them.
1097
+ process.stderr.write("[doug] landing-block check skipped: the final message is unreadable\n");
1098
+ return null;
1099
+ }
1100
+ const missing = [];
1101
+ for (const id of pending) {
1102
+ const marker = join(root, id, "landing-block.pending");
1103
+ try {
1104
+ const lines = landingBlockLines(root, id);
1105
+ if (lines.length && !lines.every((l) => message.includes(l))) missing.push({ id, lines });
1106
+ } catch (e) {
1107
+ process.stderr.write(`[doug] landing-block check skipped: ${id}: ${e && e.message ? e.message : String(e)}\n`);
1108
+ }
1109
+ try {
1110
+ unlinkSync(marker);
1111
+ } catch {}
1112
+ }
1113
+ if (!missing.length) return null;
1114
+ return missing.map((m) => `Landing block missing from your final message for ${m.id}. Paste these lines verbatim:\n${m.lines.join("\n")}`).join("\n\n");
1115
+ } catch (e) {
1116
+ process.stderr.write(`[doug] landing-block check skipped: ${e && e.message ? e.message : String(e)}\n`);
1117
+ return null;
1118
+ }
1119
+ }
1120
+ // Quiet sweep for the systemMessage exits: delete each owned marker whose lines are all in the final message,
1121
+ // keep the rest. Never blocks, never changes the exit's text.
1122
+ function sweepLandingMarkers(input) {
1123
+ try {
1124
+ const message = input.last_assistant_message;
1125
+ if (typeof message !== "string") return;
1126
+ const { root, pending } = ownedLandingMarkers(input);
1127
+ for (const id of pending) {
1128
+ try {
1129
+ const lines = landingBlockLines(root, id);
1130
+ if (lines.length && lines.every((l) => message.includes(l))) unlinkSync(join(root, id, "landing-block.pending"));
1131
+ } catch (e) {
1132
+ process.stderr.write(`[doug] landing-block check skipped: ${id}: ${e && e.message ? e.message : String(e)}\n`);
1133
+ }
1134
+ }
1135
+ } catch (e) {
1136
+ process.stderr.write(`[doug] landing-block check skipped: ${e && e.message ? e.message : String(e)}\n`);
1137
+ }
1138
+ }
1139
+ function systemMessage(message) {
1140
+ if (landingInput) sweepLandingMarkers(landingInput);
1141
+ return systemMessageRaw(message);
1142
+ }
1143
+ function allow() {
1144
+ const reason = landingInput ? landingBlockReason(landingInput) : null;
1145
+ if (reason) return blockStop(reason);
1146
+ return allowRaw();
1147
+ }
1148
+
1011
1149
  const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
1012
1150
 
1013
1151
  runHook("stop-gate", async (input) => {
1014
- if (input.stop_hook_active) return allow();
1152
+ if (input.stop_hook_active) return allowRaw();
1153
+ if (input.hook_event_name !== "SubagentStop") landingInput = input;
1015
1154
  const dir = projectDir(input);
1016
1155
  const cfg = loadConfig(dir);
1017
1156
  let state = loadState(dir, input.session_id);
@@ -1394,8 +1533,8 @@ runHook("stop-gate", async (input) => {
1394
1533
  // Draft and rejected plans are not contracts yet, so they are ignored.
1395
1534
  let scope = null;
1396
1535
  if (Array.isArray(changed) && cfg.stopGate.planScope !== false) {
1397
- const { plan, reason, error } = loadScopePlan(dir);
1398
- if (reason === "unreadable") process.stderr.write(`[doug] ${PLAN_RELPATH} is unreadable (${error}); scope not checked\n`);
1536
+ const { plan, reason, error, file } = loadScopePlan(dir);
1537
+ if (reason === "unreadable") process.stderr.write(`[doug] ${file || PLAN_RELPATH} is unreadable (${error}); scope not checked\n`);
1399
1538
  if (plan) {
1400
1539
  const violations = scopeViolations(changed, plan, { ignore: cfg.stopGate.ignoreChangedPaths || [] });
1401
1540
  scope = { plan: plan.title, status: plan.status, outOfScope: violations.map((v) => v.file) };
@@ -1449,7 +1588,10 @@ runHook("stop-gate", async (input) => {
1449
1588
  state.deferrals = n;
1450
1589
  state.lastDeferral = { ids, fingerprint: currentFingerprint };
1451
1590
  saveState(dir, input.session_id, state);
1452
- if (repeat) return allow();
1591
+ if (repeat) {
1592
+ if (landingInput) sweepLandingMarkers(landingInput);
1593
+ return allowRaw();
1594
+ }
1453
1595
  return systemMessage(
1454
1596
  `[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
1455
1597
  );
@@ -1498,7 +1640,7 @@ runHook("stop-gate", async (input) => {
1498
1640
  };
1499
1641
  const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
1500
1642
  ? "no-agent-changes"
1501
- : subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
1643
+ : subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint, vdir, forced)
1502
1644
  ? "tree-unchanged"
1503
1645
  : readOnlyTurn
1504
1646
  ? "read-only-turn"
@@ -1588,7 +1730,7 @@ runHook("stop-gate", async (input) => {
1588
1730
 
1589
1731
  // card stop-gate-process-storm, case 3: only one gate runs the verification commands at a time per
1590
1732
  // project (the 2026-09-16 process storm: four concurrent SubagentStops each ran the full suite, each
1591
- // 300s timeout orphaning ten vitest workers). A gate holds .doug/.state/gate.lock while its commands run;
1733
+ // 300s timeout orphaning ten vitest workers). A gate holds gate.lock (gateLockPath: under the git common dir) while its commands run;
1592
1734
  // a second gate that finds a live lock waits for it, bounded by stopGate.timeoutMs, then reloads state and
1593
1735
  // reapplies the skip above — the holder's own green run may have just recorded the very `verified` tree
1594
1736
  // this gate needs, letting it skip instead of also running the full suite.
@@ -1863,7 +2005,7 @@ runHook("stop-gate", async (input) => {
1863
2005
  const evidenceProblemAdded = problems.length > postCommandProblems;
1864
2006
 
1865
2007
  // 3c. Test-count ratchet (card test-count-ratchet): its own kind of problem, not a failed command.
1866
- const counted = countCheck(dir, cfg, state, input, ranGreen);
2008
+ const counted = countCheck(dir, cfg, state, input, ranGreen, vdir);
1867
2009
  problems.push(...counted.problems);
1868
2010
  const countProblemAdded = counted.problems.length > 0;
1869
2011
 
@@ -1884,7 +2026,7 @@ runHook("stop-gate", async (input) => {
1884
2026
  // A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
1885
2027
  if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
1886
2028
  if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
1887
- if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
2029
+ if (problems.length === 0 && !skipVerification) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
1888
2030
  if (problems.length === 0 && subagent) {
1889
2031
  // A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
1890
2032
  state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };