@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.
Files changed (44) hide show
  1. package/dist/ablate.js +2 -2
  2. package/dist/ablate.js.map +1 -1
  3. package/dist/generate/agents.js +4 -0
  4. package/dist/generate/agents.js.map +1 -1
  5. package/dist/generate/claude-md.js +1 -1
  6. package/dist/generate/claude-md.js.map +1 -1
  7. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  8. package/node_modules/@dougharness/flow/agents/coder.md +2 -0
  9. package/node_modules/@dougharness/flow/agents/implementer.md +2 -0
  10. package/node_modules/@dougharness/flow/agents/planner.md +2 -1
  11. package/node_modules/@dougharness/flow/agents/reviewer.md +2 -0
  12. package/node_modules/@dougharness/flow/agents/tester.md +2 -0
  13. package/node_modules/@dougharness/flow/lib/board.mjs +9 -9
  14. package/node_modules/@dougharness/flow/lib/card-loop.mjs +54 -5
  15. package/node_modules/@dougharness/flow/lib/code-index.mjs +2 -2
  16. package/node_modules/@dougharness/flow/lib/coordinator.mjs +4 -2
  17. package/node_modules/@dougharness/flow/lib/decisions.mjs +3 -5
  18. package/node_modules/@dougharness/flow/lib/learn.mjs +105 -57
  19. package/node_modules/@dougharness/flow/lib/memory.mjs +1418 -57
  20. package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
  21. package/node_modules/@dougharness/flow/package.json +1 -1
  22. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +178 -1
  23. package/node_modules/@dougharness/flow/scripts/board.mjs +43 -15
  24. package/node_modules/@dougharness/flow/scripts/learn.mjs +4 -2
  25. package/node_modules/@dougharness/flow/scripts/memory.mjs +870 -39
  26. package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
  27. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  28. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  29. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +4 -0
  30. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
  31. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  32. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -3
  33. package/node_modules/@dougharness/flow/workflows/card-queen.js +54 -5
  34. package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
  35. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  36. package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
  37. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +6 -3
  38. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +175 -4
  39. package/node_modules/@dougharness/gates/lib/test-totals.mjs +1 -1
  40. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
  41. package/node_modules/@dougharness/gates/package.json +1 -1
  42. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +110 -125
  43. package/node_modules/@dougharness/gates/scripts/verify.mjs +47 -13
  44. 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, jaccard, reflectTokens, REFLECT_DUPLICATE_JACCARD } from "./memory.mjs";
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
- // Union-find clustering of live lessons by near-duplicate text (memory.mjs's own REFLECT_DUPLICATE_JACCARD
84
- // threshold and jaccard function - the same "near-duplicate" memory-reflect already uses to bump a lesson
85
- // instead of appending one). A cluster of >=2 is "repeated" by text; a lesson on its own with helpful >= 3 is
86
- // "repeated" by use. Both paths only ever report a count of >= 2, so a caller can treat `count >= 2` as one
87
- // uniform test.
88
- function groupRepeatedLessons(live) {
89
- const n = live.length;
90
- const parent = Array.from({ length: n }, (_, i) => i);
91
- function find(x) {
92
- while (parent[x] !== x) {
93
- parent[x] = parent[parent[x]];
94
- x = parent[x];
95
- }
96
- return x;
97
- }
98
- function union(a, b) {
99
- const ra = find(a);
100
- const rb = find(b);
101
- if (ra !== rb) parent[ra] = rb;
102
- }
103
- const tokens = live.map((l) => reflectTokens(l.text));
104
- for (let i = 0; i < n; i++) {
105
- for (let j = i + 1; j < n; j++) {
106
- if (jaccard(tokens[i], tokens[j]) >= REFLECT_DUPLICATE_JACCARD) union(i, j);
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
- const clusters = new Map();
110
- for (let i = 0; i < n; i++) {
111
- const r = find(i);
112
- if (!clusters.has(r)) clusters.set(r, []);
113
- clusters.get(r).push(i);
114
- }
115
- const repeated = [];
116
- for (const idxs of clusters.values()) {
117
- if (idxs.length >= 2) {
118
- const members = idxs.map((i) => live[i]).sort((a, b) => String(a.created || "").localeCompare(String(b.created || "")) || a.id.localeCompare(b.id));
119
- repeated.push({ text: members[0].text, ids: members.map((l) => l.id).sort(), kind: members[0].kind, count: members.length });
120
- } else {
121
- const l = live[idxs[0]];
122
- if (l.helpful >= 3) repeated.push({ text: l.text, ids: [l.id], kind: l.kind, count: l.helpful });
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
- repeated.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text));
126
- return repeated;
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
- repeated: groupRepeatedLessons(live),
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 under 60 lines" against this count.
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 repeated feedback/pitfall lesson not already in CLAUDE.md.
503
- for (const r of signals.lessons.repeated) {
504
- if (r.kind !== "feedback" && r.kind !== "pitfall") continue;
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 = r.kind === "feedback" ? "## Working agreement" : "## Gotchas";
507
- const bulletLine = `- ${r.text}`;
508
- const evidence = { count: r.count, ids: r.ids, kind: r.kind };
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 (countLines(updated) >= 60) {
516
- 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 });
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 repeated lesson, and no block description, with
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.repeated.some((r) => textReferencesTokens(r.text, tokens)) ||
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, repeated lesson, or block description across ${signals.outcomes.rows.length} outcome rows and ${signals.trace.files.length} trace files mentions "${tokens.join(", ")}"`,
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
  });