@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.
- package/LICENSE +21 -0
- package/dist/ablate.d.ts +96 -0
- package/dist/ablate.js +440 -0
- package/dist/ablate.js.map +1 -0
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +19 -0
- package/dist/apply.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +80 -0
- package/dist/bin.js.map +1 -0
- package/dist/board-page.d.ts +15 -0
- package/dist/board-page.js +27 -0
- package/dist/board-page.js.map +1 -0
- package/dist/board-serve-process.d.ts +24 -0
- package/dist/board-serve-process.js +227 -0
- package/dist/board-serve-process.js.map +1 -0
- package/dist/board-serve.d.ts +9 -0
- package/dist/board-serve.js +302 -0
- package/dist/board-serve.js.map +1 -0
- package/dist/board.d.ts +6 -0
- package/dist/board.js +515 -0
- package/dist/board.js.map +1 -0
- package/dist/detect/fs.d.ts +6 -0
- package/dist/detect/fs.js +50 -0
- package/dist/detect/fs.js.map +1 -0
- package/dist/detect/index.d.ts +3 -0
- package/dist/detect/index.js +13 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/node.d.ts +3 -0
- package/dist/detect/node.js +179 -0
- package/dist/detect/node.js.map +1 -0
- package/dist/detect/python.d.ts +2 -0
- package/dist/detect/python.js +388 -0
- package/dist/detect/python.js.map +1 -0
- package/dist/detect/repo.d.ts +2 -0
- package/dist/detect/repo.js +102 -0
- package/dist/detect/repo.js.map +1 -0
- package/dist/detect/types.d.ts +98 -0
- package/dist/detect/types.js +2 -0
- package/dist/detect/types.js.map +1 -0
- package/dist/diff.d.ts +12 -0
- package/dist/diff.js +105 -0
- package/dist/diff.js.map +1 -0
- package/dist/generate/agents.d.ts +19 -0
- package/dist/generate/agents.js +184 -0
- package/dist/generate/agents.js.map +1 -0
- package/dist/generate/claude-md.d.ts +9 -0
- package/dist/generate/claude-md.js +111 -0
- package/dist/generate/claude-md.js.map +1 -0
- package/dist/generate/config.d.ts +51 -0
- package/dist/generate/config.js +155 -0
- package/dist/generate/config.js.map +1 -0
- package/dist/generate/proposal.d.ts +11 -0
- package/dist/generate/proposal.js +175 -0
- package/dist/generate/proposal.js.map +1 -0
- package/dist/generate/settings.d.ts +35 -0
- package/dist/generate/settings.js +213 -0
- package/dist/generate/settings.js.map +1 -0
- package/dist/generate/skills.d.ts +8 -0
- package/dist/generate/skills.js +264 -0
- package/dist/generate/skills.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +69 -0
- package/dist/init.js.map +1 -0
- package/dist/trace.d.ts +9 -0
- package/dist/trace.js +106 -0
- package/dist/trace.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/flow/LICENSE +21 -0
- package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
- package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +35 -0
- package/node_modules/@dougharness/flow/agents/planner.md +70 -0
- package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
- package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
- package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
- package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
- package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
- package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
- package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
- package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
- package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
- package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
- package/node_modules/@dougharness/flow/package.json +19 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
- package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
- package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
- package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
- package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
- package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
- package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/gates/LICENSE +21 -0
- package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
- package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
- package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
- package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
- package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
- package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
- package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
- package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
- package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
- package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
- package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
- package/node_modules/@dougharness/gates/package.json +17 -0
- package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
- package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
- package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
- package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
- package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
- package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
- package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
- package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
- package/package.json +33 -0
- 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
|
+
}
|