@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,742 @@
1
+ // Deterministic proposals from the outcomes/lessons store and the run trace (card learn-signals). Log-then-
2
+ // propose, never auto-apply: collectSignals counts what already happened (the outcomes table, lesson counters,
3
+ // permission denials and skill invocations from the run trace), proposeChanges renders each candidate change as
4
+ // a unified diff against its target file's current content, writeProposals stores them under
5
+ // .doug/.state/learn/, and applyProposal is the only function that ever touches a tracked file - and only for a
6
+ // proposal file the caller names, refusing a target under protectedPaths. No LLM call, no network: every
7
+ // function here is pure given its inputs (an open memory store, a trace directory, file contents and a skill
8
+ // list the caller already read).
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
10
+ import { join, relative, resolve, sep, dirname } from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { listOutcomes, listLessons, jaccard, reflectTokens, REFLECT_DUPLICATE_JACCARD } from "./memory.mjs";
14
+
15
+ // --- collectSignals ----------------------------------------------------------------------------------------
16
+
17
+ // A hand row's own gate text is the only record of pass/fail on that track (no independent verifier runs on
18
+ // it); a flow row's `verified` column is that record on the flow track. See memory.mjs's OUTCOMES_COLUMNS_SQL
19
+ // comment for why the two tracks are never averaged together.
20
+ function isGateFailure(row) {
21
+ if (row.track === "flow") return row.verified === 0;
22
+ if (row.track === "hand") return !(typeof row.gate === "string" && row.gate.startsWith("typecheck 0"));
23
+ return false;
24
+ }
25
+
26
+ // Union-find clustering of live lessons by near-duplicate text (memory.mjs's own REFLECT_DUPLICATE_JACCARD
27
+ // threshold and jaccard function - the same "near-duplicate" memory-reflect already uses to bump a lesson
28
+ // instead of appending one). A cluster of >=2 is "repeated" by text; a lesson on its own with helpful >= 3 is
29
+ // "repeated" by use. Both paths only ever report a count of >= 2, so a caller can treat `count >= 2` as one
30
+ // uniform test.
31
+ function groupRepeatedLessons(live) {
32
+ const n = live.length;
33
+ const parent = Array.from({ length: n }, (_, i) => i);
34
+ function find(x) {
35
+ while (parent[x] !== x) {
36
+ parent[x] = parent[parent[x]];
37
+ x = parent[x];
38
+ }
39
+ return x;
40
+ }
41
+ function union(a, b) {
42
+ const ra = find(a);
43
+ const rb = find(b);
44
+ if (ra !== rb) parent[ra] = rb;
45
+ }
46
+ const tokens = live.map((l) => reflectTokens(l.text));
47
+ for (let i = 0; i < n; i++) {
48
+ for (let j = i + 1; j < n; j++) {
49
+ if (jaccard(tokens[i], tokens[j]) >= REFLECT_DUPLICATE_JACCARD) union(i, j);
50
+ }
51
+ }
52
+ const clusters = new Map();
53
+ for (let i = 0; i < n; i++) {
54
+ const r = find(i);
55
+ if (!clusters.has(r)) clusters.set(r, []);
56
+ clusters.get(r).push(i);
57
+ }
58
+ const repeated = [];
59
+ for (const idxs of clusters.values()) {
60
+ if (idxs.length >= 2) {
61
+ const members = idxs.map((i) => live[i]).sort((a, b) => String(a.created || "").localeCompare(String(b.created || "")) || a.id.localeCompare(b.id));
62
+ repeated.push({ text: members[0].text, ids: members.map((l) => l.id).sort(), kind: members[0].kind, count: members.length });
63
+ } else {
64
+ const l = live[idxs[0]];
65
+ if (l.helpful >= 3) repeated.push({ text: l.text, ids: [l.id], kind: l.kind, count: l.helpful });
66
+ }
67
+ }
68
+ repeated.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text));
69
+ return repeated;
70
+ }
71
+
72
+ function listTraceFiles(traceDir) {
73
+ if (!existsSync(traceDir)) return [];
74
+ return readdirSync(traceDir)
75
+ .filter((f) => f.endsWith(".jsonl"))
76
+ .sort();
77
+ }
78
+
79
+ // Every well-formed line of a trace file, in order; a torn line (a crashed hook mid-write) is skipped, never
80
+ // fatal - mirrors plugins/doug-gates/lib/trace.mjs's readTrace, duplicated locally rather than imported across
81
+ // the plugin boundary (no workspace dependency exists between doug-flow and doug-gates; memory.mjs's own
82
+ // globToRegExp/scopeMatchesFiles follow the same local-duplication precedent for doug-gates/lib/glob.mjs).
83
+ function readTraceFileLines(file) {
84
+ const out = [];
85
+ let text;
86
+ try {
87
+ text = readFileSync(file, "utf8");
88
+ } catch {
89
+ return out;
90
+ }
91
+ for (const line of text.split("\n")) {
92
+ if (!line.trim()) continue;
93
+ try {
94
+ const o = JSON.parse(line);
95
+ if (o && typeof o === "object" && o.event) out.push(o);
96
+ } catch {
97
+ // a torn line from a crashed hook is skipped, never fatal
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ // `denials` is the actual denial signal: an explicit PermissionDenied trace line, grouped by (tool, detail).
104
+ // `unmatched` is a separate, weaker signal - a PreToolUse line whose toolUseId never gets a matching
105
+ // PostToolUse in the same file. Review MINOR 1: on this repo's 36 real trace files that gap heuristic alone
106
+ // produced 246 "denials" that were Artifact, StructuredOutput, and ordinary successful grep/ls/cat calls, not
107
+ // denials - an unmatched PreToolUse can be a hook timeout, an interrupted call, a session end, or simply a
108
+ // tool whose PostToolUse never fires. It is reported for a human reader (`learn.mjs signals`) and never drives
109
+ // a proposal - `demote` reads `denials` only. Agent/AskUserQuestion/Skill calls are excluded from `unmatched`
110
+ // (they route through their own gates, not a plain allow/deny), and so is the very last line of a file (that
111
+ // call may simply still be in flight when the trace stopped).
112
+ //
113
+ // Review MAJOR 1: a denied call fires both a PreToolUse line (with no matching PostToolUse - denied calls
114
+ // never run) and a PermissionDenied line, both carrying the same toolUseId. Counting the PreToolUse side as an
115
+ // `unmatched` gap too double-counted the same denial - a single denied Bash call read as 2, tripping `demote`
116
+ // on its own. So a toolUseId the file also carries a PermissionDenied line for is excluded from `unmatched`:
117
+ // it was already counted once, in `denials`.
118
+ const EXCLUDED_GAP_TOOLS = new Set(["Agent", "AskUserQuestion", "Skill"]);
119
+
120
+ function bumpGroup(map, tool, detail, reason) {
121
+ const key = `${tool || ""} ${detail || ""}`;
122
+ const cur = map.get(key) || { tool: tool || null, detail: detail || null, reason: null, count: 0 };
123
+ cur.count += 1;
124
+ if (!cur.reason && reason) cur.reason = reason;
125
+ map.set(key, cur);
126
+ }
127
+
128
+ function sortGroups(groups) {
129
+ return groups.sort((a, b) => b.count - a.count || String(a.tool).localeCompare(String(b.tool)));
130
+ }
131
+
132
+ function collectTraceSignals(traceDir) {
133
+ const files = listTraceFiles(traceDir);
134
+ const denialGroups = new Map();
135
+ const unmatchedGroups = new Map();
136
+ const skillCounts = new Map();
137
+ const instructionsLoaded = new Map();
138
+
139
+ for (const name of files) {
140
+ const lines = readTraceFileLines(join(traceDir, name));
141
+ const postedToolUseIds = new Set(lines.filter((l) => l.event === "PostToolUse" && l.toolUseId).map((l) => l.toolUseId));
142
+ const deniedToolUseIds = new Set(lines.filter((l) => l.event === "PermissionDenied" && l.toolUseId).map((l) => l.toolUseId));
143
+ lines.forEach((l, idx) => {
144
+ if (l.event === "PermissionDenied") {
145
+ bumpGroup(denialGroups, l.tool, l.detail, l.reason || null);
146
+ return;
147
+ }
148
+ if (l.event === "PreToolUse") {
149
+ if (l.tool === "Skill" && l.detail) skillCounts.set(l.detail, (skillCounts.get(l.detail) || 0) + 1);
150
+ const isLastLine = idx === lines.length - 1;
151
+ const alreadyDenied = l.toolUseId && deniedToolUseIds.has(l.toolUseId);
152
+ if (!isLastLine && l.toolUseId && !alreadyDenied && !postedToolUseIds.has(l.toolUseId) && !EXCLUDED_GAP_TOOLS.has(l.tool)) {
153
+ bumpGroup(unmatchedGroups, l.tool, l.detail, null);
154
+ }
155
+ return;
156
+ }
157
+ if (l.event === "InstructionsLoaded") {
158
+ const key = l.detail || "(unknown)";
159
+ const cur = instructionsLoaded.get(key) || { file: key, count: 0, reasons: new Set() };
160
+ cur.count += 1;
161
+ if (l.reason) cur.reasons.add(l.reason);
162
+ instructionsLoaded.set(key, cur);
163
+ }
164
+ });
165
+ }
166
+
167
+ return {
168
+ files,
169
+ denials: sortGroups([...denialGroups.values()]),
170
+ unmatched: sortGroups([...unmatchedGroups.values()]).map(({ tool, detail, count }) => ({ tool, detail, count })),
171
+ skills: [...skillCounts.entries()].map(([skill, count]) => ({ skill, count })).sort((a, b) => b.count - a.count || a.skill.localeCompare(b.skill)),
172
+ instructionsLoaded: [...instructionsLoaded.values()]
173
+ .map((v) => ({ file: v.file, count: v.count, reasons: [...v.reasons].sort() }))
174
+ .sort((a, b) => b.count - a.count || a.file.localeCompare(b.file)),
175
+ };
176
+ }
177
+
178
+ // Signals for `learn.mjs propose` (card learn-signals): everything counted from the store (`m`, already open -
179
+ // openMemory(dir)) and the run trace (`traceDir`, default <dir>/.doug/.state/trace). `now` is accepted for a
180
+ // stable, injectable clock but nothing here currently reads it - kept for parity with collectSignals' sibling
181
+ // writeProposals, whose directory name is timestamped.
182
+ export function collectSignals(m, { dir, traceDir, now = new Date() } = {}) {
183
+ void now;
184
+ const rows = listOutcomes(m, {});
185
+ const gateFailures = rows.filter(isGateFailure).length;
186
+ const blocksByClass = { real: 0, marginal: 0, false: 0 };
187
+ for (const r of rows) for (const b of r.blocks || []) if (Object.prototype.hasOwnProperty.call(blocksByClass, b.class)) blocksByClass[b.class] += 1;
188
+ let fixSum = 0;
189
+ let fixMax = 0;
190
+ let usdSum = 0;
191
+ let usdN = 0;
192
+ const byCard = {};
193
+ for (const r of rows) {
194
+ if (typeof r.fix_passes === "number") {
195
+ fixSum += r.fix_passes;
196
+ fixMax = Math.max(fixMax, r.fix_passes);
197
+ }
198
+ if (typeof r.usd === "number") {
199
+ usdSum += r.usd;
200
+ usdN += 1;
201
+ }
202
+ if (r.card) {
203
+ const c = byCard[r.card] || (byCard[r.card] = { rows: 0, gateFailures: 0, usd: 0 });
204
+ c.rows += 1;
205
+ if (isGateFailure(r)) c.gateFailures += 1;
206
+ if (typeof r.usd === "number") c.usd += r.usd;
207
+ }
208
+ }
209
+
210
+ const allLessons = listLessons(m, { includeSuperseded: true });
211
+ const live = allLessons.filter((l) => !l.superseded_by);
212
+
213
+ const td = traceDir || join(dir, ".doug/.state/trace");
214
+ const trace = collectTraceSignals(td);
215
+
216
+ return {
217
+ outcomes: { rows, gateFailures, blocksByClass, fixPasses: { sum: fixSum, max: fixMax }, usd: { sum: usdSum, n: usdN }, byCard },
218
+ lessons: {
219
+ total: allLessons.length,
220
+ live: live.length,
221
+ helpful: live.filter((l) => l.helpful > 0).length,
222
+ harmful: live.filter((l) => l.harmful > 0).length,
223
+ stale: live.filter((l) => l.stale).length,
224
+ repeated: groupRepeatedLessons(live),
225
+ },
226
+ trace,
227
+ };
228
+ }
229
+
230
+ // --- proposeChanges -----------------------------------------------------------------------------------------
231
+
232
+ function normalize(s) {
233
+ return String(s || "")
234
+ .toLowerCase()
235
+ .replace(/\s+/g, " ")
236
+ .trim();
237
+ }
238
+
239
+ // wc -l semantics: the count of newline-terminated lines, not split("\n").length (which counts one extra empty
240
+ // element for a file ending in "\n"). CLAUDE.md's own header says "Keep it under 60 lines" against this count.
241
+ export function countLines(text) {
242
+ if (text === "") return 0;
243
+ return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
244
+ }
245
+
246
+ function findSection(lines, heading) {
247
+ const start = lines.findIndex((l) => l.trim() === heading);
248
+ if (start === -1) return null;
249
+ let end = lines.length;
250
+ for (let i = start + 1; i < lines.length; i++) {
251
+ if (/^##\s/.test(lines[i])) {
252
+ end = i;
253
+ break;
254
+ }
255
+ }
256
+ return { start, end };
257
+ }
258
+
259
+ // Inserts `bulletLine` as the last `- ` item of the named "## Heading" section; null when the heading is
260
+ // missing (nothing to insert under).
261
+ export function insertBulletUnderHeading(text, heading, bulletLine) {
262
+ const lines = text.split("\n");
263
+ const sec = findSection(lines, heading);
264
+ if (!sec) return null;
265
+ let insertAt = sec.start + 1;
266
+ for (let i = sec.start + 1; i < sec.end; i++) if (lines[i].startsWith("- ")) insertAt = i + 1;
267
+ lines.splice(insertAt, 0, bulletLine);
268
+ return lines.join("\n");
269
+ }
270
+
271
+ export function removeExactLine(text, line) {
272
+ const lines = text.split("\n");
273
+ const idx = lines.indexOf(line);
274
+ if (idx === -1) return null;
275
+ lines.splice(idx, 1);
276
+ return lines.join("\n");
277
+ }
278
+
279
+ // A rule line's key phrase: its backticked tokens, plus its first 4 significant words (stopwords and words
280
+ // shorter than 3 letters dropped). Documented heuristic, not a semantic match - see docs/learn.md for its
281
+ // limits (a rule paraphrased with none of these tokens is missed; a coincidental word overlap is a false
282
+ // negative on delete, which only ever makes delete too cautious, never too eager).
283
+ const KEY_PHRASE_STOPWORDS = new Set(["do", "not", "the", "a", "an", "to", "is", "and", "or", "of", "in", "on", "for", "this", "that", "never", "always", "its", "own"]);
284
+
285
+ function keyPhraseTokens(line) {
286
+ const backticks = [...line.matchAll(/`([^`]+)`/g)].map((m) => m[1].toLowerCase());
287
+ const words = line
288
+ .replace(/`[^`]*`/g, " ")
289
+ .toLowerCase()
290
+ .replace(/[^a-z0-9\s-]/g, " ")
291
+ .split(/\s+/)
292
+ .filter((w) => w.length >= 3 && !KEY_PHRASE_STOPWORDS.has(w));
293
+ return [...new Set([...backticks, ...words.slice(0, 4)])];
294
+ }
295
+
296
+ function textReferencesTokens(text, tokens) {
297
+ const t = normalize(text);
298
+ return tokens.some((tok) => tok && t.includes(tok));
299
+ }
300
+
301
+ // --- a tiny unified-diff renderer for one file's old/new text (no dependency added: git apply reads plain
302
+ // unified diffs with "a/"/"b/" headers, which is all applyProposal needs to spend). LCS-based, single-file,
303
+ // standard 3-line-context hunks - adequate for the small, mostly single-line edits this module ever proposes
304
+ // (one CLAUDE.md bullet, one JSON key). null when old and new are identical (nothing to propose).
305
+ function computeLineOps(a, b) {
306
+ const n = a.length;
307
+ const m = b.length;
308
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
309
+ for (let i = n - 1; i >= 0; i--) {
310
+ for (let j = m - 1; j >= 0; j--) {
311
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
312
+ }
313
+ }
314
+ const ops = [];
315
+ let i = 0;
316
+ let j = 0;
317
+ while (i < n && j < m) {
318
+ if (a[i] === b[j]) {
319
+ ops.push({ type: "equal", line: a[i] });
320
+ i++;
321
+ j++;
322
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
323
+ ops.push({ type: "del", line: a[i] });
324
+ i++;
325
+ } else {
326
+ ops.push({ type: "add", line: b[j] });
327
+ j++;
328
+ }
329
+ }
330
+ while (i < n) {
331
+ ops.push({ type: "del", line: a[i] });
332
+ i++;
333
+ }
334
+ while (j < m) {
335
+ ops.push({ type: "add", line: b[j] });
336
+ j++;
337
+ }
338
+ return ops;
339
+ }
340
+
341
+ // split("\n") on a trailing-newline-terminated string yields one phantom empty trailing element that names no
342
+ // real line; both files here always end in "\n" (every writer in this module ends its output that way), so it
343
+ // is dropped from both sides before diffing rather than counted as a spurious matching context line at EOF.
344
+ function fileLines(text) {
345
+ const parts = text.split("\n");
346
+ if (parts.length && parts[parts.length - 1] === "") parts.pop();
347
+ return parts;
348
+ }
349
+
350
+ export function unifiedDiff(relPath, oldText, newText, context = 3) {
351
+ if (oldText === newText) return null;
352
+ const ops = computeLineOps(fileLines(oldText), fileLines(newText));
353
+ let oldLine = 1;
354
+ let newLine = 1;
355
+ const numbered = ops.map((op) => {
356
+ const rec = { op, oldLine, newLine };
357
+ if (op.type === "equal") {
358
+ oldLine++;
359
+ newLine++;
360
+ } else if (op.type === "del") oldLine++;
361
+ else newLine++;
362
+ return rec;
363
+ });
364
+ const ranges = [];
365
+ let i = 0;
366
+ while (i < numbered.length) {
367
+ if (numbered[i].op.type === "equal") {
368
+ i++;
369
+ continue;
370
+ }
371
+ let end = i;
372
+ while (end < numbered.length && numbered[end].op.type !== "equal") end++;
373
+ ranges.push({ start: Math.max(0, i - context), end: Math.min(numbered.length, end + context) });
374
+ i = end;
375
+ }
376
+ const merged = [];
377
+ for (const r of ranges) {
378
+ if (merged.length && r.start <= merged[merged.length - 1].end) merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, r.end);
379
+ else merged.push({ ...r });
380
+ }
381
+ const hunks = merged.map((r) => {
382
+ const slice = numbered.slice(r.start, r.end);
383
+ const oldStart = slice[0].oldLine;
384
+ const newStart = slice[0].newLine;
385
+ const oldCount = slice.filter((s) => s.op.type !== "add").length;
386
+ const newCount = slice.filter((s) => s.op.type !== "del").length;
387
+ const body = slice.map((s) => (s.op.type === "equal" ? " " : s.op.type === "del" ? "-" : "+") + s.op.line).join("\n");
388
+ return `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n${body}`;
389
+ });
390
+ return `--- a/${relPath}\n+++ b/${relPath}\n${hunks.join("\n")}\n`;
391
+ }
392
+
393
+ function getPath(obj, path) {
394
+ return path.split(".").reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj);
395
+ }
396
+
397
+ function setPath(obj, path, value) {
398
+ const keys = path.split(".");
399
+ const out = { ...obj };
400
+ let cur = out;
401
+ for (let i = 0; i < keys.length - 1; i++) {
402
+ cur[keys[i]] = { ...(cur[keys[i]] || {}) };
403
+ cur = cur[keys[i]];
404
+ }
405
+ cur[keys[keys.length - 1]] = value;
406
+ return out;
407
+ }
408
+
409
+ // The small table `demote` matches a repeated Bash denial against: a regex over the denied command, the exact
410
+ // phrase that rule takes in CLAUDE.md (so a demote proposal only ever fires against a rule that is really
411
+ // there), and the .doug/config.json key that would make the rule mechanical instead of prose. The three
412
+ // entries are the three examples the card names; a new rule joins this table by hand, same shape.
413
+ const DEMOTE_RULES = [
414
+ { id: "branch-delete", label: "the branch -D rule", claudePhrase: "git branch -D", detailRe: /git\s+branch\s+-D\b/, configKey: "bash.denyDestructive" },
415
+ { id: "raw-pnpm-test", label: "the pnpm test rule", claudePhrase: "never pnpm test", detailRe: /^pnpm\s+test(?!:unit)\b/, configKey: "bash.denyRawTest" },
416
+ { id: "no-trailers", label: "the trailer rule", claudePhrase: "Co-Authored-By", detailRe: /Co-Authored-By|Claude-Session/, configKey: "commit.denyTrailers" },
417
+ ];
418
+
419
+ // Review MINOR 7: a `tighten` target is the real path readOwnSkills() (scripts/learn.mjs) read the skill's
420
+ // description from (`skill.file`), not a hardcoded `plugins/doug-flow/skills/<name>/SKILL.md` - a caller may
421
+ // point `learn.mjs` at a checkout laid out differently, or at a skill outside this repository entirely. Made
422
+ // relative to `dir` when the file is really inside it (the common case: readable, and matches how every other
423
+ // proposal's `target` reads), else reported absolute rather than a misleading relative path. Falls back to the
424
+ // historical hardcoded shape only for a caller (or an older test fixture) that supplies no `file` at all.
425
+ function tightenTarget(dir, skill) {
426
+ if (!skill.file) return `plugins/doug-flow/skills/${skill.name}/SKILL.md`;
427
+ const resolved = resolve(skill.file);
428
+ if (dir) {
429
+ const base = resolve(dir);
430
+ if (resolved === base || resolved.startsWith(`${base}${sep}`)) return relative(base, resolved).split(sep).join("/");
431
+ }
432
+ return resolved;
433
+ }
434
+
435
+ // Renders each candidate change as `{ id, kind, target, reason, evidence, diff }`. `claudeMd` and `config` are
436
+ // the current text/parsed-object the caller already read (CLAUDE.md, .doug/config.json); `skills` is an array
437
+ // of `{ name, description, file }` for every skill the caller wants considered for `tighten` (normally every
438
+ // plugins/doug-flow/skills/*/SKILL.md, `file` the real path it was read from). Pure: with no signals the
439
+ // result is `[]`, never an error.
440
+ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } = {}) {
441
+ const proposals = [];
442
+ let n = 0;
443
+ const nextId = () => String(++n).padStart(2, "0");
444
+
445
+ // promote: a repeated feedback/pitfall lesson not already in CLAUDE.md.
446
+ for (const r of signals.lessons.repeated) {
447
+ if (r.kind !== "feedback" && r.kind !== "pitfall") continue;
448
+ if (normalize(claudeMd).includes(normalize(r.text))) continue;
449
+ const heading = r.kind === "feedback" ? "## Working agreement" : "## Gotchas";
450
+ const bulletLine = `- ${r.text}`;
451
+ const evidence = { count: r.count, ids: r.ids, kind: r.kind };
452
+ const reason = `lesson repeated ${r.count}× (ids: ${r.ids.join(", ")}); promoting to CLAUDE.md's ${heading.replace("## ", "")}`;
453
+ const updated = insertBulletUnderHeading(claudeMd, heading, bulletLine);
454
+ if (updated === null) {
455
+ proposals.push({ id: nextId(), kind: "promote", target: "CLAUDE.md", reason: `${reason}, but CLAUDE.md has no "${heading}" section`, evidence, diff: null });
456
+ continue;
457
+ }
458
+ if (countLines(updated) >= 60) {
459
+ proposals.push({ id: nextId(), kind: "promote", target: "CLAUDE.md", reason: `${reason}, but CLAUDE.md is at its 60-line limit (would be ${countLines(updated)})`, evidence, diff: null });
460
+ continue;
461
+ }
462
+ proposals.push({ id: nextId(), kind: "promote", target: "CLAUDE.md", reason, evidence, diff: unifiedDiff("CLAUDE.md", claudeMd, updated) });
463
+ }
464
+
465
+ // demote: a repeated Bash denial matching a known prose rule, not already backed by its config key.
466
+ for (const group of signals.trace.denials) {
467
+ if (group.tool !== "Bash" || group.count < 2) continue;
468
+ for (const rule of DEMOTE_RULES) {
469
+ if (!rule.detailRe.test(group.detail || "")) continue;
470
+ if (!claudeMd.includes(rule.claudePhrase)) continue;
471
+ if (getPath(config, rule.configKey) === true) continue;
472
+ const updatedConfig = setPath(config, rule.configKey, true);
473
+ const oldText = `${JSON.stringify(config, null, 2)}\n`;
474
+ const newText = `${JSON.stringify(updatedConfig, null, 2)}\n`;
475
+ proposals.push({
476
+ id: nextId(),
477
+ kind: "demote",
478
+ target: ".doug/config.json",
479
+ reason: `Bash was denied ${group.count}× matching ${rule.label} ("${group.detail}"); .doug/config.json is a protected path, apply this by hand`,
480
+ evidence: { count: group.count, detail: group.detail, configKey: rule.configKey },
481
+ diff: unifiedDiff(".doug/config.json", oldText, newText),
482
+ });
483
+ break;
484
+ }
485
+ }
486
+
487
+ // delete: a CLAUDE.md rule line referenced by no denial, no repeated lesson, and no block description, with
488
+ // enough signal density to trust the absence (>=10 outcome rows, >=5 trace files).
489
+ if (signals.outcomes.rows.length >= 10 && signals.trace.files.length >= 5) {
490
+ const blockDescriptions = signals.outcomes.rows.flatMap((r) => (r.blocks || []).map((b) => b.description || ""));
491
+ const lines = claudeMd.split("\n");
492
+ for (const heading of ["## Working agreement", "## Gotchas"]) {
493
+ const sec = findSection(lines, heading);
494
+ if (!sec) continue;
495
+ for (let i = sec.start + 1; i < sec.end; i++) {
496
+ const line = lines[i];
497
+ if (!line.startsWith("- ")) continue;
498
+ const tokens = keyPhraseTokens(line);
499
+ if (!tokens.length) continue;
500
+ const referenced =
501
+ signals.trace.denials.some((d) => textReferencesTokens(d.detail, tokens)) ||
502
+ signals.lessons.repeated.some((r) => textReferencesTokens(r.text, tokens)) ||
503
+ blockDescriptions.some((d) => textReferencesTokens(d, tokens));
504
+ if (referenced) continue;
505
+ const updated = removeExactLine(claudeMd, line);
506
+ proposals.push({
507
+ id: nextId(),
508
+ kind: "delete",
509
+ target: "CLAUDE.md",
510
+ reason: `no denial, repeated lesson, or block description across ${signals.outcomes.rows.length} outcome rows and ${signals.trace.files.length} trace files mentions "${tokens.join(", ")}"`,
511
+ evidence: { outcomeRows: signals.outcomes.rows.length, traceFiles: signals.trace.files.length, keyPhrase: tokens },
512
+ diff: updated === null ? null : unifiedDiff("CLAUDE.md", claudeMd, updated),
513
+ });
514
+ }
515
+ }
516
+ }
517
+
518
+ // tighten: a skill never invoked while another was invoked often, with a long description.
519
+ const busiest = signals.trace.skills.reduce((max, s) => (s.count > (max ? max.count : 0) ? s : max), null);
520
+ if (busiest && busiest.count >= 5) {
521
+ for (const skill of skills) {
522
+ if (!skill.description || skill.description.length <= 400) continue;
523
+ if (skill.name === busiest.skill) continue;
524
+ const own = signals.trace.skills.find((s) => s.skill === skill.name);
525
+ if (own && own.count > 0) continue;
526
+ proposals.push({
527
+ id: nextId(),
528
+ kind: "tighten",
529
+ target: tightenTarget(dir, skill),
530
+ reason: `"${skill.name}" was invoked 0 times across the trace while "${busiest.skill}" was invoked ${busiest.count} times; its description is ${skill.description.length} characters`,
531
+ evidence: { skill: skill.name, invokedCount: 0, busiest: { skill: busiest.skill, count: busiest.count }, descriptionLength: skill.description.length },
532
+ diff: null,
533
+ });
534
+ }
535
+ }
536
+
537
+ return proposals;
538
+ }
539
+
540
+ // --- writeProposals / applyProposal --------------------------------------------------------------------------
541
+
542
+ export const LEARN_STATE_RELPATH = ".doug/.state/learn";
543
+
544
+ // Review MINOR 5: second resolution let two runs inside the same second collide and overwrite each other's
545
+ // proposals. Millisecond resolution (still no ":" or "." - safe as a bare directory name), and - belt and
546
+ // braces, since two runs can still land in the same millisecond - writeProposals appends "-2", "-3", ... to
547
+ // the first name that doesn't already exist on disk.
548
+ function tsFolderName(now) {
549
+ const iso = now.toISOString(); // 2026-09-11T19:05:07.123Z
550
+ return `${iso.slice(0, 10)}T${iso.slice(11, 23).replace(/[:.]/g, "")}`;
551
+ }
552
+
553
+ function freeFolder(dir, base) {
554
+ let folder = join(dir, LEARN_STATE_RELPATH, base);
555
+ let n = 1;
556
+ while (existsSync(folder)) {
557
+ n += 1;
558
+ folder = join(dir, LEARN_STATE_RELPATH, `${base}-${n}`);
559
+ }
560
+ return folder;
561
+ }
562
+
563
+ // Writes proposals.json (every proposal, including diff:null ones) and one NN-<kind>.diff per proposal that
564
+ // carries a diff, under .doug/.state/learn/<timestamp>/ - never a tracked file. Returns the paths written.
565
+ export function writeProposals(proposals, { dir, now = new Date() } = {}) {
566
+ const folder = freeFolder(dir, tsFolderName(now));
567
+ mkdirSync(folder, { recursive: true });
568
+ const proposalsPath = join(folder, "proposals.json");
569
+ writeFileSync(proposalsPath, `${JSON.stringify(proposals, null, 2)}\n`);
570
+ const diffPaths = [];
571
+ for (const p of proposals) {
572
+ if (!p.diff) continue;
573
+ const diffPath = join(folder, `${p.id}-${p.kind}.diff`);
574
+ writeFileSync(diffPath, p.diff);
575
+ diffPaths.push(diffPath);
576
+ }
577
+ return { dir: folder, proposalsPath, diffPaths };
578
+ }
579
+
580
+ function readConfigProtectedPaths(dir) {
581
+ try {
582
+ const config = JSON.parse(readFileSync(join(dir, ".doug/config.json"), "utf8"));
583
+ return Array.isArray(config.protectedPaths) ? config.protectedPaths : [];
584
+ } catch {
585
+ return [];
586
+ }
587
+ }
588
+
589
+ // The same "**"/"*" matching memory.mjs's own local scope matcher and plugins/doug-gates/lib/glob.mjs use,
590
+ // duplicated locally rather than imported (see readTraceFileLines' comment above).
591
+ function globToRegExp(glob) {
592
+ let re = "";
593
+ let i = 0;
594
+ while (i < glob.length) {
595
+ const c = glob[i];
596
+ if (c === "*") {
597
+ if (glob[i + 1] === "*") {
598
+ if (glob[i + 2] === "/") {
599
+ re += "(?:.*/)?";
600
+ i += 3;
601
+ } else {
602
+ re += ".*";
603
+ i += 2;
604
+ }
605
+ } else {
606
+ re += "[^/]*";
607
+ i += 1;
608
+ }
609
+ } else if (/[.+^$()|[\]\\]/.test(c)) {
610
+ re += `\\${c}`;
611
+ i += 1;
612
+ } else {
613
+ re += c;
614
+ i += 1;
615
+ }
616
+ }
617
+ return new RegExp(`^${re}$`);
618
+ }
619
+
620
+ function isProtectedPath(relPath, protectedPaths) {
621
+ const p = String(relPath || "").replace(/\\/g, "/");
622
+ return protectedPaths.some((pattern) => {
623
+ const g = String(pattern).replace(/\\/g, "/");
624
+ if (globToRegExp(g).test(p)) return true;
625
+ if (!g.includes("*") && (p === g || p.startsWith(`${g}/`))) return true;
626
+ return false;
627
+ });
628
+ }
629
+
630
+ // Review MINOR 2: reading only the first "+++ b/" header let a hand-built (or hand-edited) two-file diff carry
631
+ // a second, unrefused target past the protectedPaths check - a second file section wrote straight into the
632
+ // protected .doug/config.json. Every "+++ b/" AND "--- a/" header is parsed now (the union of both sides' file
633
+ // names - normally identical per section, since this module never renames a file), so a multi-file diff is
634
+ // caught by its target count alone before any single target is even looked at.
635
+ function parseDiffTargets(diffText) {
636
+ const targets = new Set();
637
+ for (const m of diffText.matchAll(/^\+\+\+ b\/(.+)$/gm)) targets.add(m[1]);
638
+ for (const m of diffText.matchAll(/^--- a\/(.+)$/gm)) targets.add(m[1]);
639
+ return [...targets];
640
+ }
641
+
642
+ // True for an absolute path, or a relative path whose ".." segments walk out of `dir` - the diff-apply
643
+ // equivalent of protect-paths.mjs's outside-the-project refusal, checked the same way applyProposal checks
644
+ // protectedPaths: never by trusting the string, always by resolving it against `dir` first.
645
+ function isOutsideRepo(dir, relPath) {
646
+ if (!relPath || relPath.startsWith("/")) return true;
647
+ const resolved = resolve(dir, relPath);
648
+ const base = resolve(dir);
649
+ return resolved !== base && !resolved.startsWith(`${base}${sep}`);
650
+ }
651
+
652
+ // The applied-proposal ledger (cards memory-decisions, proposal-ledger-forgeable): one JSON line per successful
653
+ // applyProposal, appended after `git apply` succeeds, never before. plugins/doug-gates/lib/proposals.mjs reads
654
+ // this same file (its own small local copy of readAppliedLedger, not an import - no workspace dependency exists
655
+ // between doug-flow and doug-gates; see this file's header comment for the same precedent) to tell an approved
656
+ // proposal's write from a hand edit, so the line shape here is a contract: { target, sha256, diffSha256, at,
657
+ // proposal }, where `proposal` is the diff file's path relative to `dir` (forward slashes) and `diffSha256` is
658
+ // the sha256 of that diff file's bytes at apply time - card proposal-ledger-forgeable: a hand-written line
659
+ // carrying only `target`/`sha256` is not enough on its own, since the reader re-derives the target's content
660
+ // from this diff (git apply onto the committed base) rather than trusting the sha256 alone.
661
+ export const PROPOSAL_LEDGER_RELPATH = ".doug/.state/proposals/applied.jsonl";
662
+
663
+ // Every well-formed line, tolerant of a missing file or a torn line (a crashed process mid-write) - never fatal.
664
+ export function readAppliedLedger(dir) {
665
+ const file = join(dir, PROPOSAL_LEDGER_RELPATH);
666
+ if (!existsSync(file)) return [];
667
+ let text;
668
+ try {
669
+ text = readFileSync(file, "utf8");
670
+ } catch {
671
+ return [];
672
+ }
673
+ const out = [];
674
+ for (const line of text.split("\n")) {
675
+ if (!line.trim()) continue;
676
+ try {
677
+ const o = JSON.parse(line);
678
+ if (o && typeof o === "object" && o.target) out.push(o);
679
+ } catch {
680
+ // a torn line is skipped, never fatal
681
+ }
682
+ }
683
+ return out;
684
+ }
685
+
686
+ function appendLedgerLine(dir, entry) {
687
+ const file = join(dir, PROPOSAL_LEDGER_RELPATH);
688
+ mkdirSync(dirname(file), { recursive: true });
689
+ appendFileSync(file, `${JSON.stringify(entry)}\n`);
690
+ }
691
+
692
+ // sha256 of `target`'s current bytes under `dir`, or the sha of empty content when it cannot be read (a deleted
693
+ // target) - so a deleted file still gets a ledger line rather than throwing.
694
+ function targetSha256(dir, target) {
695
+ try {
696
+ return createHash("sha256").update(readFileSync(join(dir, target))).digest("hex");
697
+ } catch {
698
+ return createHash("sha256").update("").digest("hex");
699
+ }
700
+ }
701
+
702
+ // Applies ONE proposal's diff file with `git apply`. Refuses (never touching the tree) when the diff file
703
+ // itself does not resolve under `dir`/LEARN_STATE_RELPATH (card proposal-ledger-forgeable: the ledger's
704
+ // re-derivation guard only ever looks there, so a diff from anywhere else could never satisfy it, and applying
705
+ // it would be a genuine write the gate could not later prove genuine), when the diff names more than one
706
+ // target file (a proposal is single-file, so this is refused outright before any target is checked), when its
707
+ // one target is outside the repository, when it is under .doug/config.json's protectedPaths, or when
708
+ // `git apply --check` fails; `run` is injectable for tests (same signature as node:child_process's spawnSync:
709
+ // `run(args) -> {status, stdout, stderr}`, invoked with cwd `dir`). Never called by `propose` - that guarantee
710
+ // is the whole point of this module. On success, appends one line to the applied-proposal ledger (above)
711
+ // before returning.
712
+ export function applyProposal(file, { dir, run } = {}) {
713
+ // review MINOR 5: resolved against `dir`, not process.cwd() - a caller may reasonably pass a path relative
714
+ // to `dir` while its own cwd is elsewhere (an absolute `file` is unaffected: path.resolve stops at the
715
+ // rightmost absolute segment). Every read and git invocation below uses this resolved `fileAbs`, never the
716
+ // original `file` string, so behavior does not depend on the calling process's cwd.
717
+ const fileAbs = resolve(dir, file);
718
+ const learnDirAbs = resolve(dir, LEARN_STATE_RELPATH);
719
+ if (fileAbs !== learnDirAbs && !fileAbs.startsWith(`${learnDirAbs}${sep}`)) {
720
+ return { ok: false, reason: `${file} is not under ${LEARN_STATE_RELPATH}; a proposal diff must live there, refusing` };
721
+ }
722
+ const raw = readFileSync(fileAbs);
723
+ const diffText = raw.toString("utf8");
724
+ const targets = parseDiffTargets(diffText);
725
+ if (!targets.length) return { ok: false, reason: `${file} has no recognizable diff header (+++ b/<path> or --- a/<path>)` };
726
+ if (targets.length > 1) return { ok: false, reason: `${file} touches more than one file (${targets.join(", ")}); a proposal is single-file, refusing` };
727
+ const [target] = targets;
728
+ if (isOutsideRepo(dir, target)) return { ok: false, reason: `${target} is outside the repository; refusing` };
729
+ const protectedPaths = readConfigProtectedPaths(dir);
730
+ if (isProtectedPath(target, protectedPaths)) {
731
+ return { ok: false, reason: `${target} is a protected path (.doug/config.json protectedPaths); apply it by hand` };
732
+ }
733
+ const git = run || ((args) => spawnSync("git", args, { cwd: dir, encoding: "utf8" }));
734
+ const check = git(["apply", "--check", fileAbs]);
735
+ if (check.status !== 0) return { ok: false, reason: `git apply --check failed: ${(check.stderr || "").trim()}` };
736
+ const applied = git(["apply", fileAbs]);
737
+ if (applied.status !== 0) return { ok: false, reason: `git apply failed: ${(applied.stderr || "").trim()}` };
738
+ const diffSha256 = createHash("sha256").update(raw).digest("hex");
739
+ const proposalRel = relative(dir, fileAbs).split(sep).join("/");
740
+ appendLedgerLine(dir, { target, sha256: targetSha256(dir, target), diffSha256, at: new Date().toISOString(), proposal: proposalRel });
741
+ return { ok: true, target };
742
+ }