@dougharness/cli 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,127 @@
1
+ // The block ledger (card stop-gate-block-ledger): one JSON line per Stop/SubagentStop block or stand-down,
2
+ // appended to .doug/.state/blocks.jsonl, so a later report (hand-report-schema) can read what the gate did
3
+ // without relying on the lead's own memory of it. `classifyFinalBlock` only LABELS a block for that report -
4
+ // it never decides whether the gate itself blocks or lets a stop through; that call is stop-gate.mjs's alone,
5
+ // made before classification ever runs. `appendBlockLine` never throws (a hook reader must never throw,
6
+ // CLAUDE.md gotchas): a failed write just skips the line, leaving the gate's own decision unchanged.
7
+ // The race class has a known limit: a check command that leaves unignored untracked output behind makes every block read 'race', and a content change to an already-dirty file is not seen.
8
+
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { changedFiles } from "./baseline.mjs";
12
+
13
+ export const BLOCKS_RELPATH = ".doug/.state/blocks.jsonl";
14
+
15
+ // A leading "./" is not part of a path token's identity (same normalisation stop-gate.mjs's own claim
16
+ // matching uses).
17
+ function stripLeadingDotSlash(f) {
18
+ return typeof f === "string" && f.startsWith("./") ? f.slice(2) : f;
19
+ }
20
+
21
+ // Test files a failed verification command's output names: every path token ending in ".test.<ext>" or
22
+ // ".spec.<ext>" (ext = letters) on a line containing "FAIL" or "failed", deduplicated, normalised to a
23
+ // repository-relative path when absolute under `dir`. Moved here from stop-gate.mjs (card
24
+ // stop-gate-block-ledger) so the classifier below and stop-gate.mjs's own tests_red_by_design coverage check
25
+ // share one implementation instead of two that could drift. Never throws: a parse failure reads as no files
26
+ // named.
27
+ export function failingTestFilesFrom(output, dir) {
28
+ try {
29
+ const files = new Set();
30
+ const tokenRe = /[^\s"'`]+\.(?:test|spec)\.[A-Za-z]+/g;
31
+ for (const line of String(output || "").split("\n")) {
32
+ if (!line.includes("FAIL") && !line.includes("failed")) continue;
33
+ for (const m of line.matchAll(tokenRe)) {
34
+ let f = m[0];
35
+ f = f.replace(/^[([<"'`]+/, "");
36
+ f = stripLeadingDotSlash(f);
37
+ if (typeof dir === "string" && dir && f.startsWith(dir + "/")) f = f.slice(dir.length + 1);
38
+ files.add(f);
39
+ }
40
+ }
41
+ return [...files];
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ // The final verification block's class (brief Rules, first match wins):
48
+ // 1. 'expected-red' — the only problems are failed commands (no pre-verification problem, no evidence
49
+ // problem, no budget-dropped command: the same precondition stop-gate.mjs's own honoured-claim path
50
+ // uses), EVERY failed command's output names at least one test file (review pass 2, item 1 MAJOR: the
51
+ // same per-command precondition redByDesignCoverage in stop-gate.mjs already enforces for honouring a
52
+ // claim — a second, unrelated command that names no test file at all must not be waved through just
53
+ // because the one command the claim does cover is also failing), and every named file across all of
54
+ // them is in the standing tests_red_by_design claim (`redByDesignClaimFiles`).
55
+ // 2. 'load-timeout' — at least one command timed out or was dropped for budget, and no failed command's
56
+ // captured output names a failing test file.
57
+ // 3. 'race' — the working tree (`changedFiles(dir)`, as a set) differs now from `changedAtStart`. Either
58
+ // side being unknown (null: no git, or git unavailable) is never a race. Skipped entirely once
59
+ // `budgetDeadline` (the gate's own hook-timeout deadline, gateBudget() in stop-gate.mjs) has already
60
+ // passed (review pass 2, item 4): a second `git status` here is exactly the kind of extra work that
61
+ // could push a slow gate past the platform's own hook timeout, discarding its block message (F1) — a
62
+ // deadline already blown is not worth spending on a label, so this falls through to 'real' instead.
63
+ // 4. 'real' — otherwise.
64
+ //
65
+ // Never throws (review pass 2, item 2 MAJOR): this call sits between the gate's decision to block and the
66
+ // blockStop call that carries it out (stop-gate.mjs), so an unguarded throw here would fail the *whole gate*
67
+ // open — the one outcome every hook reader must never risk (CLAUDE.md gotchas). Any failure classifies as
68
+ // 'real', the least specific label, rather than ever propagating.
69
+ export function classifyFinalBlock({ preVerificationProblems, evidenceProblemAdded, commandFailures, postCommandProblems, results, redByDesignClaimFiles, changedAtStart, dir, budgetDeadline }) {
70
+ try {
71
+ const failures = Array.isArray(commandFailures) ? commandFailures : [];
72
+ const namedByCommand = failures.map((cf) => failingTestFilesFrom(cf.output, dir));
73
+ const allNamed = [...new Set(namedByCommand.flat())];
74
+
75
+ const onlyFailedCommands =
76
+ preVerificationProblems === 0 &&
77
+ !evidenceProblemAdded &&
78
+ failures.length > 0 &&
79
+ postCommandProblems - preVerificationProblems === failures.length;
80
+ const everyCommandNamedAFile = failures.length > 0 && namedByCommand.every((files) => files.length > 0);
81
+ if (onlyFailedCommands && everyCommandNamedAFile && Array.isArray(redByDesignClaimFiles) && redByDesignClaimFiles.length > 0) {
82
+ const claimSet = new Set(redByDesignClaimFiles);
83
+ if (allNamed.every((f) => claimSet.has(f))) return "expected-red";
84
+ }
85
+
86
+ const timedOutOrDropped = (Array.isArray(results) ? results : []).some((r) => r.timedOut || r.skipped === "budget");
87
+ if (timedOutOrDropped && allNamed.length === 0) return "load-timeout";
88
+
89
+ if (Array.isArray(changedAtStart) && !(typeof budgetDeadline === "number" && Date.now() >= budgetDeadline)) {
90
+ const changedNow = changedFiles(dir);
91
+ if (Array.isArray(changedNow)) {
92
+ const before = new Set(changedAtStart);
93
+ const after = new Set(changedNow);
94
+ const same = before.size === after.size && [...before].every((f) => after.has(f));
95
+ if (!same) return "race";
96
+ }
97
+ }
98
+
99
+ return "real";
100
+ } catch {
101
+ return "real";
102
+ }
103
+ }
104
+
105
+ // Appends one line to .doug/.state/blocks.jsonl: { hook, at, agent, reason, class, session }. `reason` is
106
+ // reduced to its own first line here (message.split("\n")[0]) whatever the caller passes, so a multi-line
107
+ // block message never leaks past its headline into the ledger. Call synchronously, right before each
108
+ // blockStop/systemMessage in stop-gate.mjs — those each call process.exit(0) themselves, so anything queued
109
+ // after them never runs. Never throws: an unwritable .doug/.state, or a blocks.jsonl path that is itself a
110
+ // directory, just skips the line silently; the gate's own decision is unaffected either way.
111
+ export function appendBlockLine(dir, { hook, agent, reason, class: cls, session }) {
112
+ try {
113
+ const line =
114
+ JSON.stringify({
115
+ hook,
116
+ at: new Date().toISOString(),
117
+ agent: agent ?? null,
118
+ reason: String(reason).split("\n")[0],
119
+ class: cls,
120
+ session: session ?? null,
121
+ }) + "\n";
122
+ mkdirSync(join(dir, ".doug/.state"), { recursive: true });
123
+ appendFileSync(join(dir, BLOCKS_RELPATH), line);
124
+ } catch {
125
+ // a ledger write must never turn a block into a pass (a hook reader must never throw)
126
+ }
127
+ }
@@ -0,0 +1,97 @@
1
+ // Checkpoint on green: when the stop gate passes with changes present, record the working tree so a
2
+ // later regression can be undone with git instead of by memory. Off by default (config key
3
+ // `checkpoint`). Two modes:
4
+ // commit stage every change (except Claude Code's worktrees) and commit on the current branch
5
+ // tag build a commit from a temporary index and tag it doug/checkpoint/<utc time>; the branch,
6
+ // the index, and the working tree are untouched. Restore with `git checkout <tag> -- .`
7
+ // Never runs while a protected path is dirty, even one the Stop scan ignores (a lockfile), because a
8
+ // checkpoint must never quietly commit what a person is supposed to look at. Never throws.
9
+
10
+ import { execFileSync } from "node:child_process";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { matchAny } from "./glob.mjs";
15
+
16
+ export const MODES = ["commit", "tag"];
17
+ export const DEFAULT_MESSAGE = "doug: checkpoint";
18
+ // Claude Code keeps subagent worktrees inside the repo, untracked and unignored. Never stage them,
19
+ // nor the per-session hook state (doug init ignores it, but a checkpoint must not depend on that).
20
+ const STAGE_PATHSPEC = [".", ":(exclude).claude/worktrees", ":(exclude).doug/.state"];
21
+
22
+ function git(args, cwd, env) {
23
+ return execFileSync("git", args, {
24
+ cwd,
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ timeout: 60000,
28
+ env: env ? { ...process.env, ...env } : process.env,
29
+ }).trim();
30
+ }
31
+
32
+ function tryGit(args, cwd, env) {
33
+ try {
34
+ return git(args, cwd, env);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export function refName(at = new Date()) {
41
+ return "doug/checkpoint/" + at.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
42
+ }
43
+
44
+ export function commitMessage(cfg, gateResults) {
45
+ const head = (cfg.checkpoint && cfg.checkpoint.message) || DEFAULT_MESSAGE;
46
+ const gate = (gateResults || []).map((r) => `${r.name} ${r.ok ? "ok" : "failed"}`).join(", ");
47
+ return gate ? `${head}\n\ngate: ${gate}` : head;
48
+ }
49
+
50
+ // Returns one of: { skipped }, { error }, { mode, ref, sha }.
51
+ export function checkpoint({ dir, cfg, changed, gateResults, at = new Date() }) {
52
+ const c = cfg.checkpoint || {};
53
+ if (!c.enabled) return { skipped: "disabled" };
54
+ const mode = c.mode || "commit";
55
+ if (!MODES.includes(mode)) return { error: `checkpoint.mode must be one of ${MODES.join(", ")}, got ${JSON.stringify(c.mode)}` };
56
+ if (!Array.isArray(changed)) return { skipped: "not a git repository" };
57
+ if (changed.length === 0) return { skipped: "no changes" };
58
+ const dirtyProtected = changed.filter((f) => matchAny(cfg.protectedPaths || [], f));
59
+ if (dirtyProtected.length) return { skipped: `protected path dirty: ${dirtyProtected.join(", ")}` };
60
+ const message = commitMessage(cfg, gateResults);
61
+
62
+ try {
63
+ if (mode === "commit") {
64
+ const branch = tryGit(["symbolic-ref", "--short", "-q", "HEAD"], dir);
65
+ if (!branch) return { skipped: "detached HEAD; a checkpoint commit needs a branch" };
66
+ git(["add", "-A", "--", ...STAGE_PATHSPEC], dir);
67
+ git(["commit", "-q", "-m", message], dir); // the repo's own commit hooks run; never bypassed
68
+ return { mode, ref: branch, sha: git(["rev-parse", "--short", "HEAD"], dir) };
69
+ }
70
+ const tmp = mkdtempSync(join(tmpdir(), "doug-checkpoint-"));
71
+ try {
72
+ const env = { GIT_INDEX_FILE: join(tmp, "index") };
73
+ const parent = tryGit(["rev-parse", "--verify", "-q", "HEAD"], dir);
74
+ if (parent) git(["read-tree", "HEAD"], dir, env);
75
+ git(["add", "-A", "--", ...STAGE_PATHSPEC], dir, env);
76
+ const tree = git(["write-tree"], dir, env);
77
+ const args = ["commit-tree", tree, "-m", message];
78
+ if (parent) args.push("-p", parent);
79
+ const full = git(args, dir);
80
+ const ref = refName(at);
81
+ git(["tag", ref, full], dir);
82
+ return { mode, ref, sha: full.slice(0, 7) };
83
+ } finally {
84
+ rmSync(tmp, { recursive: true, force: true });
85
+ }
86
+ } catch (err) {
87
+ const text = (err && (err.stderr || err.message)) || String(err);
88
+ return { error: String(text).trim().split("\n").slice(-3).join("\n") };
89
+ }
90
+ }
91
+
92
+ export function describeCheckpoint(ck) {
93
+ if (ck.error) return `[doug] Checkpoint failed: ${ck.error}`;
94
+ if (ck.skipped) return `[doug] Checkpoint skipped: ${ck.skipped}.`;
95
+ if (ck.mode === "commit") return `[doug] Checkpoint committed as ${ck.sha} on ${ck.ref}.`;
96
+ return `[doug] Checkpoint tagged ${ck.ref} (${ck.sha}); branch, index, and working tree untouched. Restore with: git checkout ${ck.ref} -- .`;
97
+ }
@@ -0,0 +1,168 @@
1
+ // Loads .doug/config.json from the project directory and merges defaults.
2
+ // The installer writes this file; hooks only read it. No network, no LLM.
3
+
4
+ import { readFileSync, existsSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+
7
+ export const CONFIG_RELPATH = ".doug/config.json";
8
+
9
+ export const DEFAULTS = Object.freeze({
10
+ version: 1,
11
+ packageManager: null,
12
+ commands: {},
13
+ formatter: null,
14
+ protectedPaths: [".env", ".env.*", ".git/**", "node_modules/**", ".doug/config.json"],
15
+ // Written only through an approved proposal diff (card memory-decisions): a new ADR, an ADR amendment, or a
16
+ // rules file is rendered by decisions.mjs and applied with `learn.mjs apply` after the user approves it.
17
+ // protect-paths.mjs refuses the Edit/Write/MultiEdit/NotebookEdit tools on these, and the Stop gate flags a
18
+ // Bash-written change to them whose content is not recorded in the applied-proposal ledger
19
+ // (.doug/.state/proposals/applied.jsonl). Set to [] to turn this off.
20
+ proposalPaths: ["docs/decisions/**", ".claude/rules/**"],
21
+ // Paths outside the project directory (protect-paths.mjs) that an Edit/Write/MultiEdit/NotebookEdit may still
22
+ // touch: each entry is an absolute or ~-prefixed file or directory path (lib/paths.mjs isAllowedOutside).
23
+ // Empty by default so an existing project's behavior is unchanged.
24
+ allowedOutsidePaths: [],
25
+ bash: {
26
+ denyNoVerify: true,
27
+ denyForcePushTo: ["main", "master"],
28
+ denyDestructive: true,
29
+ packageManagerGuard: true,
30
+ },
31
+ // Deny secrets in edits/commands (lib/secret-rules.mjs): an AWS key id, a PEM private-key header,
32
+ // a GitHub/Slack token, or an inline password/secret/api_key/token assignment with a literal value.
33
+ secrets: {
34
+ enabled: true,
35
+ rules: { awsKeyId: true, pemPrivateKey: true, githubToken: true, slackToken: true, inlineAssignment: true },
36
+ placeholders: ["your_", "your-", "xxx", "placeholder", "example", "changeme", "dummy", "fake", "sample", "redacted"],
37
+ ignorePaths: [],
38
+ },
39
+ editLoop: { threshold: 6, windowMinutes: 30 },
40
+ stopGate: {
41
+ commands: [],
42
+ onlyIfEdited: true,
43
+ timeoutMs: 300000,
44
+ // Card stop-gate-budget-under-hook-timeout: the platform's own Stop/SubagentStop hook `timeout`
45
+ // (plugins/doug-gates/hooks/hooks.json and .claude/settings.json, seconds), the gate reads this
46
+ // to bound its own wait-plus-commands under it — a hook the platform kills is cancelled, fails
47
+ // open, and its output is discarded. Must equal that hook's declared `timeout`; nothing else keeps
48
+ // them in step (vendored-copies.test.mjs pins it). A non-number or non-positive value reads as 600.
49
+ hookTimeoutSec: 600,
50
+ maxBlocks: 3,
51
+ // Card stop-gate-deferral-cap: consecutive main Stops a subagent-in-flight deferral (D3 below) may skip
52
+ // before the gate runs the full verification anyway. A non-number, non-finite, or below-1 value (0,
53
+ // negative, a string, null, an object) reads as the default, 10 (stop-gate.mjs's normaliser).
54
+ maxDeferrals: 10,
55
+ // Protected paths that tools other than Edit may legitimately change (lockfiles via the package manager;
56
+ // .doug/config.json, written by `doug init`). They stay protected from hand edits — protect-paths.mjs still
57
+ // refuses the Edit/Write tool on them, and that refusal is the control that matters — but the Stop scan,
58
+ // which only catches Bash-originated writes after the fact, does not flag them. .doug/config.json needs this
59
+ // for the same reason a lockfile does: the installer's own first-run output must not block the very first
60
+ // Stop.
61
+ ignoreChangedPaths: [".doug/config.json"],
62
+ // With an approved or done .doug/plan.json, block the stop when a changed file is outside the
63
+ // files the plan's tasks own (lib/scope.mjs). ignoreChangedPaths applies here too.
64
+ planScope: true,
65
+ // Block the stop while this session has run no test or verify command itself (lib/evidence.mjs): the gate
66
+ // commands, any other configured command, a known test runner, or one of evidencePatterns (regular expressions).
67
+ requireEvidence: true,
68
+ evidencePatterns: [],
69
+ },
70
+ // Checkpoint on green (lib/checkpoint.mjs): when the stop gate passes with changes present, commit
71
+ // on the current branch ("commit") or tag a commit built off a temporary index ("tag"). Off by default.
72
+ checkpoint: { enabled: false, mode: "commit", message: "doug: checkpoint" },
73
+ // Run trace (lib/trace.mjs): one JSONL line per subagent start/stop and tool call under .doug/.state/trace/.
74
+ trace: { enabled: true },
75
+ budget: { maxTurns: null },
76
+ anchor: [],
77
+ // Context-window handoff (card context-window-handoff): the status line records context_window.used_percentage
78
+ // into session state when enabled, and the Stop gate advises (never compacts) at a green boundary once pct
79
+ // reaches threshold. Off by default so an existing project is unchanged. repeatAfter is the points of growth
80
+ // required before the Stop-gate notice repeats. Requires trace.enabled (on by default): the notice's "no
81
+ // subagent running" claim reads the run trace, so it stays silent while the trace is off rather than guess.
82
+ contextWindow: { enabled: false, threshold: 80, repeatAfter: 5 },
83
+ // Per-subagent cap on WebSearch plus WebFetch calls (scripts/research-cap.mjs, card research-fetch-cap): null
84
+ // or 0 turns it off. The main session is never capped.
85
+ research: { maxFetches: 6 },
86
+ });
87
+
88
+ export function projectDir(input) {
89
+ return resolve(process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd());
90
+ }
91
+
92
+ function deepMerge(base, over) {
93
+ if (Array.isArray(base) || Array.isArray(over)) return over ?? base;
94
+ if (typeof base !== "object" || base === null) return over ?? base;
95
+ const out = { ...base };
96
+ for (const [k, v] of Object.entries(over || {})) {
97
+ out[k] = k in base && typeof base[k] === "object" && !Array.isArray(base[k]) ? deepMerge(base[k], v) : v;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // Card gates-config-shape-check (decision, option a), widened by card gates-config-shape-all-arrays: every
103
+ // array-typed value in DEFAULTS (nested objects included) — the keys the gates iterate directly (matchAny's
104
+ // `for (const raw of patterns)` in lib/glob.mjs, or an unguarded `for (const entry of ...)`) — must never reach a
105
+ // gate as anything but an array. deepMerge's own ternary only recurses into an object base, so an array-typed
106
+ // default's raw override (of any shape, including null) is assigned straight through. Fold that back to the
107
+ // default here: a non-null, non-array value (a number, string, object, or boolean) warns once on stderr naming
108
+ // the dotted path and falls back to its default, the way memory.embeddings already does
109
+ // (plugins/doug-flow/lib/embeddings.mjs:48). An explicit null falls back to the default silently: for these keys
110
+ // deepMerge's object branch (`out[k] = ... deepMerge(base[k], v) : v`) assigns v straight through, so a null
111
+ // override already reached the gates as null before ddcc165 — the fallback here now covers that case too,
112
+ // without a warning, matching the rest of loadConfig's `over ?? base` convention. A valid array, including [],
113
+ // passes through unchanged. Array entries of the wrong type are out of scope. Derived by walking DEFAULTS itself
114
+ // (not a hand list), so a new array-typed default is covered automatically. A parent object replaced by a
115
+ // non-object in the user file (e.g. `stopGate: [1]`) is walked against DEFAULTS's shape, not the user's, so it
116
+ // never throws; that parent's own shape is otherwise unchanged from HEAD behaviour (no new parent normalisation).
117
+ function arrayShapePaths(defaults, prefix = []) {
118
+ let out = [];
119
+ for (const [k, v] of Object.entries(defaults)) {
120
+ const path = [...prefix, k];
121
+ if (Array.isArray(v)) out.push(path);
122
+ else if (v && typeof v === "object") out = out.concat(arrayShapePaths(v, path));
123
+ }
124
+ return out;
125
+ }
126
+
127
+ function normaliseArrayShapes(cfg) {
128
+ for (const path of arrayShapePaths(DEFAULTS)) {
129
+ // Walk cfg alongside path, stopping (leaving cfg as-is) if a parent along the way is not a plain object —
130
+ // covers a parent replaced by a non-object (stopGate: [1], secrets: "x") without throwing.
131
+ let parent = cfg;
132
+ let ok = true;
133
+ for (let i = 0; i < path.length - 1; i++) {
134
+ const next = parent[path[i]];
135
+ if (next === null || typeof next !== "object" || Array.isArray(next)) {
136
+ ok = false;
137
+ break;
138
+ }
139
+ parent = next;
140
+ }
141
+ if (!ok) continue;
142
+ const key = path[path.length - 1];
143
+ const v = parent[key];
144
+ if (v === undefined || Array.isArray(v)) continue;
145
+ const def = path.reduce((o, k) => o[k], DEFAULTS);
146
+ if (v === null) {
147
+ parent[key] = def;
148
+ continue;
149
+ }
150
+ process.stderr.write(`[doug] ${CONFIG_RELPATH} ${path.join(".")} is not an array; using the default\n`);
151
+ parent[key] = def;
152
+ }
153
+ }
154
+
155
+ export function loadConfig(dir) {
156
+ const file = join(dir, CONFIG_RELPATH);
157
+ if (!existsSync(file)) return { ...DEFAULTS, _present: false };
158
+ try {
159
+ const raw = JSON.parse(readFileSync(file, "utf8"));
160
+ const merged = { ...deepMerge(DEFAULTS, raw), _present: true };
161
+ normaliseArrayShapes(merged);
162
+ return merged;
163
+ } catch (err) {
164
+ // A broken config must never silently disable gates. Fall back to defaults and say so.
165
+ process.stderr.write(`[doug] ${CONFIG_RELPATH} is unreadable (${err.message}); using defaults\n`);
166
+ return { ...DEFAULTS, _present: false, _error: String(err.message) };
167
+ }
168
+ }
@@ -0,0 +1,59 @@
1
+ // Verification evidence (proposal B, card verification-evidence): did this session itself run a test or verify
2
+ // command? guard-bash records every allowed Bash command in the session state; the stop gate asks here whether any
3
+ // of them is one of the gate commands, another configured project command, a known test runner, or a pattern
4
+ // from stopGate.evidencePatterns. The gate's own run says whether verification passes; this says whether the
5
+ // agent ever looked, so "ran and failed" and "never ran" get different messages.
6
+
7
+ export const RUNNER_PATTERNS = [
8
+ /\bvitest\b/,
9
+ /\bjest\b/,
10
+ /\bmocha\b/,
11
+ /\bnode\s+--test\b/,
12
+ /\bpytest\b/,
13
+ /\bpython3?\s+-m\s+unittest\b/,
14
+ /(^|[\s;&|(])tsc(\s|$)/,
15
+ /\bcargo\s+test\b/,
16
+ /\bgo\s+test\b/,
17
+ /\bswift\s+test\b/,
18
+ /\bxcodebuild\b.*\btest\b/,
19
+ /\bmvn\b.*\btest\b/,
20
+ /\bgradle\w*\b.*\btest\b/,
21
+ /\bdotnet\s+test\b/,
22
+ /\bmake\s+(test|check)\b/,
23
+ ];
24
+
25
+ // The commands the stop gate runs, by name, plus every other configured project command except install.
26
+ export function evidenceCommands(cfg) {
27
+ const out = [];
28
+ for (const entry of (cfg.stopGate && cfg.stopGate.commands) || []) {
29
+ if (typeof entry !== "string") continue;
30
+ const named = cfg.commands && cfg.commands[entry];
31
+ out.push(typeof named === "string" ? named : entry);
32
+ }
33
+ for (const [name, command] of Object.entries(cfg.commands || {})) {
34
+ if (name === "install" || typeof command !== "string" || out.includes(command)) continue;
35
+ out.push(command);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // { ran: the recorded commands that count as verification, looked: what counts }.
41
+ export function verificationEvidence(commands, cfg) {
42
+ const looked = evidenceCommands(cfg);
43
+ const extra = ((cfg.stopGate && cfg.stopGate.evidencePatterns) || []).flatMap((p) => {
44
+ try {
45
+ return [new RegExp(p)];
46
+ } catch {
47
+ return [];
48
+ }
49
+ });
50
+ const ran = (commands || []).filter((c) => typeof c === "string" && (looked.some((g) => g && c.includes(g)) || RUNNER_PATTERNS.some((r) => r.test(c)) || extra.some((r) => r.test(c))));
51
+ return { ran, looked };
52
+ }
53
+
54
+ export function describeMissingEvidence(looked, gatePassed) {
55
+ const named = looked.length ? looked.map((c) => `\`${c}\``).join(", ") + ", " : "";
56
+ return gatePassed
57
+ ? `No test or verify command ran in this session (looked for ${named}or a test runner such as vitest, jest, or pytest). The gate ran them here and they pass, but run the test command yourself and report its result before finishing; do not report work as verified that you did not verify.`
58
+ : `This session never ran a test or verify command itself (looked for ${named}or a test runner). Run it, read the failure above, and fix it before finishing.`;
59
+ }
@@ -0,0 +1,86 @@
1
+ // Minimal glob matching with no dependencies.
2
+ // Supports **, *, ?, {a,b}, and leading "!" negation in matchAny.
3
+ // Paths are matched as forward-slash relative paths.
4
+
5
+ const cache = new Map();
6
+
7
+ export function globToRegExp(glob) {
8
+ if (cache.has(glob)) return cache.get(glob);
9
+ let re = "";
10
+ let i = 0;
11
+ while (i < glob.length) {
12
+ const c = glob[i];
13
+ if (c === "*") {
14
+ if (glob[i + 1] === "*") {
15
+ // "**/" matches zero or more directories; "**" at end matches everything
16
+ if (glob[i + 2] === "/") {
17
+ re += "(?:.*/)?";
18
+ i += 3;
19
+ } else {
20
+ re += ".*";
21
+ i += 2;
22
+ }
23
+ } else {
24
+ re += "[^/]*";
25
+ i += 1;
26
+ }
27
+ } else if (c === "?") {
28
+ re += "[^/]";
29
+ i += 1;
30
+ } else if (c === "{") {
31
+ const end = glob.indexOf("}", i);
32
+ if (end === -1) {
33
+ re += "\\{";
34
+ i += 1;
35
+ } else {
36
+ const alts = glob
37
+ .slice(i + 1, end)
38
+ .split(",")
39
+ .map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"));
40
+ re += "(?:" + alts.join("|") + ")";
41
+ i = end + 1;
42
+ }
43
+ } else if (/[.+^$()|[\]\\]/.test(c)) {
44
+ re += "\\" + c;
45
+ i += 1;
46
+ } else {
47
+ re += c;
48
+ i += 1;
49
+ }
50
+ }
51
+ const compiled = new RegExp("^" + re + "$");
52
+ cache.set(glob, compiled);
53
+ return compiled;
54
+ }
55
+
56
+ export function normalizePath(p) {
57
+ return p.replace(/\\/g, "/").replace(/^\.\//, "");
58
+ }
59
+
60
+ export function matchGlob(glob, path) {
61
+ const n = normalizePath(path);
62
+ const g = normalizePath(glob);
63
+ if (globToRegExp(g).test(n)) return true;
64
+ // A bare directory-ish pattern like "dist" also matches "dist/anything"
65
+ if (!g.includes("*") && !g.includes("?") && n.startsWith(g + "/")) return true;
66
+ // Patterns without a slash match a basename anywhere (like .gitignore)
67
+ if (!g.includes("/")) {
68
+ const base = n.split("/").pop();
69
+ if (globToRegExp(g).test(base)) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ // Returns the first matching pattern, or null. Negations ("!x") exclude.
75
+ export function matchAny(patterns, path) {
76
+ let hit = null;
77
+ for (const raw of patterns) {
78
+ const negated = raw.startsWith("!");
79
+ const pat = negated ? raw.slice(1) : raw;
80
+ if (matchGlob(pat, path)) {
81
+ if (negated) return null;
82
+ hit = hit ?? raw;
83
+ }
84
+ }
85
+ return hit;
86
+ }
@@ -0,0 +1,91 @@
1
+ // Hook I/O against the Claude Code hook contract (Sept 2026).
2
+ // - Input: one JSON object on stdin.
3
+ // - Output: exit 0 with a JSON decision on stdout. Exit 2 with stderr only as a last resort.
4
+ // Every hook fails OPEN on its own bugs, but visibly: a systemMessage names the hook and the error.
5
+
6
+ export async function readInput(timeoutMs = 3000) {
7
+ const chunks = [];
8
+ const done = new Promise((resolve) => {
9
+ process.stdin.on("data", (c) => chunks.push(c));
10
+ process.stdin.on("end", resolve);
11
+ process.stdin.on("error", resolve);
12
+ setTimeout(resolve, timeoutMs).unref();
13
+ });
14
+ await done;
15
+ const text = Buffer.concat(chunks).toString("utf8").trim();
16
+ if (!text) return {};
17
+ try {
18
+ return JSON.parse(text);
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ // Field-name compatibility across doc revisions.
25
+ export function toolResponse(input) {
26
+ return input.tool_response ?? input.tool_output ?? null;
27
+ }
28
+ export function userPrompt(input) {
29
+ return input.prompt ?? input.user_prompt ?? "";
30
+ }
31
+ export function sessionStartReason(input) {
32
+ return input.source ?? input.session_start_reason ?? "";
33
+ }
34
+
35
+ function emit(obj) {
36
+ process.stdout.write(JSON.stringify(obj) + "\n");
37
+ }
38
+
39
+ export function allow() {
40
+ process.exit(0);
41
+ }
42
+
43
+ export function denyTool(reason) {
44
+ emit({
45
+ hookSpecificOutput: {
46
+ hookEventName: "PreToolUse",
47
+ permissionDecision: "deny",
48
+ permissionDecisionReason: reason,
49
+ },
50
+ });
51
+ process.exit(0);
52
+ }
53
+
54
+ export function askTool(reason) {
55
+ emit({
56
+ hookSpecificOutput: {
57
+ hookEventName: "PreToolUse",
58
+ permissionDecision: "ask",
59
+ permissionDecisionReason: reason,
60
+ },
61
+ });
62
+ process.exit(0);
63
+ }
64
+
65
+ export function addContext(hookEventName, text, extra = {}) {
66
+ emit({ ...extra, hookSpecificOutput: { hookEventName, additionalContext: text } });
67
+ process.exit(0);
68
+ }
69
+
70
+ export function blockStop(reason) {
71
+ emit({ decision: "block", reason });
72
+ process.exit(0);
73
+ }
74
+
75
+ export function systemMessage(message) {
76
+ emit({ systemMessage: message });
77
+ process.exit(0);
78
+ }
79
+
80
+ // Wraps a hook main() so that an unexpected exception never silently blocks or silently passes.
81
+ export function runHook(name, main) {
82
+ readInput()
83
+ .then((input) => main(input))
84
+ .then(() => process.exit(0))
85
+ .catch((err) => {
86
+ const msg = `[doug:${name}] hook error, failing open: ${err && err.stack ? err.stack.split("\n")[0] : String(err)}`;
87
+ process.stderr.write(msg + "\n");
88
+ emit({ systemMessage: msg });
89
+ process.exit(0);
90
+ });
91
+ }