@dougharness/cli 1.4.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 (32) hide show
  1. package/dist/ablate.js +2 -2
  2. package/dist/ablate.js.map +1 -1
  3. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  4. package/node_modules/@dougharness/flow/agents/planner.md +1 -0
  5. package/node_modules/@dougharness/flow/lib/board.mjs +9 -9
  6. package/node_modules/@dougharness/flow/lib/card-loop.mjs +44 -5
  7. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
  8. package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
  9. package/node_modules/@dougharness/flow/lib/memory.mjs +1380 -44
  10. package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
  11. package/node_modules/@dougharness/flow/package.json +1 -1
  12. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +162 -1
  13. package/node_modules/@dougharness/flow/scripts/board.mjs +38 -14
  14. package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
  15. package/node_modules/@dougharness/flow/scripts/memory.mjs +658 -36
  16. package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
  17. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +6 -2
  18. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  19. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +2 -0
  20. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
  21. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  22. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +1 -1
  23. package/node_modules/@dougharness/flow/workflows/card-queen.js +44 -5
  24. package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
  25. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  26. package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
  27. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +5 -3
  28. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
  29. package/node_modules/@dougharness/gates/package.json +1 -1
  30. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +48 -33
  31. package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
  32. package/package.json +3 -3
@@ -19,13 +19,26 @@
19
19
  // condition columns from it; without one the row still records
20
20
  // (exit 0) and an extra stdout line says the landing is
21
21
  // unstamped (--json: "stamped": true|false instead)
22
+ // memory.mjs record <card-id> --hand --stop <reason> [dir] [--detail <text>] [--json]
23
+ // record a stopped by-hand run as exactly one stop row (run
24
+ // stop:<12 hex>, commit null, stop_class <reason>, stop_reason
25
+ // --detail or the reason); the same stop recorded again updates
26
+ // that row. Condition columns and memory_config come from
27
+ // condition.json when present, else the row is unstamped (extra
28
+ // stdout line; --json: "stamped": false). --stop with no value
29
+ // or with --commit exits 2 and writes nothing
22
30
  // memory.mjs condition open <card-id> [dir] [--json]
23
31
  // (card outcomes-condition-columns) writes
24
32
  // .doug/.state/reports/<card-id>/condition.json: harnessCommit
25
33
  // (git HEAD), configHash (conditionConfigHash: .doug/config.json
26
34
  // plus the CLAUDE.md Models table text), class and arm
27
35
  // (<class>/default) from the card's board record, assignedBy
28
- // "policy", exploreProbability null, openedAt. Refuses (exit 1,
36
+ // "policy", exploreProbability null, openedAt, memoryConfig (card
37
+ // memory-outcome-arms: memoryConfig() from the project's
38
+ // embeddings config and staleDays, track "hand": delivery
39
+ // index+fetch; a flow row from `record <report.json>` is
40
+ // stamped with track "flow": delivery index+fetch, fetch top-k,
41
+ // fetchTokens 4000). Refuses (exit 1,
29
42
  // nothing written) when the file already exists, the card is
30
43
  // missing or has no class, or there is no git HEAD
31
44
  // memory.mjs condition backfill [dir] [--json]
@@ -39,9 +52,22 @@
39
52
  // outcomes rows with a harness commit." (--json: {"backfilled": n})
40
53
  // memory.mjs outcomes [dir] [--run <id>] [--task <id>] [--json]
41
54
  // list recorded outcomes, filtered by run and/or task
55
+ // memory.mjs arms [dir] [--min <n>] [--json]
56
+ // (card memory-outcome-arms) compares outcome arms by the memory
57
+ // configuration stamped on each row (outcomes.memory_config):
58
+ // landings, stops, then stop-or-land rate, fix rounds, harmful
59
+ // marks per landing and cost per landing once an arm has --min
60
+ // landings (default 12; an integer >= 1), else "not compared: <n>
61
+ // of <min> landings"; a last line says whether two arms are
62
+ // comparable. Reads existing rows only: no database -> "No
63
+ // outcomes recorded." (exit 0, database not created); no claude
64
+ // call, no network request
42
65
  // memory.mjs lessons [dir] [--search <text>] [--k <n>] [--all] [--json]
43
66
  // list lessons, or full-text search them (--search); --all
44
67
  // includes superseded rows
68
+ // memory.mjs lessons --unconfirmed [dir] [--json]
69
+ // read-only review list: live lessons with helpful 0, harmful 0
70
+ // and no `lesson show --card` fetch; id, kind, created, title
45
71
  // memory.mjs lesson add --text <text> --kind <feedback|project|pitfall|pattern> [dir]
46
72
  // [--scope <glob,glob>] [--citation <text>] [--card <id>]
47
73
  // [--commit <sha>] [--source-agent <name>] [--source-model <name>] [--json]
@@ -60,11 +86,34 @@
60
86
  // memory.mjs lesson unstale <id> [dir] [--json]
61
87
  // clear a lesson's stale mark (set by recall's citation
62
88
  // re-check) so it is a candidate again; refuses on an unknown id
89
+ // memory.mjs lesson unarchive <id> [dir] [--json]
90
+ // restore an archived lesson (clears archived and
91
+ // archived_reason); refuses on an unknown or non-archived id
92
+ // memory.mjs lesson show <id> [dir] [--card <card-id>] [--json]
93
+ // (card memory-progressive-access) one lesson in full: cost
94
+ // line, citation line, superseded/stale status when set, a
95
+ // blank line, then the whole text, never cut. Read-only unless
96
+ // --card <card-id> (a plain id, else exit 2): then, after the
97
+ // lesson is found and printed, one JSON line {id, at} is
98
+ // appended to .doug/.state/reports/<card-id>/fetched.jsonl
99
+ // (reflect --hand credits only those). No
100
+ // store or unknown id -> one stderr line, exit 1 (nothing
101
+ // created); no id -> usage, exit 2. --json: the row with
102
+ // hasVector and tokens instead of the embedding blob
103
+ // memory.mjs history show <ref> [dir] [--json]
104
+ // (card memory-progressive-access) fetch run history (L0) by
105
+ // id: <card> lists its runs, <card>/<run> lists the run's
106
+ // entries with token costs, <card>/<run>:<line> prints one
107
+ // entry whole (a leading history: and a .jsonl suffix are
108
+ // accepted). Needs no memory database; bad id -> exit 2,
109
+ // missing -> one stderr line, exit 1
63
110
  // memory.mjs brief-lessons <card-id> [dir] [--k <n>] [--json]
64
111
  // (card memory-every-session) recall for a hand-track card's
65
112
  // briefs: query = the card's title and goal, k 8. Prints the
66
- // workflow's lessons header, the rendered `- [id] ...` lines
67
- // (capped at 2000 chars like plan.mjs), then the window line
113
+ // workflow's lessons header, the index `- [id] title (kind,
114
+ // ~n tokens)` lines (capped at 2000 chars; the lead fetches
115
+ // full text with `lesson show`), an `index: omitted` line
116
+ // naming lessons the cap dropped, then the window line
68
117
  // when lessons were hidden, then `memoryUsed: <ids>`;
69
118
  // writes .doug/.state/reports/<card-id>/lessons.json. No database
70
119
  // -> "No lessons recalled for <id>." (database not created);
@@ -92,11 +141,13 @@
92
141
  // filtered in code (a pitfall needs a block classed real, a
93
142
  // pattern needs a first-pass verified task, the citation must be
94
143
  // checkable, the text must not already be derivable from the
95
- // checkout, and a near-duplicate bumps the existing lesson's
96
- // helpful instead of appending); idempotent on the report's
144
+ // checkout, and a proposal may name one listed lesson
145
+ // it repeats (that lesson's seen rises, nothing is appended) or
146
+ // supersedes (the new lesson is written and the old retired); a
147
+ // near-duplicate with neither is rejected); idempotent on the report's
97
148
  // sha256 (a second reflect on the same report is a no-op, exit
98
- // 0); --dry-run applies nothing but still runs the LLM pass and
99
- // prints what would happen. Cost is measured from the same local
149
+ // 0); --dry-run applies nothing, makes no model call, and
150
+ // prints the counters it would credit and the prompt it would send. Cost is measured from the same local
100
151
  // transcripts cost.mjs reads, falling back to the CLI's own
101
152
  // cost estimate. For each appended lesson with no CLAUDE.md home
102
153
  // it prints `claude-md candidate for lesson <id>: <command>`
@@ -108,9 +159,48 @@
108
159
  // (card memory-every-session) the same pass for a hand landing:
109
160
  // assembles .doug/.state/reports/<card-id>/hand-report.json from
110
161
  // the saved briefs/, reviews/ and lessons.json (memoryUsed = its
111
- // ids; verified true; passes = --rounds, default 1; one fixed
162
+ // ids that the card's fetched.jsonl lists; verified true; passes = --rounds, default 1; one fixed
112
163
  // blocker ledger entry per --review id), then reflects on it
113
164
  // exactly as above. --commit is required (exit 2). No schema change.
165
+ // memory.mjs reflect --failures <card-id> [dir] [--session <id>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
166
+ // (card memory-failure-lessons) the failure pass: reads the card's
167
+ // run history for stopped reports, blocks classed real and
168
+ // denials, prints the item count with the blocks it hid by class
169
+ // and the entries it could not read, then a cheap-row pass proposes
170
+ // pitfall lessons citing a history ref of one item; a proposal's
171
+ // `repeats` names a live pitfall and records an occurrence in
172
+ // .doug/.state/memory/pitfalls.jsonl (no new lesson, no helpful).
173
+ // Exclusive with --hand and a report path (exit 2); idempotent on
174
+ // the slice; --dry-run makes no model call and prints the prompt it would send;
175
+ // not wired into any loop (its spend cap is open)
176
+ // memory.mjs ideas [dir] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--cap <n>] [--dry-run] [--json]
177
+ // (card memory-idea-proposals, ADR 0019 L3) one cheap-row `claude
178
+ // -p --json-schema` call (--max-usd default 0.5) over the live
179
+ // lessons, run history, pitfall occurrences and the Backlog and
180
+ // Ready cards files at most --cap (default 3) idea cards to
181
+ // Backlog only, source `memory-proposed: <citations>`, and appends
182
+ // priority reviews naming Backlog cards to
183
+ // .doug/.state/memory/priority-reviews.jsonl. An item with an
184
+ // unresolved citation is dropped; a duplicate or invalid idea is
185
+ // skipped. Never touches Ready. No store or no board exits 1 with
186
+ // no call; a failed call exits 1 and writes nothing. --dry-run
187
+ // makes no model call, writes neither file and prints the prompt
188
+ // it would send and the files it would write
189
+ // memory.mjs model regen [dir] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
190
+ // (card memory-project-model, ADR 0019 L2) one cheap-row `claude
191
+ // -p --json-schema` call (--max-usd default 0.5) over the live
192
+ // lessons and the run-history index writes .doug/project-model.md:
193
+ // failure patterns, conventions and open risks, at most 2000
194
+ // characters in all. A statement with any citation that does not
195
+ // resolve in code (a live lesson id, or a run-history ref whose
196
+ // file and line exist) is dropped whole; over the cap the most
197
+ // cited statements stay. Prints one line with the counts, the
198
+ // drops and the cost. A failed call or zero surviving statements
199
+ // exits 1 and leaves an existing file untouched; no store exits 1
200
+ // with no call. --dry-run makes no model call, writes nothing and prints the prompt
201
+ // memory.mjs model show [dir]
202
+ // prints .doug/project-model.md verbatim, read-only; missing ->
203
+ // one stderr line, exit 1
114
204
  // memory.mjs doctor [dir] [--json]
115
205
  // probe the configured embedding provider (memory.embeddings in
116
206
  // .doug/config.json), or a local Ollama at localhost:11434 when
@@ -190,11 +280,11 @@
190
280
  // lesson add and import embed only the row(s) they just wrote, best-effort, when a provider is configured: a
191
281
  // provider failure prints one stderr line and never turns into a nonzero exit for that add/import. A pending
192
282
  // row from an earlier add is left alone until `reembed` (or a later add/import) picks it up.
193
- import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
283
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, appendFileSync, statSync } from "node:fs";
194
284
  import { resolve, join, dirname } from "node:path";
195
285
  import { createHash } from "node:crypto";
196
286
  import { spawnSync } from "node:child_process";
197
- import { parseAdversaryClasses, loadBoard, findCard } from "../lib/board.mjs";
287
+ import { parseAdversaryClasses, loadBoard, saveBoard, findCard } from "../lib/board.mjs";
198
288
  import { planFor, unwrapReport } from "../lib/plan.mjs";
199
289
  import { costRun } from "../lib/cost.mjs";
200
290
  import { loadModels } from "../lib/models.mjs";
@@ -211,7 +301,7 @@ import {
211
301
  } from "../lib/embeddings.mjs";
212
302
 
213
303
  const argv = process.argv.slice(2);
214
- const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check"]);
304
+ const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check", "unconfirmed"]);
215
305
  const REPEATABLE = new Set(["adversary", "review", "harmful"]);
216
306
  const opts = {};
217
307
  const positional = [];
@@ -284,7 +374,8 @@ async function bestEffortEmbed(m, dir, ids) {
284
374
  if (!ids || !ids.length) return;
285
375
  const provider = createProvider(readMemoryConfig(dir));
286
376
  if (!provider) return;
287
- if (embeddingChangeDetected(m, provider)) {
377
+ const { LIVE_LESSON_SQL } = await import("../lib/memory.mjs");
378
+ if (embeddingChangeDetected(m, provider, LIVE_LESSON_SQL)) {
288
379
  process.stderr.write(
289
380
  `Note: ${provider.model}/${provider.dims} is new to this store; leaving ${ids.length} lesson(s) pending. Run \`memory.mjs reembed\` first — it runs the cosine sanity check before this store commits to a new model.\n`
290
381
  );
@@ -350,10 +441,10 @@ function parseLastJsonObject(text) {
350
441
  // stdin, a 180s timeout, and a 64MB stdout buffer (a report's prompt plus a model's proposals can run long).
351
442
  // Throws on any failure the spec names (spawn error, timeout, unparseable stdout, is_error, a missing
352
443
  // structured_output.lessons array) so runReflect's single catch handles every case uniformly.
353
- function makePropose({ claudeBin, model, maxUsd, dir }) {
444
+ function makePropose({ claudeBin, model, maxUsd, dir, schema = null, resultKey = "lessons", extraKeys = [] }) {
354
445
  return async (prompt) => {
355
446
  const { REFLECT_SCHEMA } = await import("../lib/memory.mjs");
356
- const args = ["-p", "--output-format", "json", "--json-schema", JSON.stringify(REFLECT_SCHEMA), "--model", model, "--max-budget-usd", String(maxUsd)];
447
+ const args = ["-p", "--output-format", "json", "--json-schema", JSON.stringify(schema ?? REFLECT_SCHEMA), "--model", model, "--max-budget-usd", String(maxUsd)];
357
448
  const res = spawnSync(claudeBin, args, { input: prompt, encoding: "utf8", cwd: dir, timeout: 180000, maxBuffer: 64 * 1024 * 1024 });
358
449
  if (res.error) throw new Error(`claude spawn failed: ${res.error.message}`);
359
450
  if (res.signal) throw new Error(`claude was killed by signal ${res.signal} (likely the 180s timeout)`);
@@ -365,10 +456,11 @@ function makePropose({ claudeBin, model, maxUsd, dir }) {
365
456
  throw new Error(`claude printed no parseable JSON: ${err.message}`);
366
457
  }
367
458
  if (envelope.is_error) throw new Error(`claude reported is_error${envelope.result ? `: ${envelope.result}` : ""}`);
368
- const lessons = envelope.structured_output && envelope.structured_output.lessons;
369
- if (!Array.isArray(lessons)) throw new Error("claude's structured_output.lessons is missing");
459
+ const items = envelope.structured_output && envelope.structured_output[resultKey];
460
+ if (!Array.isArray(items)) throw new Error(`claude's structured_output.${resultKey} is missing`);
370
461
  return {
371
- lessons,
462
+ [resultKey]: items,
463
+ ...Object.fromEntries(extraKeys.map((k) => [k, Array.isArray(envelope.structured_output[k]) ? envelope.structured_output[k] : []])),
372
464
  sessionId: envelope.session_id || null,
373
465
  totalCostUsd: typeof envelope.total_cost_usd === "number" ? envelope.total_cost_usd : null,
374
466
  };
@@ -384,6 +476,10 @@ function idsList(ids) {
384
476
  return ids.length ? ids.join(", ") : "none";
385
477
  }
386
478
 
479
+ function supersededList(pairs) {
480
+ return pairs.length ? pairs.map((p) => `${p.old}->${p.new}`).join(", ") : "none";
481
+ }
482
+
387
483
  function reviewIssueCountText(count) {
388
484
  return count === null || count === undefined ? "null" : count;
389
485
  }
@@ -405,7 +501,7 @@ function outcomeLine(row) {
405
501
  `verified ${verifiedWord(row.verified)}`,
406
502
  ...(row.partial === 1 || row.partial === 0 ? [`partial ${partialWord(row.partial)}`] : []),
407
503
  `review ${reviewIssueCountText(row.review_issue_count)} issue(s)`,
408
- `adversary ${adv} (${blocksText(row.blocks)} block(s))`,
504
+ String(row.run).startsWith("stop:") ? `gate blocks ${blocksText(row.blocks)}` : `adversary ${adv} (${blocksText(row.blocks)} block(s))`,
409
505
  `passes ${row.passes ?? "null"}`,
410
506
  `tokens ${tokensText(row.tokens)}`,
411
507
  `usd ${usdText(row.usd)}`,
@@ -444,11 +540,11 @@ function sanityLine(result) {
444
540
  // embedded under provider.model/provider.dims — a model or dims change, including the very first embed ever
445
541
  // under any model. Mirrors lessonsNeedingEmbedding's WHERE clause in lib/memory.mjs so "no lesson matches"
446
542
  // means the same thing here as it does there.
447
- function embeddingChangeDetected(m, provider) {
448
- const total = m.prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").get().n;
543
+ function embeddingChangeDetected(m, provider, LIVE_LESSON_SQL) {
544
+ const total = m.prepare(`SELECT COUNT(*) AS n FROM lessons WHERE ${LIVE_LESSON_SQL}`).get().n;
449
545
  if (total === 0) return false;
450
546
  const matching = m
451
- .prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding_model = ? AND embedding_dims = ?")
547
+ .prepare(`SELECT COUNT(*) AS n FROM lessons WHERE ${LIVE_LESSON_SQL} AND embedding_model = ? AND embedding_dims = ?`)
452
548
  .get(provider.model, provider.dims).n;
453
549
  return matching === 0;
454
550
  }
@@ -515,6 +611,56 @@ try {
515
611
  const handUsage =
516
612
  'usage: memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--cost <usd>] [--json]';
517
613
  const [id, ...extra] = rest;
614
+ if (opts.stop !== undefined) {
615
+ // Card memory-hand-stop-rows: a stopped by-hand run records exactly one stop row.
616
+ const stopUsage =
617
+ 'usage: memory.mjs record <card-id> --hand --stop <reason> [dir] [--detail <text>] [--json]';
618
+ if (!id || String(opts.stop).trim() === "" || opts.commit !== undefined) fail(stopUsage, 2);
619
+ if (id.endsWith(".json") || existsSync(resolve(id))) fail(`${stopUsage}\n--hand takes a card id, not a report.`, 2);
620
+ if (opts.run !== undefined || opts.adversary !== undefined || opts["claude-dir"] !== undefined || extra.length > 1) {
621
+ fail(stopUsage, 2);
622
+ }
623
+ const dir = dirFrom(rest, 1);
624
+ const conditionFile = join(dir, ".doug", ".state", "reports", id, "condition.json");
625
+ let condition = null;
626
+ if (existsSync(conditionFile)) {
627
+ try {
628
+ condition = JSON.parse(readFileSync(conditionFile, "utf8"));
629
+ } catch (err) {
630
+ fail(`cannot read ${conditionFile}: ${err.message}`);
631
+ }
632
+ }
633
+ const reason = String(opts.stop).trim();
634
+ const detail = textOrNull(opts.detail);
635
+ const reportHash = createHash("sha256")
636
+ .update(JSON.stringify({ card: id, reason, detail, openedAt: condition?.openedAt ?? null }))
637
+ .digest("hex");
638
+ const { openMemory, recordOutcomes, stopOutcomeRow } = await import("../lib/memory.mjs");
639
+ const row = stopOutcomeRow({ card: id, report: { outcome: "stopped", reason, detail }, reportHash, blocks: [], condition });
640
+ const m = openMemory(dir);
641
+ let result;
642
+ try {
643
+ result = recordOutcomes(m, [row]);
644
+ } finally {
645
+ m.close();
646
+ }
647
+ const stamped = !!condition;
648
+ if (opts.json) {
649
+ process.stdout.write(
650
+ JSON.stringify({ run: row.run, card: id, inserted: result.inserted, updated: result.updated, stamped }, null, 2) + "\n"
651
+ );
652
+ } else {
653
+ process.stdout.write(
654
+ `Recorded a hand-track stop for ${id} (${reason}) in .doug/.state/memory/memory.db (${result.inserted} new, ${result.updated} updated).\n`
655
+ );
656
+ if (!stamped) {
657
+ process.stdout.write(
658
+ `No condition.json for ${id}: this stop is unstamped (memory.mjs condition open ${id} writes one before the brief).\n`
659
+ );
660
+ }
661
+ }
662
+ break;
663
+ }
518
664
  if (!id || !opts.commit) fail(handUsage, 2);
519
665
  if (id.endsWith(".json") || existsSync(resolve(id))) {
520
666
  fail(`${handUsage}\n--hand takes a card id, not a report.`, 2);
@@ -600,8 +746,9 @@ try {
600
746
  }
601
747
  }
602
748
  // Lazy: a Node without node:sqlite must fail loudly here, not with a stack trace, and never before this point.
603
- const { openMemory, recordOutcomes, outcomeRows } = await import("../lib/memory.mjs");
604
- const rows = outcomeRows(report, { plan, classes, run, reportHash, reportPath, card: opts.card ?? null, commit: opts.commit ?? null, cost });
749
+ const { openMemory, recordOutcomes, outcomeRows, memoryConfig } = await import("../lib/memory.mjs");
750
+ const flowConfig = memoryConfig({ embeddings: readMemoryConfig(dir).embeddings, staleDays: readMemoryStaleDays(dir), track: "flow" });
751
+ const rows = outcomeRows(report, { plan, classes, run, reportHash, reportPath, card: opts.card ?? null, commit: opts.commit ?? null, cost, memoryConfig: flowConfig });
605
752
  const m = openMemory(dir);
606
753
  let result;
607
754
  try {
@@ -622,12 +769,85 @@ try {
622
769
  case "reflect": {
623
770
  const usage =
624
771
  'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
772
+ if (opts.failures !== undefined) {
773
+ const failuresUsage =
774
+ "usage: memory.mjs reflect --failures <card-id> [dir] [--session <id>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]";
775
+ const card = textOrNull(opts.failures);
776
+ if (!card || opts.hand) fail(`${failuresUsage}\n--failures takes a card id and is exclusive with --hand and a report path.`, 2);
777
+ if (rest.length > 1) fail(failuresUsage, 2);
778
+ if (rest.length === 1) {
779
+ // A lone positional that is a file, or names a .json report, is a report path, not a dir.
780
+ let isFile = /\.json$/i.test(rest[0]);
781
+ try {
782
+ if (statSync(resolve(rest[0])).isFile()) isFile = true;
783
+ } catch {
784
+ // missing path: judged by its name above
785
+ }
786
+ if (isFile) fail(`${failuresUsage}\n--failures takes a card id and is exclusive with --hand and a report path.`, 2);
787
+ }
788
+ const fdir = dirFrom(rest, 0);
789
+ const fmodels = loadModels(fdir);
790
+ const fmodel = textOrNull(opts.model) || (fmodels.tiers.cheap && fmodels.tiers.cheap.model) || "haiku";
791
+ let fmaxUsd = 0.5;
792
+ if (opts["max-usd"] !== undefined) {
793
+ fmaxUsd = Number(opts["max-usd"]);
794
+ if (!Number.isFinite(fmaxUsd) || fmaxUsd <= 0) fail(`${failuresUsage}\n--max-usd must be a positive number, got ${JSON.stringify(opts["max-usd"])}.`, 2);
795
+ }
796
+ const fClaudeBin = textOrNull(opts["claude-bin"]) || process.env.DOUG_CLAUDE_BIN || "claude";
797
+ const fDry = !!opts["dry-run"];
798
+ const { openMemory, runFailureReflect, FAILURE_REFLECT_SCHEMA } = await import("../lib/memory.mjs");
799
+ const m = openMemory(fdir);
800
+ let fres;
801
+ try {
802
+ fres = await runFailureReflect(m, {
803
+ dir: fdir,
804
+ card,
805
+ session: textOrNull(opts.session),
806
+ model: fmodel,
807
+ claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined,
808
+ dryRun: fDry,
809
+ propose: fDry ? null : makePropose({ claudeBin: fClaudeBin, model: fmodel, maxUsd: fmaxUsd, dir: fdir, schema: FAILURE_REFLECT_SCHEMA }),
810
+ });
811
+ if (!fDry && fres.ok && fres.appended && fres.appended.length) await bestEffortEmbed(m, fdir, fres.appended);
812
+ } finally {
813
+ m.close();
814
+ }
815
+ if (fres.alreadyReflected) {
816
+ if (opts.json) process.stdout.write(JSON.stringify(fres.row, null, 2) + "\n");
817
+ else process.stdout.write(`Already reflected on ${fres.row.recorded} for the failures of ${card}; nothing to do.\n`);
818
+ break;
819
+ }
820
+ if (fres.ok === false) {
821
+ process.stderr.write(`reflect: the failure pass failed for ${card}: ${fres.reason}\n`);
822
+ if (opts.json) process.stdout.write(JSON.stringify(fres, null, 2) + "\n");
823
+ process.exit(1);
824
+ }
825
+ if (opts.json) {
826
+ process.stdout.write(JSON.stringify(fres, null, 2) + "\n");
827
+ break;
828
+ }
829
+ const hiddenText = Object.entries(fres.hidden).map(([k, n]) => `${k} ${n}`).join(", ") || "none";
830
+ const prefix = fDry ? "(dry run) " : "";
831
+ process.stdout.write(`${prefix}Failures of ${card}: ${fres.items} item(s); hidden blocks: ${hiddenText}; unreadable: ${fres.unreadable}\n`);
832
+ if (fres.items === 0) break;
833
+ if (fDry && fres.proposed === null) {
834
+ process.stdout.write(`${prefix}no model call; the prompt that would be sent:\n${fres.prompt}\n`);
835
+ process.stdout.write(`${prefix}Would append pitfall lessons to the memory store, .doug/.state/memory/pitfalls.jsonl and a reflections row; ${costLine(fres.usd, fres.usdSource)}\n`);
836
+ break;
837
+ }
838
+ process.stdout.write(
839
+ `${prefix}Reflected: proposed ${fres.proposed}, ${fDry ? "would append" : "appended"} ${fres.appended.length} (${idsList(fres.appended)}), repeated ${fres.repeated.length} (${idsList(fres.repeated)}), rejected ${fres.rejected.length}; model ${fres.model}; ${costLine(fres.usd, fres.usdSource)}\n`
840
+ );
841
+ for (const r of fres.rejected) process.stdout.write(` rejected ${r.text ? JSON.stringify(r.text) : "(no text)"}: ${r.reason}\n`);
842
+ break;
843
+ }
625
844
  let dir;
626
845
  let reportPath;
627
846
  let reportText;
628
847
  let report;
629
848
  let classes;
630
849
  let handCard = null;
850
+ let rounds = 1;
631
851
  if (opts.hand) {
632
852
  const handUsage =
633
853
  'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "<R<n> or <card-id>/R<n>>=<real|marginal|false>[: <reason>]"]... [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
@@ -635,7 +855,6 @@ try {
635
855
  if (!id || !opts.commit) fail(handUsage, 2);
636
856
  handCard = id;
637
857
  dir = dirFrom(rest, 1);
638
- let rounds = 1;
639
858
  if (opts.rounds !== undefined) {
640
859
  rounds = Number(opts.rounds);
641
860
  if (!Number.isInteger(rounds) || rounds < 1) fail(`${handUsage}\n--rounds must be a positive integer, got ${JSON.stringify(opts.rounds)}.`, 2);
@@ -678,6 +897,16 @@ try {
678
897
  } catch {
679
898
  memoryUsed = [];
680
899
  }
900
+ const fetched = new Set();
901
+ try {
902
+ for (const line of readFileSync(join(base, "fetched.jsonl"), "utf8").split("\n")) {
903
+ try {
904
+ const rec = JSON.parse(line);
905
+ if (rec && typeof rec.id === "string") fetched.add(rec.id);
906
+ } catch {}
907
+ }
908
+ } catch {}
909
+ memoryUsed = [...new Set(memoryUsed.filter((x) => fetched.has(x)))];
681
910
  const ledger = classes.map((c) => {
682
911
  return { id: c.id, stage: "adversary", seat: "reviewer", severity: "blocker", status: "fixed", description: c.reason || `reviewer finding ${c.id}` };
683
912
  });
@@ -695,6 +924,23 @@ try {
695
924
  mkdirSync(base, { recursive: true });
696
925
  writeFileSync(reportPath, reportText, "utf8");
697
926
  report = handReport;
927
+ if (!opts["dry-run"]) {
928
+ try {
929
+ const { historyScanner, writeRunHistory } = await import("../lib/memory.mjs");
930
+ const entries = [{ kind: "report", source: "hand-report.json", value: handReport }];
931
+ for (const f of readDirFiles("briefs")) {
932
+ const mm = /^(\d+)-(.+)\.md$/.exec(f.name);
933
+ entries.push({ kind: "brief", seat: mm ? mm[2] : null, round: mm ? Number(mm[1]) : null, source: `briefs/${f.name}`, value: f.text });
934
+ }
935
+ for (const f of readDirFiles("reviews")) {
936
+ const mm = /^(\d+)\.md$/.exec(f.name);
937
+ entries.push({ kind: "review", seat: "reviewer", round: mm ? Number(mm[1]) : null, source: `reviews/${f.name}`, value: f.text });
938
+ }
939
+ writeRunHistory(dir, { card: id, run: `hand:${opts.commit}`, entries, scan: await historyScanner(dir) });
940
+ } catch (err) {
941
+ process.stderr.write(`reflect: warning: cannot write the run history for ${id}: ${err.message || err}\n`);
942
+ }
943
+ }
698
944
  } else {
699
945
  const [reportFile] = rest;
700
946
  if (!reportFile) fail(usage, 2);
@@ -728,7 +974,7 @@ try {
728
974
  const runLabel = opts.run || `report:${reportHash.slice(0, 12)}`;
729
975
 
730
976
  const { openMemory, runReflect } = await import("../lib/memory.mjs");
731
- const propose = makePropose({ claudeBin, model, maxUsd, dir });
977
+ const propose = dryRun ? null : makePropose({ claudeBin, model, maxUsd, dir });
732
978
  const m = openMemory(dir);
733
979
  let result;
734
980
  try {
@@ -749,6 +995,10 @@ try {
749
995
  if (!dryRun && result.ok && result.appended && result.appended.length) {
750
996
  await bestEffortEmbed(m, dir, result.appended);
751
997
  }
998
+ // Card memory-outcome-arms: only an explicit --rounds is a measurement; the default of 1 is never written.
999
+ if (handCard && !dryRun && opts.rounds !== undefined && (result.ok || result.alreadyReflected)) {
1000
+ m.prepare("UPDATE outcomes SET passes = ?, fix_passes = ? WHERE run = ? AND task = ?").run(rounds, rounds - 1, `hand:${opts.commit}`, handCard);
1001
+ }
752
1002
  } finally {
753
1003
  m.close();
754
1004
  }
@@ -773,11 +1023,20 @@ try {
773
1023
  } else {
774
1024
  const c = result.counters;
775
1025
  const prefix = result.dryRun ? "(dry run) " : "";
1026
+ if (result.dryRun && result.proposed === null) {
1027
+ process.stdout.write(
1028
+ `${prefix}Reflected on ${runLabel}: counters helpful +${c.helpful.length} (${idsList(c.helpful)}) harmful +${c.harmful.length} (${idsList(c.harmful)}); ` +
1029
+ `cheap-row pass not run (dry run, no model call); ${costLine(result.usd, result.usdSource)}\n`
1030
+ );
1031
+ process.stdout.write(`${prefix}the prompt that would be sent:\n${result.prompt}\n`);
1032
+ break;
1033
+ }
776
1034
  process.stdout.write(
777
1035
  `${prefix}Reflected on ${runLabel}: counters helpful +${c.helpful.length} (${idsList(c.helpful)}) harmful +${c.harmful.length} (${idsList(c.harmful)}); ` +
778
1036
  `proposed ${result.proposed}, appended ${result.appended.length} (${idsList(result.appended)}), bumped ${result.bumped.length} (${idsList(result.bumped)}), ` +
779
- `rejected ${result.rejected.length}; model ${result.model}; ${costLine(result.usd, result.usdSource)}\n`
1037
+ `superseded ${result.superseded.length} (${supersededList(result.superseded)}), rejected ${result.rejected.length}; model ${result.model}; ${costLine(result.usd, result.usdSource)}\n`
780
1038
  );
1039
+ for (const n of result.notes || []) process.stdout.write(` note: ${n}\n`);
781
1040
  for (const r of result.rejected) {
782
1041
  process.stdout.write(` rejected ${r.text ? JSON.stringify(r.text) : "(no text)"}: ${r.reason}\n`);
783
1042
  }
@@ -811,8 +1070,70 @@ try {
811
1070
  }
812
1071
  break;
813
1072
  }
1073
+ case "arms": {
1074
+ const armsUsage = "usage: memory.mjs arms [dir] [--min <n>] [--json]";
1075
+ let min = 12;
1076
+ if (opts.min !== undefined) {
1077
+ min = /^\d+$/.test(String(opts.min)) ? Number(opts.min) : NaN;
1078
+ if (!Number.isInteger(min) || min < 1) fail(`${armsUsage}\n--min must be an integer >= 1, got ${JSON.stringify(opts.min)}.`, 2);
1079
+ }
1080
+ const dir = dirFrom(rest, 0);
1081
+ const { memoryPath, openMemory, compareMemoryArms } = await import("../lib/memory.mjs");
1082
+ if (!existsSync(memoryPath(dir))) {
1083
+ process.stdout.write("No outcomes recorded.\n");
1084
+ break;
1085
+ }
1086
+ const m = openMemory(dir);
1087
+ let res;
1088
+ try {
1089
+ res = compareMemoryArms(m, { min });
1090
+ } finally {
1091
+ m.close();
1092
+ }
1093
+ if (opts.json) {
1094
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
1095
+ break;
1096
+ }
1097
+ const num = (v, d = 2) => (typeof v === "number" ? v.toFixed(d) : "n/a");
1098
+ const label = (cfg) => {
1099
+ try {
1100
+ const c = JSON.parse(cfg);
1101
+ const e = c.embeddings ? `${c.embeddings.provider}/${c.embeddings.model}/${c.embeddings.dims}` : "keyword-only";
1102
+ return `${c.stage} ${e} staleDays ${c.staleDays}`;
1103
+ } catch {
1104
+ return cfg;
1105
+ }
1106
+ };
1107
+ const lines = res.arms.map((a) => {
1108
+ const head = `${label(a.config)}: ${a.landings} landings, ${a.stops} stops`;
1109
+ if (!a.ready) return `${head}; not compared: ${a.landings} of ${res.min} landings`;
1110
+ const fix = a.fixRounds.n ? `${num(a.fixRounds.mean)} (n ${a.fixRounds.n})` : "n/a (none measured)";
1111
+ return `${head}; land rate ${num(a.landRate)}; fix rounds ${fix}; harmful per landing ${num(a.harmfulPerLanding)}; cost per landing ${typeof a.costPerLanding === "number" ? `$${a.costPerLanding.toFixed(2)}` : "n/a"} (${a.priced} priced)`;
1112
+ });
1113
+ if (res.unstamped) lines.push(`${res.unstamped} rows carry no memory configuration and are in no arm.`);
1114
+ lines.push(res.comparable ? "Two or more arms are comparable." : "Not comparable yet: fewer than two arms have enough landings.");
1115
+ process.stdout.write(lines.join("\n") + "\n");
1116
+ break;
1117
+ }
814
1118
  case "lessons": {
815
1119
  const dir = dirFrom(rest, 0);
1120
+ if (opts.unconfirmed) {
1121
+ if (opts.search !== undefined || opts.all) fail("usage: memory.mjs lessons --unconfirmed [dir] [--json] (not with --search or --all)", 2);
1122
+ let list = [];
1123
+ if (existsSync(join(dir, ".doug", ".state", "memory", "memory.db"))) {
1124
+ const { openMemory, listUnconfirmedLessons, readFetchedLessonIds } = await import("../lib/memory.mjs");
1125
+ const m = openMemory(dir);
1126
+ try {
1127
+ list = listUnconfirmedLessons(m, { fetchedIds: readFetchedLessonIds(dir) });
1128
+ } finally {
1129
+ m.close();
1130
+ }
1131
+ }
1132
+ if (opts.json) process.stdout.write(JSON.stringify(list, null, 2) + "\n");
1133
+ else if (!list.length) process.stdout.write("no unconfirmed lessons\n");
1134
+ else process.stdout.write(list.map((r) => `${r.id} ${r.kind} ${r.created} ${r.title}`).join("\n") + "\n");
1135
+ break;
1136
+ }
816
1137
  const { openMemory, listLessons, searchLessons } = await import("../lib/memory.mjs");
817
1138
  const m = openMemory(dir);
818
1139
  let rows;
@@ -839,7 +1160,9 @@ try {
839
1160
  "usage: memory.mjs lesson add --text <text> --kind <feedback|project|pitfall|pattern> [dir]\n" +
840
1161
  " [--scope <glob,glob>] [--citation <text>] [--card <id>]\n" +
841
1162
  " [--commit <sha>] [--source-agent <name>] [--source-model <name>] [--json]\n" +
842
- " memory.mjs lesson unstale <id> [dir] [--json]";
1163
+ " memory.mjs lesson unstale <id> [dir] [--json]\n" +
1164
+ " memory.mjs lesson unarchive <id> [dir] [--json]\n" +
1165
+ " memory.mjs lesson show <id> [dir] [--card <card-id>] [--json]";
843
1166
  const [sub, ...subrest] = rest;
844
1167
  if (sub === "unstale") {
845
1168
  const [id] = subrest;
@@ -860,6 +1183,57 @@ try {
860
1183
  }
861
1184
  break;
862
1185
  }
1186
+ if (sub === "unarchive") {
1187
+ const [id] = subrest;
1188
+ if (!id) fail(usage, 2);
1189
+ const dir = dirFrom(subrest, 1);
1190
+ const { openMemory, unarchiveLesson } = await import("../lib/memory.mjs");
1191
+ const m = openMemory(dir);
1192
+ let row;
1193
+ try {
1194
+ row = unarchiveLesson(m, id);
1195
+ } finally {
1196
+ m.close();
1197
+ }
1198
+ if (opts.json) {
1199
+ process.stdout.write(JSON.stringify(row, null, 2) + "\n");
1200
+ } else {
1201
+ process.stdout.write(`Restored archived lesson ${row.id}.\n`);
1202
+ }
1203
+ break;
1204
+ }
1205
+ if (sub === "show") {
1206
+ const [id] = subrest;
1207
+ if (!id) fail(usage, 2);
1208
+ if (opts.card !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(opts.card))) fail(usage, 2);
1209
+ const dir = dirFrom(subrest, 1);
1210
+ if (!existsSync(join(dir, ".doug", ".state", "memory", "memory.db"))) fail(`no lesson ${id}: no memory store`);
1211
+ const { openMemory, getLesson, lessonTokens } = await import("../lib/memory.mjs");
1212
+ const m = openMemory(dir);
1213
+ let row;
1214
+ try {
1215
+ row = getLesson(m, id);
1216
+ } finally {
1217
+ m.close();
1218
+ }
1219
+ if (!row) fail(`no lesson ${id}`);
1220
+ const tokens = lessonTokens(row.text);
1221
+ if (opts.json) {
1222
+ const { embedding, ...rest2 } = row;
1223
+ process.stdout.write(JSON.stringify({ ...rest2, hasVector: embedding != null, tokens }, null, 2) + "\n");
1224
+ } else {
1225
+ const head = [`lesson ${row.id} (${row.kind}, ~${tokens} tokens)`, `citation: ${row.citation || "none"}`];
1226
+ if (row.superseded_by) head.push(`superseded by ${row.superseded_by}`);
1227
+ if (row.stale) head.push(`stale since ${row.stale}`);
1228
+ process.stdout.write(head.join("\n") + "\n\n" + row.text + "\n");
1229
+ }
1230
+ if (opts.card !== undefined) {
1231
+ const fdir = join(dir, ".doug", ".state", "reports", String(opts.card));
1232
+ mkdirSync(fdir, { recursive: true });
1233
+ appendFileSync(join(fdir, "fetched.jsonl"), JSON.stringify({ id: row.id, at: new Date().toISOString() }) + "\n");
1234
+ }
1235
+ break;
1236
+ }
863
1237
  if (sub !== "add") fail(usage, 2);
864
1238
  const dir = dirFrom(subrest, 0);
865
1239
  const sourceAgent = String(opts["source-agent"] !== undefined ? opts["source-agent"] : "lead").trim();
@@ -993,21 +1367,22 @@ try {
993
1367
  }
994
1368
  const query = `${card.title}\n${card.goal ?? ""}`;
995
1369
  const header = "Lessons from earlier runs on this repository (memory; each cites where it was learned):";
996
- const saved = { card: id, query, mode: null, reason: null, ids: [], text: null, recalledAt: new Date().toISOString(), hidden: null };
1370
+ const saved = { card: id, query, mode: null, reason: null, ids: [], text: null, omitted: [], recalledAt: new Date().toISOString(), hidden: null };
997
1371
  let note = null;
998
1372
  let error = null;
999
1373
  if (existsSync(join(dir, ".doug", ".state", "memory", "memory.db"))) {
1000
1374
  try {
1001
1375
  const provider = createProvider(readMemoryConfig(dir));
1002
1376
  const staleDays = readMemoryStaleDays(dir);
1003
- const { openMemory, recallLessons, degradedWarning, degradedReason, detectLocalProvider, renderLessons, windowNote } = await import("../lib/memory.mjs");
1377
+ const { openMemory, recallLessons, degradedWarning, degradedReason, detectLocalProvider, renderLessonIndex, windowNote } = await import("../lib/memory.mjs");
1004
1378
  const detectedProvider = provider ? null : await detectLocalProvider();
1005
1379
  const m = openMemory(dir);
1006
1380
  try {
1007
1381
  const result = await recallLessons(m, query, { k, checkoutDir: dir, provider, staleDays, detectedProvider });
1008
1382
  const warning = degradedWarning(result, { what: "memory recall", detectedProvider });
1009
1383
  if (warning) process.stderr.write(warning + "\n");
1010
- const rendered = renderLessons(result.lessons);
1384
+ const rendered = renderLessonIndex(result.lessons);
1385
+ saved.omitted = rendered.omitted;
1011
1386
  saved.mode = result.mode;
1012
1387
  saved.hidden = result.hidden ?? null;
1013
1388
  note = windowNote(result.hidden, staleDays);
@@ -1023,6 +1398,7 @@ try {
1023
1398
  saved.reason = null;
1024
1399
  saved.ids = [];
1025
1400
  saved.text = null;
1401
+ saved.omitted = [];
1026
1402
  saved.hidden = null;
1027
1403
  note = null;
1028
1404
  saved.error = error;
@@ -1035,12 +1411,257 @@ try {
1035
1411
  if (opts.json) {
1036
1412
  process.stdout.write(JSON.stringify(saved, null, 2) + "\n");
1037
1413
  } else if (saved.ids.length) {
1038
- process.stdout.write([header, saved.text, ...(note ? [note] : []), `memoryUsed: ${saved.ids.join(", ")}`].join("\n") + "\n");
1414
+ const omittedLine = saved.omitted.length
1415
+ ? [`index: omitted ${saved.omitted.length} lessons over the 2000-character index budget: ${saved.omitted.join(", ")}`]
1416
+ : [];
1417
+ process.stdout.write([header, saved.text, ...omittedLine, ...(note ? [note] : []), `memoryUsed: ${saved.ids.join(", ")}`].join("\n") + "\n");
1039
1418
  } else {
1040
1419
  process.stdout.write(`No lessons recalled for ${id}.\n${note ? note + "\n" : ""}`);
1041
1420
  }
1042
1421
  break;
1043
1422
  }
1423
+ case "history": {
1424
+ const usage = "usage: memory.mjs history show <card>[/<run>[:<line>]] [dir] [--json]";
1425
+ const [sub, ref, ...extra] = rest;
1426
+ if (sub !== "show" || !ref) fail(usage, 2);
1427
+ const dir = dirFrom(extra, 0);
1428
+ const { parseHistoryRef, readRunHistory } = await import("../lib/memory.mjs");
1429
+ let parsed;
1430
+ try {
1431
+ parsed = parseHistoryRef(ref);
1432
+ } catch (err) {
1433
+ fail(`${err.message}\n${usage}`, 2);
1434
+ }
1435
+ let data;
1436
+ try {
1437
+ data = readRunHistory(dir, parsed);
1438
+ } catch (err) {
1439
+ fail(err.message);
1440
+ }
1441
+ if (opts.json) {
1442
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
1443
+ } else if (parsed.line !== null) {
1444
+ const e = data;
1445
+ const out = [`history ${parsed.card}/${parsed.run}:${parsed.line} (${e.kind}, seat ${e.seat ?? "-"}, round ${e.round ?? "-"}, ${e.source}, ~${e.tokens} tokens)`];
1446
+ if (e.truncated) out.push(`truncated at capture: ${e.bytes} bytes, head kept`);
1447
+ if (e.redacted) out.push(`redacted: ${e.redacted}`);
1448
+ process.stdout.write(out.join("\n") + "\n\n" + (e.text ?? "") + "\n");
1449
+ } else if (parsed.run !== null) {
1450
+ const out = [`history ${data.card}/${data.run}: ${data.entries.length} entries, ~${data.tokens} tokens`];
1451
+ for (const e of data.entries) {
1452
+ if (e.kind === "cap") {
1453
+ out.push(`${e.line} cap dropped ${e.dropped} entries over ${e.maxRunBytes} bytes`);
1454
+ continue;
1455
+ }
1456
+ out.push(
1457
+ `${e.line} ${e.kind} ${e.seat ?? "-"} ${e.round ?? "-"} ${e.source} ~${e.tokens} tokens` +
1458
+ (e.truncated ? ` truncated from ${e.bytes} bytes` : "") +
1459
+ (e.redacted ? ` redacted: ${e.redacted}` : "")
1460
+ );
1461
+ }
1462
+ process.stdout.write(out.join("\n") + "\n");
1463
+ } else {
1464
+ const out = [`history ${data.card}: ${data.runs.length} runs`];
1465
+ for (const r of data.runs) out.push(`${data.card}/${r.run} ${r.entries} entries ~${r.tokens} tokens`);
1466
+ process.stdout.write(out.join("\n") + "\n");
1467
+ }
1468
+ break;
1469
+ }
1470
+ case "ideas": {
1471
+ const usage = "usage: memory.mjs ideas [dir] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--cap <n>] [--dry-run] [--json]";
1472
+ const dir = dirFrom(rest, 0);
1473
+ const lib = await import("../lib/memory.mjs");
1474
+ const models = loadModels(dir);
1475
+ const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
1476
+ let maxUsd = 0.5;
1477
+ if (opts["max-usd"] !== undefined) {
1478
+ maxUsd = Number(opts["max-usd"]);
1479
+ if (!Number.isFinite(maxUsd) || maxUsd <= 0) fail(`${usage}\n--max-usd must be a positive number, got ${JSON.stringify(opts["max-usd"])}.`, 2);
1480
+ }
1481
+ let cap = lib.IDEA_CAP;
1482
+ if (opts.cap !== undefined) {
1483
+ cap = Number(opts.cap);
1484
+ if (!Number.isInteger(cap) || cap < 1) fail(`${usage}\n--cap must be an integer of at least 1, got ${JSON.stringify(opts.cap)}.`, 2);
1485
+ }
1486
+ const claudeBin = textOrNull(opts["claude-bin"]) || process.env.DOUG_CLAUDE_BIN || "claude";
1487
+ const dryRun = !!opts["dry-run"];
1488
+ if (!existsSync(lib.memoryPath(dir))) fail("no memory store: nothing to propose ideas from");
1489
+ let board;
1490
+ try {
1491
+ board = loadBoard(dir);
1492
+ } catch (err) {
1493
+ fail(`ideas: ${err.message}`);
1494
+ }
1495
+ const m = lib.openMemory(dir);
1496
+ let call;
1497
+ let ideaCheck;
1498
+ let reviewCheck;
1499
+ let dryPrompt = null;
1500
+ try {
1501
+ const prompt = lib.buildIdeasPrompt({
1502
+ lessons: lib.listLessons(m),
1503
+ runs: lib.listRunHistoryRefs(dir),
1504
+ pitfalls: lib.readPitfallOccurrences(dir),
1505
+ cards: board.cards.filter((c) => c.column === "backlog" || c.column === "ready"),
1506
+ });
1507
+ if (dryRun) {
1508
+ dryPrompt = prompt;
1509
+ } else {
1510
+ try {
1511
+ call = await makePropose({ claudeBin, model, maxUsd, dir, schema: lib.IDEAS_SCHEMA, resultKey: "ideas", extraKeys: ["reviews"] })(prompt);
1512
+ } catch (err) {
1513
+ fail(`ideas: the model call failed: ${err.message}`);
1514
+ }
1515
+ ideaCheck = lib.checkIdeaCitations(m, dir, call.ideas);
1516
+ reviewCheck = lib.checkIdeaCitations(m, dir, call.reviews);
1517
+ }
1518
+ } finally {
1519
+ m.close();
1520
+ }
1521
+ if (dryRun) {
1522
+ const writes = [".doug/board.json", lib.PRIORITY_REVIEWS_RELPATH];
1523
+ if (opts.json) {
1524
+ process.stdout.write(JSON.stringify({ dryRun: true, prompt: dryPrompt, wouldWrite: writes, usd: 0, usdSource: "none" }, null, 2) + "\n");
1525
+ } else {
1526
+ process.stdout.write(`(dry run) no model call; the prompt that would be sent:\n${dryPrompt}\n`);
1527
+ for (const w of writes) process.stdout.write(`Would write ${w}\n`);
1528
+ process.stdout.write(`(dry run) Would file idea cards and reviews from the model's proposals; ${costLine(0, "none")}\n`);
1529
+ }
1530
+ break;
1531
+ }
1532
+ const cost = lib.computeReflectCost({ dir, sessionId: call.sessionId, totalCostUsd: call.totalCostUsd });
1533
+ const date = new Date().toISOString().slice(0, 10);
1534
+ try {
1535
+ board = loadBoard(dir);
1536
+ } catch (err) {
1537
+ fail(`ideas: ${err.message}`);
1538
+ }
1539
+ const filed = lib.fileIdeaCards(board, ideaCheck.kept, { cap, date });
1540
+ const reviewsChecked = lib.checkPriorityReviews(board, reviewCheck.kept, { cap, recorded: lib.readPriorityReviews(dir) });
1541
+ const at = new Date().toISOString();
1542
+ const reviewLines = reviewsChecked.kept.map((r) => ({ at, text: r.text, cards: r.cards, citations: r.citations }));
1543
+ const droppedReviews = [...reviewCheck.dropped, ...reviewsChecked.dropped];
1544
+ if (!dryRun) {
1545
+ if (filed.filed.length) saveBoard(dir, filed.board);
1546
+ if (reviewLines.length) {
1547
+ const file = join(dir, lib.PRIORITY_REVIEWS_RELPATH);
1548
+ mkdirSync(dirname(file), { recursive: true });
1549
+ appendFileSync(file, reviewLines.map((l) => JSON.stringify(l)).join("\n") + "\n", "utf8");
1550
+ }
1551
+ }
1552
+ const filedCards = filed.filed.map((id) => {
1553
+ const c = filed.board.cards.find((x) => x.id === id);
1554
+ return { id, title: c.title };
1555
+ });
1556
+ if (opts.json) {
1557
+ process.stdout.write(
1558
+ JSON.stringify(
1559
+ { filed: filedCards, skipped: filed.skipped, reviews: reviewLines, droppedReviews, unresolved: ideaCheck.dropped, usd: cost.usd, usdSource: cost.usdSource, dryRun },
1560
+ null,
1561
+ 2
1562
+ ) + "\n"
1563
+ );
1564
+ break;
1565
+ }
1566
+ const out = [];
1567
+ for (const c of filedCards) out.push(`${dryRun ? "would file" : "filed"} ${c.id}: ${c.title}`);
1568
+ for (const s2 of filed.skipped) out.push(`skipped ${s2.id}: ${s2.reason}`);
1569
+ for (const u of ideaCheck.dropped) out.push(`skipped ${u.id}: unresolved citation`);
1570
+ for (const r of reviewLines) out.push(`${dryRun ? "would record" : "recorded"} review: ${r.text} (${r.cards.join(", ")})`);
1571
+ out.push(
1572
+ `${dryRun ? "Would file" : "Filed"} ${filedCards.length} idea cards to Backlog, ${filed.skipped.length + ideaCheck.dropped.length} skipped, ${reviewLines.length} reviews kept, ${droppedReviews.length} dropped; ${costLine(cost.usd, cost.usdSource)}`
1573
+ );
1574
+ process.stdout.write(out.join("\n") + "\n");
1575
+ break;
1576
+ }
1577
+ case "model": {
1578
+ const usage = "usage: memory.mjs model regen [dir] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json] | memory.mjs model show [dir]";
1579
+ const [sub, ...subrest] = rest;
1580
+ if (sub !== "regen" && sub !== "show") fail(usage, 2);
1581
+ const dir = dirFrom(subrest, 0);
1582
+ const lib = await import("../lib/memory.mjs");
1583
+ const modelPath = join(dir, lib.PROJECT_MODEL_RELPATH);
1584
+ if (sub === "show") {
1585
+ if (!existsSync(modelPath)) fail(`no project model at ${lib.PROJECT_MODEL_RELPATH}: run \`memory.mjs model regen\``);
1586
+ process.stdout.write(readFileSync(modelPath, "utf8"));
1587
+ break;
1588
+ }
1589
+ const models = loadModels(dir);
1590
+ const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
1591
+ let maxUsd = 0.5;
1592
+ if (opts["max-usd"] !== undefined) {
1593
+ maxUsd = Number(opts["max-usd"]);
1594
+ if (!Number.isFinite(maxUsd) || maxUsd <= 0) fail(`${usage}\n--max-usd must be a positive number, got ${JSON.stringify(opts["max-usd"])}.`, 2);
1595
+ }
1596
+ const claudeBin = textOrNull(opts["claude-bin"]) || process.env.DOUG_CLAUDE_BIN || "claude";
1597
+ const dryRun = !!opts["dry-run"];
1598
+ if (!existsSync(lib.memoryPath(dir))) fail("no memory store: nothing to build a project model from");
1599
+ const m = lib.openMemory(dir);
1600
+ let call;
1601
+ let checked;
1602
+ let dryPrompt = null;
1603
+ try {
1604
+ const lessons = lib.listLessons(m);
1605
+ const prompt = lib.buildProjectModelPrompt({ lessons, runs: lib.listRunHistoryRefs(dir) });
1606
+ if (dryRun) {
1607
+ dryPrompt = prompt;
1608
+ } else {
1609
+ try {
1610
+ call = await makePropose({ claudeBin, model, maxUsd, dir, schema: lib.PROJECT_MODEL_SCHEMA, resultKey: "statements" })(prompt);
1611
+ } catch (err) {
1612
+ fail(`model regen: the model call failed: ${err.message}`);
1613
+ }
1614
+ checked = lib.checkProjectModelCitations(m, dir, call.statements);
1615
+ }
1616
+ } finally {
1617
+ m.close();
1618
+ }
1619
+ if (dryRun) {
1620
+ if (opts.json) {
1621
+ process.stdout.write(JSON.stringify({ path: modelPath, dryRun: true, prompt: dryPrompt, usd: 0, usdSource: "none" }, null, 2) + "\n");
1622
+ } else {
1623
+ process.stdout.write(`(dry run) no model call; the prompt that would be sent:\n${dryPrompt}\n`);
1624
+ process.stdout.write(`Would write ${lib.PROJECT_MODEL_RELPATH} from the model's statements; ${costLine(0, "none")}\n`);
1625
+ }
1626
+ break;
1627
+ }
1628
+ const cost = lib.computeReflectCost({ dir, sessionId: call.sessionId, totalCostUsd: call.totalCostUsd });
1629
+ const rendered = lib.renderProjectModel(checked.kept);
1630
+ const counts = `dropped ${checked.dropped.length} unresolved, ${rendered.overCap.length} over cap`;
1631
+ if (!rendered.kept.length) {
1632
+ fail(`model regen: nothing was written: no statement survived (${call.statements.length} proposed; ${counts}); ${costLine(cost.usd, cost.usdSource)}`);
1633
+ }
1634
+ if (!dryRun) {
1635
+ mkdirSync(dirname(modelPath), { recursive: true });
1636
+ writeFileSync(modelPath, rendered.text, "utf8");
1637
+ }
1638
+ if (opts.json) {
1639
+ process.stdout.write(
1640
+ JSON.stringify(
1641
+ {
1642
+ path: modelPath,
1643
+ statements: rendered.kept.length,
1644
+ chars: rendered.chars,
1645
+ cap: lib.PROJECT_MODEL_CHAR_CAP,
1646
+ unresolved: checked.dropped.length,
1647
+ overCap: rendered.overCap.length,
1648
+ dropped: checked.dropped,
1649
+ usd: cost.usd,
1650
+ usdSource: cost.usdSource,
1651
+ dryRun,
1652
+ },
1653
+ null,
1654
+ 2
1655
+ ) + "\n"
1656
+ );
1657
+ break;
1658
+ }
1659
+ if (dryRun) process.stdout.write(rendered.text + "\n");
1660
+ process.stdout.write(
1661
+ `${dryRun ? "Would write" : "Wrote"} ${lib.PROJECT_MODEL_RELPATH}: ${rendered.kept.length} statements, ${rendered.chars} characters (cap ${lib.PROJECT_MODEL_CHAR_CAP}); ${counts}; ${costLine(cost.usd, cost.usdSource)}\n`
1662
+ );
1663
+ break;
1664
+ }
1044
1665
  case "doctor": {
1045
1666
  const dir = dirFrom(rest, 0);
1046
1667
  const cfg = readMemoryConfig(dir);
@@ -1097,7 +1718,7 @@ try {
1097
1718
  if (opts.check && opts["no-check"]) {
1098
1719
  fail(`${usage}\n--check and --no-check are mutually exclusive.`, 2);
1099
1720
  }
1100
- const { openMemory, embedLessons, lessonsNeedingEmbedding } = await import("../lib/memory.mjs");
1721
+ const { openMemory, embedLessons, lessonsNeedingEmbedding, LIVE_LESSON_SQL } = await import("../lib/memory.mjs");
1101
1722
  const m = openMemory(dir);
1102
1723
  let result;
1103
1724
  let checkStatus;
@@ -1107,7 +1728,7 @@ try {
1107
1728
  if (opts["no-check"]) {
1108
1729
  process.stderr.write("embed-check: skipped (--no-check)\n");
1109
1730
  checkStatus = "skipped";
1110
- } else if (opts.check || embeddingChangeDetected(m, provider)) {
1731
+ } else if (opts.check || embeddingChangeDetected(m, provider, LIVE_LESSON_SQL)) {
1111
1732
  const pairs = loadPairsOrFail(opts);
1112
1733
  const check = await checkEmbeddingSanity(provider, pairs);
1113
1734
  if (!check.ok) {
@@ -1125,7 +1746,7 @@ try {
1125
1746
  onProgress: ({ embedded, total }) => process.stderr.write(`embedded ${embedded}/${total}\n`),
1126
1747
  });
1127
1748
  stillMissing = lessonsNeedingEmbedding(m, { model: provider.model, dims: provider.dims }).length;
1128
- liveTotal = m.prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").get().n;
1749
+ liveTotal = m.prepare(`SELECT COUNT(*) AS n FROM lessons WHERE ${LIVE_LESSON_SQL}`).get().n;
1129
1750
  } finally {
1130
1751
  m.close();
1131
1752
  }
@@ -1366,7 +1987,7 @@ try {
1366
1987
  if (headRes.status !== 0 || !headRes.stdout.trim()) {
1367
1988
  fail(`cannot open condition for "${id}": no git HEAD in ${dir}${headRes.stderr && headRes.stderr.trim() ? ` (${headRes.stderr.trim()})` : " (not a git repository, or no commits yet)"}.`);
1368
1989
  }
1369
- const { conditionConfigHash } = await import("../lib/memory.mjs");
1990
+ const { conditionConfigHash, memoryConfig } = await import("../lib/memory.mjs");
1370
1991
  const written = {
1371
1992
  harnessCommit: headRes.stdout.trim(),
1372
1993
  configHash: conditionConfigHash(dir),
@@ -1375,6 +1996,7 @@ try {
1375
1996
  assignedBy: "policy",
1376
1997
  exploreProbability: null,
1377
1998
  openedAt: new Date().toISOString(),
1999
+ memoryConfig: memoryConfig({ embeddings: readMemoryConfig(dir).embeddings, staleDays: readMemoryStaleDays(dir), track: "hand" }),
1378
2000
  };
1379
2001
  mkdirSync(dirname(file), { recursive: true });
1380
2002
  writeFileSync(file, JSON.stringify(written, null, 2) + "\n");
@@ -1458,7 +2080,7 @@ try {
1458
2080
  break;
1459
2081
  }
1460
2082
  default:
1461
- fail("usage: memory.mjs <record|reflect|outcomes|lessons|lesson|import|recall|brief-lessons|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
2083
+ fail("usage: memory.mjs <record|reflect|outcomes|arms|lessons|lesson|history|model|import|recall|brief-lessons|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
1462
2084
  }
1463
2085
  } catch (err) {
1464
2086
  fail(err.message);