@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,314 @@
1
+ // Proposal builders for docs/decisions/ ADRs and .claude/rules/ conventions (card memory-decisions). Pure given
2
+ // their inputs (a project dir to read existing files from, and the caller's title/body/paths text) - no LLM, no
3
+ // network - and every function here only ever calls writeProposals (lib/learn.mjs), which writes under
4
+ // .doug/.state/learn/. Nothing here ever touches docs/decisions/ or .claude/rules/ directly; the only writer for
5
+ // those paths is learn.mjs's applyProposal, and only after the user approves the diff this module renders.
6
+ //
7
+ // The .claude/rules/ contract this module's proposeRule follows (research note, memory-decisions): Claude Code
8
+ // loads every *.md under .claude/rules/ recursively; the frontmatter key is `paths:`, a YAML list of globs; a
9
+ // rule with no `paths` loads unconditionally for every file at session start; a path-scoped rule loads on demand
10
+ // when a matching file is read. Source: https://code.claude.com/docs/en/memory.md#path-specific-rules
11
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { unifiedDiff, writeProposals, countLines, insertBulletUnderHeading, removeExactLine } from "./learn.mjs";
14
+
15
+ const DECISIONS_RELDIR = "docs/decisions";
16
+ const RULES_RELDIR = ".claude/rules";
17
+ const CLAUDE_MD_RELPATH = "CLAUDE.md";
18
+ const GATES_SCRIPTS_RELDIR = "plugins/doug-gates/scripts";
19
+
20
+ // Scans docs/decisions/NNNN-*.md and returns the next zero-padded number ("0001" when the directory is empty
21
+ // or missing). A file that does not match the NNNN-*.md shape (a stray README, say) is ignored rather than
22
+ // breaking the scan.
23
+ export function nextDecisionNumber(dir) {
24
+ const decisionsDir = join(dir, DECISIONS_RELDIR);
25
+ let max = 0;
26
+ if (existsSync(decisionsDir)) {
27
+ for (const f of readdirSync(decisionsDir)) {
28
+ const m = /^(\d{4})-.+\.md$/.exec(f);
29
+ if (m) max = Math.max(max, Number(m[1]));
30
+ }
31
+ }
32
+ return String(max + 1).padStart(4, "0");
33
+ }
34
+
35
+ // lowercase, non-alphanumerics collapsed to a single "-", trimmed of leading/trailing "-", capped at ~60 chars
36
+ // (re-trimmed after the cut so a mid-word slice never leaves a trailing "-").
37
+ export function slugify(title) {
38
+ const collapsed = String(title || "")
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, "-")
41
+ .replace(/^-+|-+$/g, "");
42
+ return collapsed.slice(0, 60).replace(/-+$/, "");
43
+ }
44
+
45
+ // The repo's ADR shape, exactly like docs/decisions/0006-out-of-workflow-access.md: "# NNNN. <title>", blank,
46
+ // "Date: YYYY-MM-DD. Status: accepted.", blank, then the body verbatim (the author's own markdown - never
47
+ // wrapped or reformatted), with exactly one trailing newline on the whole file regardless of how the body's own
48
+ // trailing whitespace was written.
49
+ export function renderDecision({ number, title, body, date }) {
50
+ const trimmedBody = String(body || "").replace(/\s+$/, "");
51
+ return `# ${number}. ${title}\n\nDate: ${date}. Status: accepted.\n\n${trimmedBody}\n`;
52
+ }
53
+
54
+ function validRuleName(name) {
55
+ return /^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/.test(String(name ?? ""));
56
+ }
57
+
58
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
59
+
60
+ // Today's local date as YYYY-MM-DD (not UTC - a caller in any timezone gets their own "today").
61
+ function todayLocal() {
62
+ const d = new Date();
63
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
64
+ }
65
+
66
+ // Resolves the `date` a caller passes to proposeDecision/proposeAmendment: defaults to today's local date when
67
+ // omitted (undefined, null, or ""), refuses anything else that is not YYYY-MM-DD. renderDecision and the
68
+ // amendment text below interpolate this string directly with no check of their own, so a missing or malformed
69
+ // date must never reach them (reviewer defect: a lib caller with no `date` wrote a literal "Date: undefined.").
70
+ function resolveDate(date) {
71
+ const d = date === undefined || date === null || date === "" ? todayLocal() : String(date);
72
+ if (!DATE_RE.test(d)) return { ok: false, reason: `date must be YYYY-MM-DD, got ${JSON.stringify(date)}` };
73
+ return { ok: true, date: d };
74
+ }
75
+
76
+ // unifiedDiff(relPath, oldText, newText) returns null when the two are identical - nothing to propose. Every
77
+ // proposal builder below goes through this rather than handing a null diff to writeProposals, which would
78
+ // silently write no diff file and leave the caller printing `diff: undefined` / `apply with: ... undefined`
79
+ // (reviewer defect, reproduced with `memory.mjs rule propose <name> --file <path-to-the-same-content>`).
80
+ function diffOrRefuse(relPath, oldText, newText) {
81
+ const diff = unifiedDiff(relPath, oldText, newText);
82
+ if (diff === null) return { ok: false, reason: `${relPath} already has exactly this content; nothing to propose` };
83
+ return { ok: true, diff };
84
+ }
85
+
86
+ // Builds one proposal for `content` at `relPath`: a diff from the file's current text when it exists (an
87
+ // ordinary unifiedDiff), or a creation diff (unifiedDiff from "" - git apply creates the file, including any
88
+ // missing parent directory, from a plain "--- a/<p>" / "+++ b/<p>" header with an all-add hunk; verified against
89
+ // a real `git apply --check` in decisions.test.mjs, so no special "new file mode" header is needed). Refuses
90
+ // (via diffOrRefuse) rather than building a proposal with no diff when `content` already matches the file.
91
+ function buildProposal(dir, relPath, content, { kind, id, reason }) {
92
+ const target = join(dir, relPath);
93
+ const exists = existsSync(target);
94
+ const oldText = exists ? readFileSync(target, "utf8") : "";
95
+ const diffResult = diffOrRefuse(relPath, oldText, content);
96
+ if (!diffResult.ok) return diffResult;
97
+ return { ok: true, proposal: { id, kind, target: relPath, reason, diff: diffResult.diff } };
98
+ }
99
+
100
+ function writeOne(proposal, { dir, now }) {
101
+ const written = writeProposals([proposal], { dir, now });
102
+ return { ok: true, proposal, dir: written.dir, diffPath: written.diffPaths[0] };
103
+ }
104
+
105
+ // Proposes a brand-new ADR at docs/decisions/<NNNN>-<slug>.md. Refuses (never writing anything) when the target
106
+ // already exists, title/body is empty, the title slugs to nothing (punctuation-only, e.g. "!!!"), or `date` is
107
+ // given and is not YYYY-MM-DD; `date` defaults to today's local date when omitted.
108
+ export function proposeDecision({ dir, title, body, date, now = new Date() } = {}) {
109
+ if (!String(title || "").trim()) return { ok: false, reason: "title is required" };
110
+ const slug = slugify(title);
111
+ if (!slug) return { ok: false, reason: "title yields an empty slug" };
112
+ if (!String(body || "").trim()) return { ok: false, reason: "body is required" };
113
+ const resolvedDate = resolveDate(date);
114
+ if (!resolvedDate.ok) return resolvedDate;
115
+ const number = nextDecisionNumber(dir);
116
+ const relPath = `${DECISIONS_RELDIR}/${number}-${slug}.md`;
117
+ if (existsSync(join(dir, relPath))) return { ok: false, reason: `${relPath} already exists` };
118
+ const content = renderDecision({ number, title, body, date: resolvedDate.date });
119
+ const built = buildProposal(dir, relPath, content, { kind: "decision", id: "01", reason: `new ADR ${number}: ${title}` });
120
+ if (!built.ok) return built;
121
+ return writeOne(built.proposal, { dir, now });
122
+ }
123
+
124
+ // `id` is a decision number ("0007") or a full basename/path ("0007-x.md", or a path ending in one) - only the
125
+ // leading NNNN is read from it. Finds the one existing docs/decisions/<NNNN>-*.md; refuses when none or several
126
+ // match, `text` is empty, or `date` is given and is not YYYY-MM-DD (`date` defaults to today's local date when
127
+ // omitted). Appends a dated "## Amendment" section to the file's current content.
128
+ export function proposeAmendment({ dir, id, text, date, now = new Date() } = {}) {
129
+ const m = /^(\d{4})/.exec(String(id ?? "").split("/").pop());
130
+ if (!m) return { ok: false, reason: `${id} is not a valid decision id (expected NNNN, or a NNNN-slug.md basename/path)` };
131
+ const number = m[1];
132
+ const decisionsDir = join(dir, DECISIONS_RELDIR);
133
+ const matches = existsSync(decisionsDir) ? readdirSync(decisionsDir).filter((f) => new RegExp(`^${number}-.+\\.md$`).test(f)) : [];
134
+ if (matches.length === 0) return { ok: false, reason: `no ADR found for ${number} in ${DECISIONS_RELDIR}/` };
135
+ if (matches.length > 1) return { ok: false, reason: `ambiguous: multiple ADRs match ${number}: ${matches.sort().join(", ")}` };
136
+ if (!String(text || "").trim()) return { ok: false, reason: "text is required" };
137
+ const resolvedDate = resolveDate(date);
138
+ if (!resolvedDate.ok) return resolvedDate;
139
+ const relPath = `${DECISIONS_RELDIR}/${matches[0]}`;
140
+ const oldText = readFileSync(join(dir, relPath), "utf8");
141
+ const appendText = String(text).replace(/\n+$/, "");
142
+ const newText = `${oldText}\n## Amendment ${resolvedDate.date}\n\n${appendText}\n`;
143
+ const diffResult = diffOrRefuse(relPath, oldText, newText);
144
+ if (!diffResult.ok) return diffResult;
145
+ const proposal = { id: "01", kind: "amend", target: relPath, reason: `amend ${matches[0]}`, diff: diffResult.diff };
146
+ return writeOne(proposal, { dir, now });
147
+ }
148
+
149
+ // Proposes creating or replacing .claude/rules/<name>.md. `name` must be a slug ([a-z0-9][a-z0-9-]*), optionally
150
+ // with one "/" level for a subdirectory ("web/api"); refused otherwise (so "../x" or "A B" cannot escape the
151
+ // rules directory or produce a malformed file name). With a non-empty `paths` list, the file gets a `paths:`
152
+ // YAML frontmatter block (the research note's contract: a path-scoped rule loads only for a matching file);
153
+ // with none, the body alone (loads unconditionally for every file, at session start).
154
+ export function proposeRule({ dir, name, paths = [], body, now = new Date() } = {}) {
155
+ if (!validRuleName(name)) return { ok: false, reason: `"${name}" is not a valid rule name (expected [a-z0-9][a-z0-9-]*, optionally one /subdir level)` };
156
+ if (!String(body || "").trim()) return { ok: false, reason: "body is required" };
157
+ const relPath = `${RULES_RELDIR}/${name}.md`;
158
+ const trimmedBody = String(body).replace(/\s+$/, "");
159
+ const list = Array.isArray(paths) ? paths.filter((p) => String(p || "").trim()) : [];
160
+ const content = list.length ? `---\npaths:\n${list.map((p) => ` - "${p}"`).join("\n")}\n---\n\n${trimmedBody}\n` : `${trimmedBody}\n`;
161
+ const exists = existsSync(join(dir, relPath));
162
+ const built = buildProposal(dir, relPath, content, { kind: "rule", id: "01", reason: `${exists ? "update" : "new"} rule ${relPath}` });
163
+ if (!built.ok) return built;
164
+ return writeOne(built.proposal, { dir, now });
165
+ }
166
+
167
+ function isPlainObject(v) {
168
+ return v !== null && typeof v === "object" && !Array.isArray(v);
169
+ }
170
+
171
+ function nonEmptyString(v) {
172
+ return typeof v === "string" && v.trim() !== "";
173
+ }
174
+
175
+ // Validates one `remove` entry against `oldLines` (CLAUDE.md's lines before any edit is applied). Returns
176
+ // { ok:false, reason } on the first problem, else { ok:true }. Never throws for any shape JSON.parse can
177
+ // produce - every field is type-checked before use (lesson 1ee7d426432aca82: a data-driven reader that throws
178
+ // is a defect).
179
+ function validateRemoveEntry(entry, oldLines, dir) {
180
+ if (!isPlainObject(entry)) return { ok: false, reason: "each remove entry must be an object" };
181
+ if (typeof entry.line !== "string") return { ok: false, reason: "each remove entry needs a string \"line\"" };
182
+ const count = oldLines.filter((l) => l === entry.line).length;
183
+ if (count === 0) return { ok: false, reason: `remove line not found in CLAUDE.md: ${JSON.stringify(entry.line)}` };
184
+ if (count > 1) return { ok: false, reason: `remove line occurs ${count} times in CLAUDE.md, expected exactly once: ${JSON.stringify(entry.line)}` };
185
+ if (!nonEmptyString(entry.source)) return { ok: false, reason: "each remove entry needs a non-empty string \"source\"" };
186
+ if (!nonEmptyString(entry.enforcedBy)) return { ok: false, reason: "each remove entry needs a non-empty string \"enforcedBy\"" };
187
+ if (entry.enforcedBy.endsWith(".mjs")) {
188
+ const relHook = `${GATES_SCRIPTS_RELDIR}/${entry.enforcedBy}`;
189
+ if (!existsSync(join(dir, relHook))) return { ok: false, reason: `enforcedBy names a hook that does not exist: ${relHook}` };
190
+ }
191
+ return { ok: true };
192
+ }
193
+
194
+ // Validates one `add` entry: `heading` must be the trimmed text of some line of CLAUDE.md, and that line must
195
+ // itself start with "## " (so an add can never target a non-heading line or a heading level other than "##").
196
+ function validateAddEntry(entry, claudeLines) {
197
+ if (!isPlainObject(entry)) return { ok: false, reason: "each add entry must be an object" };
198
+ if (typeof entry.line !== "string") return { ok: false, reason: "each add entry needs a string \"line\"" };
199
+ if (!entry.line.startsWith("- ")) return { ok: false, reason: `add line must start with "- ": ${JSON.stringify(entry.line)}` };
200
+ if (!nonEmptyString(entry.source)) return { ok: false, reason: "each add entry needs a non-empty string \"source\"" };
201
+ if (typeof entry.heading !== "string" || !entry.heading.startsWith("## ")) {
202
+ return { ok: false, reason: `add heading must be a "## " heading: ${JSON.stringify(entry.heading)}` };
203
+ }
204
+ const found = claudeLines.some((l) => l.trim() === entry.heading);
205
+ if (!found) return { ok: false, reason: `add heading not found in CLAUDE.md: ${JSON.stringify(entry.heading)}` };
206
+ return { ok: true };
207
+ }
208
+
209
+ // The substring from the "## Models" line to end of text - null when CLAUDE.md has no such line (never expected
210
+ // in this repo's own CLAUDE.md, but a caller could point `dir` elsewhere).
211
+ function modelsSectionText(text) {
212
+ const lines = text.split("\n");
213
+ const start = lines.findIndex((l) => l.trim() === "## Models");
214
+ if (start === -1) return null;
215
+ return lines.slice(start).join("\n");
216
+ }
217
+
218
+ // The lines strictly between the "```sh" fence and the next "```" fence, scoped to the "## Commands" section
219
+ // (from that heading to the next "## " heading or end of file) - null when there is no "## Commands" heading
220
+ // or no ```sh fence within its section. An unrelated ```sh fence elsewhere in the file (before or after the
221
+ // Commands section) must never be mistaken for the Commands block (F1: an earlier shell fence let an edit
222
+ // change the real Commands block undetected).
223
+ function commandsBlockText(text) {
224
+ const lines = text.split("\n");
225
+ const headingIdx = lines.findIndex((l) => l.trim() === "## Commands");
226
+ if (headingIdx === -1) return null;
227
+ let sectionEnd = lines.length;
228
+ for (let i = headingIdx + 1; i < lines.length; i++) {
229
+ if (lines[i].startsWith("## ")) {
230
+ sectionEnd = i;
231
+ break;
232
+ }
233
+ }
234
+ const section = lines.slice(headingIdx + 1, sectionEnd);
235
+ const start = section.findIndex((l) => l.startsWith("```sh"));
236
+ if (start === -1) return null;
237
+ let end = -1;
238
+ for (let i = start + 1; i < section.length; i++) {
239
+ if (section[i] === "```") {
240
+ end = i;
241
+ break;
242
+ }
243
+ }
244
+ if (end === -1) return null;
245
+ return section.slice(start + 1, end).join("\n");
246
+ }
247
+
248
+ // Proposes a diff to CLAUDE.md from an already-parsed, source-cited `edits` object: { add?: [{ heading, line,
249
+ // source }], remove?: [{ line, enforcedBy, source }], note? }. Pure given its inputs: reads CLAUDE.md, never
250
+ // writes it - only ever calls writeOne/writeProposals (learn.mjs), which writes under .doug/.state/learn/. Every
251
+ // refusal returns { ok:false, reason } with nothing written; `edits` can be ANY value JSON.parse can produce
252
+ // (null, an array, a string, a number, or an object whose fields are the wrong shape) and this never throws -
253
+ // every field is type-checked before use (lesson 1ee7d426432aca82).
254
+ export function proposeClaudeMd({ dir, edits, now = new Date() } = {}) {
255
+ const target = join(dir, CLAUDE_MD_RELPATH);
256
+ if (!existsSync(target)) return { ok: false, reason: `${CLAUDE_MD_RELPATH} does not exist under ${dir}` };
257
+
258
+ if (!isPlainObject(edits)) return { ok: false, reason: "edits must be an object" };
259
+ const rawAdd = edits.add;
260
+ const rawRemove = edits.remove;
261
+ if (rawAdd !== undefined && !Array.isArray(rawAdd)) return { ok: false, reason: "edits.add must be an array" };
262
+ if (rawRemove !== undefined && !Array.isArray(rawRemove)) return { ok: false, reason: "edits.remove must be an array" };
263
+ const add = rawAdd || [];
264
+ const remove = rawRemove || [];
265
+ if (add.length === 0 && remove.length === 0) return { ok: false, reason: "edits.add and edits.remove cannot both be empty" };
266
+
267
+ const oldText = readFileSync(target, "utf8");
268
+ const oldLines = oldText.split("\n");
269
+
270
+ for (const entry of remove) {
271
+ const v = validateRemoveEntry(entry, oldLines, dir);
272
+ if (!v.ok) return v;
273
+ }
274
+ for (const entry of add) {
275
+ const v = validateAddEntry(entry, oldLines);
276
+ if (!v.ok) return v;
277
+ }
278
+
279
+ let text = oldText;
280
+ for (const entry of remove) {
281
+ const updated = removeExactLine(text, entry.line);
282
+ if (updated === null) return { ok: false, reason: `remove line not found in CLAUDE.md: ${JSON.stringify(entry.line)}` };
283
+ text = updated;
284
+ }
285
+ for (const entry of add) {
286
+ const updated = insertBulletUnderHeading(text, entry.heading, entry.line);
287
+ if (updated === null) return { ok: false, reason: `add heading not found in CLAUDE.md: ${JSON.stringify(entry.heading)}` };
288
+ text = updated;
289
+ }
290
+ const newText = text;
291
+
292
+ if (modelsSectionText(oldText) !== modelsSectionText(newText)) {
293
+ return { ok: false, reason: "this edit would change the Models section; the Models table is parsed by lib/models.mjs and is not editable here" };
294
+ }
295
+ if (commandsBlockText(oldText) !== commandsBlockText(newText)) {
296
+ return { ok: false, reason: "this edit would change the fenced Commands block, which is not editable here" };
297
+ }
298
+ const after = countLines(newText);
299
+ if (after > 60) return { ok: false, reason: `CLAUDE.md would be ${after} lines, over its 60-line limit` };
300
+
301
+ const diffResult = diffOrRefuse(CLAUDE_MD_RELPATH, oldText, newText);
302
+ if (!diffResult.ok) return diffResult;
303
+
304
+ const before = countLines(oldText);
305
+ const reason = `CLAUDE.md: +${add.length} -${remove.length} lines (${before} -> ${after} lines)`;
306
+ const rationale = {
307
+ note: typeof edits.note === "string" ? edits.note : "",
308
+ added: add.map((e) => ({ heading: e.heading, line: e.line, source: e.source })),
309
+ removed: remove.map((e) => ({ line: e.line, enforcedBy: e.enforcedBy, source: e.source })),
310
+ lines: { before, after },
311
+ };
312
+ const proposal = { id: "01", kind: "claude-md", target: CLAUDE_MD_RELPATH, reason, rationale, diff: diffResult.diff };
313
+ return writeOne(proposal, { dir, now });
314
+ }