@dougharness/cli 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ablate.js +2 -2
- package/dist/ablate.js.map +1 -1
- package/dist/generate/agents.js +4 -0
- package/dist/generate/agents.js.map +1 -1
- package/dist/generate/claude-md.js +1 -1
- package/dist/generate/claude-md.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/coder.md +2 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +2 -0
- package/node_modules/@dougharness/flow/agents/planner.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +2 -0
- package/node_modules/@dougharness/flow/agents/tester.md +2 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +9 -9
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +54 -5
- package/node_modules/@dougharness/flow/lib/code-index.mjs +2 -2
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/decisions.mjs +3 -5
- package/node_modules/@dougharness/flow/lib/learn.mjs +105 -57
- package/node_modules/@dougharness/flow/lib/memory.mjs +1418 -57
- package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +178 -1
- package/node_modules/@dougharness/flow/scripts/board.mjs +43 -15
- package/node_modules/@dougharness/flow/scripts/learn.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/memory.mjs +870 -39
- package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +4 -0
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -3
- package/node_modules/@dougharness/flow/workflows/card-queen.js +54 -5
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +6 -3
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +175 -4
- package/node_modules/@dougharness/gates/lib/test-totals.mjs +1 -1
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +110 -125
- package/node_modules/@dougharness/gates/scripts/verify.mjs +47 -13
- package/package.json +3 -3
|
@@ -10,7 +10,18 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, append
|
|
|
10
10
|
import { join, relative, resolve, sep, dirname } from "node:path";
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
|
-
import { listOutcomes, listLessons,
|
|
13
|
+
import { listOutcomes, listLessons, readPitfallOccurrences, HISTORY_DIR_RELPATH } from "./memory.mjs";
|
|
14
|
+
|
|
15
|
+
export const RULE_MIN_CARDS = 3;
|
|
16
|
+
export const RULE_MIN_SESSIONS = 2;
|
|
17
|
+
|
|
18
|
+
// The one format of a .claude/rules file (learn's rule proposals and decisions.mjs proposeRule share it): a quoted
|
|
19
|
+
// `paths:` frontmatter list and the body, or the body alone with no paths.
|
|
20
|
+
export function renderRule(paths, body) {
|
|
21
|
+
const list = Array.isArray(paths) ? paths.filter((p) => String(p || "").trim()) : [];
|
|
22
|
+
const trimmed = String(body).replace(/\s+$/, "");
|
|
23
|
+
return list.length ? `---\npaths:\n${list.map((p) => ` - "${p}"`).join("\n")}\n---\n\n${trimmed}\n` : `${trimmed}\n`;
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
// --- collectSignals ----------------------------------------------------------------------------------------
|
|
16
27
|
|
|
@@ -80,50 +91,70 @@ function isGateFailure(row) {
|
|
|
80
91
|
return false;
|
|
81
92
|
}
|
|
82
93
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
94
|
+
// The file a rule proposal's `paths:` frontmatter is scoped by (card memory-failure-lessons, ADR 0019 line 60): the
|
|
95
|
+
// union over a card's history `report` entries of `filesChanged`. An absolute path counts only when it lies under
|
|
96
|
+
// that report's `worktree` and is then made relative to it; a relative path is kept; any path with a `..` segment,
|
|
97
|
+
// and any other absolute path, is dropped. Sorted, de-duplicated; never throws.
|
|
98
|
+
export function cardFilesTouched(dir, card) {
|
|
99
|
+
const out = new Set();
|
|
100
|
+
try {
|
|
101
|
+
if (typeof card !== "string" || !/^[A-Za-z0-9_.-]+$/.test(card) || card.includes("..")) return [];
|
|
102
|
+
const cardDir = join(dir, HISTORY_DIR_RELPATH, card);
|
|
103
|
+
if (!existsSync(cardDir)) return [];
|
|
104
|
+
for (const file of readdirSync(cardDir).filter((f) => f.endsWith(".jsonl")).sort()) {
|
|
105
|
+
let text;
|
|
106
|
+
try {
|
|
107
|
+
text = readFileSync(join(cardDir, file), "utf8");
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const raw of text.split("\n")) {
|
|
112
|
+
if (raw === "") continue;
|
|
113
|
+
let entry;
|
|
114
|
+
let report;
|
|
115
|
+
try {
|
|
116
|
+
entry = JSON.parse(raw);
|
|
117
|
+
if (!entry || entry.kind !== "report") continue;
|
|
118
|
+
report = JSON.parse(entry.text);
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!report || !Array.isArray(report.filesChanged)) continue;
|
|
123
|
+
const worktree = typeof report.worktree === "string" && report.worktree ? report.worktree.replace(/\/+$/, "") : null;
|
|
124
|
+
for (const f of report.filesChanged) {
|
|
125
|
+
if (typeof f !== "string" || f === "" || f.split("/").includes("..")) continue;
|
|
126
|
+
if (f.startsWith("/")) {
|
|
127
|
+
if (worktree && f.startsWith(`${worktree}/`)) out.add(f.slice(worktree.length + 1));
|
|
128
|
+
} else out.add(f.replace(/^\.\//, ""));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
107
131
|
}
|
|
132
|
+
} catch {
|
|
133
|
+
// fail open: whatever was read so far
|
|
108
134
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
return [...out].sort();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A pitfall lesson's distinct cards, sessions and files from the occurrence ledger (card memory-failure-lessons).
|
|
139
|
+
// Only occurrences count: helpful counters and text similarity play no part (ADR 0019 lines 39 and 60). A null
|
|
140
|
+
// session never counts toward the sessions.
|
|
141
|
+
function collectPitfallSignals(dir, live) {
|
|
142
|
+
const pitfalls = new Map(live.filter((l) => l.kind === "pitfall").map((l) => [l.id, l]));
|
|
143
|
+
const byLesson = new Map();
|
|
144
|
+
for (const o of readPitfallOccurrences(dir)) {
|
|
145
|
+
if (!pitfalls.has(o.lesson)) continue;
|
|
146
|
+
const cur = byLesson.get(o.lesson) || { cards: new Set(), sessions: new Set(), citations: [] };
|
|
147
|
+
if (typeof o.card === "string" && o.card) cur.cards.add(o.card);
|
|
148
|
+
if (typeof o.session === "string" && o.session) cur.sessions.add(o.session);
|
|
149
|
+
cur.citations.push(o.citation);
|
|
150
|
+
byLesson.set(o.lesson, cur);
|
|
124
151
|
}
|
|
125
|
-
|
|
126
|
-
|
|
152
|
+
return [...byLesson.entries()]
|
|
153
|
+
.map(([id, v]) => {
|
|
154
|
+
const cards = [...v.cards].sort();
|
|
155
|
+
return { id, text: pitfalls.get(id).text, cards, sessions: [...v.sessions].sort(), citations: v.citations, files: [...new Set(cards.flatMap((c) => cardFilesTouched(dir, c)))].sort() };
|
|
156
|
+
})
|
|
157
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
function listTraceFiles(traceDir) {
|
|
@@ -278,7 +309,7 @@ export function collectSignals(m, { dir, traceDir, now = new Date() } = {}) {
|
|
|
278
309
|
helpful: live.filter((l) => l.helpful > 0).length,
|
|
279
310
|
harmful: live.filter((l) => l.harmful > 0).length,
|
|
280
311
|
stale: live.filter((l) => l.stale).length,
|
|
281
|
-
|
|
312
|
+
pitfalls: collectPitfallSignals(dir, live),
|
|
282
313
|
},
|
|
283
314
|
trace,
|
|
284
315
|
};
|
|
@@ -294,12 +325,19 @@ function normalize(s) {
|
|
|
294
325
|
}
|
|
295
326
|
|
|
296
327
|
// wc -l semantics: the count of newline-terminated lines, not split("\n").length (which counts one extra empty
|
|
297
|
-
// element for a file ending in "\n"). CLAUDE.md's own header says "Keep it
|
|
328
|
+
// element for a file ending in "\n"). CLAUDE.md's own header says "Keep it to 200 lines or fewer" against this count.
|
|
298
329
|
export function countLines(text) {
|
|
299
330
|
if (text === "") return 0;
|
|
300
331
|
return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
|
|
301
332
|
}
|
|
302
333
|
|
|
334
|
+
// The one CLAUDE.md size limit, shared by learn.mjs and decisions.mjs: 200 lines are allowed, 201 are over. 200 is
|
|
335
|
+
// Anthropic's documented target per CLAUDE.md file, not a measured threshold.
|
|
336
|
+
export const CLAUDE_MD_MAX_LINES = 200;
|
|
337
|
+
export function exceedsClaudeMdLimit(text) {
|
|
338
|
+
return countLines(text) > CLAUDE_MD_MAX_LINES;
|
|
339
|
+
}
|
|
340
|
+
|
|
303
341
|
function findSection(lines, heading) {
|
|
304
342
|
const start = lines.findIndex((l) => l.trim() === heading);
|
|
305
343
|
if (start === -1) return null;
|
|
@@ -499,21 +537,31 @@ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } =
|
|
|
499
537
|
let n = 0;
|
|
500
538
|
const nextId = () => String(++n).padStart(2, "0");
|
|
501
539
|
|
|
502
|
-
// promote: a
|
|
503
|
-
|
|
504
|
-
|
|
540
|
+
// promote: a pitfall cited by RULE_MIN_CARDS distinct cards over RULE_MIN_SESSIONS distinct sessions (card
|
|
541
|
+
// memory-failure-lessons, ADR 0019 line 60). With known files it becomes a path-scoped .claude/rules file; with none, a
|
|
542
|
+
// CLAUDE.md Gotchas bullet (refused over the 200-line limit). Feedback lessons have no trigger.
|
|
543
|
+
for (const r of signals.lessons.pitfalls ?? []) {
|
|
544
|
+
if (r.cards.length < RULE_MIN_CARDS || r.sessions.length < RULE_MIN_SESSIONS) continue;
|
|
545
|
+
const evidence = { id: r.id, cards: r.cards, sessions: r.sessions, citations: r.citations, files: r.files };
|
|
546
|
+
const counted = `pitfall ${r.id} cited by ${r.cards.length} cards over ${r.sessions.length} sessions`;
|
|
547
|
+
if (r.files.length) {
|
|
548
|
+
const relPath = `.claude/rules/pitfall-${r.id}.md`;
|
|
549
|
+
const body = `${r.text}\n\nEvidence:\n${r.citations.map((c) => `- ${c}`).join("\n")}`;
|
|
550
|
+
const abs = join(dir, relPath);
|
|
551
|
+
const oldText = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
552
|
+
proposals.push({ id: nextId(), kind: "rule", target: relPath, reason: `${counted}; proposing a rule scoped to the files those cards touched`, evidence, diff: unifiedDiff(relPath, oldText, renderRule(r.files, body)) });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
505
555
|
if (normalize(claudeMd).includes(normalize(r.text))) continue;
|
|
506
|
-
const heading =
|
|
507
|
-
const
|
|
508
|
-
const
|
|
509
|
-
const reason = `lesson repeated ${r.count}× (ids: ${r.ids.join(", ")}); promoting to CLAUDE.md's ${heading.replace("## ", "")}`;
|
|
510
|
-
const updated = insertBulletUnderHeading(claudeMd, heading, bulletLine);
|
|
556
|
+
const heading = "## Gotchas";
|
|
557
|
+
const reason = `${counted}; no file known, promoting to CLAUDE.md's Gotchas`;
|
|
558
|
+
const updated = insertBulletUnderHeading(claudeMd, heading, `- ${r.text}`);
|
|
511
559
|
if (updated === null) {
|
|
512
560
|
proposals.push({ id: nextId(), kind: "promote", target: "CLAUDE.md", reason: `${reason}, but CLAUDE.md has no "${heading}" section`, evidence, diff: null });
|
|
513
561
|
continue;
|
|
514
562
|
}
|
|
515
|
-
if (
|
|
516
|
-
proposals.push({ id: nextId(), kind: "promote", target: "CLAUDE.md", reason: `${reason}, but CLAUDE.md is
|
|
563
|
+
if (exceedsClaudeMdLimit(updated)) {
|
|
564
|
+
proposals.push({ id: nextId(), kind: "promote", target: "CLAUDE.md", reason: `${reason}, but CLAUDE.md is over its ${CLAUDE_MD_MAX_LINES}-line limit (would be ${countLines(updated)})`, evidence, diff: null });
|
|
517
565
|
continue;
|
|
518
566
|
}
|
|
519
567
|
proposals.push({ id: nextId(), kind: "promote", target: "CLAUDE.md", reason, evidence, diff: unifiedDiff("CLAUDE.md", claudeMd, updated) });
|
|
@@ -541,7 +589,7 @@ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } =
|
|
|
541
589
|
}
|
|
542
590
|
}
|
|
543
591
|
|
|
544
|
-
// delete: a CLAUDE.md rule line referenced by no denial, no
|
|
592
|
+
// delete: a CLAUDE.md rule line referenced by no denial, no pitfall lesson, and no block description, with
|
|
545
593
|
// enough signal density to trust the absence (>=10 outcome rows, >=5 trace files).
|
|
546
594
|
if (signals.outcomes.rows.length >= 10 && signals.trace.files.length >= 5) {
|
|
547
595
|
const blockDescriptions = signals.outcomes.rows.flatMap((r) => (r.blocks || []).map((b) => b.description || ""));
|
|
@@ -556,7 +604,7 @@ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } =
|
|
|
556
604
|
if (!tokens.length) continue;
|
|
557
605
|
const referenced =
|
|
558
606
|
signals.trace.denials.some((d) => textReferencesTokens(d.detail, tokens)) ||
|
|
559
|
-
signals.lessons.
|
|
607
|
+
(signals.lessons.pitfalls ?? []).some((r) => textReferencesTokens(r.text, tokens)) ||
|
|
560
608
|
blockDescriptions.some((d) => textReferencesTokens(d, tokens));
|
|
561
609
|
if (referenced) continue;
|
|
562
610
|
const updated = removeExactLine(claudeMd, line);
|
|
@@ -564,7 +612,7 @@ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } =
|
|
|
564
612
|
id: nextId(),
|
|
565
613
|
kind: "delete",
|
|
566
614
|
target: "CLAUDE.md",
|
|
567
|
-
reason: `no denial,
|
|
615
|
+
reason: `no denial, pitfall lesson, or block description across ${signals.outcomes.rows.length} outcome rows and ${signals.trace.files.length} trace files mentions "${tokens.join(", ")}" (rule: ${line.slice(2)})`,
|
|
568
616
|
evidence: { outcomeRows: signals.outcomes.rows.length, traceFiles: signals.trace.files.length, keyPhrase: tokens },
|
|
569
617
|
diff: updated === null ? null : unifiedDiff("CLAUDE.md", claudeMd, updated),
|
|
570
618
|
});
|