@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
@@ -11,14 +11,15 @@
11
11
  // can supersede a lesson (superseded_by) but never delete or rewrite its text — two triggers on the table enforce
12
12
  // that in the database itself, not just in this module's API.
13
13
  import { createRequire } from "node:module";
14
- import { fileURLToPath } from "node:url";
15
- import { mkdirSync, existsSync, readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { mkdirSync, existsSync, readFileSync, statSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
16
16
  import { dirname, resolve, join } from "node:path";
17
17
  import { createHash } from "node:crypto";
18
18
  import { homedir } from "node:os";
19
19
  import { request as httpRequest } from "node:http";
20
20
  import { request as httpsRequest } from "node:https";
21
- import { classifyBlocks, adversaryBlocks } from "./board.mjs";
21
+ import { classifyBlocks, adversaryBlocks, addCard } from "./board.mjs";
22
+ import { initialState, currentAction, next as cardLoopNext, briefPrompt } from "./card-loop.mjs";
22
23
  import { transcriptUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
23
24
  import { MODELS_HEADING } from "./models.mjs";
24
25
 
@@ -61,7 +62,15 @@ const LESSON_KINDS = ["feedback", "project", "pitfall", "pattern"];
61
62
  // verified is null on a hand row even though the gate passed: no independent verifier ran on that track, and
62
63
  // the gate's own text lives in the `gate` column instead. A consumer that averages fix_passes or counts
63
64
  // blocks across both tracks must filter on track. wall_clock, gate, and note only exist on a hand row; they
64
- // are null on a flow row.
65
+ // are null on a flow row. Exception (card memory-outcome-arms): `reflect --hand --rounds <n>` (not a dry run)
66
+ // writes passes n and fix_passes n - 1 onto the landing's hand row; without --rounds both stay null, since the
67
+ // default of 1 round is not a measurement. A later `record --hand` of the same commit keeps those values (the
68
+ // upsert only replaces passes and fix_passes when the new row carries passes).
69
+ //
70
+ // memory_config (v10, card memory-outcome-arms): the canonical JSON (keys sorted) of the memoryConfig() the row
71
+ // ran under, copied from condition.json; null on an unstamped row. It travels with the condition columns. A flow
72
+ // row is stamped with memoryConfig({ track: "flow" }) (delivery "index+fetch" with fetchTokens, cards memory-flow-index-fetch, memory-atomic-lessons) when recorded
73
+ // through `memory.mjs record <report.json>`; a hand row carries the hand stamp (delivery "index+fetch").
65
74
  //
66
75
  // partial (v5, card worker-context-handoff): 1 when the task's own outcome was still partial after its one
67
76
  // resume (graceful degradation on a context limit, never a verification failure), 0 when it was not, null on a
@@ -77,8 +86,10 @@ const LESSON_KINDS = ["feedback", "project", "pitfall", "pattern"];
77
86
  // ran under (`<class>/default` until exploration exists); assigned_by is `'policy'` or `'explore'`;
78
87
  // explore_probability is the explore arm's assignment probability, null otherwise. backfilled is 1 only for a
79
88
  // row `memory.mjs condition backfill` stamped after the fact, 0 for every other row (including an unstamped
80
- // hand row, which has never been touched by either path). On today's flow track these seven columns are always
81
- // null: a flow row is never stamped by `condition open`/`record --hand` — that wiring is a later card.
89
+ // hand row, which has never been touched by either path).
90
+ // A stopped hand card report yields a stop row (stopOutcomeRow below): track 'hand', run 'stop:<report hash>', stop_class and
91
+ // stop_reason from the report, commit_sha null. On the flow track these seven columns are null: a flow
92
+ // row is never stamped by `condition open`/`record --hand` (its memory_config alone is stamped, see above).
82
93
  const OUTCOMES_COLUMNS_SQL = `
83
94
  id INTEGER PRIMARY KEY,
84
95
  recorded TEXT NOT NULL,
@@ -124,6 +135,7 @@ const OUTCOMES_COLUMNS_SQL = `
124
135
  assigned_by TEXT,
125
136
  explore_probability REAL,
126
137
  backfilled INTEGER NOT NULL DEFAULT 0,
138
+ memory_config TEXT,
127
139
  UNIQUE(run, task)
128
140
  `;
129
141
 
@@ -150,7 +162,10 @@ const LESSONS_COLUMNS_SQL = `
150
162
  embedding_model TEXT,
151
163
  embedding_dims INTEGER,
152
164
  embedding BLOB,
153
- stale TEXT
165
+ stale TEXT,
166
+ seen INTEGER NOT NULL DEFAULT 0,
167
+ archived TEXT,
168
+ archived_reason TEXT
154
169
  `;
155
170
 
156
171
  const LESSONS_TRIGGERS_SQL = `
@@ -423,6 +438,61 @@ function migrateV8ToV9(db) {
423
438
  }
424
439
  }
425
440
 
441
+ // v9 had no memory_config column on outcomes (card memory-outcome-arms): the canonical JSON of the memory
442
+ // configuration a row ran under, copied from condition.json's memoryConfig. Additive like v8->v9: one guarded
443
+ // ALTER, then the version bump; old rows stay null. OUTCOMES_COLUMNS_SQL carries memory_config for a v1 store's
444
+ // rebuild, so this step is a no-op there instead of a duplicate-column error.
445
+ const V10_MEMORY_COLUMNS = [["memory_config", "TEXT"]];
446
+
447
+ function migrateV9ToV10(db) {
448
+ db.exec("BEGIN");
449
+ try {
450
+ const cols = db.prepare("PRAGMA table_info(outcomes)").all().map((c) => c.name);
451
+ for (const [name, type] of V10_MEMORY_COLUMNS) {
452
+ if (!cols.includes(name)) db.exec(`ALTER TABLE outcomes ADD COLUMN ${name} ${type};`);
453
+ }
454
+ db.exec("PRAGMA user_version = 10");
455
+ db.exec("COMMIT");
456
+ } catch (err) {
457
+ db.exec("ROLLBACK");
458
+ throw err;
459
+ }
460
+ }
461
+
462
+ // v10 had no seen column on lessons (card memory-reflect-supersede): how many times a reflect pass judged a
463
+ // proposal a repeat of the lesson. One guarded ALTER (LESSONS_COLUMNS_SQL already carries seen for a rebuilt
464
+ // store), then the version bump. seen is not in the no-rewrite trigger's column list.
465
+ function migrateV10ToV11(db) {
466
+ db.exec("BEGIN");
467
+ try {
468
+ const cols = db.prepare("PRAGMA table_info(lessons)").all().map((c) => c.name);
469
+ if (!cols.includes("seen")) db.exec("ALTER TABLE lessons ADD COLUMN seen INTEGER NOT NULL DEFAULT 0;");
470
+ db.exec("PRAGMA user_version = 11");
471
+ db.exec("COMMIT");
472
+ } catch (err) {
473
+ db.exec("ROLLBACK");
474
+ throw err;
475
+ }
476
+ }
477
+
478
+ // v11 had no archived status on lessons (card memory-feedback-rank): archived is an ISO time (NULL = live) and
479
+ // archived_reason names the counters that met the archive rule. Two guarded ALTERs (LESSONS_COLUMNS_SQL already
480
+ // carries both for a rebuilt store), then the version bump. Neither is in the no-rewrite trigger's column list,
481
+ // and the no-delete trigger is unchanged: an archived row is kept.
482
+ function migrateV11ToV12(db) {
483
+ db.exec("BEGIN");
484
+ try {
485
+ const cols = db.prepare("PRAGMA table_info(lessons)").all().map((c) => c.name);
486
+ if (!cols.includes("archived")) db.exec("ALTER TABLE lessons ADD COLUMN archived TEXT;");
487
+ if (!cols.includes("archived_reason")) db.exec("ALTER TABLE lessons ADD COLUMN archived_reason TEXT;");
488
+ db.exec("PRAGMA user_version = 12");
489
+ db.exec("COMMIT");
490
+ } catch (err) {
491
+ db.exec("ROLLBACK");
492
+ throw err;
493
+ }
494
+ }
495
+
426
496
  export function openMemory(dir) {
427
497
  const file = memoryPath(dir);
428
498
  mkdirSync(dirname(file), { recursive: true });
@@ -431,7 +501,7 @@ export function openMemory(dir) {
431
501
  const version = db.prepare("PRAGMA user_version").get().user_version;
432
502
  if (version === 0) {
433
503
  db.exec(SCHEMA_SQL);
434
- db.exec("PRAGMA user_version = 9");
504
+ db.exec("PRAGMA user_version = 12");
435
505
  } else if (version === 1) {
436
506
  migrateV1ToV2(db);
437
507
  migrateV2ToV3(db);
@@ -441,6 +511,9 @@ export function openMemory(dir) {
441
511
  migrateV6ToV7(db);
442
512
  migrateV7ToV8(db);
443
513
  migrateV8ToV9(db);
514
+ migrateV9ToV10(db);
515
+ migrateV10ToV11(db);
516
+ migrateV11ToV12(db);
444
517
  } else if (version === 2) {
445
518
  migrateV2ToV3(db);
446
519
  migrateV3ToV4(db);
@@ -449,6 +522,9 @@ export function openMemory(dir) {
449
522
  migrateV6ToV7(db);
450
523
  migrateV7ToV8(db);
451
524
  migrateV8ToV9(db);
525
+ migrateV9ToV10(db);
526
+ migrateV10ToV11(db);
527
+ migrateV11ToV12(db);
452
528
  } else if (version === 3) {
453
529
  migrateV3ToV4(db);
454
530
  migrateV4ToV5(db);
@@ -456,29 +532,56 @@ export function openMemory(dir) {
456
532
  migrateV6ToV7(db);
457
533
  migrateV7ToV8(db);
458
534
  migrateV8ToV9(db);
535
+ migrateV9ToV10(db);
536
+ migrateV10ToV11(db);
537
+ migrateV11ToV12(db);
459
538
  } else if (version === 4) {
460
539
  migrateV4ToV5(db);
461
540
  migrateV5ToV6(db);
462
541
  migrateV6ToV7(db);
463
542
  migrateV7ToV8(db);
464
543
  migrateV8ToV9(db);
544
+ migrateV9ToV10(db);
545
+ migrateV10ToV11(db);
546
+ migrateV11ToV12(db);
465
547
  } else if (version === 5) {
466
548
  migrateV5ToV6(db);
467
549
  migrateV6ToV7(db);
468
550
  migrateV7ToV8(db);
469
551
  migrateV8ToV9(db);
552
+ migrateV9ToV10(db);
553
+ migrateV10ToV11(db);
554
+ migrateV11ToV12(db);
470
555
  } else if (version === 6) {
471
556
  migrateV6ToV7(db);
472
557
  migrateV7ToV8(db);
473
558
  migrateV8ToV9(db);
559
+ migrateV9ToV10(db);
560
+ migrateV10ToV11(db);
561
+ migrateV11ToV12(db);
474
562
  } else if (version === 7) {
475
563
  migrateV7ToV8(db);
476
564
  migrateV8ToV9(db);
565
+ migrateV9ToV10(db);
566
+ migrateV10ToV11(db);
567
+ migrateV11ToV12(db);
477
568
  } else if (version === 8) {
478
569
  migrateV8ToV9(db);
479
- } else if (version !== 9) {
570
+ migrateV9ToV10(db);
571
+ migrateV10ToV11(db);
572
+ migrateV11ToV12(db);
573
+ } else if (version === 9) {
574
+ migrateV9ToV10(db);
575
+ migrateV10ToV11(db);
576
+ migrateV11ToV12(db);
577
+ } else if (version === 10) {
578
+ migrateV10ToV11(db);
579
+ migrateV11ToV12(db);
580
+ } else if (version === 11) {
581
+ migrateV11ToV12(db);
582
+ } else if (version !== 12) {
480
583
  db.close();
481
- throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version 9`);
584
+ throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version 12`);
482
585
  }
483
586
  return {
484
587
  file,
@@ -512,7 +615,7 @@ export function probeSqliteFts5() {
512
615
  // Read-only look at the store for `doug doctor`: creates nothing, migrates nothing, never throws.
513
616
  export function inspectMemory(dir) {
514
617
  const file = memoryPath(dir);
515
- const out = { file, exists: false, userVersion: null, maxUserVersion: 9, quickCheck: null, error: null };
618
+ const out = { file, exists: false, userVersion: null, maxUserVersion: 12, quickCheck: null, error: null };
516
619
  if (!existsSync(file)) return out;
517
620
  out.exists = true;
518
621
  let db;
@@ -616,6 +719,20 @@ export function supersedeLesson(m, oldId, newId) {
616
719
  return getLesson(m, oldId);
617
720
  }
618
721
 
722
+ // The one live-lesson predicate: not superseded, not stale, not archived. Every reader of "live" lessons uses it.
723
+ export const LIVE_LESSON_SQL = "superseded_by IS NULL AND stale IS NULL AND archived IS NULL";
724
+ export const isLiveLesson = (row) => !row.superseded_by && !row.stale && !row.archived;
725
+
726
+ // Restores an archived lesson (card memory-archived-readers): clears archived and archived_reason, the way back
727
+ // from reflect --harmful's archive. Neither column is protected by lessons_no_rewrite; nothing is deleted.
728
+ export function unarchiveLesson(m, id) {
729
+ const row = getLesson(m, id);
730
+ if (!row) throw new Error(`lesson ${id} does not exist`);
731
+ if (!row.archived) throw new Error(`lesson ${id} is not archived`);
732
+ m.prepare("UPDATE lessons SET archived = NULL, archived_reason = NULL WHERE id = ?").run(id);
733
+ return getLesson(m, id);
734
+ }
735
+
619
736
  // Clears a stale mark (set by recallLessons's citation re-check) so the lesson is a candidate again — the way
620
737
  // back once a checkout is fixed up or the mark turns out to be wrong. `stale` is not one of the columns the
621
738
  // lessons_no_rewrite trigger protects, so this is an ordinary UPDATE, not a special case.
@@ -633,6 +750,42 @@ export function listLessons(m, { includeSuperseded = false } = {}) {
633
750
  return rows.map(lessonRowToObject);
634
751
  }
635
752
 
753
+ // Ids of every lesson fetched with `lesson show --card`: the {"id": ...} lines of every
754
+ // <dir>/.doug/.state/reports/*/fetched.jsonl. Never throws; unreadable files and bad lines are skipped.
755
+ export function readFetchedLessonIds(dir) {
756
+ const ids = new Set();
757
+ let cards = [];
758
+ try {
759
+ cards = readdirSync(join(dir, ".doug", ".state", "reports"));
760
+ } catch {
761
+ return ids;
762
+ }
763
+ for (const card of cards) {
764
+ let text;
765
+ try {
766
+ text = readFileSync(join(dir, ".doug", ".state", "reports", card, "fetched.jsonl"), "utf8");
767
+ } catch {
768
+ continue;
769
+ }
770
+ for (const line of text.split("\n")) {
771
+ try {
772
+ const id = JSON.parse(line)?.id;
773
+ if (typeof id === "string") ids.add(id);
774
+ } catch {}
775
+ }
776
+ }
777
+ return ids;
778
+ }
779
+
780
+ // Live lessons no run has confirmed (ADR 0019): helpful = 0 and harmful = 0 and never fetched. Read-only.
781
+ export function listUnconfirmedLessons(m, { fetchedIds = new Set() } = {}) {
782
+ return m
783
+ .prepare(`SELECT id, kind, created, text FROM lessons WHERE ${LIVE_LESSON_SQL} AND helpful = 0 AND harmful = 0 ORDER BY created ASC, id ASC`)
784
+ .all()
785
+ .filter((r) => !fetchedIds.has(r.id))
786
+ .map((r) => ({ id: r.id, kind: r.kind, created: r.created, title: lessonTitle(r.text) }));
787
+ }
788
+
636
789
  // Whitespace-split tokens, each quoted for FTS5 MATCH and OR'd together; shared by searchLessons and
637
790
  // recallLessons's BM25 side, and by lib/code-index.mjs's searchIndex (same FTS5 dialect, one code_chunks_fts
638
791
  // table instead of lessons_fts). Returns null for an empty/whitespace-only query (nothing to match).
@@ -698,7 +851,7 @@ export function lessonsNeedingEmbedding(m, { model, dims }) {
698
851
  const rows = m
699
852
  .prepare(
700
853
  `SELECT * FROM lessons
701
- WHERE superseded_by IS NULL AND stale IS NULL
854
+ WHERE ${LIVE_LESSON_SQL}
702
855
  AND (embedding IS NULL OR embedding_model IS NOT ? OR embedding_dims IS NOT ?)
703
856
  ORDER BY created ASC, id ASC`
704
857
  )
@@ -930,20 +1083,21 @@ function recallRows(m, provider, nowMs, staleDays, sig) {
930
1083
  // No live row holds a vector under this model/dims: nothing to decode, and no blob is read.
931
1084
  const any = m
932
1085
  .prepare(
933
- "SELECT 1 AS x FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding IS NOT NULL AND embedding_model = ? AND embedding_dims = ? LIMIT 1"
1086
+ `SELECT 1 AS x FROM lessons WHERE ${LIVE_LESSON_SQL} AND embedding IS NOT NULL AND embedding_model = ? AND embedding_dims = ? LIMIT 1`
934
1087
  )
935
1088
  .get(provider.model, provider.dims);
936
1089
  if (!any) return null;
937
1090
 
938
1091
  const stmt = m.prepare(
939
- `SELECT id, created, confirmed, scope,
1092
+ `SELECT id, created, confirmed, scope, harmful, helpful, citation,
940
1093
  CASE WHEN embedding_model = ? AND embedding_dims = ? THEN embedding END AS emb
941
1094
  FROM lessons
942
- WHERE superseded_by IS NULL AND stale IS NULL AND ${SQL_OUTSIDE_WINDOW} = 0`
1095
+ WHERE ${LIVE_LESSON_SQL} AND ${SQL_OUTSIDE_WINDOW} = 0`
943
1096
  );
944
1097
  const ids = [];
945
1098
  const anchorMs = [];
946
1099
  const scopeRaw = [];
1100
+ const fbInfo = [];
947
1101
  const vecs = [];
948
1102
  let bytes = 0;
949
1103
  const cutoffMs = (cutoffJd - 2440587.5) * 86400000;
@@ -952,6 +1106,7 @@ function recallRows(m, provider, nowMs, staleDays, sig) {
952
1106
  ids.push(r.id);
953
1107
  anchorMs.push(new Date(r.confirmed || r.created).getTime());
954
1108
  scopeRaw.push(r.scope);
1109
+ fbInfo.push({ harmful: r.harmful, helpful: r.helpful, citation: r.citation });
955
1110
  const vec = blobToFloats(r.emb);
956
1111
  vecs.push(vec);
957
1112
  if (vec) bytes += vec.byteLength;
@@ -964,6 +1119,7 @@ function recallRows(m, provider, nowMs, staleDays, sig) {
964
1119
  ids,
965
1120
  anchorMs: Float64Array.from(anchorMs),
966
1121
  scopeRaw,
1122
+ fbInfo,
967
1123
  vecs,
968
1124
  live: new Uint8Array(ids.length).fill(1),
969
1125
  bytes,
@@ -1003,6 +1159,28 @@ function followOwnWrites(m, entry, written, confirmedIso, sigBefore) {
1003
1159
  entry.sig = sigAfter;
1004
1160
  }
1005
1161
 
1162
+ // What the stale window hides (card recall-window-report): over live rows (not superseded, not stale), the
1163
+ // count outside the window by exactly ageDaysOf's rule, the live total, and the oldest hidden age in whole days.
1164
+ export function windowHidden(m, { staleDays = 30, now = new Date() } = {}) {
1165
+ const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
1166
+ const rows = m.prepare(`SELECT created, confirmed FROM lessons WHERE ${LIVE_LESSON_SQL}`).all();
1167
+ let count = 0;
1168
+ let oldest = -Infinity;
1169
+ for (const r of rows) {
1170
+ const age = ageDaysOf(r.confirmed || r.created, nowMs);
1171
+ if (age <= staleDays) continue;
1172
+ count++;
1173
+ if (age > oldest) oldest = age;
1174
+ }
1175
+ return { count, total: rows.length, oldestDays: count ? Math.floor(oldest) : null };
1176
+ }
1177
+
1178
+ // The one line that says so; null when the window hid nothing.
1179
+ export function windowNote(hidden, staleDays = 30) {
1180
+ if (!hidden || !hidden.count) return null;
1181
+ return `window: hid ${hidden.count} of ${hidden.total} live lessons older than ${staleDays} days (oldest ${hidden.oldestDays} days)`;
1182
+ }
1183
+
1006
1184
  // --- Dense-ranked recall -----------------------------------------------------------------------------------
1007
1185
  // With a provider and vectors stored under its model/dims, every vectored live in-window lesson is ranked by
1008
1186
  // cosine similarity x recency weight x scope boost (memory-benchmark section 9: cosine x recency beat the old
@@ -1013,8 +1191,51 @@ function followOwnWrites(m, entry, written, confirmedIso, sigBefore) {
1013
1191
  // returned unchecked (checked: false in the result) rather than either throwing (no checkoutDir given) or
1014
1192
  // marking every row stale forever (a wrong one given). Never throws for a provider or checkout problem (a
1015
1193
  // database problem may still throw, same as every other export here). See design #2 in the memory-recall card.
1194
+ // Feedback in recall scoring (card memory-feedback-rank, ADR 0019). harmful (the lead's explicit mark) and the
1195
+ // lesson's evidence class (by citation) multiply the score; helpful enters only as a capped, optionally decayed
1196
+ // term that ships off (helpfulCap 0), because it rises on every verified use and would feed itself. harmful 0,
1197
+ // weight 1 and helpfulCap 0 leave the score exactly as before. The multiplier is applied to the score's
1198
+ // magnitude (score + |score| * (factor - 1)) so a negative cosine is lowered by a factor under 1, not raised.
1199
+ export const RECALL_FEEDBACK = {
1200
+ harmfulPenalty: 0.01,
1201
+ helpfulCap: 0,
1202
+ helpfulStep: 0.05,
1203
+ helpfulDecay: 0,
1204
+ evidenceWeights: { report: 1, history: 1, file: 1, none: 1 },
1205
+ };
1206
+
1207
+ export function evidenceClassOf(lesson) {
1208
+ const c = lesson && typeof lesson.citation === "string" ? lesson.citation : "";
1209
+ if (!c) return "none";
1210
+ if (c.startsWith("report:")) return "report";
1211
+ if (c.startsWith("history:")) return "history";
1212
+ return "file";
1213
+ }
1214
+
1215
+ function mergeFeedback(over) {
1216
+ const o = over || {};
1217
+ return { ...RECALL_FEEDBACK, ...o, evidenceWeights: { ...RECALL_FEEDBACK.evidenceWeights, ...(o.evidenceWeights || {}) } };
1218
+ }
1219
+
1220
+ function feedbackScore(score, info, fb) {
1221
+ if (!info) return score;
1222
+ const harmful = Number(info.harmful) || 0;
1223
+ let factor = 1;
1224
+ if (harmful > 0) factor *= 1 / (1 + fb.harmfulPenalty * harmful);
1225
+ const w = fb.evidenceWeights[evidenceClassOf(info)];
1226
+ if (w !== undefined && w !== 1) factor *= w;
1227
+ if (fb.helpfulCap > 0) {
1228
+ const h = Math.max(0, Number(info.helpful) || 0);
1229
+ const d = fb.helpfulDecay;
1230
+ const eff = d > 0 && d < 1 ? (1 - Math.pow(d, h)) / (1 - d) : h;
1231
+ factor *= 1 + fb.helpfulStep * Math.min(eff, fb.helpfulCap);
1232
+ }
1233
+ return factor === 1 ? score : score + Math.abs(score) * (factor - 1);
1234
+ }
1235
+
1016
1236
  export async function recallLessons(m, query, opts = {}) {
1017
1237
  const { files = [], k = 8, now = new Date(), provider = null, checkoutDir = null, staleDays = 30, detectedProvider = null } = opts;
1238
+ const fb = mergeFeedback(opts.feedback);
1018
1239
  const nowDate = now instanceof Date ? now : new Date(now);
1019
1240
  const nowIsoStr = nowDate.toISOString();
1020
1241
  const nowMs = nowDate.getTime();
@@ -1028,10 +1249,10 @@ export async function recallLessons(m, query, opts = {}) {
1028
1249
  if (match) {
1029
1250
  const rows = m
1030
1251
  .prepare(
1031
- `SELECT l.id, l.created, l.confirmed, l.scope,
1252
+ `SELECT l.id, l.created, l.confirmed, l.scope, l.harmful, l.helpful, l.citation,
1032
1253
  (l.embedding IS NOT NULL AND l.embedding_model = ? AND l.embedding_dims = ?) AS has_match
1033
1254
  FROM lessons_fts f JOIN lessons l ON l.id = f.id
1034
- WHERE lessons_fts MATCH ? AND l.superseded_by IS NULL AND l.stale IS NULL
1255
+ WHERE lessons_fts MATCH ? AND ${LIVE_LESSON_SQL}
1035
1256
  ORDER BY bm25(lessons_fts) LIMIT 50`
1036
1257
  )
1037
1258
  .all(provider ? provider.model : null, provider ? provider.dims : null, match);
@@ -1039,7 +1260,7 @@ export async function recallLessons(m, query, opts = {}) {
1039
1260
  const age = ageDaysOf(r.confirmed || r.created, nowMs);
1040
1261
  if (!(age <= staleDays)) continue;
1041
1262
  bm25Ranked.push(r.id);
1042
- bm25Info.set(r.id, { scope: r.scope, hasMatch: !!r.has_match, age });
1263
+ bm25Info.set(r.id, { scope: r.scope, hasMatch: !!r.has_match, age, fb: { harmful: r.harmful, helpful: r.helpful, citation: r.citation } });
1043
1264
  }
1044
1265
  }
1045
1266
 
@@ -1101,7 +1322,7 @@ export async function recallLessons(m, query, opts = {}) {
1101
1322
  // model, or the caller detected a usable provider; hybrid reports how many live in-window rows lack a vector.
1102
1323
  // The existence check is bounded and only runs when no other term already decided it.
1103
1324
  const hasAnyVector = (db) =>
1104
- !!db.prepare("SELECT 1 AS x FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding IS NOT NULL LIMIT 1").get();
1325
+ !!db.prepare(`SELECT 1 AS x FROM lessons WHERE ${LIVE_LESSON_SQL} AND embedding IS NOT NULL LIMIT 1`).get();
1105
1326
  const degraded = mode === "keyword-only" && (!!provider || hasAnyVector(m) || !!detectedProvider);
1106
1327
  const coverage = {};
1107
1328
  if (mode === "hybrid") {
@@ -1125,21 +1346,27 @@ export async function recallLessons(m, query, opts = {}) {
1125
1346
  // object per stored vector.
1126
1347
  const bm25Only = (id) => {
1127
1348
  const info = bm25Info.get(id);
1128
- return { id, score: weigh(info.age, info.scope, 1 / (60 + bm25Rank.get(id) + 1)), sides: ["bm25"] };
1349
+ return { id, score: feedbackScore(weigh(info.age, info.scope, 1 / (60 + bm25Rank.get(id) + 1)), info.fb, fb), sides: ["bm25"] };
1129
1350
  };
1130
1351
  let candidates;
1131
1352
  if (mode === "hybrid") {
1132
1353
  // (e) vectored lessons by cosine x recency x scope, then BM25 hits with no vector, in BM25 order.
1133
1354
  const order = Int32Array.from(cosine);
1134
1355
  const scores = new Float64Array(rows.n);
1135
- for (const r of cosine) scores[r] = weigh(ages[r], rows.scopeRaw[r], cosineScores[r]);
1356
+ for (const r of cosine) scores[r] = feedbackScore(weigh(ages[r], rows.scopeRaw[r], cosineScores[r]), rows.fbInfo[r], fb);
1136
1357
  order.sort((x, y) => scores[y] - scores[x] || idCmp(rows.ids[x], rows.ids[y]));
1137
1358
  const dense = { length: order.length, at: (j) => {
1138
1359
  const r = order[j];
1139
1360
  const id = rows.ids[r];
1140
1361
  return { id, score: scores[r], sides: bm25Rank.has(id) ? ["bm25", "vector"] : ["vector"] };
1141
1362
  } };
1142
- const appended = bm25Ranked.filter((id) => !bm25Info.get(id).hasMatch).map(bm25Only);
1363
+ // The tail keeps BM25 order (not the recency-weighted score), reordered only by the feedback factor: the sort
1364
+ // key is the rank score 1/(60+rank) through feedbackScore, so a harmful twin falls below its harmful-0 twin.
1365
+ const appended = bm25Ranked
1366
+ .filter((id) => !bm25Info.get(id).hasMatch)
1367
+ .map((id, pos) => ({ cand: bm25Only(id), pos, key: feedbackScore(1 / (60 + bm25Rank.get(id) + 1), bm25Info.get(id).fb, fb) }))
1368
+ .sort((a, b) => b.key - a.key || a.pos - b.pos)
1369
+ .map((x) => x.cand);
1143
1370
  candidates = {
1144
1371
  length: dense.length + appended.length,
1145
1372
  at: (j) => (j < dense.length ? dense.at(j) : appended[j - dense.length]),
@@ -1186,7 +1413,8 @@ export async function recallLessons(m, query, opts = {}) {
1186
1413
  }
1187
1414
  if (rows && written.length) followOwnWrites(m, rows, written, nowIsoStr, sigBefore);
1188
1415
 
1189
- return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, degraded, ...coverage, lessons: result };
1416
+ const hidden = windowHidden(m, { staleDays, now: nowDate });
1417
+ return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, degraded, ...coverage, hidden, lessons: result };
1190
1418
  }
1191
1419
 
1192
1420
  // Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
@@ -1236,6 +1464,72 @@ export function renderLessons(lessons) {
1236
1464
  return text.length ? { text: text.join("\n"), ids } : { text: null, ids: [] };
1237
1465
  }
1238
1466
 
1467
+ // The lesson index (card memory-progressive-access, ADR 0019 retrieval): one line per lesson in rank order carrying
1468
+ // the id, a one-line title, the kind and the token cost of the WHOLE text (chars / 4, the repo's rule), so the lead
1469
+ // can fetch the full text of the lessons a card needs with `memory.mjs lesson show <id>`. A line that would push the
1470
+ // running total past `cap` is skipped (not stopped at) and its id is reported in `omitted`: no silent filter.
1471
+ const INDEX_TITLE_CHARS = 100;
1472
+ export const lessonTokens = (text) => Math.ceil(String(text).length / 4);
1473
+ export function lessonTitle(text) {
1474
+ const first = String(text).split("\n").find((l) => l.trim() !== "") ?? "";
1475
+ const line = first.trim().replace(/\s+/g, " ");
1476
+ if (line.length <= INDEX_TITLE_CHARS) return line;
1477
+ const slice = line.slice(0, INDEX_TITLE_CHARS);
1478
+ const sp = slice.lastIndexOf(" ");
1479
+ return (sp > 0 ? slice.slice(0, sp) : slice).trimEnd() + "…";
1480
+ }
1481
+ export function renderLessonIndex(lessons, { cap = LESSONS_CHAR_CAP } = {}) {
1482
+ const text = [];
1483
+ const ids = [];
1484
+ const omitted = [];
1485
+ let length = 0;
1486
+ for (const lesson of Array.isArray(lessons) ? lessons : []) {
1487
+ const line = `- [${lesson.id}] ${lessonTitle(lesson.text)} (${lesson.kind}, ~${lessonTokens(lesson.text)} tokens)`;
1488
+ const nextLength = length + (text.length ? 1 : 0) + line.length;
1489
+ if (nextLength > cap) {
1490
+ omitted.push(lesson.id);
1491
+ continue;
1492
+ }
1493
+ text.push(line);
1494
+ ids.push(lesson.id);
1495
+ length = nextLength;
1496
+ }
1497
+ return { text: text.length ? text.join("\n") : null, ids, omitted };
1498
+ }
1499
+
1500
+ // The flow track's delivery (card memory-atomic-lessons): the index, then the full text of the top lessons in
1501
+ // rank order up to FLOW_FETCH_TOKENS (chars / 4, rounded up, per lesson). A lesson whose full text would pass the
1502
+ // remaining budget is skipped (not stopped at), stays in the index, is named on a `not fetched` line and is never
1503
+ // cut. Fetched lessons use the `lesson show` form. `ids` are the FETCHED ids in rank order (what the run credits as
1504
+ // used), `indexIds` every indexed id, `omitted` the ids the index cap dropped, `unfetched` the indexed ids over budget.
1505
+ export const FLOW_FETCH_TOKENS = 4000;
1506
+ export function renderFlowLessons(lessons, { indexCap = LESSONS_CHAR_CAP, fetchTokens = FLOW_FETCH_TOKENS } = {}) {
1507
+ const index = renderLessonIndex(lessons, { cap: indexCap });
1508
+ if (index.text === null) return { text: null, ids: [], indexIds: [], omitted: index.omitted, unfetched: [] };
1509
+ const byId = new Map((Array.isArray(lessons) ? lessons : []).map((l) => [l.id, l]));
1510
+ const fetched = [];
1511
+ const unfetched = [];
1512
+ let used = 0;
1513
+ for (const id of index.ids) {
1514
+ const lesson = byId.get(id);
1515
+ const tokens = lessonTokens(lesson.text);
1516
+ if (used + tokens > fetchTokens) {
1517
+ unfetched.push(id);
1518
+ continue;
1519
+ }
1520
+ used += tokens;
1521
+ fetched.push(lesson);
1522
+ }
1523
+ const parts = [index.text];
1524
+ if (index.omitted.length) parts.push(`index: omitted ${index.omitted.length} lessons over the ${indexCap}-character index budget: ${index.omitted.join(", ")}`);
1525
+ if (unfetched.length) parts.push(`not fetched (over the ~${fetchTokens}-token fetch budget): ${unfetched.join(", ")}`);
1526
+ let text = parts.join("\n");
1527
+ if (fetched.length) {
1528
+ text += "\n\n" + fetched.map((l) => `lesson ${l.id} (${l.kind}, ~${lessonTokens(l.text)} tokens)\ncitation: ${l.citation || "none"}\n\n${l.text}`).join("\n\n");
1529
+ }
1530
+ return { text, ids: fetched.map((l) => l.id), indexIds: index.ids, omitted: index.omitted, unfetched };
1531
+ }
1532
+
1239
1533
  // The lessons a session starts with when no card query exists (card memory-every-session): live, non-stale
1240
1534
  // lessons confirmed-or-created within staleDays, ordered by helpful minus harmful desc, then confirmed-or-created
1241
1535
  // desc, then id ascending. No provider, no citation re-check, nothing written.
@@ -1245,7 +1539,7 @@ export function topLessons(m, { k = 5, staleDays = 30, now = new Date() } = {})
1245
1539
  .prepare(
1246
1540
  `SELECT id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created, confirmed,
1247
1541
  helpful, harmful, superseded_by, stale
1248
- FROM lessons WHERE superseded_by IS NULL AND stale IS NULL`
1542
+ FROM lessons WHERE ${LIVE_LESSON_SQL}`
1249
1543
  )
1250
1544
  .all()
1251
1545
  .map(lessonRowToObject)
@@ -1401,7 +1695,7 @@ function findPlanTask(plan, id) {
1401
1695
  }
1402
1696
 
1403
1697
  export function outcomeRows(report, opts) {
1404
- const { plan = null, classes = [], run = null, reportHash, reportPath = null, card = null, commit = null, cost = null, now = nowIso() } = opts || {};
1698
+ const { plan = null, classes = [], run = null, reportHash, reportPath = null, card = null, commit = null, cost = null, now = nowIso(), memoryConfig: flowMemoryConfig = null } = opts || {};
1405
1699
  const allBlocks = classifyBlocks(report, classes).map((b) => ({
1406
1700
  id: b.id,
1407
1701
  pass: b.pass,
@@ -1471,8 +1765,9 @@ export function outcomeRows(report, opts) {
1471
1765
  gate: null,
1472
1766
  note: null,
1473
1767
  partial: toIntOrNull(task.partial),
1474
- // The seven condition columns (card outcomes-condition-columns) are always null on a flow row today —
1475
- // see the comment above OUTCOMES_COLUMNS_SQL. backfilled is 0, never null: it is NOT NULL DEFAULT 0.
1768
+ // The seven condition columns (card outcomes-condition-columns) are null on a flow row — see the comment
1769
+ // above OUTCOMES_COLUMNS_SQL; memory_config is the flow stamp when the caller passes one (card
1770
+ // memory-flow-index-fetch). backfilled is 0, never null: it is NOT NULL DEFAULT 0.
1476
1771
  harness_commit: null,
1477
1772
  config_hash: null,
1478
1773
  class: null,
@@ -1480,6 +1775,7 @@ export function outcomeRows(report, opts) {
1480
1775
  assigned_by: null,
1481
1776
  explore_probability: null,
1482
1777
  backfilled: 0,
1778
+ memory_config: flowMemoryConfig ? JSON.stringify(sortKeysDeep(flowMemoryConfig)) : null,
1483
1779
  });
1484
1780
  }
1485
1781
  }
@@ -1577,29 +1873,322 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
1577
1873
  assigned_by: condition ? condition.assignedBy ?? null : null,
1578
1874
  explore_probability: condition ? condition.exploreProbability ?? null : null,
1579
1875
  backfilled: 0,
1876
+ memory_config: condition && condition.memoryConfig ? JSON.stringify(sortKeysDeep(condition.memoryConfig)) : null,
1877
+ };
1878
+ }
1879
+
1880
+ function sortKeysDeep(v) {
1881
+ if (Array.isArray(v)) return v.map(sortKeysDeep);
1882
+ if (v && typeof v === "object") return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeysDeep(v[k])]));
1883
+ return v;
1884
+ }
1885
+
1886
+ // The memory configuration an outcome row is stamped with (card memory-outcome-arms, ADR 0019 Measurement), so
1887
+ // outcomes can be compared per configuration. Pure: the caller reads the project config. Excludes baseUrl and
1888
+ // apiKeyEnv (where the provider lives, not what it computes). MEMORY_STAGE names the recall/ranking/delivery
1889
+ // generation: a card that changes recall or ranking for every track bumps it, which starts a new arm. A change to one
1890
+ // track's delivery alone (card memory-atomic-lessons: the flow track) changes that track's stamp fields instead.
1891
+ export const MEMORY_STAGE = "progressive-access";
1892
+
1893
+ // `track` names the delivery the row's briefs actually got (card memory-flow-index-fetch): a hand card's briefs
1894
+ // carry the index plus a lead fetch; a flow task's briefs (plan.mjs) carry the index plus the full text of the top
1895
+ // lessons up to FLOW_FETCH_TOKENS (card memory-atomic-lessons), told apart from the hand stamp by fetch/fetchTokens. Any other track throws
1896
+ // rather than claiming a delivery.
1897
+ export function memoryConfig({ embeddings = null, staleDays = 30, track = "hand" } = {}) {
1898
+ if (track !== "hand" && track !== "flow") throw new Error(`memoryConfig: unknown track ${JSON.stringify(track)}`);
1899
+ const emb = embeddings ? { provider: embeddings.provider ?? null, model: embeddings.model ?? null, dims: embeddings.dims ?? null } : null;
1900
+ if (track === "flow") {
1901
+ return { stage: MEMORY_STAGE, embeddings: emb, staleDays, k: 8, delivery: "index+fetch", indexChars: LESSONS_CHAR_CAP, fetch: "top-k", fetchTokens: FLOW_FETCH_TOKENS };
1902
+ }
1903
+ return {
1904
+ stage: MEMORY_STAGE,
1905
+ embeddings: emb,
1906
+ staleDays,
1907
+ k: 8,
1908
+ delivery: "index+fetch",
1909
+ indexChars: LESSONS_CHAR_CAP,
1580
1910
  };
1581
1911
  }
1582
1912
 
1913
+ // One stopped hand card report, one row (card memory-record-stops, ADR 0019 gap 3): run = 'stop:<first 12 hex of
1914
+ // the report hash>', so recording the same report again updates the same row, a different stop of the card adds
1915
+ // a row, and a later landing ('hand:<commit>') sits beside it. stop_class is the report's typed `reason` and
1916
+ // stop_reason its prose `detail` (the reason when detail is blank). `blocks` are the blocks.jsonl lines
1917
+ // attributed to the run, kept as {at, hook, agent, class, reason}. Every other column is null as on handOutcomeRow.
1918
+ export function stopOutcomeRow({ card, report, reportHash, reportPath = null, run = null, blocks = [], now = nowIso(), condition = null, memoryConfig: stamp = null } = {}) {
1919
+ if (!card) throw new Error("stopOutcomeRow requires card");
1920
+ if (!report || report.outcome !== "stopped") throw new Error("stopOutcomeRow requires a stopped report");
1921
+ if (!reportHash) throw new Error("stopOutcomeRow requires reportHash");
1922
+ const detail = typeof report.detail === "string" && report.detail.trim() !== "" ? report.detail : null;
1923
+ return {
1924
+ ...handOutcomeRow({ card, commit: "x", now, condition }),
1925
+ ...(!(condition && condition.memoryConfig) && stamp ? { memory_config: JSON.stringify(sortKeysDeep(stamp)) } : {}),
1926
+ run: `stop:${String(reportHash).slice(0, 12)}`,
1927
+ run_id: run ?? null,
1928
+ report_hash: reportHash,
1929
+ report_path: reportPath,
1930
+ commit_sha: null,
1931
+ stop_class: report.reason ?? null,
1932
+ stop_reason: detail ?? report.reason ?? null,
1933
+ blocks: blocks.map((b) => ({ at: b.at ?? null, hook: b.hook ?? null, agent: b.agent ?? null, class: b.class ?? null, reason: b.reason ?? null })),
1934
+ };
1935
+ }
1936
+
1937
+ // The agent id a blocks.jsonl line belongs to: a string `agentId` field, else the id in a subagent-stop reason.
1938
+ export function blockAgentId(line) {
1939
+ if (!line || typeof line !== "object") return null;
1940
+ if (typeof line.agentId === "string" && line.agentId !== "") return line.agentId;
1941
+ const m = typeof line.reason === "string" ? /Subagent \S+ \(([^)\s]+)\)/.exec(line.reason) : null;
1942
+ return m ? m[1] : null;
1943
+ }
1944
+
1945
+ function parseJsonLines(text) {
1946
+ const out = [];
1947
+ for (const raw of String(text ?? "").split("\n")) {
1948
+ if (raw.trim() === "") continue;
1949
+ try {
1950
+ const v = JSON.parse(raw);
1951
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) out.push(v);
1952
+ } catch {
1953
+ // malformed line: skipped
1954
+ }
1955
+ }
1956
+ return out;
1957
+ }
1958
+
1959
+ // Every agentId on any parseable line of the given queen journal texts.
1960
+ export function runAgentIds(journalTexts) {
1961
+ const ids = new Set();
1962
+ for (const t of journalTexts ?? []) for (const v of parseJsonLines(t)) if (typeof v.agentId === "string" && v.agentId !== "") ids.add(v.agentId);
1963
+ return ids;
1964
+ }
1965
+
1966
+ // The parsed blocks.jsonl lines whose agent id is one of the run's, in file order. A main-session line has none.
1967
+ export function attributeBlocks(ledgerText, agentIds) {
1968
+ return parseJsonLines(ledgerText).filter((v) => {
1969
+ const id = blockAgentId(v);
1970
+ return id !== null && agentIds.has(id);
1971
+ });
1972
+ }
1973
+
1974
+ // Run history (card memory-run-history, ADR 0019 L0): an append-only file per card run under
1975
+ // .doug/.state/history/<card>/<run>.jsonl. Capture depth (ADR 0019 line 30, Open there, decided here for the artifact
1976
+ // slice): copy the text of Doug's own run artifacts (card report, attributed gate blocks and trace denials, each
1977
+ // seat's hand-back from the queen journal, each seat's brief re-rendered by replaying lib/card-loop.mjs); never copy
1978
+ // Claude Code transcripts and never reference into the Claude Code store. Each entry is secret-scanned on its WHOLE
1979
+ // text first (a hit replaces the entire entry), then capped at HISTORY_ENTRY_MAX_BYTES keeping the head; each run
1980
+ // file is capped at HISTORY_RUN_MAX_BYTES with a final 'cap' line counting the dropped entries (no silent filter).
1981
+ // No age bound: files are never deleted or rewritten.
1982
+ export const HISTORY_DIR_RELPATH = ".doug/.state/history";
1983
+ export const HISTORY_ENTRY_MAX_BYTES = 65536;
1984
+ export const HISTORY_RUN_MAX_BYTES = 1048576;
1985
+
1986
+ export function historyEntry({ kind, seat = null, round = null, source, value }, { scan, maxBytes = HISTORY_ENTRY_MAX_BYTES } = {}) {
1987
+ const text0 = typeof value === "string" ? value : String(JSON.stringify(value === undefined ? null : value, null, 2));
1988
+ const bytes = Buffer.byteLength(text0, "utf8");
1989
+ const base = { kind, seat, round, source, bytes };
1990
+ let redacted = null;
1991
+ if (typeof scan !== "function") redacted = "no-scanner";
1992
+ else {
1993
+ try {
1994
+ const hit = scan(text0);
1995
+ if (hit) redacted = typeof hit.rule === "string" && hit.rule !== "" ? hit.rule : "secret";
1996
+ } catch {
1997
+ redacted = "scan-error";
1998
+ }
1999
+ }
2000
+ if (redacted !== null) return { ...base, truncated: false, redacted, text: `[redacted: ${redacted}]` };
2001
+ if (bytes <= maxBytes) return { ...base, truncated: false, redacted: null, text: text0 };
2002
+ const buf = Buffer.from(text0, "utf8");
2003
+ let cut = maxBytes;
2004
+ while (cut > 0 && (buf[cut] & 0xc0) === 0x80) cut--;
2005
+ return { ...base, truncated: true, redacted: null, text: buf.subarray(0, cut).toString("utf8") };
2006
+ }
2007
+
2008
+ // Seat runs of a queen Workflow journal, in started-line order: {seat, round, agentId, result}.
2009
+ export function journalSeatRuns(journalText) {
2010
+ try {
2011
+ const started = [];
2012
+ const results = new Map();
2013
+ for (const v of parseJsonLines(journalText)) {
2014
+ if (v.type === "started" && typeof v.key === "string") started.push(v);
2015
+ else if (v.type === "result" && typeof v.key === "string") results.set(v.key, v.result);
2016
+ }
2017
+ return started.map((v) => {
2018
+ const parts = String(v.label ?? "").split(":");
2019
+ const last = parts[parts.length - 1];
2020
+ const round = /^\d+$/.test(last) ? Number(last) : null;
2021
+ return { seat: parts[0], round, agentId: v.agentId ?? null, result: results.has(v.key) ? results.get(v.key) ?? null : null };
2022
+ });
2023
+ } catch {
2024
+ return [];
2025
+ }
2026
+ }
2027
+
2028
+ // Each seat run's brief, re-rendered by replaying the card-loop contract over the runs' hand-backs; null from the
2029
+ // first seat that does not match the contract's next seat (or on any error).
2030
+ export function replayBriefs(queenArgs, seatRuns) {
2031
+ const runs = Array.isArray(seatRuns) ? seatRuns : [];
2032
+ const out = runs.map(() => null);
2033
+ try {
2034
+ if (queenArgs === null || typeof queenArgs !== "object" || queenArgs.card === null || typeof queenArgs.card !== "object") return out;
2035
+ let s = initialState(queenArgs.card, queenArgs.options);
2036
+ for (let i = 0; i < runs.length; i++) {
2037
+ const a = currentAction(s);
2038
+ if (a.kind !== "seat" || a.seat !== runs[i].seat) break;
2039
+ out[i] = briefPrompt(a.brief, { repoRoot: queenArgs.repoRoot, worktreePath: queenArgs.worktreePath });
2040
+ s = cardLoopNext(s, runs[i].result).state;
2041
+ }
2042
+ } catch {
2043
+ // out keeps null from the failing seat on
2044
+ }
2045
+ return out;
2046
+ }
2047
+
2048
+ // The parsed trace lines that are PermissionDenied events of one of the run's agents, in file order.
2049
+ export function attributeDenials(traceText, agentIds) {
2050
+ try {
2051
+ return parseJsonLines(traceText).filter((v) => v.event === "PermissionDenied" && typeof v.agent === "string" && agentIds.has(v.agent));
2052
+ } catch {
2053
+ return [];
2054
+ }
2055
+ }
2056
+
2057
+ const historySafe = (x) => String(x).replace(/[^A-Za-z0-9_.-]/g, "_").replace(/\.{2,}/g, "_").slice(0, 120) || "_";
2058
+
2059
+ // Writes <dir>/.doug/.state/history/<card>/<run>.jsonl once; an existing file is never touched.
2060
+ export function writeRunHistory(dir, { card, run, entries, scan, maxEntryBytes = HISTORY_ENTRY_MAX_BYTES, maxRunBytes = HISTORY_RUN_MAX_BYTES }) {
2061
+ const path = join(dir, HISTORY_DIR_RELPATH, historySafe(card), historySafe(run) + ".jsonl");
2062
+ const lines = [];
2063
+ let used = 0;
2064
+ let truncated = 0;
2065
+ let dropped = 0;
2066
+ for (const e of entries) {
2067
+ const h = historyEntry(e, { scan, maxBytes: maxEntryBytes });
2068
+ const line = JSON.stringify({ kind: h.kind, card, run, seat: h.seat, round: h.round, source: h.source, bytes: h.bytes, truncated: h.truncated, redacted: h.redacted, text: h.text });
2069
+ const size = Buffer.byteLength(line) + 1;
2070
+ if (dropped === 0 && used + size <= maxRunBytes) {
2071
+ lines.push(line);
2072
+ used += size;
2073
+ if (h.truncated) truncated++;
2074
+ } else dropped++;
2075
+ }
2076
+ if (dropped > 0) lines.push(JSON.stringify({ kind: "cap", card, run, dropped, maxRunBytes }));
2077
+ mkdirSync(dirname(path), { recursive: true });
2078
+ try {
2079
+ writeFileSync(path, lines.map((l) => l + "\n").join(""), { flag: "wx" });
2080
+ } catch (e) {
2081
+ if (e && e.code === "EEXIST") return { path, written: false, lines: 0, truncated: 0, dropped: 0 };
2082
+ throw e;
2083
+ }
2084
+ return { path, written: true, lines: lines.length - (dropped > 0 ? 1 : 0), truncated, dropped };
2085
+ }
2086
+
2087
+ // The run-history secret scanner both writers use (autopilot report, reflect --hand), so redaction cannot drift:
2088
+ // .doug/config.json `secrets`; enabled === false scans nothing; otherwise the vendored secret-rules scanner. An
2089
+ // import failure returns undefined, which reads every entry as redacted 'no-scanner'.
2090
+ export async function historyScanner(dir) {
2091
+ let secrets = {};
2092
+ try {
2093
+ secrets = JSON.parse(readFileSync(join(dir, ".doug/config.json"), "utf8")).secrets ?? {};
2094
+ } catch {
2095
+ // no config: defaults
2096
+ }
2097
+ if (secrets && secrets.enabled === false) return () => null;
2098
+ try {
2099
+ const sr = await import(pathToFileURL(join(dir, ".doug/hooks/lib/secret-rules.mjs")).href);
2100
+ return (t) => sr.scanText(t, secrets ?? {});
2101
+ } catch {
2102
+ return undefined;
2103
+ }
2104
+ }
2105
+
2106
+ // History fetch (card memory-progressive-access, ADR 0019 L0 citation form history:<card>/<file>:<line>).
2107
+ // <ref> = [history:]<card>[/<run>[.jsonl]][:<line>]; ids are limited to safe name characters so a ref can never leave
2108
+ // the history directory.
2109
+ export function parseHistoryRef(ref) {
2110
+ const bad = () => new Error(`bad history id: ${JSON.stringify(ref)}`);
2111
+ if (typeof ref !== "string") throw bad();
2112
+ let rest = ref.startsWith("history:") ? ref.slice("history:".length) : ref;
2113
+ let line = null;
2114
+ const colon = rest.lastIndexOf(":");
2115
+ if (colon !== -1) {
2116
+ const n = rest.slice(colon + 1);
2117
+ if (!/^[0-9]+$/.test(n) || Number(n) < 1) throw bad();
2118
+ line = Number(n);
2119
+ rest = rest.slice(0, colon);
2120
+ }
2121
+ const parts = rest.split("/");
2122
+ if (parts.length > 2) throw bad();
2123
+ const [card, runRaw] = parts;
2124
+ const run = runRaw === undefined ? null : runRaw.replace(/\.jsonl$/, "");
2125
+ for (const part of [card, run]) {
2126
+ if (part === null) continue;
2127
+ if (!/^[A-Za-z0-9_.-]+$/.test(part) || part.includes("..")) throw bad();
2128
+ }
2129
+ if (line !== null && run === null) throw bad();
2130
+ return { card, run, line };
2131
+ }
2132
+
2133
+ export function readRunHistory(dir, { card, run = null, line = null }) {
2134
+ const root = join(dir, HISTORY_DIR_RELPATH);
2135
+ const cardDir = join(root, card);
2136
+ const parseLines = (path) =>
2137
+ readFileSync(path, "utf8")
2138
+ .split("\n")
2139
+ .filter((l) => l !== "")
2140
+ .map((l) => JSON.parse(l));
2141
+ if (run === null) {
2142
+ if (!existsSync(cardDir)) throw new Error(`no history for card ${card}`);
2143
+ const runs = readdirSync(cardDir)
2144
+ .filter((f) => f.endsWith(".jsonl"))
2145
+ .map((f) => f.slice(0, -".jsonl".length))
2146
+ .sort()
2147
+ .map((r) => {
2148
+ const entries = parseLines(join(cardDir, r + ".jsonl")).filter((e) => e.kind !== "cap");
2149
+ return { run: r, entries: entries.length, tokens: entries.reduce((n, e) => n + lessonTokens(e.text ?? ""), 0) };
2150
+ });
2151
+ return { card, runs };
2152
+ }
2153
+ const file = join(cardDir, run + ".jsonl");
2154
+ if (!existsSync(file)) throw new Error(`no history for ${card}/${run}`);
2155
+ const lines = parseLines(file);
2156
+ if (line !== null) {
2157
+ if (line > lines.length) throw new Error(`no line ${line} in ${card}/${run} (${lines.length} lines)`);
2158
+ const e = lines[line - 1];
2159
+ return { ...e, line, tokens: lessonTokens(e.text ?? "") };
2160
+ }
2161
+ const entries = lines.map((e, i) => {
2162
+ if (e.kind === "cap") return { line: i + 1, kind: "cap", dropped: e.dropped, maxRunBytes: e.maxRunBytes };
2163
+ return { line: i + 1, kind: e.kind, seat: e.seat, round: e.round, source: e.source, bytes: e.bytes, truncated: e.truncated, redacted: e.redacted, tokens: lessonTokens(e.text ?? "") };
2164
+ });
2165
+ return { card, run, tokens: entries.reduce((n, e) => n + (e.tokens ?? 0), 0), entries };
2166
+ }
2167
+
1583
2168
  const OUTCOME_COLUMNS = [
1584
2169
  "recorded", "run", "run_id", "report_hash", "report_path", "card", "task", "spec_hash", "files", "size", "shape",
1585
2170
  "models", "implemented", "verified", "reviewed", "review_issues", "review_issue_count", "adversary_verdict",
1586
2171
  "adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "stop_class", "in_scope",
1587
2172
  "budget_spent", "tokens", "usd", "codex_usd", "commit_sha", "track", "wall_clock", "gate", "note", "partial",
1588
- "harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled",
2173
+ "harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled", "memory_config",
1589
2174
  ];
1590
2175
 
1591
2176
  const JSON_COLUMNS = new Set(["files", "models", "review_issues", "blocks", "budget_spent", "tokens"]);
1592
2177
 
1593
- // The seven condition columns (card outcomes-condition-columns) upsert atomically, all seven together, keyed on
2178
+ // The eight condition columns (seven from card outcomes-condition-columns, plus memory_config) upsert atomically, all eight together, keyed on
1594
2179
  // whether the *new* row carries a harness_commit — never column-by-column (a plain per-column COALESCE let a
1595
2180
  // re-record with no condition zero out an existing stamp's `backfilled` flag while keeping its old
1596
2181
  // harness_commit, mixing half of one stamp with half of another, since `backfilled` defaults to 0, not null,
1597
2182
  // when a caller omits it). When `excluded.harness_commit` is null (this row carries no condition), every one of
1598
- // the seven columns keeps the existing row's value untouched; otherwise every one of the seven takes the new
2183
+ // the eight columns keeps the existing row's value untouched; otherwise every one of the eight takes the new
1599
2184
  // row's value, including `backfilled` (a fresh `condition open`/`record --hand` stamp legitimately replaces an
1600
2185
  // older backfilled placeholder with backfilled 0). This is what keeps a `condition backfill` stamp — or any
1601
2186
  // other already-recorded stamp — intact across a later re-record for the same run+task that carries none.
1602
- const CONDITION_COLUMNS = new Set(["harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled"]);
2187
+ const CONDITION_COLUMNS = new Set(["harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled", "memory_config"]);
2188
+
2189
+ // passes and fix_passes upsert as a pair keyed on the new row's passes (card memory-record-keeps-rounds): a
2190
+ // re-record whose row carries no passes (a hand row) keeps the measured pair `reflect --hand --rounds` wrote.
2191
+ const ROUND_COLUMNS = new Set(["passes", "fix_passes"]);
1603
2192
 
1604
2193
  export function recordOutcomes(m, rows) {
1605
2194
  let inserted = 0;
@@ -1619,7 +2208,9 @@ export function recordOutcomes(m, rows) {
1619
2208
  .map((c) =>
1620
2209
  CONDITION_COLUMNS.has(c)
1621
2210
  ? `${c} = CASE WHEN excluded.harness_commit IS NULL THEN outcomes.${c} ELSE excluded.${c} END`
1622
- : `${c} = excluded.${c}`
2211
+ : ROUND_COLUMNS.has(c)
2212
+ ? `${c} = CASE WHEN excluded.passes IS NULL THEN outcomes.${c} ELSE excluded.${c} END`
2213
+ : `${c} = excluded.${c}`
1623
2214
  )
1624
2215
  .join(", ");
1625
2216
  m.prepare(
@@ -1657,6 +2248,58 @@ export function listOutcomes(m, { run = null, task = null } = {}) {
1657
2248
  });
1658
2249
  }
1659
2250
 
2251
+ // Compare outcome arms by memory configuration (card memory-outcome-arms, ADR 0019 Measurement). Reads outcomes and
2252
+ // reflections only. One arm per distinct memory_config, ordered by the arm's earliest recorded row; rows with no
2253
+ // memory_config are counted in `unstamped` and belong to no arm. landings are rows with a commit, stops rows with
2254
+ // no commit and either a stop_class or a `stop:` run (a stop row with no typed cause). An arm is compared (`ready`) once it has `min` landings; until then its four metrics
2255
+ // are null: landRate = landings / (landings + stops); fixRounds = mean fix_passes over landings that have one
2256
+ // (n of them; mean null when n is 0); harmfulPerLanding = harmful marks of the reflections joined to a landing
2257
+ // by card and commit_sha, per landing; costPerLanding = mean usd over priced landings (null when none).
2258
+ export function compareMemoryArms(m, { min = 12 } = {}) {
2259
+ const rows = m.prepare("SELECT * FROM outcomes ORDER BY recorded ASC, id ASC").all();
2260
+ const byConfig = new Map();
2261
+ let unstamped = 0;
2262
+ for (const row of rows) {
2263
+ if (row.memory_config === null || row.memory_config === undefined) {
2264
+ unstamped++;
2265
+ continue;
2266
+ }
2267
+ if (!byConfig.has(row.memory_config)) byConfig.set(row.memory_config, []);
2268
+ byConfig.get(row.memory_config).push(row);
2269
+ }
2270
+ const reflections = m.prepare("SELECT card, commit_sha, counters FROM reflections WHERE card IS NOT NULL AND commit_sha IS NOT NULL").all();
2271
+ const harmfulOf = (r) => {
2272
+ try {
2273
+ const h = JSON.parse(r.counters).harmful;
2274
+ return Array.isArray(h) ? h.length : 0;
2275
+ } catch {
2276
+ return 0;
2277
+ }
2278
+ };
2279
+ const arms = [];
2280
+ for (const [config, armRows] of byConfig) {
2281
+ const landed = armRows.filter((r) => r.commit_sha !== null && r.commit_sha !== undefined);
2282
+ const stops = armRows.filter((r) => (r.commit_sha === null || r.commit_sha === undefined) && ((r.stop_class !== null && r.stop_class !== undefined) || (typeof r.run === "string" && r.run.startsWith("stop:")))).length;
2283
+ const landings = landed.length;
2284
+ const ready = landings >= min;
2285
+ const arm = { config, landings, stops, ready, landRate: null, fixRounds: null, harmfulPerLanding: null, costPerLanding: null, priced: null };
2286
+ if (ready) {
2287
+ arm.landRate = landings / (landings + stops);
2288
+ const fixes = landed.map((r) => r.fix_passes).filter((v) => typeof v === "number");
2289
+ arm.fixRounds = { mean: fixes.length ? fixes.reduce((a, b) => a + b, 0) / fixes.length : null, n: fixes.length };
2290
+ const keys = new Set(landed.map((r) => `${r.task}\u0000${r.commit_sha}`));
2291
+ let harmful = 0;
2292
+ for (const r of reflections) if (keys.has(`${r.card}\u0000${r.commit_sha}`)) harmful += harmfulOf(r);
2293
+ arm.harmfulPerLanding = harmful / landings;
2294
+ const usds = landed.map((r) => r.usd).filter((v) => typeof v === "number");
2295
+ arm.priced = usds.length;
2296
+ arm.costPerLanding = usds.length ? usds.reduce((a, b) => a + b, 0) / usds.length : null;
2297
+ }
2298
+ arms.push(arm);
2299
+ }
2300
+ return { min, unstamped, arms, comparable: arms.filter((a) => a.ready).length >= 2 };
2301
+ }
2302
+
1660
2303
  // --- reflect (card memory-reflect) ------------------------------------------------------------------------------
1661
2304
  // A cheap-row LLM pass, run after `memory.mjs record`, that proposes at most 3 lessons from one report and bumps
1662
2305
  // helpful/harmful counters on lessons the report's tasks used. Everything here is pure and unit-testable except
@@ -1681,6 +2324,11 @@ export const REFLECT_SCHEMA = {
1681
2324
  task: { type: "string" },
1682
2325
  scope: { type: "array", items: { type: "string" } },
1683
2326
  notDerivableBecause: { type: "string" },
2327
+ // Optional (card memory-reflect-supersede, ADR 0019 card 8): the id of one existing lesson listed in the
2328
+ // prompt that this proposal repeats (nothing new is written, that lesson's seen count rises) or
2329
+ // supersedes (it contradicts or corrects it). Never both; code checks the id is live and listed.
2330
+ repeats: { type: "string" },
2331
+ supersedes: { type: "string" },
1684
2332
  // Optional (card reflect-offers-claude-md): the CLAUDE.md bullet this lesson would be, only when it is a
1685
2333
  // rule a future session should follow.
1686
2334
  claudeMd: {
@@ -1713,6 +2361,13 @@ function reflectionRowToObject(row) {
1713
2361
  };
1714
2362
  }
1715
2363
 
2364
+ // The archive rule (card memory-feedback-rank, ADR 0019): harmful at least min and at least helpful. Archived
2365
+ // rows are kept (never deleted) and hidden from recall; ARCHIVE_HARMFUL_MIN is the bench-checked threshold.
2366
+ export const ARCHIVE_HARMFUL_MIN = 2;
2367
+ export function meetsArchiveRule({ harmful, helpful }, min = ARCHIVE_HARMFUL_MIN) {
2368
+ return harmful >= min && harmful >= helpful;
2369
+ }
2370
+
1716
2371
  // Deterministic, no LLM. harmful is an explicit mark (card reflect-harmful-irrelevant-lessons): only an id in
1717
2372
  // `harmfulIds` (the lead's judgement that the lesson misled the work) gets harmful +1, once however often it is
1718
2373
  // named or used; a real block alone bumps nothing. For each task whose memoryUsed is non-empty, a real block earns
@@ -1802,6 +2457,12 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
1802
2457
  );
1803
2458
  lines.push("- notDerivableBecause must say why the lesson is not something a reader could already get by reading the code; never restate a fact already sitting verbatim in a file.");
1804
2459
  lines.push("- never propose a lesson whose text is already covered by one of the existing lessons listed below.");
2460
+ lines.push(
2461
+ "- supersedes is optional: set it to the id of one existing lesson listed below when your new lesson contradicts or corrects that lesson. The old lesson is then retired in favour of yours."
2462
+ );
2463
+ lines.push(
2464
+ "- repeats is optional: set it to the id of one existing lesson listed below when your lesson says the same thing as that lesson. Nothing new is written and that lesson's seen count rises. Never set both repeats and supersedes."
2465
+ );
1805
2466
  lines.push("- propose at most 3 lessons; an empty list is a perfectly good answer when nothing here is worth remembering.");
1806
2467
  lines.push("- a task marked partial=true below ran out of context, not verification; propose no lesson that blames its code or its tests for that.");
1807
2468
  const headings = dir ? claudeMdHeadings(dir) : [];
@@ -1831,7 +2492,7 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
1831
2492
  }
1832
2493
  }
1833
2494
  lines.push("");
1834
- lines.push("Existing lessons (never propose a near-duplicate of one of these):");
2495
+ lines.push("Existing lessons (never propose a near-duplicate of one of these; name one in repeats or supersedes when it applies):");
1835
2496
  const shownLessons = mostRecentLessons(existingLessons, REFLECT_PROMPT_LESSON_CAP);
1836
2497
  if (existingLessons.length > REFLECT_PROMPT_LESSON_CAP) {
1837
2498
  lines.push(`(showing the ${REFLECT_PROMPT_LESSON_CAP} most recently created/confirmed of ${existingLessons.length} total; older lessons are not shown)`);
@@ -1944,17 +2605,20 @@ export function jaccard(a, b) {
1944
2605
  // - Jaccard similarity over lower-cased word tokens (length >= 3) at or above REFLECT_DUPLICATE_JACCARD against
1945
2606
  // every non-superseded existing lesson or a row appended earlier in this same pass (`existingLessons` and
1946
2607
  // `appendedRows`), so two near-identical proposals in one pass only ever add the first.
1947
- // Returns { duplicate: <live lesson row> } (bump), { rejected: <superseded lesson row> }, or null (not a dup).
1948
- function findReflectDuplicate(m, proposal, { existingLessons, appendedRows }) {
2608
+ // Returns { duplicate: <live lesson row> } (an identical hash: a repeat), { similar: <lesson row> } (word overlap,
2609
+ // never merged), { rejected: <superseded lesson row> }, or null (not a dup). skipSimilarity: a model judgement
2610
+ // (supersedes) was given, so word overlap is not consulted.
2611
+ function findReflectDuplicate(m, proposal, { existingLessons, appendedRows, skipSimilarity = false }) {
1949
2612
  const proposalId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
1950
2613
  const collision = getLesson(m, proposalId);
1951
2614
  if (collision) return collision.superseded_by ? { rejected: collision } : { duplicate: collision };
1952
2615
  for (const row of appendedRows) {
1953
2616
  if (row.id === proposalId) return { duplicate: row };
1954
2617
  }
2618
+ if (skipSimilarity) return null;
1955
2619
  const tokens = reflectTokens(proposal.text);
1956
2620
  for (const lesson of [...existingLessons, ...appendedRows]) {
1957
- if (jaccard(tokens, reflectTokens(lesson.text)) >= REFLECT_DUPLICATE_JACCARD) return { duplicate: lesson };
2621
+ if (jaccard(tokens, reflectTokens(lesson.text)) >= REFLECT_DUPLICATE_JACCARD) return { similar: lesson };
1958
2622
  }
1959
2623
  return null;
1960
2624
  }
@@ -1962,7 +2626,7 @@ function findReflectDuplicate(m, proposal, { existingLessons, appendedRows }) {
1962
2626
  // Filters C.1-C.4, in order. C.5 (at most 3 appended) is enforced by the caller, but only against a proposal
1963
2627
  // this function has already said ok:true and not a duplicate — a would-be duplicate past the cap still bumps.
1964
2628
  // Returns { ok:true }, { ok:true, duplicate:<lesson row> } (bump instead of append), or { ok:false, reason }.
1965
- function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows }) {
2629
+ function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows, judgement = null }) {
1966
2630
  if (!proposal || typeof proposal !== "object") return { ok: false, reason: "proposal is not an object" };
1967
2631
  const { kind, text, citation, task, notDerivableBecause } = proposal;
1968
2632
  if (kind !== "pitfall" && kind !== "pattern") return { ok: false, reason: `kind must be "pitfall" or "pattern", got ${JSON.stringify(kind)}` };
@@ -1979,16 +2643,24 @@ function evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLes
1979
2643
  if (typeof notDerivableBecause !== "string" || !notDerivableBecause.trim()) return { ok: false, reason: "notDerivableBecause is empty" };
1980
2644
  const derivableCheck = checkReflectDerivable(proposal, dir);
1981
2645
  if (!derivableCheck.ok) return derivableCheck;
1982
- const dup = findReflectDuplicate(m, proposal, { existingLessons, appendedRows });
2646
+ // A valid repeats judgement: nothing new is written, the named lesson's seen count rises.
2647
+ if (judgement && judgement.kind === "repeats") return { ok: true, duplicate: judgement.target };
2648
+ const dup = findReflectDuplicate(m, proposal, { existingLessons, appendedRows, skipSimilarity: Boolean(judgement) });
1983
2649
  if (dup && dup.rejected) return { ok: false, reason: `addLesson id collision with a superseded lesson ${dup.rejected.id}` };
2650
+ // An identical hash is a code fact, not a similarity score: a repeat of the colliding lesson.
1984
2651
  if (dup && dup.duplicate) return { ok: true, duplicate: dup.duplicate };
2652
+ // The similarity score never merges (ADR 0019 card 8): with no judgement a near-duplicate is dropped.
2653
+ if (dup && dup.similar) {
2654
+ return { ok: false, reason: `near-duplicate of ${dup.similar.id} (word overlap >= ${REFLECT_DUPLICATE_JACCARD}); no repeats or supersedes judgement` };
2655
+ }
2656
+ if (judgement && judgement.kind === "supersedes") return { ok: true, supersedes: judgement.target };
1985
2657
  return { ok: true };
1986
2658
  }
1987
2659
 
1988
2660
  // Cost (D): prefer the real per-message transcript at <claudeDir>/projects/<projectSlug(dir)>/<sessionId>.jsonl,
1989
2661
  // same as cost.mjs prices every other agent; fall back to the envelope's own total_cost_usd (a client-side
1990
2662
  // estimate) when the transcript is missing or its model is unpriced; null with no source when neither is usable.
1991
- function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
2663
+ export function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
1992
2664
  const resolvedClaudeDir = claudeDir || join(homedir(), ".claude");
1993
2665
  if (sessionId) {
1994
2666
  const file = join(resolvedClaudeDir, "projects", projectSlug(dir), `${sessionId}.jsonl`);
@@ -2110,7 +2782,7 @@ export async function runReflect(m, report, opts = {}) {
2110
2782
  now = nowIso(),
2111
2783
  } = opts;
2112
2784
  if (!reportHash) throw new Error("runReflect requires a reportHash");
2113
- if (typeof propose !== "function") throw new Error("runReflect requires a propose function");
2785
+ if (typeof propose !== "function" && !dryRun) throw new Error("runReflect requires a propose function");
2114
2786
 
2115
2787
  const existing = m.prepare("SELECT * FROM reflections WHERE report_hash = ?").get(reportHash);
2116
2788
  if (existing) return { alreadyReflected: true, row: reflectionRowToObject(existing) };
@@ -2137,12 +2809,23 @@ export async function runReflect(m, report, opts = {}) {
2137
2809
  }
2138
2810
 
2139
2811
  let counters;
2812
+ const archived = [];
2140
2813
  if (dryRun) {
2141
2814
  counters = computeReflectCounters(m, blocks, report, { apply: false, harmfulIds });
2142
2815
  } else {
2143
2816
  m.exec("BEGIN");
2144
2817
  try {
2145
2818
  counters = computeReflectCounters(m, blocks, report, { apply: true, harmfulIds });
2819
+ for (const id of counters.harmful) {
2820
+ const row = getLesson(m, id);
2821
+ if (!row || row.archived || !meetsArchiveRule(row)) continue;
2822
+ m.prepare("UPDATE lessons SET archived = ?, archived_reason = ? WHERE id = ?").run(
2823
+ now,
2824
+ `harmful ${row.harmful}, helpful ${row.helpful}`,
2825
+ id
2826
+ );
2827
+ archived.push(id);
2828
+ }
2146
2829
  m.prepare(
2147
2830
  `INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
2148
2831
  VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`
@@ -2157,6 +2840,30 @@ export async function runReflect(m, report, opts = {}) {
2157
2840
  const existingLessons = listLessons(m);
2158
2841
  const prompt = buildReflectPrompt(report, { blocks, plan, existingLessons, dir });
2159
2842
 
2843
+ // A dry run with no propose makes no model call: it reports the counters it would credit and the prompt it would send, and spends nothing.
2844
+ if (dryRun && typeof propose !== "function") {
2845
+ return {
2846
+ ok: true,
2847
+ dryRun: true,
2848
+ reportHash,
2849
+ prompt,
2850
+ counters,
2851
+ archived: [],
2852
+ proposed: null,
2853
+ appended: [],
2854
+ bumped: [],
2855
+ rejected: [],
2856
+ claudeMd: [],
2857
+ claudeMdDropped: [],
2858
+ notes: [],
2859
+ superseded: [],
2860
+ usd: 0,
2861
+ usdSource: "none",
2862
+ model,
2863
+ sessionId: null,
2864
+ };
2865
+ }
2866
+
2160
2867
  let proposeResult = null;
2161
2868
  let proposeError = null;
2162
2869
  try {
@@ -2182,6 +2889,7 @@ export async function runReflect(m, report, opts = {}) {
2182
2889
  dryRun,
2183
2890
  reportHash,
2184
2891
  counters,
2892
+ archived,
2185
2893
  proposed: null,
2186
2894
  appended: [],
2187
2895
  bumped: [],
@@ -2190,6 +2898,8 @@ export async function runReflect(m, report, opts = {}) {
2190
2898
  usdSource: null,
2191
2899
  claudeMd: [],
2192
2900
  claudeMdDropped: [],
2901
+ notes: [],
2902
+ superseded: [],
2193
2903
  model,
2194
2904
  sessionId: null,
2195
2905
  reason: proposeError.message,
@@ -2204,13 +2914,25 @@ export async function runReflect(m, report, opts = {}) {
2204
2914
  const suggestions = new Map(); // lesson id -> { lesson text, claudeMd suggestion } for appended lessons only
2205
2915
  const bumped = [];
2206
2916
  const rejected = [];
2917
+ const notes = [];
2918
+ const superseded = [];
2919
+ const shownIds = new Set(mostRecentLessons(existingLessons, REFLECT_PROMPT_LESSON_CAP).map((l) => l.id));
2920
+ const supersededThisPass = new Set();
2207
2921
  for (const proposal of proposals) {
2208
- const verdict = evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows });
2922
+ const judged = judgeReflectProposal(m, proposal, { shownIds, supersededThisPass, notes });
2923
+ if (judged.reject) {
2924
+ rejected.push({ text: proposal && typeof proposal.text === "string" ? proposal.text : null, reason: judged.reject });
2925
+ continue;
2926
+ }
2927
+ const verdict = evaluateReflectProposal(m, report, blocks, proposal, { dir, existingLessons, appendedRows, judgement: judged.judgement });
2209
2928
  if (verdict.duplicate) {
2210
- // The 3-item cap (C.5) limits appends, never evaluation: a near-duplicate found past the cap still bumps
2211
- // the lesson it matches, it is just never counted against the 3.
2212
- if (!dryRun) m.prepare("UPDATE lessons SET helpful = helpful + 1 WHERE id = ?").run(verdict.duplicate.id);
2929
+ // The 3-item cap (C.5) limits appends, never evaluation: a repeat found past the cap still bumps seen on
2930
+ // the lesson it names, it is just never counted against the 3. A repeat bumps seen, never helpful.
2931
+ if (!dryRun) m.prepare("UPDATE lessons SET seen = seen + 1 WHERE id = ?").run(verdict.duplicate.id);
2213
2932
  bumped.push(verdict.duplicate.id);
2933
+ if (judged.judgement && judged.judgement.kind === "supersedes") {
2934
+ notes.push(`supersedes ${judged.judgement.target.id} ignored: the proposal is an exact repeat of ${verdict.duplicate.id}`);
2935
+ }
2214
2936
  continue;
2215
2937
  }
2216
2938
  if (!verdict.ok) {
@@ -2225,6 +2947,10 @@ export async function runReflect(m, report, opts = {}) {
2225
2947
  const dryId = computeLessonId({ kind: proposal.kind, citation: proposal.citation || null, text: proposal.text });
2226
2948
  appendedRows.push({ id: dryId, text: proposal.text, kind: proposal.kind });
2227
2949
  suggestions.set(dryId, { text: proposal.text, claudeMd: proposal.claudeMd });
2950
+ if (verdict.supersedes) {
2951
+ superseded.push({ old: verdict.supersedes.id, new: dryId });
2952
+ supersededThisPass.add(verdict.supersedes.id);
2953
+ }
2228
2954
  continue;
2229
2955
  }
2230
2956
  const reportTask = findReportTask(report, proposal.task);
@@ -2240,6 +2966,15 @@ export async function runReflect(m, report, opts = {}) {
2240
2966
  });
2241
2967
  appendedRows.push(row);
2242
2968
  suggestions.set(row.id, { text: proposal.text, claudeMd: proposal.claudeMd });
2969
+ if (verdict.supersedes) {
2970
+ try {
2971
+ supersedeLesson(m, verdict.supersedes.id, row.id);
2972
+ superseded.push({ old: verdict.supersedes.id, new: row.id });
2973
+ supersededThisPass.add(verdict.supersedes.id);
2974
+ } catch (err) {
2975
+ notes.push(`supersedes ${verdict.supersedes.id} not applied: ${err && err.message ? err.message : String(err)}`);
2976
+ }
2977
+ }
2243
2978
  }
2244
2979
 
2245
2980
  let claudeMd = [];
@@ -2275,15 +3010,639 @@ export async function runReflect(m, report, opts = {}) {
2275
3010
  dryRun,
2276
3011
  reportHash,
2277
3012
  counters,
3013
+ archived,
2278
3014
  proposed: proposals.length,
2279
3015
  appended: appendedRows.map((r) => r.id),
2280
3016
  bumped,
2281
3017
  rejected,
2282
3018
  claudeMd,
2283
3019
  claudeMdDropped,
3020
+ notes,
3021
+ superseded,
2284
3022
  usd: cost.usd,
2285
3023
  usdSource: cost.usdSource,
2286
3024
  model,
2287
3025
  sessionId,
2288
3026
  };
2289
3027
  }
3028
+
3029
+ // The judgement fields of one reflect proposal (card memory-reflect-supersede). A target is valid only when it is
3030
+ // one of the lessons the prompt listed, is live, and was not superseded earlier in this pass. An invalid id is
3031
+ // ignored with a note and the proposal goes on as if the field were absent. Both fields set is a rejection.
3032
+ function judgeReflectProposal(m, proposal, { shownIds, supersededThisPass, notes }) {
3033
+ const field = (name) => (proposal && typeof proposal[name] === "string" && proposal[name].trim() ? proposal[name].trim() : null);
3034
+ const repeatsId = field("repeats");
3035
+ const supersedesId = field("supersedes");
3036
+ if (repeatsId && supersedesId) return { reject: "repeats and supersedes are both set; name one lesson in one field" };
3037
+ const check = (name, id) => {
3038
+ const row = getLesson(m, id);
3039
+ let why = null;
3040
+ if (!row) why = "no such lesson";
3041
+ else if (row.superseded_by || supersededThisPass.has(id)) why = "already superseded";
3042
+ else if (!shownIds.has(id)) why = "not listed in the prompt";
3043
+ if (why) {
3044
+ notes.push(`${name} ${id} ignored: ${why}`);
3045
+ return null;
3046
+ }
3047
+ return row;
3048
+ };
3049
+ if (repeatsId) {
3050
+ const target = check("repeats", repeatsId);
3051
+ return { judgement: target ? { kind: "repeats", target } : null };
3052
+ }
3053
+ if (supersedesId) {
3054
+ const target = check("supersedes", supersedesId);
3055
+ return { judgement: target ? { kind: "supersedes", target } : null };
3056
+ }
3057
+ return { judgement: null };
3058
+ }
3059
+
3060
+ // --- failure lessons (card memory-failure-lessons, ADR 0019 lines 39-41 and 60) -------------------------------------
3061
+ // Reflection over the failure slice of the run history: stopped reports, blocks classed `real`, and denials. A
3062
+ // pitfall cites the exact history line (history:<card>/<run>:<line>); a repeat of a pitfall is the model's `repeats`
3063
+ // judgement and lands in an append-only occurrence ledger (never in helpful, never from word similarity), which
3064
+ // learn.mjs counts by distinct cards and sessions.
3065
+
3066
+ export const PITFALL_LEDGER_RELPATH = ".doug/.state/memory/pitfalls.jsonl";
3067
+
3068
+ const FAILURE_ITEM_TEXT_MAX = 4000;
3069
+ const FAILURE_PROMPT_MAX = 16000;
3070
+ const FAILURE_TAIL_MAX = 1000;
3071
+ const FAILURE_APPEND_MAX = 3;
3072
+
3073
+ function parseJsonObject(text) {
3074
+ try {
3075
+ const v = JSON.parse(text);
3076
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
3077
+ } catch {
3078
+ return null;
3079
+ }
3080
+ }
3081
+
3082
+ function failureItemText(kind, v, raw) {
3083
+ const parts = [];
3084
+ if (kind === "stop") {
3085
+ for (const k of ["reason", "stoppedAt", "detail", "error"]) if (typeof v[k] === "string" && v[k]) parts.push(`${k}: ${v[k]}`);
3086
+ if (!parts.length) parts.push(`report: ${raw}`);
3087
+ } else if (kind === "block") {
3088
+ if (typeof v.reason === "string") parts.push(`reason: ${v.reason}`);
3089
+ for (const f of Array.isArray(v.failures) ? v.failures : []) {
3090
+ if (!f || typeof f !== "object") continue;
3091
+ const tail = typeof f.tail === "string" ? f.tail.slice(-FAILURE_TAIL_MAX) : "";
3092
+ parts.push(`command: ${f.command ?? ""} (exit ${f.exit ?? "?"}${f.timedOut ? ", timed out" : ""})${tail ? `\n${tail}` : ""}`);
3093
+ }
3094
+ } else {
3095
+ parts.push(`tool: ${v.tool ?? ""}`);
3096
+ if (v.detail !== undefined) parts.push(`detail: ${typeof v.detail === "string" ? v.detail : JSON.stringify(v.detail)}`);
3097
+ }
3098
+ return parts.join("\n");
3099
+ }
3100
+
3101
+ // The failure slice of one card's run history; never throws. See ADR 0019 lines 40 and 50: a block that is not
3102
+ // `real` is counted in `hidden` by class, and an entry that cannot be parsed in `unreadable`.
3103
+ export function failureSlice(dir, card, { session = null } = {}) {
3104
+ const out = { items: [], hidden: {}, unreadable: 0 };
3105
+ try {
3106
+ const cardName = historySafe(card);
3107
+ const cardDir = join(dir, HISTORY_DIR_RELPATH, cardName);
3108
+ if (!existsSync(cardDir)) return out;
3109
+ const files = readdirSync(cardDir).filter((f) => f.endsWith(".jsonl")).sort();
3110
+ for (const file of files) {
3111
+ const run = file.slice(0, -".jsonl".length);
3112
+ let text;
3113
+ try {
3114
+ text = readFileSync(join(cardDir, file), "utf8");
3115
+ } catch {
3116
+ continue;
3117
+ }
3118
+ const entries = [];
3119
+ for (const raw of text.split("\n")) {
3120
+ if (raw === "") continue;
3121
+ const e = parseJsonObject(raw);
3122
+ entries.push(e && typeof e.kind === "string" ? { kind: e.kind, text: typeof e.text === "string" ? e.text : "" } : null);
3123
+ }
3124
+ const parsed = entries.map((e) => (e && (e.kind === "report" || e.kind === "block" || e.kind === "denial") ? parseJsonObject(e.text) : null));
3125
+ const sessionOf = (v) => (v && typeof v.session === "string" && v.session !== "" ? v.session : null);
3126
+ const runSession = parsed.map(sessionOf).find((s) => s !== null) ?? null;
3127
+ entries.forEach((e, i) => {
3128
+ if (!e) return;
3129
+ const v = parsed[i];
3130
+ let kind = null;
3131
+ if (e.kind === "report") {
3132
+ if (!v) return void out.unreadable++;
3133
+ if (v.outcome === "stopped" || (v.ok === false && !v.paused)) kind = "stop";
3134
+ } else if (e.kind === "block") {
3135
+ if (!v) return void out.unreadable++;
3136
+ if (v.class === "real") kind = "block";
3137
+ else {
3138
+ const cls = typeof v.class === "string" && v.class ? v.class : "unclassified";
3139
+ out.hidden[cls] = (out.hidden[cls] ?? 0) + 1;
3140
+ }
3141
+ } else if (e.kind === "denial") kind = "denial";
3142
+ if (!kind) return;
3143
+ const body = v ?? {};
3144
+ out.items.push({
3145
+ ref: `history:${cardName}/${run}:${i + 1}`,
3146
+ kind,
3147
+ card: cardName,
3148
+ run,
3149
+ line: i + 1,
3150
+ session: sessionOf(v) ?? runSession ?? (typeof session === "string" && session !== "" ? session : null),
3151
+ text: failureItemText(kind, body, e.text),
3152
+ });
3153
+ });
3154
+ }
3155
+ } catch {
3156
+ // fail open: whatever was read so far
3157
+ }
3158
+ return out;
3159
+ }
3160
+
3161
+ // Occurrence ledger: one JSON line per {lesson, citation} (de-duplicated), append-only.
3162
+ export function readPitfallOccurrences(dir) {
3163
+ let text;
3164
+ try {
3165
+ text = readFileSync(join(dir, PITFALL_LEDGER_RELPATH), "utf8");
3166
+ } catch {
3167
+ return [];
3168
+ }
3169
+ const seen = new Set();
3170
+ const out = [];
3171
+ for (const v of parseJsonLines(text)) {
3172
+ if (typeof v.lesson !== "string" || typeof v.citation !== "string") continue;
3173
+ const key = `${v.lesson}\u0000${v.citation}`;
3174
+ if (seen.has(key)) continue;
3175
+ seen.add(key);
3176
+ out.push(v);
3177
+ }
3178
+ return out;
3179
+ }
3180
+
3181
+ export function recordPitfallOccurrence(dir, occ) {
3182
+ const { lesson, citation, card = null, run = null, session = null, at = nowIso() } = occ ?? {};
3183
+ if (typeof lesson !== "string" || typeof citation !== "string") throw new Error("recordPitfallOccurrence needs a lesson and a citation");
3184
+ if (readPitfallOccurrences(dir).some((o) => o.lesson === lesson && o.citation === citation)) return { written: false };
3185
+ const path = join(dir, PITFALL_LEDGER_RELPATH);
3186
+ mkdirSync(dirname(path), { recursive: true });
3187
+ appendFileSync(path, JSON.stringify({ lesson, citation, card, run, session, at }) + "\n", "utf8");
3188
+ return { written: true };
3189
+ }
3190
+
3191
+ export const FAILURE_REFLECT_SCHEMA = {
3192
+ type: "object",
3193
+ required: ["lessons"],
3194
+ properties: {
3195
+ lessons: {
3196
+ type: "array",
3197
+ items: {
3198
+ type: "object",
3199
+ required: ["kind", "text", "citation", "notDerivableBecause"],
3200
+ properties: {
3201
+ kind: { enum: ["pitfall"] },
3202
+ text: { type: "string" },
3203
+ citation: { type: "string" },
3204
+ notDerivableBecause: { type: "string" },
3205
+ scope: { type: "array", items: { type: "string" } },
3206
+ repeats: { type: "string" },
3207
+ },
3208
+ },
3209
+ },
3210
+ },
3211
+ };
3212
+
3213
+ export function buildFailureReflectPrompt(slice, { existingLessons = [] } = {}) {
3214
+ const lines = [
3215
+ "You are the reflection pass of a coding harness, reading the FAILURES of one card: stopped runs, gate blocks classed real, and permission denials.",
3216
+ "Propose at most 3 lessons of kind \"pitfall\": a short, specific rule that would have prevented the failure. Each lesson's `citation` must be exactly the ref of one item below (a history:<card>/<run>:<line> string). Do not cite anything else.",
3217
+ "`notDerivableBecause` says why the rule cannot be read from the repository's code or docs. Skip failures that are plain test failures with no transferable lesson.",
3218
+ "When an item is a repeat of an existing pitfall listed below, set `repeats` to that pitfall's id instead of inventing a new rule; do not reword an existing pitfall as a new one.",
3219
+ "",
3220
+ ];
3221
+ const items = slice.items ?? [];
3222
+ lines.push(`Failure items (${items.length}):`);
3223
+ let used = 0;
3224
+ let shown = 0;
3225
+ for (const item of items) {
3226
+ let t = String(item.text ?? "");
3227
+ if (t.length > FAILURE_ITEM_TEXT_MAX) t = t.slice(0, FAILURE_ITEM_TEXT_MAX) + " [cut]";
3228
+ const block = `\n[${item.ref}] kind: ${item.kind}\n${t}`;
3229
+ if (used + block.length > FAILURE_PROMPT_MAX) break;
3230
+ lines.push(block);
3231
+ used += block.length;
3232
+ shown++;
3233
+ }
3234
+ if (shown < items.length) lines.push(`\n(${items.length - shown} more item(s) left out by the ${FAILURE_PROMPT_MAX}-character cap)`);
3235
+ const hidden = Object.entries(slice.hidden ?? {});
3236
+ if (hidden.length) lines.push(`\nBlocks not shown (not real): ${hidden.map(([k, n]) => `${k} ${n}`).join(", ")}.`);
3237
+ const pitfalls = existingLessons.filter((l) => l.kind === "pitfall" && !l.superseded_by);
3238
+ lines.push("", `Existing pitfalls (${pitfalls.length}), which \`repeats\` may name:`);
3239
+ for (const l of pitfalls) lines.push(`${l.id}: ${l.text}`);
3240
+ return lines.join("\n");
3241
+ }
3242
+
3243
+ export async function runFailureReflect(m, opts = {}) {
3244
+ const { dir = process.cwd(), card, session = null, propose, dryRun = false, model = null, claudeDir, now = nowIso() } = opts;
3245
+ if (typeof card !== "string" || !card) throw new Error("runFailureReflect requires a card");
3246
+ if (typeof propose !== "function" && !dryRun) throw new Error("runFailureReflect requires a propose function");
3247
+ const slice = failureSlice(dir, card, { session });
3248
+ const base = { items: slice.items.length, hidden: slice.hidden, unreadable: slice.unreadable };
3249
+ if (!slice.items.length) return { ok: true, dryRun, ...base, appended: [], repeated: [], rejected: [] };
3250
+
3251
+ const key = "failures:" + createHash("sha256").update(slice.items.map((i) => i.ref).sort().join("\n")).digest("hex");
3252
+ const existing = m.prepare("SELECT * FROM reflections WHERE report_hash = ?").get(key);
3253
+ if (existing) return { alreadyReflected: true, ...base, row: reflectionRowToObject(existing) };
3254
+
3255
+ const prompt = buildFailureReflectPrompt(slice, { existingLessons: listLessons(m) });
3256
+ // A dry run with no propose makes no model call: it returns the prompt it would send and spends nothing.
3257
+ if (dryRun && typeof propose !== "function") {
3258
+ return { ok: true, dryRun: true, ...base, prompt, proposed: null, appended: [], repeated: [], rejected: [], usd: 0, usdSource: "none", model };
3259
+ }
3260
+ let result;
3261
+ try {
3262
+ result = await propose(prompt);
3263
+ if (!result || !Array.isArray(result.lessons)) throw new Error("the failure pass returned no lessons array");
3264
+ } catch (err) {
3265
+ return { ok: false, dryRun, ...base, appended: [], repeated: [], rejected: [{ text: null, reason: err.message }], reason: err.message };
3266
+ }
3267
+
3268
+ const byRef = new Map(slice.items.map((i) => [i.ref, i]));
3269
+ const appended = [];
3270
+ const appendedIds = new Set();
3271
+ const repeated = [];
3272
+ const rejected = [];
3273
+ const occur = (lesson, item) => {
3274
+ if (!dryRun) recordPitfallOccurrence(dir, { lesson, citation: item.ref, card: item.card, run: item.run, session: item.session, at: now });
3275
+ if (!repeated.includes(lesson)) repeated.push(lesson);
3276
+ };
3277
+ for (const p of result.lessons) {
3278
+ const text = p && typeof p.text === "string" ? p.text : null;
3279
+ const reject = (reason) => rejected.push({ text, reason });
3280
+ if (!p || typeof p !== "object") {
3281
+ reject("proposal is not an object");
3282
+ continue;
3283
+ }
3284
+ if (p.kind !== "pitfall") {
3285
+ reject(`kind must be "pitfall", got ${JSON.stringify(p.kind)}`);
3286
+ continue;
3287
+ }
3288
+ if (!text || !text.trim()) {
3289
+ reject("text is empty");
3290
+ continue;
3291
+ }
3292
+ if (typeof p.notDerivableBecause !== "string" || !p.notDerivableBecause.trim()) {
3293
+ reject("notDerivableBecause is empty");
3294
+ continue;
3295
+ }
3296
+ const item = typeof p.citation === "string" ? byRef.get(p.citation) : undefined;
3297
+ if (!item) {
3298
+ reject(`citation ${JSON.stringify(p.citation)} is not a failure item of this card's history`);
3299
+ continue;
3300
+ }
3301
+ const derivable = checkReflectDerivable(p, dir);
3302
+ if (!derivable.ok) {
3303
+ reject(derivable.reason);
3304
+ continue;
3305
+ }
3306
+ if (p.repeats !== undefined && p.repeats !== null && p.repeats !== "") {
3307
+ const target = typeof p.repeats === "string" ? getLesson(m, p.repeats) : null;
3308
+ if (!target || target.kind !== "pitfall" || target.superseded_by) {
3309
+ reject(`repeats ${JSON.stringify(p.repeats)} is not a live pitfall lesson`);
3310
+ continue;
3311
+ }
3312
+ occur(target.id, item);
3313
+ continue;
3314
+ }
3315
+ const id = computeLessonId({ kind: "pitfall", citation: item.ref, text });
3316
+ const collision = getLesson(m, id);
3317
+ if (collision || appendedIds.has(id)) {
3318
+ if (collision && collision.superseded_by) reject(`addLesson id collision with a superseded lesson ${id}`);
3319
+ else occur(id, item);
3320
+ continue;
3321
+ }
3322
+ if (appended.length >= FAILURE_APPEND_MAX) {
3323
+ reject(`at most ${FAILURE_APPEND_MAX} lessons appended per reflection`);
3324
+ continue;
3325
+ }
3326
+ if (dryRun) {
3327
+ appended.push(id);
3328
+ appendedIds.add(id);
3329
+ continue;
3330
+ }
3331
+ const row = addLesson(m, {
3332
+ text,
3333
+ kind: "pitfall",
3334
+ scope: Array.isArray(p.scope) ? p.scope : [],
3335
+ citation: item.ref,
3336
+ source: { agent: "reflect", model },
3337
+ card: item.card,
3338
+ });
3339
+ appended.push(row.id);
3340
+ appendedIds.add(row.id);
3341
+ recordPitfallOccurrence(dir, { lesson: row.id, citation: item.ref, card: item.card, run: item.run, session: item.session, at: now });
3342
+ }
3343
+
3344
+ const sessionId = result.sessionId || null;
3345
+ const cost = computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd: typeof result.totalCostUsd === "number" ? result.totalCostUsd : null });
3346
+ if (!dryRun) {
3347
+ m.prepare(
3348
+ `INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
3349
+ VALUES (?, ?, NULL, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
3350
+ ).run(key, now, card, model, JSON.stringify({ helpful: [], harmful: [], unknown: [] }), result.lessons.length, JSON.stringify(appended), JSON.stringify(repeated), JSON.stringify(rejected), cost.usd, cost.usdSource, sessionId);
3351
+ }
3352
+ return { ok: true, dryRun, ...base, proposed: result.lessons.length, appended, repeated, rejected, usd: cost.usd, usdSource: cost.usdSource, model, sessionId, key };
3353
+ }
3354
+
3355
+ // The on-demand cited project model (card memory-project-model, ADR 0019 L2): `memory.mjs model regen` asks one
3356
+ // cheap-row model call for statements about this project learned from runs, each citing live lessons or run
3357
+ // history, and keeps only statements whose every citation resolves in code. The whole file stays under the cap.
3358
+ export const PROJECT_MODEL_CHAR_CAP = 2000;
3359
+ export const PROJECT_MODEL_RELPATH = ".doug/project-model.md";
3360
+ const PROJECT_MODEL_SECTIONS = [
3361
+ ["failure-patterns", "Failure patterns"],
3362
+ ["conventions", "Conventions"],
3363
+ ["open-risks", "Open risks"],
3364
+ ];
3365
+ const PROJECT_MODEL_LESSON_TEXT_MAX = 500;
3366
+
3367
+ export const PROJECT_MODEL_SCHEMA = {
3368
+ type: "object",
3369
+ required: ["statements"],
3370
+ properties: {
3371
+ statements: {
3372
+ type: "array",
3373
+ items: {
3374
+ type: "object",
3375
+ required: ["section", "text", "citations"],
3376
+ properties: {
3377
+ section: { enum: PROJECT_MODEL_SECTIONS.map(([key]) => key) },
3378
+ text: { type: "string" },
3379
+ citations: { type: "array", items: { type: "string" }, minItems: 1 },
3380
+ },
3381
+ },
3382
+ },
3383
+ },
3384
+ };
3385
+
3386
+ // Every run-history ref `<card>/<run>` under .doug/.state/history, in directory order.
3387
+ export function listRunHistoryRefs(dir) {
3388
+ const root = join(dir, HISTORY_DIR_RELPATH);
3389
+ const refs = [];
3390
+ try {
3391
+ for (const card of readdirSync(root).sort()) {
3392
+ let files = [];
3393
+ try {
3394
+ files = readdirSync(join(root, card)).filter((f) => f.endsWith(".jsonl")).sort();
3395
+ } catch {
3396
+ continue;
3397
+ }
3398
+ for (const f of files) refs.push(`${card}/${f.slice(0, -".jsonl".length)}`);
3399
+ }
3400
+ } catch {
3401
+ return [];
3402
+ }
3403
+ return refs;
3404
+ }
3405
+
3406
+ export function buildProjectModelPrompt({ lessons = [], runs = [] } = {}) {
3407
+ const lines = [
3408
+ "You write the project model of a coding harness: what is true about THIS project now, learned from its runs only. Three sections: failure-patterns (what keeps going wrong), conventions (conventions learned from runs), open-risks (what is still unresolved or fragile).",
3409
+ "Write short factual statements. Each statement has a `section` (failure-patterns, conventions or open-risks), a `text`, and `citations`: one or more lesson ids (the 16 hex characters in brackets below) or run-history refs (`<card>/<run>` or `<card>/<run>:<line>`, exactly as listed below). You must cite; a statement whose every citation is not exactly one of the ids or refs below is dropped by code.",
3410
+ "Do not restate CLAUDE.md, the ADRs or board cards: link to them by path or card id instead. A statement that should become a rule is not written here; rules go through the proposal path.",
3411
+ `Keep the whole document under ${PROJECT_MODEL_CHAR_CAP} characters, header included: a handful of dense statements, each worth its line. Prefer statements that cite several lessons.`,
3412
+ "",
3413
+ `Live lessons (${lessons.length}):`,
3414
+ ];
3415
+ for (const l of lessons) {
3416
+ const text = String(l.text ?? "").replace(/\s+/g, " ").trim();
3417
+ const shown = text.length > PROJECT_MODEL_LESSON_TEXT_MAX ? text.slice(0, PROJECT_MODEL_LESSON_TEXT_MAX) + "..." : text;
3418
+ lines.push(`- [${l.id}] (${l.kind}) ${shown}`);
3419
+ }
3420
+ lines.push("", `Run history refs (${runs.length}):`);
3421
+ for (const r of runs) lines.push(`- ${r}`);
3422
+ return lines.join("\n") + "\n";
3423
+ }
3424
+
3425
+ // A citation resolves when it is the id of a live (not superseded) lesson, or a history ref with a run whose file
3426
+ // (and line, when named) exists. Anything else, including a thrown parse or read, is unresolved. A statement with
3427
+ // any unresolved citation is dropped whole.
3428
+ function citationResolver(m, dir) {
3429
+ return (c) => {
3430
+ if (typeof c !== "string") return false;
3431
+ if (/^[0-9a-f]{16}$/.test(c)) {
3432
+ const row = getLesson(m, c);
3433
+ return !!row && !row.superseded_by;
3434
+ }
3435
+ try {
3436
+ const ref = parseHistoryRef(c);
3437
+ if (ref.run === null) return false;
3438
+ readRunHistory(dir, ref);
3439
+ return true;
3440
+ } catch {
3441
+ return false;
3442
+ }
3443
+ };
3444
+ }
3445
+
3446
+ export function checkProjectModelCitations(m, dir, statements) {
3447
+ const resolves = citationResolver(m, dir);
3448
+ const kept = [];
3449
+ const dropped = [];
3450
+ for (const s of statements) {
3451
+ const cites = Array.isArray(s && s.citations) ? s.citations : [];
3452
+ if (cites.length > 0 && cites.every(resolves)) kept.push(s);
3453
+ else dropped.push({ text: s && s.text, reason: "unresolved", citations: cites });
3454
+ }
3455
+ return { kept, dropped };
3456
+ }
3457
+
3458
+ // The on-demand idea pass (card memory-idea-proposals, ADR 0019 L3): `memory.mjs ideas` asks one cheap-row model
3459
+ // call over live lessons, run history and pitfall occurrences for idea cards and priority reviews. Ideas are
3460
+ // filed to Backlog only, cited, capped per pass; reviews go to a jsonl file for the user. Never Ready.
3461
+ export const IDEA_CAP = 3;
3462
+ export const PRIORITY_REVIEWS_RELPATH = ".doug/.state/memory/priority-reviews.jsonl";
3463
+ const IDEAS_LESSON_TEXT_MAX = 500;
3464
+
3465
+ export const IDEAS_SCHEMA = {
3466
+ type: "object",
3467
+ required: ["ideas", "reviews"],
3468
+ properties: {
3469
+ ideas: {
3470
+ type: "array",
3471
+ items: {
3472
+ type: "object",
3473
+ required: ["id", "title", "goal", "component", "size", "citations"],
3474
+ properties: {
3475
+ id: { type: "string" },
3476
+ title: { type: "string" },
3477
+ goal: { type: "string" },
3478
+ component: { type: "string" },
3479
+ size: { type: "string" },
3480
+ citations: { type: "array", items: { type: "string" }, minItems: 1 },
3481
+ },
3482
+ },
3483
+ },
3484
+ reviews: {
3485
+ type: "array",
3486
+ items: {
3487
+ type: "object",
3488
+ required: ["text", "cards", "citations"],
3489
+ properties: {
3490
+ text: { type: "string" },
3491
+ cards: { type: "array", items: { type: "string" }, minItems: 1 },
3492
+ citations: { type: "array", items: { type: "string" }, minItems: 1 },
3493
+ },
3494
+ },
3495
+ },
3496
+ },
3497
+ };
3498
+
3499
+ export function buildIdeasPrompt({ lessons = [], runs = [], pitfalls = [], cards = [] } = {}) {
3500
+ const lines = [
3501
+ "You look over the lessons and run history of a coding harness and propose follow-up work for its user: idea cards and priority reviews.",
3502
+ "Look for recurring failures, repeated manual steps and unfinished follow-ups. An idea is filed only when it is realistic drift (a form a model or a person would plausibly write) or when one card would close the whole class; otherwise propose nothing.",
3503
+ "Each idea has an `id` (kebab-case, not any card id listed below), `title`, `goal` (name the defect or gap plainly), `component`, `size` and `citations`. Do not duplicate a card listed below.",
3504
+ "Each review (`text`, `cards`, `citations`) groups Backlog cards for the user to look at, for example Backlog cards that share a recurring pitfall. `cards` may name Backlog cards only. Never propose a change to Ready.",
3505
+ "You must cite: `citations` are lesson ids (the 16 hex characters in brackets below) or run-history refs (`<card>/<run>` or `<card>/<run>:<line>`), exactly as listed below. An item with any citation not exactly one of them, or with none, is dropped by code.",
3506
+ "",
3507
+ `Live lessons (${lessons.length}):`,
3508
+ ];
3509
+ for (const l of lessons) {
3510
+ const text = String(l.text ?? "").replace(/\s+/g, " ").trim();
3511
+ const shown = text.length > IDEAS_LESSON_TEXT_MAX ? text.slice(0, IDEAS_LESSON_TEXT_MAX) + "..." : text;
3512
+ lines.push(`- [${l.id}] (${l.kind}) ${shown}`);
3513
+ }
3514
+ lines.push("", `Run history refs (${runs.length}):`);
3515
+ for (const r of runs) lines.push(`- ${r}`);
3516
+ lines.push("", `Pitfall occurrences (${pitfalls.length}):`);
3517
+ for (const p of pitfalls) lines.push(`- lesson ${p.lesson} (card ${p.card ?? "?"}, run ${p.run ?? "?"}): ${p.citation ?? ""}`);
3518
+ lines.push("", `Existing Backlog and Ready cards (${cards.length}):`);
3519
+ for (const c of cards) lines.push(`- ${c.id} (${c.column}): ${c.title}`);
3520
+ return lines.join("\n") + "\n";
3521
+ }
3522
+
3523
+ export function checkIdeaCitations(m, dir, items) {
3524
+ const resolves = citationResolver(m, dir);
3525
+ const kept = [];
3526
+ const dropped = [];
3527
+ for (const it of items) {
3528
+ const cites = Array.isArray(it && it.citations) ? it.citations : [];
3529
+ if (cites.length > 0 && cites.every(resolves)) kept.push(it);
3530
+ else dropped.push({ id: it && it.id, text: it && it.text, reason: "unresolved", citations: cites });
3531
+ }
3532
+ return { kept, dropped };
3533
+ }
3534
+
3535
+ export const IDEA_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3536
+
3537
+ // Pure: files up to `cap` ideas as new Backlog cards appended after every existing card.
3538
+ export function fileIdeaCards(board, ideas, { cap = IDEA_CAP, date } = {}) {
3539
+ let next = board;
3540
+ const filed = [];
3541
+ const skipped = [];
3542
+ for (const it of ideas) {
3543
+ if (typeof it.id !== "string" || !IDEA_ID_RE.test(it.id)) {
3544
+ skipped.push({ id: it.id, reason: "id is not kebab-case" });
3545
+ continue;
3546
+ }
3547
+ if (filed.length >= cap) {
3548
+ skipped.push({ id: it.id, reason: "over cap" });
3549
+ continue;
3550
+ }
3551
+ const cites = Array.isArray(it.citations) ? it.citations.join(", ") : "";
3552
+ try {
3553
+ const added = addCard(
3554
+ next,
3555
+ {
3556
+ id: it.id,
3557
+ title: it.title,
3558
+ component: it.component,
3559
+ size: it.size,
3560
+ track: "hand",
3561
+ deps: [],
3562
+ column: "backlog",
3563
+ goal: `Memory-proposed (memory.mjs ideas, ${date}): ${it.goal} Evidence: ${cites}.`,
3564
+ },
3565
+ { date }
3566
+ );
3567
+ const cards = added.cards.slice();
3568
+ cards[cards.length - 1] = { ...cards[cards.length - 1], source: `memory-proposed: ${cites}` };
3569
+ next = { ...added, cards };
3570
+ filed.push(it.id);
3571
+ } catch (err) {
3572
+ skipped.push({ id: it.id, reason: String(err && err.message ? err.message : err).split("\n").slice(0, 2).join(" ") });
3573
+ }
3574
+ }
3575
+ return { board: next, filed, skipped };
3576
+ }
3577
+
3578
+ export function readPriorityReviews(dir) {
3579
+ const file = join(dir, PRIORITY_REVIEWS_RELPATH);
3580
+ if (!existsSync(file)) return [];
3581
+ const out = [];
3582
+ try {
3583
+ for (const line of readFileSync(file, "utf8").split("\n")) {
3584
+ if (!line.trim()) continue;
3585
+ try {
3586
+ const r = JSON.parse(line);
3587
+ if (r && typeof r === "object") out.push(r);
3588
+ } catch {
3589
+ // malformed line: skip
3590
+ }
3591
+ }
3592
+ } catch {
3593
+ return [];
3594
+ }
3595
+ return out;
3596
+ }
3597
+
3598
+ function reviewKey(r) {
3599
+ const cards = Array.isArray(r && r.cards) ? r.cards : [];
3600
+ return JSON.stringify([[...new Set(cards)].sort(), String((r && r.text) ?? "").replace(/\s+/g, " ").trim()]);
3601
+ }
3602
+
3603
+ export function checkPriorityReviews(board, reviews, { cap = IDEA_CAP, recorded = [] } = {}) {
3604
+ const backlog = new Set(board.cards.filter((c) => c.column === "backlog").map((c) => c.id));
3605
+ const seen = new Set(recorded.map(reviewKey));
3606
+ const kept = [];
3607
+ const dropped = [];
3608
+ for (const r of reviews) {
3609
+ const key = reviewKey(r);
3610
+ if (seen.has(key)) {
3611
+ dropped.push({ text: r && r.text, reason: "already recorded" });
3612
+ continue;
3613
+ }
3614
+ const cards = Array.isArray(r && r.cards) ? r.cards : [];
3615
+ if (cards.length > 0 && cards.every((id) => backlog.has(id)) && kept.length < cap) {
3616
+ kept.push(r);
3617
+ seen.add(key);
3618
+ } else dropped.push({ text: r && r.text, reason: kept.length >= cap && cards.every((id) => backlog.has(id)) ? "over cap" : "not all backlog cards" });
3619
+ }
3620
+ return { kept, dropped };
3621
+ }
3622
+
3623
+ export function renderProjectModel(statements, { date = new Date().toISOString().slice(0, 10) } = {}) {
3624
+ const header =
3625
+ `Generated by \`memory.mjs model regen\` on ${date}; do not edit by hand. Each statement cites a lesson (\`memory.mjs lesson show <id>\`) or a run record (\`memory.mjs history show <ref>\`).`;
3626
+ const build = (list) => {
3627
+ const out = ["# Project model", "", header, ""];
3628
+ for (const [key, title] of PROJECT_MODEL_SECTIONS) {
3629
+ const inSection = list.filter((s) => s.section === key);
3630
+ if (!inSection.length) continue;
3631
+ out.push(`## ${title}`);
3632
+ for (const s of inSection) out.push(`- ${s.text} [${s.citations.join(", ")}]`);
3633
+ }
3634
+ return out.join("\n") + "\n";
3635
+ };
3636
+ const known = statements.filter((s) => PROJECT_MODEL_SECTIONS.some(([key]) => key === s.section));
3637
+ const priority = known.map((s, i) => ({ s, i })).sort((a, b) => b.s.citations.length - a.s.citations.length || a.i - b.i);
3638
+ const chosen = new Set();
3639
+ for (const { s } of priority) {
3640
+ const trial = known.filter((x) => chosen.has(x) || x === s);
3641
+ if (build(trial).length > PROJECT_MODEL_CHAR_CAP) break;
3642
+ chosen.add(s);
3643
+ }
3644
+ const kept = known.filter((s) => chosen.has(s));
3645
+ const overCap = statements.filter((s) => !chosen.has(s));
3646
+ const text = build(kept);
3647
+ return { text, chars: text.length, kept, overCap };
3648
+ }