@dougharness/cli 1.0.1

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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,1274 @@
1
+ #!/usr/bin/env node
2
+ // CLI for the outcome log and lessons store (card memory-lib), on top of lib/memory.mjs. node:sqlite is loaded
3
+ // lazily, only once a subcommand needs it, so a Node without it (<22.16) prints the lib's one clear error and
4
+ // exits 1 instead of a stack trace.
5
+ // memory.mjs record <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>]
6
+ // [--adversary "<id>=<class>[: <reason>]"]... [--claude-dir <path>] [--json]
7
+ // append one outcomes row per task in the report (upsert on
8
+ // run+task); with --run, per-task tokens and USD come from the
9
+ // same local transcripts lib/cost.mjs reads (null, with a note on
10
+ // stderr, when the run's journal cannot be found)
11
+ // memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--json]
12
+ // record a hand-track landing (card id, commit, wall clock, gate
13
+ // line, note; nothing else is invented) as one outcomes row keyed
14
+ // hand:<commit> (upsert on the same commit); --commit is required;
15
+ // --run, --adversary, --claude-dir, and a report path are not
16
+ // accepted with --hand. Reads
17
+ // .doug/.state/reports/<card-id>/condition.json (card
18
+ // outcomes-condition-columns) when present and fills the seven
19
+ // condition columns from it; without one the row still records
20
+ // (exit 0) and an extra stdout line says the landing is
21
+ // unstamped (--json: "stamped": true|false instead)
22
+ // memory.mjs condition open <card-id> [dir] [--json]
23
+ // (card outcomes-condition-columns) writes
24
+ // .doug/.state/reports/<card-id>/condition.json: harnessCommit
25
+ // (git HEAD), configHash (conditionConfigHash: .doug/config.json
26
+ // plus the CLAUDE.md Models table text), class and arm
27
+ // (<class>/default) from the card's board record, assignedBy
28
+ // "policy", exploreProbability null, openedAt. Refuses (exit 1,
29
+ // nothing written) when the file already exists, the card is
30
+ // missing or has no class, or there is no git HEAD
31
+ // memory.mjs condition backfill [dir] [--json]
32
+ // (card outcomes-condition-columns) for every outcomes row with
33
+ // harness_commit null: sets harness_commit to the last commit at
34
+ // or before the row's recorded timestamp (git log --before,
35
+ // first-parent on main), class from the row's card on the board
36
+ // when it has one, backfilled 1; leaves a row untouched (and
37
+ // uncounted) when git finds no such commit; idempotent (a second
38
+ // run finds nothing left to stamp). Prints "Backfilled <n>
39
+ // outcomes rows with a harness commit." (--json: {"backfilled": n})
40
+ // memory.mjs outcomes [dir] [--run <id>] [--task <id>] [--json]
41
+ // list recorded outcomes, filtered by run and/or task
42
+ // memory.mjs lessons [dir] [--search <text>] [--k <n>] [--all] [--json]
43
+ // list lessons, or full-text search them (--search); --all
44
+ // includes superseded rows
45
+ // memory.mjs lesson add --text <text> --kind <feedback|project|pitfall|pattern> [dir]
46
+ // [--scope <glob,glob>] [--citation <text>] [--card <id>]
47
+ // [--commit <sha>] [--source-agent <name>] [--source-model <name>] [--json]
48
+ // add a lesson directly (card out-of-workflow-access): the
49
+ // markdown auto-memory roundtrip can only produce kind feedback
50
+ // or project, never pitfall or pattern. --source-agent defaults
51
+ // to "lead" and REFUSES "auto-memory", which is the import's own
52
+ // namespace (memory-import.mjs looks up the file name under the
53
+ // auto-memory dir plus source_agent "auto-memory" to decide what
54
+ // to supersede); a hand-authored lesson must never claim it. Not
55
+ // the "lessons" listing subcommand above.
56
+ // memory.mjs import [dir] [--claude-dir <path>] [--json]
57
+ // read Claude Code auto memory (~/.claude/projects/<slug>/memory)
58
+ // idempotently into lessons (source auto-memory); never writes
59
+ // anything under the auto-memory directory
60
+ // memory.mjs lesson unstale <id> [dir] [--json]
61
+ // clear a lesson's stale mark (set by recall's citation
62
+ // re-check) so it is a candidate again; refuses on an unknown id
63
+ // memory.mjs recall <query> [dir] [--files <glob,glob>] [--k <n>] [--json]
64
+ // hybrid recall (card memory-recall): BM25 over FTS5 fused by
65
+ // reciprocal rank fusion with cosine over stored vectors when a
66
+ // provider is configured and vectors exist for it, recency-
67
+ // weighted, scope-boosted by --files, each returned lesson's
68
+ // citation re-checked against [dir] only when [dir] is a real
69
+ // project root (contains .doug); keyword-only, and says so, when
70
+ // no provider is configured or no vectors match it
71
+ // memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>]
72
+ // [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>]
73
+ // [--model <name>] [--max-usd <n>] [--dry-run] [--json]
74
+ // runs after record: applies deterministic helpful/harmful
75
+ // counters to lessons named in each task's memoryUsed (committed
76
+ // even if the LLM pass below fails), then a cheap-row `claude -p
77
+ // --json-schema` pass proposes at most 3 lessons from the report,
78
+ // filtered in code (a pitfall needs a block classed real, a
79
+ // pattern needs a first-pass verified task, the citation must be
80
+ // checkable, the text must not already be derivable from the
81
+ // checkout, and a near-duplicate bumps the existing lesson's
82
+ // helpful instead of appending); idempotent on the report's
83
+ // sha256 (a second reflect on the same report is a no-op, exit
84
+ // 0); --dry-run applies nothing but still runs the LLM pass and
85
+ // prints what would happen; --hand is refused, since a hand
86
+ // landing has no report. Cost is measured from the same local
87
+ // transcripts cost.mjs reads, falling back to the CLI's own
88
+ // cost estimate. Needs Node >=22.16 (node:sqlite).
89
+ // memory.mjs doctor [dir] [--json]
90
+ // probe the configured embedding provider (memory.embeddings in
91
+ // .doug/config.json), or a local Ollama at localhost:11434 when
92
+ // nothing is configured; print what was found, the model it
93
+ // would use, and the one command to install what is missing
94
+ // memory.mjs reembed [dir] [--batch <n>] [--check] [--no-check] [--pairs <file>] [--json]
95
+ // (re)embed every lesson not yet embedded under the configured
96
+ // provider's model/dims, in batches (progress on stderr);
97
+ // refuses with a clear line when no provider is configured or
98
+ // when --batch is not an integer >= 1. Runs the embed-check
99
+ // cosine sanity check first whenever the store holds no lesson
100
+ // embedded under the configured model/dims while at least one
101
+ // lesson exists (a model/dims change, including the first embed
102
+ // ever); on a failed check it embeds nothing and exits 1.
103
+ // --check forces the check on any reembed; --no-check skips it
104
+ // (prints one stderr line) for a deliberately non-semantic test
105
+ // double
106
+ // memory.mjs index build [dir] [--batch <n>] [--check] [--no-check] [--pairs <file>] [--json]
107
+ // (card semantic-index) incrementally (re)build the opt-in
108
+ // semantic code index: hash every git-tracked, indexable file,
109
+ // re-chunk and re-embed only what changed, drop rows for files no
110
+ // longer indexable. With a provider configured, embeds pending
111
+ // chunks in batches (progress on stderr like reembed), running
112
+ // the embed-check cosine sanity check first on a model/dims pair
113
+ // the index has never used (--check/--no-check/--pairs identical
114
+ // to reembed); unlike reembed, a down provider or a failed check
115
+ // never makes build exit nonzero (exit 0, keyword-only, reason in
116
+ // the line) - only a non-git dir or a database problem does
117
+ // memory.mjs index status [dir] [--json]
118
+ // the index's size (files, chunks, text bytes, database bytes,
119
+ // how many chunks are embedded under the configured provider) and
120
+ // its refresh cost (files added/changed/removed and exactly how
121
+ // many chunks the next build would embed, estimated seconds from
122
+ // the last build's measured rate); never built is not an error
123
+ // memory.mjs index search <query> [dir] [--files <glob,glob>] [--k <n>] [--json]
124
+ // hybrid search over the code index (card semantic-index): BM25
125
+ // over FTS5 fused by reciprocal rank fusion with cosine over
126
+ // stored vectors when a provider is configured and vectors exist
127
+ // for it; --files (comma-separated globs) restricts candidates by
128
+ // path; keyword-only, and says so, with no provider or no vectors
129
+ // memory.mjs embed-check [dir] [--pairs <file>] [--json]
130
+ // cosine sanity check (card memory-measure): embeds the dozen
131
+ // paraphrase and dozen unrelated lesson pairs in evals/memory/
132
+ // pairs.json (or --pairs) against the configured provider and
133
+ // confirms paraphrases score higher than unrelated pairs by the
134
+ // file's stated margin; exit 0 ok, 1 on a failed check or an
135
+ // unreachable/unconfigured provider, 2 on usage; refuses when
136
+ // the pairs file is missing
137
+ // memory.mjs decision propose --title <text> --file <path> [--date YYYY-MM-DD] [dir] [--json]
138
+ // (card memory-decisions) proposes a new ADR at
139
+ // docs/decisions/<NNNN>-<slug>.md as a diff under
140
+ // .doug/.state/learn/ (never a tracked file); refuses when the
141
+ // target already exists or --title/--file is empty. --file -
142
+ // reads the body from stdin. Approve and write it with
143
+ // `learn.mjs apply <diff>` - docs/decisions/ is a proposal-only
144
+ // path (proposalPaths in .doug/config.json), refused by every
145
+ // other tool
146
+ // memory.mjs decision amend <NNNN> (--text <text> | --file <path>) [--date YYYY-MM-DD] [dir] [--json]
147
+ // proposes appending a dated "## Amendment" section to the one
148
+ // existing docs/decisions/<NNNN>-*.md; refuses when none or
149
+ // several ADRs match <NNNN>, or the text is empty
150
+ // memory.mjs rule propose <name> --file <path> [--paths <glob,glob>] [dir] [--json]
151
+ // proposes creating or replacing .claude/rules/<name>.md (also
152
+ // proposal-only); with --paths the rule's frontmatter scopes it
153
+ // to those globs (loads only when a matching file is read), with
154
+ // none it loads unconditionally at session start (Claude Code's
155
+ // own `paths:` contract - see docs/memory.md)
156
+ // memory.mjs claude-md propose --file <path|-> [dir] [--json]
157
+ // proposes a diff to CLAUDE.md from an edits JSON file/stdin of
158
+ // source-cited adds/removes ({ add: [{heading,line,source}],
159
+ // remove: [{line,enforcedBy,source}], note }); writes only a diff
160
+ // under .doug/.state/learn/, never CLAUDE.md itself; refuses when
161
+ // an edit would touch the Models section or the fenced Commands
162
+ // block, or would push CLAUDE.md over 60 lines. Approve and write
163
+ // it with `learn.mjs apply <diff>`
164
+ // lesson add and import embed only the row(s) they just wrote, best-effort, when a provider is configured: a
165
+ // provider failure prints one stderr line and never turns into a nonzero exit for that add/import. A pending
166
+ // row from an earlier add is left alone until `reembed` (or a later add/import) picks it up.
167
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
168
+ import { resolve, join, dirname } from "node:path";
169
+ import { createHash } from "node:crypto";
170
+ import { spawnSync } from "node:child_process";
171
+ import { parseAdversaryClasses, loadBoard, findCard } from "../lib/board.mjs";
172
+ import { loadPlan, unwrapReport } from "../lib/plan.mjs";
173
+ import { costRun } from "../lib/cost.mjs";
174
+ import { loadModels } from "../lib/models.mjs";
175
+ import {
176
+ readMemoryConfig,
177
+ readMemoryStaleDays,
178
+ readIndexConfig,
179
+ createProvider,
180
+ probeProvider,
181
+ doctorReport,
182
+ checkEmbeddingSanity,
183
+ loadSanityPairs,
184
+ DEFAULT_SANITY_PAIRS,
185
+ } from "../lib/embeddings.mjs";
186
+
187
+ const argv = process.argv.slice(2);
188
+ const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check"]);
189
+ const REPEATABLE = new Set(["adversary"]);
190
+ const opts = {};
191
+ const positional = [];
192
+ for (let i = 0; i < argv.length; i++) {
193
+ if (!argv[i].startsWith("--")) {
194
+ positional.push(argv[i]);
195
+ continue;
196
+ }
197
+ const name = argv[i].slice(2);
198
+ if (FLAGS.has(name)) {
199
+ opts[name] = true;
200
+ continue;
201
+ }
202
+ if (REPEATABLE.has(name)) (opts[name] ||= []).push(argv[i + 1] ?? "");
203
+ else opts[name] = argv[i + 1] ?? "";
204
+ i++;
205
+ }
206
+ const [cmd, ...rest] = positional;
207
+
208
+ function fail(msg, code = 1) {
209
+ process.stderr.write(msg + "\n");
210
+ process.exit(code);
211
+ }
212
+
213
+ function dirFrom(args, n) {
214
+ return resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
215
+ }
216
+
217
+ function verifiedWord(v) {
218
+ return v === 1 ? "yes" : v === 0 ? "no" : "?";
219
+ }
220
+
221
+ function partialWord(v) {
222
+ return v === 1 ? "yes" : "no";
223
+ }
224
+
225
+ function tokensText(tokens) {
226
+ if (tokens === null || tokens === undefined) return "null";
227
+ if (typeof tokens.input === "number" && typeof tokens.output === "number") return `${tokens.input}/${tokens.output}`;
228
+ if (typeof tokens.total === "number") return `${tokens.total}`;
229
+ return "null";
230
+ }
231
+
232
+ function usdText(usd) {
233
+ return typeof usd === "number" ? usd.toFixed(2) : "null";
234
+ }
235
+
236
+ function commitText(sha) {
237
+ return sha ? String(sha).slice(0, 7) : "none";
238
+ }
239
+
240
+ // An empty or whitespace-only flag value (a flag given with no following value parses as "") is not text; store
241
+ // null instead of the empty string.
242
+ function textOrNull(value) {
243
+ if (value === undefined || value === null) return null;
244
+ return String(value).trim() ? value : null;
245
+ }
246
+
247
+ // Embeds exactly the given lesson ids (the row(s) this add/import just wrote), best-effort: no provider
248
+ // configured is silent; a provider failure is one stderr line. Deliberately scoped to `ids`, not the whole
249
+ // backlog — `reembed` is the command for catching up rows an earlier add/import left pending.
250
+ //
251
+ // (reviewer MAJOR 1, card memory-measure) Never the first commit to a new model/dims pair: when the store has
252
+ // no active lesson embedded under the configured model/dims yet (embeddingChangeDetected — the same rule
253
+ // `reembed` uses to decide whether to run its cosine sanity check), this leaves the row(s) pending instead of
254
+ // embedding them, so a plain `lesson add`/`import` can never quietly establish a bad model/dims pair as
255
+ // "already in use" and skip `reembed`'s check on the next run — only `reembed`, past that check, may commit a
256
+ // store to a model/dims it has never used before.
257
+ async function bestEffortEmbed(m, dir, ids) {
258
+ if (!ids || !ids.length) return;
259
+ const provider = createProvider(readMemoryConfig(dir));
260
+ if (!provider) return;
261
+ if (embeddingChangeDetected(m, provider)) {
262
+ process.stderr.write(
263
+ `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`
264
+ );
265
+ return;
266
+ }
267
+ const { getLesson, setLessonEmbedding } = await import("../lib/memory.mjs");
268
+ const rows = ids.map((id) => getLesson(m, id)).filter(Boolean);
269
+ if (!rows.length) return;
270
+ const result = await provider.embed(
271
+ rows.map((r) => r.text),
272
+ { inputType: "document" }
273
+ );
274
+ if (!result.ok) {
275
+ process.stderr.write(`Note: embedding with ${provider.model}/${provider.dims} failed for ${rows.length} lesson(s): ${result.reason}\n`);
276
+ return;
277
+ }
278
+ for (let i = 0; i < rows.length; i++) {
279
+ setLessonEmbedding(m, rows[i].id, { model: provider.model, dims: provider.dims, vector: result.vectors[i] });
280
+ }
281
+ }
282
+
283
+ // `claude -p --output-format json` is documented to print one JSON object, but in practice a notice or warning
284
+ // line can land before or after it on stdout. Scans for every top-level {...} object (brace-balanced, respecting
285
+ // quoted strings and escapes) and parses the LAST one — the one line among many that matters is the JSON
286
+ // envelope, and it is always the outermost/last such object claude prints. Throws when none is found or the last
287
+ // candidate does not parse.
288
+ function parseLastJsonObject(text) {
289
+ const candidates = [];
290
+ let depth = 0;
291
+ let start = -1;
292
+ let inString = false;
293
+ let escape = false;
294
+ for (let i = 0; i < text.length; i++) {
295
+ const c = text[i];
296
+ if (inString) {
297
+ if (escape) escape = false;
298
+ else if (c === "\\") escape = true;
299
+ else if (c === '"') inString = false;
300
+ continue;
301
+ }
302
+ if (c === '"') {
303
+ inString = true;
304
+ continue;
305
+ }
306
+ if (c === "{") {
307
+ if (depth === 0) start = i;
308
+ depth++;
309
+ } else if (c === "}") {
310
+ if (depth > 0) {
311
+ depth--;
312
+ if (depth === 0 && start !== -1) {
313
+ candidates.push(text.slice(start, i + 1));
314
+ start = -1;
315
+ }
316
+ }
317
+ }
318
+ }
319
+ if (!candidates.length) throw new Error("no JSON object found in the output");
320
+ return JSON.parse(candidates[candidates.length - 1]);
321
+ }
322
+
323
+ // The real spawn behind reflect's injectable `propose(prompt)`: `claude -p` with the reflect schema, prompt on
324
+ // stdin, a 180s timeout, and a 64MB stdout buffer (a report's prompt plus a model's proposals can run long).
325
+ // Throws on any failure the spec names (spawn error, timeout, unparseable stdout, is_error, a missing
326
+ // structured_output.lessons array) so runReflect's single catch handles every case uniformly.
327
+ function makePropose({ claudeBin, model, maxUsd, dir }) {
328
+ return async (prompt) => {
329
+ const { REFLECT_SCHEMA } = await import("../lib/memory.mjs");
330
+ const args = ["-p", "--output-format", "json", "--json-schema", JSON.stringify(REFLECT_SCHEMA), "--model", model, "--max-budget-usd", String(maxUsd)];
331
+ const res = spawnSync(claudeBin, args, { input: prompt, encoding: "utf8", cwd: dir, timeout: 180000, maxBuffer: 64 * 1024 * 1024 });
332
+ if (res.error) throw new Error(`claude spawn failed: ${res.error.message}`);
333
+ if (res.signal) throw new Error(`claude was killed by signal ${res.signal} (likely the 180s timeout)`);
334
+ if (res.status !== 0) throw new Error(`claude exited ${res.status}: ${(res.stderr || "").trim().slice(0, 500)}`);
335
+ let envelope;
336
+ try {
337
+ envelope = parseLastJsonObject(res.stdout);
338
+ } catch (err) {
339
+ throw new Error(`claude printed no parseable JSON: ${err.message}`);
340
+ }
341
+ if (envelope.is_error) throw new Error(`claude reported is_error${envelope.result ? `: ${envelope.result}` : ""}`);
342
+ const lessons = envelope.structured_output && envelope.structured_output.lessons;
343
+ if (!Array.isArray(lessons)) throw new Error("claude's structured_output.lessons is missing");
344
+ return {
345
+ lessons,
346
+ sessionId: envelope.session_id || null,
347
+ totalCostUsd: typeof envelope.total_cost_usd === "number" ? envelope.total_cost_usd : null,
348
+ };
349
+ };
350
+ }
351
+
352
+ // Four decimals so a cheap-row (haiku) pass, typically a fraction of a cent, never reads as a misleading $0.00.
353
+ function costLine(usd, source) {
354
+ return typeof usd === "number" ? `$${usd.toFixed(4)} (${source ?? "none"})` : "cost not measured";
355
+ }
356
+
357
+ function idsList(ids) {
358
+ return ids.length ? ids.join(", ") : "none";
359
+ }
360
+
361
+ function reviewIssueCountText(count) {
362
+ return count === null || count === undefined ? "null" : count;
363
+ }
364
+
365
+ function blocksText(blocks) {
366
+ return Array.isArray(blocks) ? blocks.length : "null";
367
+ }
368
+
369
+ // A flow row has no track-specific handling below, so its line is unchanged. A hand row has no review, adversary
370
+ // blocks, or verifier (track === "hand"), so those columns print "null" rather than a false zero, and the row
371
+ // gains the columns a flow row has no equivalent for: hand (from track), wall <wall_clock>, gate <gate>.
372
+ // partial (card worker-context-handoff) prints only when set (1 or 0), right after verified: without this, a
373
+ // partial task's "verified no" reads as a verification failure, which the card's goal forbids.
374
+ function outcomeLine(row) {
375
+ const adv = row.adversary_verdict ?? "none";
376
+ const parts = [
377
+ row.run,
378
+ row.task,
379
+ `verified ${verifiedWord(row.verified)}`,
380
+ ...(row.partial === 1 || row.partial === 0 ? [`partial ${partialWord(row.partial)}`] : []),
381
+ `review ${reviewIssueCountText(row.review_issue_count)} issue(s)`,
382
+ `adversary ${adv} (${blocksText(row.blocks)} block(s))`,
383
+ `passes ${row.passes ?? "null"}`,
384
+ `tokens ${tokensText(row.tokens)}`,
385
+ `usd ${usdText(row.usd)}`,
386
+ `commit ${commitText(row.commit_sha)}`,
387
+ ];
388
+ if (row.track === "hand") {
389
+ parts.splice(2, 0, "hand");
390
+ parts.push(`wall ${row.wall_clock ?? "null"}`);
391
+ parts.push(`gate ${row.gate ?? "null"}`);
392
+ }
393
+ return parts.join(" ");
394
+ }
395
+
396
+ function lessonLine(row) {
397
+ const suffix = row.superseded_by ? ` (superseded by ${row.superseded_by})` : "";
398
+ return `${row.id} ${row.kind} ${row.text}${suffix}`;
399
+ }
400
+
401
+ // One-line (ok) or multi-line (FAILED, one offending pair per line) rendering of a checkEmbeddingSanity()
402
+ // result, shared by `embed-check` and `reembed`'s pre-check.
403
+ function sanityLine(result) {
404
+ if (result.ok) {
405
+ return (
406
+ `embed-check: ok (gap ${result.gap.toFixed(2)} >= margin ${result.margin}; ` +
407
+ `paraphrase mean ${result.paraphrase.mean.toFixed(2)}, unrelated mean ${result.unrelated.mean.toFixed(2)})`
408
+ );
409
+ }
410
+ const lines = [`embed-check: FAILED (${result.reason})`];
411
+ for (const f of result.failures || []) {
412
+ lines.push(` ${JSON.stringify(f.pair)} cosine ${f.cosine.toFixed(4)}`);
413
+ }
414
+ return lines.join("\n");
415
+ }
416
+
417
+ // True when the store holds at least one active (not superseded, not stale) lesson but none of them are
418
+ // embedded under provider.model/provider.dims — a model or dims change, including the very first embed ever
419
+ // under any model. Mirrors lessonsNeedingEmbedding's WHERE clause in lib/memory.mjs so "no lesson matches"
420
+ // means the same thing here as it does there.
421
+ function embeddingChangeDetected(m, provider) {
422
+ const total = m.prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").get().n;
423
+ if (total === 0) return false;
424
+ const matching = m
425
+ .prepare("SELECT COUNT(*) AS n FROM lessons WHERE superseded_by IS NULL AND stale IS NULL AND embedding_model = ? AND embedding_dims = ?")
426
+ .get(provider.model, provider.dims).n;
427
+ return matching === 0;
428
+ }
429
+
430
+ // Loads the sanity pairs file for `embed-check`/`reembed`, refusing (never throwing past the outer catch) when
431
+ // it is missing so the message always names the path and --pairs.
432
+ function loadPairsOrFail(opts) {
433
+ const pairsPath = opts.pairs ? resolve(opts.pairs) : DEFAULT_SANITY_PAIRS;
434
+ if (!existsSync(pairsPath)) {
435
+ fail(`no sanity pairs file at ${pairsPath}; pass --pairs <file> to point at a different one.`, 1);
436
+ }
437
+ return loadSanityPairs(pairsPath);
438
+ }
439
+
440
+ // Today's local date as YYYY-MM-DD, the default `--date` for `decision propose`/`decision amend` (card
441
+ // memory-decisions).
442
+ function today() {
443
+ return new Date().toISOString().slice(0, 10);
444
+ }
445
+
446
+ // Reads a --file argument's text: "-" reads stdin (fd 0), anything else a real file path.
447
+ function readTextArg(path) {
448
+ return path === "-" ? readFileSync(0, "utf8") : readFileSync(resolve(path), "utf8");
449
+ }
450
+
451
+ // Shared printer for decisions.mjs's propose*() results (card memory-decisions): refused -> the reason on
452
+ // stderr, exit 1 (plus the result as --json on stdout, mirroring `apply`'s own refusal shape); ok -> the
453
+ // proposal line in learn.mjs's own shape, the diff path, and how to apply it - the user approves before
454
+ // anything is written to docs/decisions/ or .claude/rules/, exactly like `learn.mjs propose`/`apply`.
455
+ function printProposalResult(result, jsonOut) {
456
+ if (!result.ok) {
457
+ if (jsonOut) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
458
+ process.stderr.write(`${result.reason}\n`);
459
+ process.exit(1);
460
+ }
461
+ if (jsonOut) {
462
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
463
+ return;
464
+ }
465
+ const { proposal, diffPath } = result;
466
+ process.stdout.write(`${proposal.id} ${proposal.kind} ${proposal.target}: ${proposal.reason}\n`);
467
+ process.stdout.write(`diff: ${diffPath}\n`);
468
+ process.stdout.write(`apply with: learn.mjs apply ${diffPath}\n`);
469
+ }
470
+
471
+ // `index build`'s own pairs loader: an explicit --pairs that is missing fails clearly (same as loadPairsOrFail,
472
+ // same exit code), but with no --pairs given at all, a missing default file (a standalone-published copy of
473
+ // this plugin outside this checkout - DEFAULT_SANITY_PAIRS lives at the repo root) is not a hard failure: build
474
+ // still runs, and buildIndex itself reports a check failure only if a model/dims pair actually new to the code
475
+ // index shows up needing one.
476
+ function loadIndexPairs(opts) {
477
+ const pairsPath = opts.pairs ? resolve(opts.pairs) : DEFAULT_SANITY_PAIRS;
478
+ if (!existsSync(pairsPath)) {
479
+ if (opts.pairs) fail(`no sanity pairs file at ${pairsPath}; pass --pairs <file> to point at a different one.`, 1);
480
+ return null;
481
+ }
482
+ return loadSanityPairs(pairsPath);
483
+ }
484
+
485
+ try {
486
+ switch (cmd) {
487
+ case "record": {
488
+ if (opts.hand) {
489
+ const handUsage =
490
+ 'usage: memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--json]';
491
+ const [id, ...extra] = rest;
492
+ if (!id || !opts.commit) fail(handUsage, 2);
493
+ if (id.endsWith(".json") || existsSync(resolve(id))) {
494
+ fail(`${handUsage}\n--hand takes a card id, not a report.`, 2);
495
+ }
496
+ if (opts.run !== undefined || opts.adversary !== undefined || opts["claude-dir"] !== undefined || extra.length > 1) {
497
+ fail(handUsage, 2);
498
+ }
499
+ const dir = dirFrom(rest, 1);
500
+ // condition.json (card outcomes-condition-columns): read when `condition open` wrote one before the
501
+ // brief; a stamp missing entirely (the common case until core-next-hand-report wires this in) is not a
502
+ // parse error, only a JSON.parse failure on a file that does exist is.
503
+ const conditionFile = join(dir, ".doug", ".state", "reports", id, "condition.json");
504
+ let condition = null;
505
+ if (existsSync(conditionFile)) {
506
+ try {
507
+ condition = JSON.parse(readFileSync(conditionFile, "utf8"));
508
+ } catch (err) {
509
+ fail(`cannot read ${conditionFile}: ${err.message}`);
510
+ }
511
+ }
512
+ const { openMemory, recordOutcomes, handOutcomeRow } = await import("../lib/memory.mjs");
513
+ const row = handOutcomeRow({ card: id, commit: opts.commit, wallClock: textOrNull(opts.wall), gate: textOrNull(opts.gate), note: textOrNull(opts.note), condition });
514
+ const m = openMemory(dir);
515
+ let result;
516
+ try {
517
+ result = recordOutcomes(m, [row]);
518
+ } finally {
519
+ m.close();
520
+ }
521
+ const stamped = !!condition;
522
+ if (opts.json) {
523
+ process.stdout.write(
524
+ JSON.stringify({ run: row.run, card: id, commit: opts.commit, inserted: result.inserted, updated: result.updated, stamped }, null, 2) + "\n"
525
+ );
526
+ } else {
527
+ process.stdout.write(
528
+ `Recorded a hand-track landing for ${id} at ${opts.commit} in .doug/.state/memory/memory.db (${result.inserted} new, ${result.updated} updated).\n`
529
+ );
530
+ if (!stamped) {
531
+ process.stdout.write(
532
+ `No condition.json for ${id}: this landing is unstamped (memory.mjs condition open ${id} writes one before the brief).\n`
533
+ );
534
+ }
535
+ }
536
+ break;
537
+ }
538
+ const [reportFile] = rest;
539
+ if (!reportFile) {
540
+ fail(
541
+ 'usage: memory.mjs record <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--claude-dir <path>] [--json]',
542
+ 2
543
+ );
544
+ }
545
+ const dir = dirFrom(rest, 1);
546
+ const reportPath = resolve(reportFile);
547
+ if (!existsSync(reportPath)) fail(`no such report file: ${reportPath}`);
548
+ let reportText;
549
+ try {
550
+ reportText = readFileSync(reportPath, "utf8");
551
+ } catch (err) {
552
+ fail(`cannot read report file ${reportPath}: ${err.message}`);
553
+ }
554
+ let report;
555
+ try {
556
+ report = unwrapReport(JSON.parse(reportText));
557
+ } catch (err) {
558
+ fail(`cannot parse report file ${reportPath} as JSON: ${err.message}`);
559
+ }
560
+ const reportHash = createHash("sha256").update(reportText).digest("hex");
561
+ const classes = parseAdversaryClasses(opts.adversary || []);
562
+ const plan = loadPlan(dir);
563
+ const run = opts.run || null;
564
+ const runLabel = run || `report:${reportHash.slice(0, 12)}`;
565
+ let cost = null;
566
+ if (run) {
567
+ try {
568
+ cost = costRun({ runId: run, projectDir: dir, claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined });
569
+ } catch (err) {
570
+ if (!/^no journal for run /.test(err.message)) throw err;
571
+ process.stderr.write(`Note: no journal for run ${run}; tokens and USD recorded as null.\n`);
572
+ cost = null;
573
+ }
574
+ }
575
+ // Lazy: a Node without node:sqlite must fail loudly here, not with a stack trace, and never before this point.
576
+ const { openMemory, recordOutcomes, outcomeRows } = await import("../lib/memory.mjs");
577
+ const rows = outcomeRows(report, { plan, classes, run, reportHash, reportPath, card: opts.card ?? null, commit: opts.commit ?? null, cost });
578
+ const m = openMemory(dir);
579
+ let result;
580
+ try {
581
+ result = recordOutcomes(m, rows);
582
+ } finally {
583
+ m.close();
584
+ }
585
+ const tasks = rows.map((r) => r.task);
586
+ if (opts.json) {
587
+ process.stdout.write(JSON.stringify({ run: runLabel, inserted: result.inserted, updated: result.updated, tasks }, null, 2) + "\n");
588
+ } else {
589
+ process.stdout.write(
590
+ `Recorded ${rows.length} outcome(s) for ${runLabel} in .doug/.state/memory/memory.db (${result.inserted} new, ${result.updated} updated).\n`
591
+ );
592
+ }
593
+ break;
594
+ }
595
+ case "reflect": {
596
+ const usage =
597
+ 'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
598
+ if (opts.hand) fail(`${usage}\n--hand is not accepted: a hand landing has no report.`, 2);
599
+ const [reportFile] = rest;
600
+ if (!reportFile) fail(usage, 2);
601
+ const dir = dirFrom(rest, 1);
602
+ const reportPath = resolve(reportFile);
603
+ if (!existsSync(reportPath)) fail(`no such report file: ${reportPath}`);
604
+ let reportText;
605
+ try {
606
+ reportText = readFileSync(reportPath, "utf8");
607
+ } catch (err) {
608
+ fail(`cannot read report file ${reportPath}: ${err.message}`);
609
+ }
610
+ let report;
611
+ try {
612
+ report = unwrapReport(JSON.parse(reportText));
613
+ } catch (err) {
614
+ fail(`cannot parse report file ${reportPath} as JSON: ${err.message}`);
615
+ }
616
+ const reportHash = createHash("sha256").update(reportText).digest("hex");
617
+ const classes = parseAdversaryClasses(opts.adversary || []);
618
+ const plan = loadPlan(dir);
619
+ const models = loadModels(dir);
620
+ const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
621
+ let maxUsd = 0.5;
622
+ if (opts["max-usd"] !== undefined) {
623
+ maxUsd = Number(opts["max-usd"]);
624
+ if (!Number.isFinite(maxUsd) || maxUsd <= 0) fail(`${usage}\n--max-usd must be a positive number, got ${JSON.stringify(opts["max-usd"])}.`, 2);
625
+ }
626
+ const claudeBin = textOrNull(opts["claude-bin"]) || process.env.DOUG_CLAUDE_BIN || "claude";
627
+ const claudeDir = opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined;
628
+ const dryRun = !!opts["dry-run"];
629
+ const runLabel = opts.run || `report:${reportHash.slice(0, 12)}`;
630
+
631
+ const { openMemory, runReflect } = await import("../lib/memory.mjs");
632
+ const propose = makePropose({ claudeBin, model, maxUsd, dir });
633
+ const m = openMemory(dir);
634
+ let result;
635
+ try {
636
+ result = await runReflect(m, report, {
637
+ reportHash,
638
+ dir,
639
+ classes,
640
+ plan,
641
+ run: opts.run ?? null,
642
+ card: opts.card ?? null,
643
+ commit: opts.commit ?? null,
644
+ model,
645
+ claudeDir,
646
+ dryRun,
647
+ propose,
648
+ });
649
+ if (!dryRun && result.ok && result.appended && result.appended.length) {
650
+ await bestEffortEmbed(m, dir, result.appended);
651
+ }
652
+ } finally {
653
+ m.close();
654
+ }
655
+
656
+ if (result.alreadyReflected) {
657
+ if (opts.json) {
658
+ process.stdout.write(JSON.stringify(result.row, null, 2) + "\n");
659
+ } else {
660
+ process.stdout.write(`Already reflected on ${result.row.recorded} for ${runLabel}; nothing to do.\n`);
661
+ }
662
+ break;
663
+ }
664
+
665
+ if (!result.ok) {
666
+ process.stderr.write(`reflect: the cheap-row pass failed for ${runLabel}: ${result.reason}\n`);
667
+ if (opts.json) process.stdout.write(JSON.stringify(result, null, 2) + "\n");
668
+ process.exit(1);
669
+ }
670
+
671
+ if (opts.json) {
672
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
673
+ } else {
674
+ const c = result.counters;
675
+ const prefix = result.dryRun ? "(dry run) " : "";
676
+ process.stdout.write(
677
+ `${prefix}Reflected on ${runLabel}: counters helpful +${c.helpful.length} (${idsList(c.helpful)}) harmful +${c.harmful.length} (${idsList(c.harmful)}); ` +
678
+ `proposed ${result.proposed}, appended ${result.appended.length} (${idsList(result.appended)}), bumped ${result.bumped.length} (${idsList(result.bumped)}), ` +
679
+ `rejected ${result.rejected.length}; model ${result.model}; ${costLine(result.usd, result.usdSource)}\n`
680
+ );
681
+ for (const r of result.rejected) {
682
+ process.stdout.write(` rejected ${r.text ? JSON.stringify(r.text) : "(no text)"}: ${r.reason}\n`);
683
+ }
684
+ }
685
+ break;
686
+ }
687
+ case "outcomes": {
688
+ const dir = dirFrom(rest, 0);
689
+ const { openMemory, listOutcomes } = await import("../lib/memory.mjs");
690
+ const m = openMemory(dir);
691
+ let rows;
692
+ try {
693
+ rows = listOutcomes(m, { run: opts.run ?? null, task: opts.task ?? null });
694
+ } finally {
695
+ m.close();
696
+ }
697
+ if (opts.json) {
698
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
699
+ } else if (!rows.length) {
700
+ process.stdout.write("no outcomes recorded\n");
701
+ } else {
702
+ process.stdout.write(rows.map(outcomeLine).join("\n") + "\n");
703
+ }
704
+ break;
705
+ }
706
+ case "lessons": {
707
+ const dir = dirFrom(rest, 0);
708
+ const { openMemory, listLessons, searchLessons } = await import("../lib/memory.mjs");
709
+ const m = openMemory(dir);
710
+ let rows;
711
+ try {
712
+ const includeSuperseded = !!opts.all;
713
+ rows =
714
+ opts.search !== undefined
715
+ ? searchLessons(m, opts.search, { k: opts.k !== undefined ? Number(opts.k) : undefined, includeSuperseded })
716
+ : listLessons(m, { includeSuperseded });
717
+ } finally {
718
+ m.close();
719
+ }
720
+ if (opts.json) {
721
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
722
+ } else if (!rows.length) {
723
+ process.stdout.write("no lessons\n");
724
+ } else {
725
+ process.stdout.write(rows.map(lessonLine).join("\n") + "\n");
726
+ }
727
+ break;
728
+ }
729
+ case "lesson": {
730
+ const usage =
731
+ "usage: memory.mjs lesson add --text <text> --kind <feedback|project|pitfall|pattern> [dir]\n" +
732
+ " [--scope <glob,glob>] [--citation <text>] [--card <id>]\n" +
733
+ " [--commit <sha>] [--source-agent <name>] [--source-model <name>] [--json]\n" +
734
+ " memory.mjs lesson unstale <id> [dir] [--json]";
735
+ const [sub, ...subrest] = rest;
736
+ if (sub === "unstale") {
737
+ const [id] = subrest;
738
+ if (!id) fail(usage, 2);
739
+ const dir = dirFrom(subrest, 1);
740
+ const { openMemory, unstaleLesson } = await import("../lib/memory.mjs");
741
+ const m = openMemory(dir);
742
+ let row;
743
+ try {
744
+ row = unstaleLesson(m, id);
745
+ } finally {
746
+ m.close();
747
+ }
748
+ if (opts.json) {
749
+ process.stdout.write(JSON.stringify(row, null, 2) + "\n");
750
+ } else {
751
+ process.stdout.write(`Cleared the stale mark on lesson ${row.id}.\n`);
752
+ }
753
+ break;
754
+ }
755
+ if (sub !== "add") fail(usage, 2);
756
+ const dir = dirFrom(subrest, 0);
757
+ const sourceAgent = String(opts["source-agent"] !== undefined ? opts["source-agent"] : "lead").trim();
758
+ if (sourceAgent.toLowerCase() === "auto-memory") {
759
+ fail(
760
+ '--source-agent auto-memory is refused: "auto-memory" is the namespace memory-import.mjs uses to find ' +
761
+ 'the row it may supersede (file name under the auto-memory dir + source_agent "auto-memory"); a ' +
762
+ "hand-authored lesson must not claim it. " +
763
+ 'Use a different --source-agent (default "lead").',
764
+ 2
765
+ );
766
+ }
767
+ const scope = opts.scope
768
+ ? String(opts.scope)
769
+ .split(",")
770
+ .map((s) => s.trim())
771
+ .filter(Boolean)
772
+ : [];
773
+ const { openMemory, addLesson, getLesson } = await import("../lib/memory.mjs");
774
+ const m = openMemory(dir);
775
+ let row;
776
+ try {
777
+ row = addLesson(m, {
778
+ text: opts.text,
779
+ kind: opts.kind,
780
+ scope,
781
+ citation: textOrNull(opts.citation),
782
+ source: { agent: sourceAgent, model: textOrNull(opts["source-model"]) },
783
+ card: textOrNull(opts.card),
784
+ commit: textOrNull(opts.commit),
785
+ });
786
+ await bestEffortEmbed(m, dir, [row.id]);
787
+ row = getLesson(m, row.id);
788
+ } finally {
789
+ m.close();
790
+ }
791
+ if (opts.json) {
792
+ process.stdout.write(JSON.stringify(row, null, 2) + "\n");
793
+ } else {
794
+ process.stdout.write(`Added lesson ${row.id} (${row.kind}) in .doug/.state/memory/memory.db.\n`);
795
+ }
796
+ break;
797
+ }
798
+ case "import": {
799
+ const dir = dirFrom(rest, 0);
800
+ const claudeDir = opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined;
801
+ const { openMemory } = await import("../lib/memory.mjs");
802
+ const { importAutoMemory } = await import("../lib/memory-import.mjs");
803
+ const m = openMemory(dir);
804
+ let result;
805
+ try {
806
+ result = importAutoMemory(m, { projectDir: dir, claudeDir });
807
+ const newIds = result.files.filter((f) => f.action === "added" || f.action === "updated").map((f) => f.id);
808
+ await bestEffortEmbed(m, dir, newIds);
809
+ } finally {
810
+ m.close();
811
+ }
812
+ if (!result.found) {
813
+ process.stdout.write(`no auto memory at ${result.dir}\n`);
814
+ break;
815
+ }
816
+ if (opts.json) {
817
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
818
+ } else {
819
+ process.stdout.write(
820
+ `Imported ${result.added + result.updated} lesson(s) from ${result.dir}: ${result.added} added, ${result.updated} updated (old row superseded), ${result.unchanged} unchanged, ${result.skipped} skipped (empty).\n`
821
+ );
822
+ }
823
+ break;
824
+ }
825
+ case "recall": {
826
+ const usage = "usage: memory.mjs recall <query> [dir] [--files <glob,glob>] [--k <n>] [--json]";
827
+ const [query, ...extra] = rest;
828
+ if (!query) fail(usage, 2);
829
+ const dir = dirFrom(extra, 0);
830
+ const files = opts.files
831
+ ? String(opts.files)
832
+ .split(",")
833
+ .map((s) => s.trim())
834
+ .filter(Boolean)
835
+ : [];
836
+ let k;
837
+ if (opts.k !== undefined) {
838
+ k = Number(opts.k);
839
+ if (!Number.isInteger(k) || k < 1) {
840
+ fail(`${usage}\n--k must be an integer >= 1, got ${JSON.stringify(opts.k)}.`, 2);
841
+ }
842
+ }
843
+ const provider = createProvider(readMemoryConfig(dir));
844
+ const staleDays = readMemoryStaleDays(dir);
845
+ const { openMemory, recallLessons } = await import("../lib/memory.mjs");
846
+ const m = openMemory(dir);
847
+ let result;
848
+ try {
849
+ result = await recallLessons(m, query, { files, k, provider, checkoutDir: dir, staleDays });
850
+ } finally {
851
+ m.close();
852
+ }
853
+ if (opts.json) {
854
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
855
+ } else {
856
+ const header =
857
+ result.mode === "hybrid"
858
+ ? `recall: hybrid via ${result.provider} ${provider.model}/${provider.dims}`
859
+ : `recall: keyword-only (${result.reason})`;
860
+ const lines = [header, ...result.lessons.map((l) => `${l.id} ${l.score.toFixed(4)} ${l.kind} ${l.text}`)];
861
+ process.stdout.write(lines.join("\n") + "\n");
862
+ }
863
+ break;
864
+ }
865
+ case "doctor": {
866
+ const dir = dirFrom(rest, 0);
867
+ const cfg = readMemoryConfig(dir);
868
+ const probe = await probeProvider(cfg);
869
+ if (opts.json) {
870
+ process.stdout.write(JSON.stringify({ embeddings: cfg.embeddings, probe }, null, 2) + "\n");
871
+ } else {
872
+ process.stdout.write(doctorReport(cfg, probe) + "\n");
873
+ }
874
+ break;
875
+ }
876
+ case "embed-check": {
877
+ const usage = "usage: memory.mjs embed-check [dir] [--pairs <file>] [--json]";
878
+ // Validated before the provider check below (card memory-docs-drift #1): a usage error must exit 2 with
879
+ // no provider needed, never surface as the provider's own exit-1 "not configured" message.
880
+ for (const key of Object.keys(opts)) {
881
+ if (key !== "pairs" && key !== "json") fail(`${usage}\n--${key} is not a recognized flag.`, 2);
882
+ }
883
+ if (opts.pairs !== undefined && (opts.pairs === "" || String(opts.pairs).startsWith("--"))) {
884
+ fail(`${usage}\n--pairs requires a value.`, 2);
885
+ }
886
+ if (rest.length > 1) {
887
+ fail(`${usage}\nexpected at most one positional argument (dir), got ${rest.length}.`, 2);
888
+ }
889
+ const dir = dirFrom(rest, 0);
890
+ const provider = createProvider(readMemoryConfig(dir));
891
+ if (!provider) {
892
+ fail("no embedding provider configured (memory.embeddings in .doug/config.json is unset); nothing to check. Run `memory.mjs doctor` for how to enable one.", 1);
893
+ }
894
+ const pairs = loadPairsOrFail(opts);
895
+ const result = await checkEmbeddingSanity(provider, pairs);
896
+ if (opts.json) {
897
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
898
+ } else {
899
+ process.stdout.write(sanityLine(result) + "\n");
900
+ }
901
+ if (!result.ok) process.exit(1);
902
+ break;
903
+ }
904
+ case "reembed": {
905
+ const usage = "usage: memory.mjs reembed [dir] [--batch <n>] [--check] [--no-check] [--pairs <file>] [--json]";
906
+ const dir = dirFrom(rest, 0);
907
+ const provider = createProvider(readMemoryConfig(dir));
908
+ if (!provider) {
909
+ fail("no embedding provider configured (memory.embeddings in .doug/config.json is unset); nothing to reembed. Run `memory.mjs doctor` for how to enable one.", 1);
910
+ }
911
+ let batch;
912
+ if (opts.batch !== undefined) {
913
+ batch = Number(opts.batch);
914
+ if (!Number.isInteger(batch) || batch < 1) {
915
+ fail(`${usage}\n--batch must be an integer >= 1, got ${JSON.stringify(opts.batch)}.`, 2);
916
+ }
917
+ }
918
+ if (opts.check && opts["no-check"]) {
919
+ fail(`${usage}\n--check and --no-check are mutually exclusive.`, 2);
920
+ }
921
+ const { openMemory, embedLessons } = await import("../lib/memory.mjs");
922
+ const m = openMemory(dir);
923
+ let result;
924
+ let checkStatus;
925
+ try {
926
+ if (opts["no-check"]) {
927
+ process.stderr.write("embed-check: skipped (--no-check)\n");
928
+ checkStatus = "skipped";
929
+ } else if (opts.check || embeddingChangeDetected(m, provider)) {
930
+ const pairs = loadPairsOrFail(opts);
931
+ const check = await checkEmbeddingSanity(provider, pairs);
932
+ if (!check.ok) {
933
+ process.stdout.write(sanityLine(check) + "\n");
934
+ throw new Error(
935
+ "embed-check failed; refusing to reembed. Pass --no-check to skip (see docs/memory.md), or --pairs to point at a different pairs file."
936
+ );
937
+ }
938
+ checkStatus = "ok";
939
+ } else {
940
+ checkStatus = "not-needed";
941
+ }
942
+ result = await embedLessons(m, provider, {
943
+ batch,
944
+ onProgress: ({ embedded, total }) => process.stderr.write(`embedded ${embedded}/${total}\n`),
945
+ });
946
+ } finally {
947
+ m.close();
948
+ }
949
+ if (opts.json) {
950
+ process.stdout.write(JSON.stringify({ ...result, check: checkStatus }, null, 2) + "\n");
951
+ } else {
952
+ process.stdout.write(
953
+ `Embedded ${result.embedded}/${result.total} lesson(s) with ${provider.model}/${provider.dims} (${result.failed} failed).\n`
954
+ );
955
+ }
956
+ break;
957
+ }
958
+ case "index": {
959
+ const [sub, ...subrest] = rest;
960
+ if (sub === "build") {
961
+ const usage = "usage: memory.mjs index build [dir] [--batch <n>] [--check] [--no-check] [--pairs <file>] [--json]";
962
+ if (opts.check && opts["no-check"]) {
963
+ fail(`${usage}\n--check and --no-check are mutually exclusive.`, 2);
964
+ }
965
+ let batch;
966
+ if (opts.batch !== undefined) {
967
+ batch = Number(opts.batch);
968
+ if (!Number.isInteger(batch) || batch < 1) {
969
+ fail(`${usage}\n--batch must be an integer >= 1, got ${JSON.stringify(opts.batch)}.`, 2);
970
+ }
971
+ }
972
+ const dir = dirFrom(subrest, 0);
973
+ const cfg = readIndexConfig(dir);
974
+ const provider = createProvider(readMemoryConfig(dir));
975
+ // Tri-state: false skips the sanity gate entirely (--no-check); "force" runs it on this build regardless
976
+ // of whether the model/dims pair is new (--check); true (default) leaves buildIndex's own auto-detect in
977
+ // charge, the same "new to this store" gate reembed applies to lessons.
978
+ const check = opts["no-check"] ? false : opts.check ? "force" : true;
979
+ const pairs = check === false ? null : loadIndexPairs(opts);
980
+ const { openMemory } = await import("../lib/memory.mjs");
981
+ const { buildIndex } = await import("../lib/code-index.mjs");
982
+ const m = openMemory(dir);
983
+ let result;
984
+ try {
985
+ result = await buildIndex(m, dir, {
986
+ cfg,
987
+ provider,
988
+ batch,
989
+ check,
990
+ pairs,
991
+ onProgress: ({ embedded, total }) => process.stderr.write(`indexed ${embedded}/${total}\n`),
992
+ });
993
+ } finally {
994
+ m.close();
995
+ }
996
+ if (opts.json) {
997
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
998
+ } else {
999
+ const pending = result.chunksEmbedded + result.chunksFailed;
1000
+ const keywordOnly = !provider || result.check === "failed" || (pending > 0 && result.chunksEmbedded === 0);
1001
+ const base = `index: ${result.files} files, ${result.chunks} chunks (${result.filesChanged} changed, ${result.filesRemoved} removed)`;
1002
+ if (keywordOnly) {
1003
+ process.stdout.write(`${base}; keyword-only (${result.reason})\n`);
1004
+ } else {
1005
+ // review MINOR 2: print embedSeconds to one decimal, the same way `status` prints its own seconds.
1006
+ // review MINOR 3: a partial failure (some batches embedded, some failed) must never hide the
1007
+ // provider's reason - it stays out of `reason` only when nothing at all embedded (the keyword-only
1008
+ // branch above already reports it there).
1009
+ let line = `${base}; embedded ${result.chunksEmbedded} of ${pending} under ${provider.model}/${provider.dims} in ${result.embedSeconds.toFixed(1)}s`;
1010
+ if (result.chunksFailed > 0) line += `; ${result.chunksFailed} failed (${result.reason})`;
1011
+ process.stdout.write(line + "\n");
1012
+ }
1013
+ }
1014
+ break;
1015
+ }
1016
+ if (sub === "status") {
1017
+ const dir = dirFrom(subrest, 0);
1018
+ const cfg = readIndexConfig(dir);
1019
+ const provider = createProvider(readMemoryConfig(dir));
1020
+ const { openMemory } = await import("../lib/memory.mjs");
1021
+ const { indexStatus } = await import("../lib/code-index.mjs");
1022
+ const m = openMemory(dir);
1023
+ let result;
1024
+ try {
1025
+ result = indexStatus(m, dir, { cfg, provider });
1026
+ } finally {
1027
+ m.close();
1028
+ }
1029
+ if (opts.json) {
1030
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1031
+ } else {
1032
+ const lines = [
1033
+ `index: ${result.files} files, ${result.chunks} chunks, ${result.textBytes} bytes of text, ${result.dbBytes} bytes on disk`,
1034
+ provider
1035
+ ? `embedded: ${result.embedded}/${result.chunks} under ${provider.model}/${provider.dims}`
1036
+ : "embedded: none (no provider configured)",
1037
+ result.built
1038
+ ? `built: ${result.built.finished} at ${result.built.headSha ?? "unknown"} (${result.built.current ? "current" : "behind HEAD"})`
1039
+ : "built: never",
1040
+ ];
1041
+ // review MINOR 1: use indexStatus's own carried `rate` rather than recomputing chunksToEmbed/seconds
1042
+ // (which is meaningless, and prints "about 0.0s at 0.0 chunks/s", when nothing is pending at all); with
1043
+ // nothing pending, print "0 chunks to embed" and skip the time clause entirely.
1044
+ const r = result.refresh;
1045
+ const prefix = `refresh: ${r.added} added, ${r.changed} changed, ${r.removed} removed; `;
1046
+ let refreshLine;
1047
+ if (r.chunksToEmbed === 0) {
1048
+ refreshLine = `${prefix}0 chunks to embed`;
1049
+ } else if (r.seconds !== null) {
1050
+ refreshLine = `${prefix}${r.chunksToEmbed} chunks to embed, about ${r.seconds.toFixed(1)}s at ${r.rate.toFixed(1)} chunks/s`;
1051
+ } else {
1052
+ refreshLine = `${prefix}${r.chunksToEmbed} chunks to embed, time unmeasured (${r.rateReason})`;
1053
+ }
1054
+ lines.push(refreshLine);
1055
+ process.stdout.write(lines.join("\n") + "\n");
1056
+ }
1057
+ break;
1058
+ }
1059
+ if (sub === "search") {
1060
+ const usage = "usage: memory.mjs index search <query> [dir] [--files <glob,glob>] [--k <n>] [--json]";
1061
+ const [query, ...extra] = subrest;
1062
+ if (!query) fail(usage, 2);
1063
+ const dir = dirFrom(extra, 0);
1064
+ const files = opts.files
1065
+ ? String(opts.files)
1066
+ .split(",")
1067
+ .map((s) => s.trim())
1068
+ .filter(Boolean)
1069
+ : [];
1070
+ let k;
1071
+ if (opts.k !== undefined) {
1072
+ k = Number(opts.k);
1073
+ if (!Number.isInteger(k) || k < 1) {
1074
+ fail(`${usage}\n--k must be an integer >= 1, got ${JSON.stringify(opts.k)}.`, 2);
1075
+ }
1076
+ }
1077
+ const provider = createProvider(readMemoryConfig(dir));
1078
+ const { openMemory } = await import("../lib/memory.mjs");
1079
+ const { searchIndex } = await import("../lib/code-index.mjs");
1080
+ const m = openMemory(dir);
1081
+ let result;
1082
+ try {
1083
+ result = await searchIndex(m, query, { k, files, provider });
1084
+ } finally {
1085
+ m.close();
1086
+ }
1087
+ if (opts.json) {
1088
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1089
+ } else {
1090
+ const header =
1091
+ result.mode === "hybrid"
1092
+ ? `index: hybrid via ${result.provider} ${provider.model}/${provider.dims}`
1093
+ : `index: keyword-only (${result.reason})`;
1094
+ const lines = [
1095
+ header,
1096
+ ...result.chunks.map((c) => {
1097
+ const firstLine = c.text.split("\n").find((l) => l.trim()) || "";
1098
+ const snippet = firstLine.trim().slice(0, 80);
1099
+ return `${c.path}:${c.startLine}-${c.endLine} ${c.score.toFixed(4)} ${snippet}`;
1100
+ }),
1101
+ ];
1102
+ process.stdout.write(lines.join("\n") + "\n");
1103
+ }
1104
+ break;
1105
+ }
1106
+ fail("usage: memory.mjs index <build|status|search> ...", 2);
1107
+ break;
1108
+ }
1109
+ case "decision": {
1110
+ const usage =
1111
+ "usage: memory.mjs decision propose --title <text> --file <path> [--date YYYY-MM-DD] [dir] [--json]\n" +
1112
+ " memory.mjs decision amend <NNNN> (--text <text> | --file <path>) [--date YYYY-MM-DD] [dir] [--json]";
1113
+ const [sub, ...subrest] = rest;
1114
+ const { proposeDecision, proposeAmendment } = await import("../lib/decisions.mjs");
1115
+ if (sub === "propose") {
1116
+ if (!opts.title || !opts.file) fail(usage, 2);
1117
+ const dir = dirFrom(subrest, 0);
1118
+ const result = proposeDecision({ dir, title: opts.title, body: readTextArg(opts.file), date: textOrNull(opts.date) || today() });
1119
+ printProposalResult(result, opts.json);
1120
+ break;
1121
+ }
1122
+ if (sub === "amend") {
1123
+ const [id, ...idRest] = subrest;
1124
+ if (!id || (!opts.text && !opts.file)) fail(usage, 2);
1125
+ const dir = dirFrom(idRest, 0);
1126
+ const text = opts.file ? readTextArg(opts.file) : opts.text;
1127
+ const result = proposeAmendment({ dir, id, text, date: textOrNull(opts.date) || today() });
1128
+ printProposalResult(result, opts.json);
1129
+ break;
1130
+ }
1131
+ fail(usage, 2);
1132
+ break;
1133
+ }
1134
+ case "rule": {
1135
+ const usage = "usage: memory.mjs rule propose <name> --file <path> [--paths <glob,glob>] [dir] [--json]";
1136
+ const [sub, ...subrest] = rest;
1137
+ if (sub !== "propose") fail(usage, 2);
1138
+ const [name, ...nameRest] = subrest;
1139
+ if (!name || !opts.file) fail(usage, 2);
1140
+ const dir = dirFrom(nameRest, 0);
1141
+ const paths = opts.paths
1142
+ ? String(opts.paths)
1143
+ .split(",")
1144
+ .map((s) => s.trim())
1145
+ .filter(Boolean)
1146
+ : [];
1147
+ const { proposeRule } = await import("../lib/decisions.mjs");
1148
+ const result = proposeRule({ dir, name, paths, body: readTextArg(opts.file) });
1149
+ printProposalResult(result, opts.json);
1150
+ break;
1151
+ }
1152
+ case "condition": {
1153
+ const usage = "usage: memory.mjs condition open <card-id> [dir] [--json]\n memory.mjs condition backfill [dir] [--json]";
1154
+ const [sub, ...subrest] = rest;
1155
+ if (sub === "open") {
1156
+ const [id, ...idRest] = subrest;
1157
+ if (!id) fail(usage, 2);
1158
+ const dir = dirFrom(idRest, 0);
1159
+ let card;
1160
+ try {
1161
+ const board = loadBoard(dir);
1162
+ card = findCard(board, id);
1163
+ } catch (err) {
1164
+ fail(`cannot open condition for "${id}": ${err.message}`);
1165
+ }
1166
+ if (!card.class) {
1167
+ fail(`cannot open condition for "${id}": the card has no class; give it one on the board first.`);
1168
+ }
1169
+ const relFile = `.doug/.state/reports/${id}/condition.json`;
1170
+ const file = join(dir, relFile);
1171
+ if (existsSync(file)) {
1172
+ fail(`${relFile} already exists; refusing to overwrite. Remove it first if you mean to reopen this landing's condition.`);
1173
+ }
1174
+ const headRes = spawnSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" });
1175
+ if (headRes.status !== 0 || !headRes.stdout.trim()) {
1176
+ 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)"}.`);
1177
+ }
1178
+ const { conditionConfigHash } = await import("../lib/memory.mjs");
1179
+ const written = {
1180
+ harnessCommit: headRes.stdout.trim(),
1181
+ configHash: conditionConfigHash(dir),
1182
+ class: card.class,
1183
+ arm: `${card.class}/default`,
1184
+ assignedBy: "policy",
1185
+ exploreProbability: null,
1186
+ openedAt: new Date().toISOString(),
1187
+ };
1188
+ mkdirSync(dirname(file), { recursive: true });
1189
+ writeFileSync(file, JSON.stringify(written, null, 2) + "\n");
1190
+ if (opts.json) {
1191
+ process.stdout.write(JSON.stringify(written, null, 2) + "\n");
1192
+ } else {
1193
+ process.stdout.write(`Wrote ${relFile}\n`);
1194
+ }
1195
+ break;
1196
+ }
1197
+ if (sub === "backfill") {
1198
+ const dir = dirFrom(subrest, 0);
1199
+ let board = null;
1200
+ try {
1201
+ board = loadBoard(dir);
1202
+ } catch {
1203
+ board = null;
1204
+ }
1205
+ // `main` must resolve before the per-row loop: without this, a missing repo or a missing `main` ref
1206
+ // makes every `git log` call below fail the same way an honest "no commit at or before this row's
1207
+ // timestamp" does (empty stdout), so every row would be silently, wrongly left uncounted with no word
1208
+ // said about why. Checked once, not once per row.
1209
+ const mainRes = spawnSync("git", ["rev-parse", "--verify", "--quiet", "main^{commit}"], { cwd: dir, encoding: "utf8" });
1210
+ const hasMain = mainRes.status === 0;
1211
+ if (!hasMain) {
1212
+ process.stderr.write(`no "main" ref in ${dir}; nothing to backfill against.\n`);
1213
+ }
1214
+ const { openMemory } = await import("../lib/memory.mjs");
1215
+ const m = openMemory(dir);
1216
+ let backfilled = 0;
1217
+ try {
1218
+ if (hasMain) {
1219
+ const rows = m.prepare("SELECT id, recorded, card FROM outcomes WHERE harness_commit IS NULL").all();
1220
+ for (const row of rows) {
1221
+ const gitRes = spawnSync(
1222
+ "git",
1223
+ ["log", "-1", "--first-parent", "--format=%H", `--before=${row.recorded}`, "main", "--"],
1224
+ { cwd: dir, encoding: "utf8" }
1225
+ );
1226
+ const sha = gitRes.status === 0 ? gitRes.stdout.trim() : "";
1227
+ if (!sha) continue;
1228
+ let cardClass = null;
1229
+ if (board && row.card) {
1230
+ try {
1231
+ cardClass = findCard(board, row.card).class ?? null;
1232
+ } catch {
1233
+ cardClass = null;
1234
+ }
1235
+ }
1236
+ m.prepare("UPDATE outcomes SET harness_commit = ?, backfilled = 1, class = COALESCE(?, class) WHERE id = ?").run(sha, cardClass, row.id);
1237
+ backfilled++;
1238
+ }
1239
+ }
1240
+ } finally {
1241
+ m.close();
1242
+ }
1243
+ if (opts.json) {
1244
+ process.stdout.write(JSON.stringify({ backfilled }, null, 2) + "\n");
1245
+ } else {
1246
+ process.stdout.write(`Backfilled ${backfilled} outcomes rows with a harness commit.\n`);
1247
+ }
1248
+ break;
1249
+ }
1250
+ fail(usage, 2);
1251
+ break;
1252
+ }
1253
+ case "claude-md": {
1254
+ const usage = "usage: memory.mjs claude-md propose --file <path|-> [dir] [--json]";
1255
+ const [sub, ...subrest] = rest;
1256
+ if (sub !== "propose" || !opts.file) fail(usage, 2);
1257
+ const dir = dirFrom(subrest, 0);
1258
+ let edits;
1259
+ try {
1260
+ edits = JSON.parse(readTextArg(opts.file));
1261
+ } catch (err) {
1262
+ fail(`${opts.file} is not valid JSON: ${err.message}`, 1);
1263
+ }
1264
+ const { proposeClaudeMd } = await import("../lib/decisions.mjs");
1265
+ const result = proposeClaudeMd({ dir, edits });
1266
+ printProposalResult(result, opts.json);
1267
+ break;
1268
+ }
1269
+ default:
1270
+ fail("usage: memory.mjs <record|reflect|outcomes|lessons|lesson|import|recall|doctor|reembed|embed-check|index|condition|decision|rule|claude-md> ...", 2);
1271
+ }
1272
+ } catch (err) {
1273
+ fail(err.message);
1274
+ }