@dougharness/cli 1.3.0 → 1.5.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 (51) hide show
  1. package/dist/ablate.js +2 -2
  2. package/dist/ablate.js.map +1 -1
  3. package/dist/bin.js +13 -4
  4. package/dist/bin.js.map +1 -1
  5. package/dist/board-page.d.ts +5 -1
  6. package/dist/board-page.js +19 -5
  7. package/dist/board-page.js.map +1 -1
  8. package/dist/board-serve.js +34 -10
  9. package/dist/board-serve.js.map +1 -1
  10. package/dist/board.d.ts +1 -1
  11. package/dist/board.js +160 -13
  12. package/dist/board.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  14. package/node_modules/@dougharness/flow/agents/planner.md +1 -0
  15. package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
  16. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  17. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  18. package/node_modules/@dougharness/flow/lib/board.mjs +375 -30
  19. package/node_modules/@dougharness/flow/lib/card-loop.mjs +237 -24
  20. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  21. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
  22. package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
  23. package/node_modules/@dougharness/flow/lib/memory.mjs +1403 -44
  24. package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
  25. package/node_modules/@dougharness/flow/package.json +1 -1
  26. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +355 -13
  27. package/node_modules/@dougharness/flow/scripts/board.mjs +160 -27
  28. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  29. package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +668 -39
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
  32. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  33. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +7 -3
  34. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  35. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +11 -2
  36. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
  37. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  38. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  39. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +3 -3
  40. package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
  41. package/node_modules/@dougharness/flow/workflows/doug-implement.js +55 -37
  42. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  43. package/node_modules/@dougharness/gates/lib/baseline.mjs +32 -0
  44. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +45 -5
  45. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  46. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
  47. package/node_modules/@dougharness/gates/package.json +1 -1
  48. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +130 -42
  49. package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
  50. package/package.json +3 -3
  51. package/templates/board-app.js +240 -30
@@ -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
  };
@@ -499,15 +530,25 @@ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } =
499
530
  let n = 0;
500
531
  const nextId = () => String(++n).padStart(2, "0");
501
532
 
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;
533
+ // promote: a pitfall cited by RULE_MIN_CARDS distinct cards over RULE_MIN_SESSIONS distinct sessions (card
534
+ // memory-failure-lessons, ADR 0019 line 60). With known files it becomes a path-scoped .claude/rules file; with none, a
535
+ // CLAUDE.md Gotchas bullet (refused at the 60-line cap). Feedback lessons have no trigger.
536
+ for (const r of signals.lessons.pitfalls ?? []) {
537
+ if (r.cards.length < RULE_MIN_CARDS || r.sessions.length < RULE_MIN_SESSIONS) continue;
538
+ const evidence = { id: r.id, cards: r.cards, sessions: r.sessions, citations: r.citations, files: r.files };
539
+ const counted = `pitfall ${r.id} cited by ${r.cards.length} cards over ${r.sessions.length} sessions`;
540
+ if (r.files.length) {
541
+ const relPath = `.claude/rules/pitfall-${r.id}.md`;
542
+ const body = `${r.text}\n\nEvidence:\n${r.citations.map((c) => `- ${c}`).join("\n")}`;
543
+ const abs = join(dir, relPath);
544
+ const oldText = existsSync(abs) ? readFileSync(abs, "utf8") : "";
545
+ 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)) });
546
+ continue;
547
+ }
505
548
  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);
549
+ const heading = "## Gotchas";
550
+ const reason = `${counted}; no file known, promoting to CLAUDE.md's Gotchas`;
551
+ const updated = insertBulletUnderHeading(claudeMd, heading, `- ${r.text}`);
511
552
  if (updated === null) {
512
553
  proposals.push({ id: nextId(), kind: "promote", target: "CLAUDE.md", reason: `${reason}, but CLAUDE.md has no "${heading}" section`, evidence, diff: null });
513
554
  continue;
@@ -541,7 +582,7 @@ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } =
541
582
  }
542
583
  }
543
584
 
544
- // delete: a CLAUDE.md rule line referenced by no denial, no repeated lesson, and no block description, with
585
+ // delete: a CLAUDE.md rule line referenced by no denial, no pitfall lesson, and no block description, with
545
586
  // enough signal density to trust the absence (>=10 outcome rows, >=5 trace files).
546
587
  if (signals.outcomes.rows.length >= 10 && signals.trace.files.length >= 5) {
547
588
  const blockDescriptions = signals.outcomes.rows.flatMap((r) => (r.blocks || []).map((b) => b.description || ""));
@@ -556,7 +597,7 @@ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } =
556
597
  if (!tokens.length) continue;
557
598
  const referenced =
558
599
  signals.trace.denials.some((d) => textReferencesTokens(d.detail, tokens)) ||
559
- signals.lessons.repeated.some((r) => textReferencesTokens(r.text, tokens)) ||
600
+ (signals.lessons.pitfalls ?? []).some((r) => textReferencesTokens(r.text, tokens)) ||
560
601
  blockDescriptions.some((d) => textReferencesTokens(d, tokens));
561
602
  if (referenced) continue;
562
603
  const updated = removeExactLine(claudeMd, line);
@@ -564,7 +605,7 @@ export function proposeChanges(signals, { dir, claudeMd, config, skills = [] } =
564
605
  id: nextId(),
565
606
  kind: "delete",
566
607
  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(", ")}"`,
608
+ 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
609
  evidence: { outcomeRows: signals.outcomes.rows.length, traceFiles: signals.trace.files.length, keyPhrase: tokens },
569
610
  diff: updated === null ? null : unifiedDiff("CLAUDE.md", claudeMd, updated),
570
611
  });