ruvnet-brain 4.3.28 → 4.3.29

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 (73) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +74 -7
  3. package/console/app.js +7 -2
  4. package/kb/corpus-release-identity.mjs +73 -0
  5. package/package.json +4 -3
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/mcp/managed-cli-interface.mjs +83 -9
  9. package/plugin/scripts/advocacy-route.mjs +4 -1
  10. package/plugin/scripts/capacity-aware-parallel-work.mjs +4 -0
  11. package/plugin/scripts/continuation-gate.mjs +71 -0
  12. package/plugin/scripts/decision-gate.mjs +29 -57
  13. package/plugin/scripts/ground-ruvnet.sh +77 -4
  14. package/plugin/scripts/grounding-stamp.sh +34 -5
  15. package/plugin/scripts/grounding-turn-gate.mjs +8 -2
  16. package/plugin/scripts/grounding-turn-mark.mjs +6 -1
  17. package/plugin/scripts/hook-input.mjs +54 -0
  18. package/plugin/scripts/hook-shim.mjs +10 -0
  19. package/plugin/scripts/memory-doctor.mjs +43 -0
  20. package/plugin/scripts/nightly-controller.mjs +6 -1
  21. package/plugin/scripts/project-progression-contract.mjs +17 -0
  22. package/plugin/scripts/project-progression-hook.mjs +18 -5
  23. package/plugin/scripts/project-progression-producer.mjs +16 -12
  24. package/plugin/scripts/ruvnet-gate1-pattern.mjs +17 -0
  25. package/plugin/scripts/session-start-core.mjs +11 -5
  26. package/plugin/scripts/session-start-update-plane.mjs +5 -1
  27. package/plugin/scripts/unprompted-runtime.mjs +29 -8
  28. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +1 -1
  29. package/scripts/adr-072-completion.mjs +2 -1
  30. package/scripts/calibrate-router.mjs +6 -6
  31. package/scripts/corpus-reconcile.mjs +32 -3
  32. package/scripts/correction-detect.mjs +10 -11
  33. package/scripts/dispatch-receipt.mjs +2 -2
  34. package/scripts/dual-host-deliberation.mjs +3 -2
  35. package/scripts/execution-policy.mjs +10 -2
  36. package/scripts/gen-console-images.mjs +0 -1
  37. package/scripts/gen-images.mjs +0 -4
  38. package/scripts/host-install-matrix.mjs +7 -4
  39. package/scripts/independent-review-receipt.mjs +7 -8
  40. package/scripts/ingest-repo.mjs +45 -1
  41. package/scripts/learning-replay-cli.mjs +2 -1
  42. package/scripts/learning-replay-fixture.mjs +2 -1
  43. package/scripts/learnings.mjs +19 -5
  44. package/scripts/lesson-migrate-agentdb.mjs +635 -0
  45. package/scripts/loop-checkpoint.mjs +37 -1
  46. package/scripts/metaharness-receipts.mjs +3 -2
  47. package/scripts/nightly-two-run-proof.mjs +6 -6
  48. package/scripts/nightly-watchdog.mjs +10 -2
  49. package/scripts/onboarding-console.mjs +98 -49
  50. package/scripts/oracle/producer-hosts.mjs +2 -1
  51. package/scripts/private-overlay.mjs +31 -9
  52. package/scripts/public-verification-aggregate.mjs +4 -3
  53. package/scripts/publication-receipt.mjs +9 -0
  54. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  55. package/scripts/rebuild-gists-from-receipts.mjs +1 -1
  56. package/scripts/reconcile-project.mjs +0 -0
  57. package/scripts/release.mjs +34 -82
  58. package/scripts/retrieval-canary.mjs +3 -2
  59. package/scripts/review-model-defaults.mjs +26 -0
  60. package/scripts/route-cheap.mjs +20 -7
  61. package/scripts/router-utilization.mjs +5 -5
  62. package/scripts/rvf-generation.mjs +68 -2
  63. package/scripts/single-source-check.mjs +270 -0
  64. package/scripts/subscription-hosts.mjs +4 -0
  65. package/scripts/sync-version.mjs +22 -0
  66. package/scripts/trismart.mjs +3 -3
  67. package/scripts/wired-check.mjs +40 -4
  68. package/console/assets/memory.webp +0 -0
  69. package/plugin/scripts/version-bump-gate.sh +0 -124
  70. package/scripts/qe/aggregate-4.3.mjs +0 -42
  71. package/scripts/release-convergence-watchdog.mjs +0 -112
  72. package/scripts/stamp-existing-rvf-generations.mjs +0 -53
  73. package/scripts/verify-channels.mjs +0 -196
@@ -0,0 +1,635 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lesson-migrate-agentdb.mjs — ONE-TIME reconciliation: fold every `lesson-*` row from the two
4
+ * AgentDB stores into the ONE plugin lesson store that lesson-gate.mjs actually reads
5
+ * (~/.config/ruvnet-brain/lessons.json), losing nothing.
6
+ *
7
+ * WHY THIS EXISTS ALONGSIDE plugin/scripts/lesson-bridge.mjs. The bridge already carries any
8
+ * AgentDB row that is TAGGED with `trigger:<key>` into the store, 1:1, as `G-<slug>` (global) /
9
+ * `P-<slug>` (project) — and running it has already merged 44 of the 91 `lesson-*` rows this repo's
10
+ * two AgentDB stores hold (verified live 2026-09-26: the store already contains 34 `G-*` and 10 `P-*`
11
+ * ids whose evidence cites the exact bridged key). The bridge does ONE thing on purpose and refuses
12
+ * to guess a trigger for an untagged row (ADR-066: a keyword classifier is exactly what produced a
13
+ * false positive once already) — and it never asks "does an EXISTING lesson already say this?", so a
14
+ * rediscovery of the same rule under a new key becomes a duplicate row, not a merge.
15
+ *
16
+ * This script is the other half: for the 47 rows the bridge cannot place (13 untagged in the global
17
+ * store, 35 in the project store — because untagged rows are the norm there, not the exception), a
18
+ * human (this migration's author) read every one against the current 61-entry store and recorded a
19
+ * decision — MERGE into an existing entry that already states the same rule (by MEANING, not string
20
+ * match), or NEW where nothing in the store covers it yet. That judgment cannot be automated without
21
+ * repeating ADR-066's mistake, so it is captured once, here, as data (MANUAL_MAP below), and the
22
+ * script only APPLIES it — mechanically, idempotently, and never silently dropping a row it does not
23
+ * recognise.
24
+ *
25
+ * READ PATH: `ruflo memory list --format json` + `ruflo memory retrieve --format json` — the CLI,
26
+ * never raw sqlite3 (this repo's memory policy exists specifically to prevent a second, disconnected
27
+ * read/write path from developing by accident).
28
+ *
29
+ * WRITE PATH: plugin/scripts/lesson-store.mjs's own `updateLessons()` — locked, atomic, backed up,
30
+ * and refuses to shrink the store. A MERGE never rewrites an existing lesson's `statement`; it only
31
+ * appends one evidence line (idempotent: skipped if that exact source key is already cited anywhere
32
+ * in the target's evidence — which is also how "already bridged" rows are detected, see
33
+ * `alreadyCites()`). A NEW lesson is created once, under an `L##` id (never `G-`/`P-` — those
34
+ * prefixes are lesson-bridge's own and `mergeBridged()` treats them as fully OWNED, i.e. a future
35
+ * bridge run wholesale-replaces every `G-*`/`P-*` id with what it currently recomputes from tagged
36
+ * rows; minting new content under those prefixes would make it silently vanish the next time
37
+ * lesson-bridge --apply runs. `L##` continues this store's own native numbering (L01-L17 already
38
+ * exist) and is untouched by the bridge).
39
+ *
40
+ * COUNTS ABOVE ARE POINT-IN-TIME (verified live 2026-09-26), NOT A CONTRACT — both AgentDB stores are
41
+ * live and grow during ordinary sessions (one new dual-homed row, lesson-blast-radius-machine-wide-
42
+ * changes, appeared in both stores between this script's dry-run and its first --apply, mid-authoring
43
+ * of this very file). MANUAL_MAP is therefore necessarily a snapshot too. The script does NOT trust
44
+ * that snapshot to be exhaustive: any row it cannot place via an already-bridged id OR an exact
45
+ * MANUAL_MAP key is a hard refusal (see `unmapped` below), never a silent skip — add a decision and
46
+ * re-run rather than assume "not in the table" means "not real".
47
+ *
48
+ * Usage:
49
+ * node scripts/lesson-migrate-agentdb.mjs --dry-run # prints the full mapping table, writes nothing
50
+ * node scripts/lesson-migrate-agentdb.mjs --apply # merges/creates (locked, atomic, backed up)
51
+ * node scripts/lesson-migrate-agentdb.mjs --dry-run --json
52
+ */
53
+ import path from 'node:path';
54
+ import os from 'node:os';
55
+ import fs from 'node:fs';
56
+ import { execFileSync } from 'node:child_process';
57
+ import {
58
+ makeLesson, loadLessons, updateLessons, ORIGIN, STATUS, ENFORCEMENT, TRIGGERS,
59
+ } from './lesson-store.mjs';
60
+
61
+ const RUFLO = process.env.RUFLO_BIN || 'ruflo';
62
+ const GLOBAL_DB = process.env.RUVNET_GLOBAL_MEMORY_DB
63
+ || path.join(os.homedir(), '.claude', 'global-memory', '.swarm', 'memory.db');
64
+ const GLOBAL_NS = process.env.RUVNET_GLOBAL_MEMORY_NS || 'global';
65
+ const PROJECT_DB = process.env.RUVNET_PROJECT_MEMORY_DB
66
+ || '/Users/stuartkerr/Code/ruvnet-brain/.swarm/memory.db';
67
+ const PROJECT_LABEL = 'project:ruvnet-brain';
68
+
69
+ const MIGRATION_MARK = 'lesson-migrate-agentdb.mjs';
70
+
71
+ // ── ruflo CLI, never raw sqlite3 ─────────────────────────────────────────────────────────────────
72
+ // execFileSync's own stdout PIPE truncates ruflo's output at exactly 65536 bytes (measured
73
+ // 2026-09-26 — a wrapper/child-process quirk in the ruflo CLI, not a maxBuffer limit: maxBuffer was
74
+ // already generous and the truncation is byte-exact at 64KiB regardless of it). Routing stdout to a
75
+ // real file descriptor instead of a pipe avoids it entirely — proven on the same command/dataset.
76
+ const SCRATCH_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'lesson-migrate-'));
77
+ let callCounter = 0;
78
+ // The CLI also occasionally prints a one-time banner to STDOUT ahead of the JSON payload — observed
79
+ // live: "Transformers.js loaded: ..." and, on the very first invocation in a fresh process context,
80
+ // "[INFO] Started Ruflo background daemon for ...". Neither is documented as part of the `--format
81
+ // json` contract and both are harmless, but either breaks a naive JSON.parse. Strip only KNOWN
82
+ // banner-line prefixes (never guess at bracket positions — a banner can itself contain '[', e.g.
83
+ // "[INFO] ...", which would defeat a "find the first [" heuristic) and parse what remains.
84
+ const BANNER_LINE = /^(\[(INFO|OK|WARN|WARNING|ERROR|SUCCESS)\]|Transformers\.js loaded:)/;
85
+ function stripBanners(text) {
86
+ return text.split('\n').filter((line) => !BANNER_LINE.test(line.trim())).join('\n');
87
+ }
88
+ // This machine runs many concurrent agent worktrees against the SAME live AgentDB files (verified
89
+ // live 2026-09-26: a `ruflo memory retrieve` on the project store hung indefinitely under real
90
+ // write contention from sibling sessions). A read that can wait forever on a lock turns "read 93
91
+ // rows" into "hang forever" — the exact class of defensive-flag mistake this repo has hit before
92
+ // (nightly-wrapper's `-readonly`/`timeout` removal, same lesson). Bound every call and fail LOUDLY
93
+ // with the exact command, never retry-in-a-loop against a possibly-still-locked file.
94
+ const CLI_TIMEOUT_MS = Number(process.env.RUVNET_LESSON_MIGRATE_TIMEOUT_MS) || 20_000;
95
+ function rufloJson(args) {
96
+ const tmp = path.join(SCRATCH_DIR, `call-${callCounter++}.json`);
97
+ const fd = fs.openSync(tmp, 'w');
98
+ try {
99
+ execFileSync(RUFLO, args, { stdio: ['ignore', fd, 'ignore'], timeout: CLI_TIMEOUT_MS });
100
+ } catch (e) {
101
+ if (e.signal || e.code === 'ETIMEDOUT') {
102
+ throw new Error(`ruflo ${args.join(' ')} did not finish within ${CLI_TIMEOUT_MS}ms (killed with ${e.signal || e.code}) — likely lock contention from a concurrent writer on this shared machine. Re-run.`);
103
+ }
104
+ throw e;
105
+ } finally {
106
+ fs.closeSync(fd);
107
+ }
108
+ const raw = fs.readFileSync(tmp, 'utf8');
109
+ fs.rmSync(tmp, { force: true });
110
+ try {
111
+ return JSON.parse(stripBanners(raw));
112
+ } catch (e) {
113
+ throw new Error(`ruflo ${args.join(' ')} did not return parseable JSON after stripping known banners: ${e.message}\n--- raw output (first 500 chars) ---\n${raw.slice(0, 500)}`);
114
+ }
115
+ }
116
+ process.on('exit', () => { try { fs.rmSync(SCRATCH_DIR, { recursive: true, force: true }); } catch { /* best effort */ } });
117
+
118
+ /** Every `lesson*` row visible via `ruflo memory list`, across every namespace when none is given. */
119
+ function listLessonRows(dbPath, namespace) {
120
+ const args = ['memory', 'list', '--path', dbPath, '--limit', '5000', '--format', 'json'];
121
+ if (namespace) args.push('-n', namespace);
122
+ let rows;
123
+ try { rows = rufloJson(args); } catch (e) {
124
+ throw new Error(`ruflo memory list failed for ${dbPath}${namespace ? ` -n ${namespace}` : ''}: ${e.message}`);
125
+ }
126
+ return rows.filter((r) => typeof r.key === 'string' && r.key.startsWith('lesson'));
127
+ }
128
+
129
+ /** Full row (content + tags + timestamps) for one key, via `ruflo memory retrieve`. */
130
+ function retrieveRow(dbPath, namespace, key) {
131
+ return rufloJson(['memory', 'retrieve', '--path', dbPath, '-n', namespace, '-k', key, '--format', 'json']);
132
+ }
133
+
134
+ function readStoreB() {
135
+ return listLessonRows(GLOBAL_DB, GLOBAL_NS).map((e) => retrieveRow(GLOBAL_DB, GLOBAL_NS, e.key));
136
+ }
137
+ function readStoreC() {
138
+ return listLessonRows(PROJECT_DB).map((e) => retrieveRow(PROJECT_DB, e.namespace, e.key));
139
+ }
140
+
141
+ function whenOf(row) {
142
+ const ts = Number(row.updatedAt || row.createdAt || 0);
143
+ return ts > 0 ? new Date(ts).toISOString().slice(0, 10) : 'unknown date';
144
+ }
145
+
146
+ /** The exact string every evidence line for a source row carries — this IS the provenance marker,
147
+ * both for lesson-bridge's own auto-merged rows (their evidence already contains it verbatim) and
148
+ * for every merge/new evidence line this script writes. Presence of `row.key` inside an entry's
149
+ * evidence[] is the single source of truth for "has this AgentDB row already been accounted for". */
150
+ function citationFor(storeLabel, dbPath, row) {
151
+ return `[AgentDB ${storeLabel}/${row.key}, recorded ${whenOf(row)}, store ${dbPath}]: ${row.content}`;
152
+ }
153
+
154
+ /** Is `key` cited in this lesson's evidence — as the WHOLE key, never merely as a hyphen-prefix of a
155
+ * longer one? Plain substring search has a real false-positive class here: two source keys in this
156
+ * dataset are literal prefixes of each other (`lesson-npx-disease` / `lesson-npx-disease-1784075245`,
157
+ * `lesson-verify-by-mechanism` / `lesson-verify-by-mechanism-not-instance`) — a naive `.includes()`
158
+ * reported the shorter key as "already cited" purely because the longer key's citation happened to
159
+ * be written first, and its own distinct content was silently never merged. Caught live 2026-09-26 on
160
+ * a second (idempotency-check) run, by diffing what SHOULD have been written against what was. Fixed
161
+ * by requiring a non-identifier character (or end of string) immediately after the match. */
162
+ function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
163
+ function alreadyCites(lesson, key) {
164
+ if (!Array.isArray(lesson.evidence)) return false;
165
+ const re = new RegExp(escapeRegExp(key) + '(?![A-Za-z0-9-])');
166
+ return lesson.evidence.some((e) => re.test(String(e)));
167
+ }
168
+
169
+ // ── THE MANUAL MAP ───────────────────────────────────────────────────────────────────────────────
170
+ // Every row lesson-bridge.mjs cannot place (no `trigger:` tag today) because it is untagged in the
171
+ // live store. Read in full, decided by MEANING against the 61 entries already in
172
+ // ~/.config/ruvnet-brain/lessons.json (verified live 2026-09-26). `store` is 'B' (global,
173
+ // ~/.claude/global-memory/.swarm/memory.db, ns global) or 'C' (this project's own
174
+ // .swarm/memory.db, across all namespaces — verified live: ruvnet-brain, default, lessons all hold
175
+ // lesson-* rows, not only the three the task brief anticipated).
176
+ //
177
+ // decision 'NEW' -> id/trigger/enforcement/severity/origin/status/ratifiedBy/statement supplied.
178
+ // decision 'MERGE' -> target id supplied; the existing entry's statement is NEVER rewritten.
179
+ const NEW_META = {
180
+ L18: {
181
+ statement: 'Before answering or handing over a command, read the whole implementation chain — '
182
+ + 'the code, its callers, its tests, and every gate a multi-step operation depends on. When '
183
+ + "something is broken, first find the last time it worked and diff that window before "
184
+ + "theorising; distinguish 'never worked' from 'regressed' before proposing a fix.",
185
+ trigger: TRIGGERS.MUTATE_MACHINE.key, enforcement: ENFORCEMENT.INJECT, severity: 'high',
186
+ origin: ORIGIN.USER_STATED, status: STATUS.RATIFIED,
187
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (Non-Negotiable #3, Stuart 2026-09-15)`,
188
+ },
189
+ L19: {
190
+ statement: "When told to do something, do it — and when you say 'I will do X next', X happens "
191
+ + 'next, not after another detour. Do not silently decline, defer, or substitute your own '
192
+ + 'judgement for a direct instruction; if you cannot comply, say so immediately and why, and '
193
+ + 'if you disagree, say so once, then comply if it is repeated.',
194
+ trigger: TRIGGERS.CHOOSE_WORK.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'high',
195
+ origin: ORIGIN.USER_STATED, status: STATUS.RATIFIED,
196
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (Non-Negotiable #1, Stuart 2026-09-15)`,
197
+ },
198
+ L20: {
199
+ statement: 'When writing a guard against a metered or free-tier allowance, sum every resource '
200
+ + "the provider aggregates into that quota — not just the one you're adding — and hard-fail "
201
+ + 'rather than warn; print the arithmetic so the check is auditable, and prove the guard by '
202
+ + 'breaking it.',
203
+ trigger: TRIGGERS.WRITE_CODE.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
204
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED,
205
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (standing lesson, AppealArmor 2026-08-22)`,
206
+ },
207
+ L21: {
208
+ statement: 'Before naming a host for any PHI-bearing or compliance-sensitive component, price '
209
+ + 'the compliance artifact (BAA/DPA/SOC2) as part of the decision and record it in the ADR — a '
210
+ + 'vendor that charges for it is a red flag, not a fixed cost, since major hyperscalers provide '
211
+ + 'it free; isolate regulated data in a dedicated project/account and gate provisioning in '
212
+ + 'code, not prose.',
213
+ trigger: TRIGGERS.RECOMMEND_ARCH.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
214
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED,
215
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (standing lesson, AppealArmor 2026-08-22)`,
216
+ },
217
+ L22: {
218
+ statement: "When reviewing a tool that wraps a client's transport (proxy, shim, interceptor), "
219
+ + 'also measure its effect on how the client and server NEGOTIATE — feature flags, capability '
220
+ + 'headers, prompt-cache behaviour, retry semantics — not only whether it exfiltrates data; and '
221
+ + 'when you finally measure, be willing to exonerate the suspect if the wire evidence clears it.',
222
+ trigger: TRIGGERS.RECOMMEND_ARCH.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
223
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED,
224
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (standing lesson, 2026-08-21)`,
225
+ },
226
+ L23: {
227
+ statement: 'On this Mac, `system_profiler SPUSBDataType` can return empty output even when USB '
228
+ + 'hardware is present and active — never treat that as proof of absence; use `ioreg -p IOUSB` '
229
+ + '/ `ioreg -r -c IOUSBHostDevice -l` for USB device truth instead.',
230
+ trigger: TRIGGERS.ASSERT_FACT.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
231
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED,
232
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (standing lesson, verified live 2026-08-06)`,
233
+ },
234
+ L24: {
235
+ statement: 'Before starting a parallel/concurrent Claude Code session in a shared directory, '
236
+ + 'isolate it in its own git worktree (never share the main checkout), commit only by explicit '
237
+ + "pathspec (never `add -A`/`add .`/`commit -a`/`stash`/`reset --hard`/branch switches in a "
238
+ + 'shared tree), and finish by running the real gates and asking before merging.',
239
+ trigger: TRIGGERS.MUTATE_MACHINE.key, enforcement: ENFORCEMENT.INJECT, severity: 'high',
240
+ origin: ORIGIN.USER_STATED, status: STATUS.RATIFIED,
241
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (standing lesson, Helix 2026-07-26)`,
242
+ },
243
+ L25: {
244
+ statement: 'Before any production `claude -p` run, measure and shed loaded context (empty/strict '
245
+ + 'MCP config, local setting sources, `--disable-slash-commands`, '
246
+ + '`--exclude-dynamic-system-prompt-sections`) and re-probe `cache_creation_input_tokens` on a '
247
+ + 'one-word call — an unshed call can cost two orders of magnitude more context than a shed '
248
+ + 'one. Keep subscription auth; never `--bare`, which forces API-key billing.',
249
+ trigger: TRIGGERS.WRITE_CODE.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'high',
250
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
251
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (measured 2026-09-14)`,
252
+ },
253
+ L26: {
254
+ statement: 'A try/catch around `JSON.parse` (or a bare `fs.existsSync`) proves the bytes parsed '
255
+ + 'as JSON, not that the content is valid — when reviewing or writing a validation/seal check, '
256
+ + 'point at the explicit assertion (kind===, schemaVersion===, digest recompute, membership '
257
+ + "check) that would throw on `{}` or a wrong value; if you can't point at that line, there is "
258
+ + 'no validation.',
259
+ trigger: TRIGGERS.WRITE_CODE.key, enforcement: ENFORCEMENT.INJECT, severity: 'normal',
260
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
261
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (standing lesson, 2026-09-13)`,
262
+ },
263
+ L27: {
264
+ statement: 'If a release fails at the publish step with CI green and all evidence inputs valid, '
265
+ + 'check for a stale non-terminal release transaction blocking new ones before assuming the '
266
+ + 'seal/lineage logic is broken; use the repo\'s own purpose-built recovery tool rather than '
267
+ + 'hand-rolling a fix.',
268
+ trigger: TRIGGERS.MUTATE_MACHINE.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
269
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
270
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (standing lesson, 2026-08-20)`,
271
+ },
272
+ L28: {
273
+ statement: 'AgentDB/ruflo memory persistence silently depends on which Node binary\'s ABI '
274
+ + "matches the machine's compiled better-sqlite3 binding — a PATH or shebang resolving to the "
275
+ + 'wrong Node version makes writes fall back to a non-persistent backend with no error and a '
276
+ + "printed success table. Before treating 'memory keeps breaking' as a code bug, check which "
277
+ + 'node every caller (including launchd/cron jobs, which may hardcode PATH order) actually '
278
+ + 'resolves to, and verify durability by an independent read-back, never by requiring the '
279
+ + 'module.',
280
+ trigger: TRIGGERS.ASSERT_FACT.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'high',
281
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
282
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (root-caused 2026-08-20)`,
283
+ },
284
+ L29: {
285
+ statement: "A monitoring or escalation system's acknowledgment predicate must never be "
286
+ + "satisfiable by the automation it is monitoring — if the fixer's own comments can satisfy "
287
+ + "the watcher's 'owner responded' check, the system silences its own alarm. A state write for "
288
+ + "dedup/attempt-tracking must also merge fields it doesn't own, never replace the whole "
289
+ + 'record.',
290
+ trigger: TRIGGERS.RECOMMEND_ARCH.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'high',
291
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
292
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (ADR-050, measured 2026-07-24)`,
293
+ },
294
+ L30: {
295
+ statement: 'An installed-but-dormant capability is a defect to surface proactively, never a '
296
+ + 'neutral state to wait and be asked about — notice what the user is actually trying to do '
297
+ + 'and recommend only the specific capability that serves THAT goal (goal-aware matching, not '
298
+ + 'evangelism); detection without a recommended fix, and an offer to apply it, is not enough.',
299
+ trigger: TRIGGERS.RECOMMEND_ARCH.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
300
+ origin: ORIGIN.USER_STATED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
301
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (product thesis, Stuart 2026-07-21)`,
302
+ },
303
+ L31: {
304
+ statement: 'Before deduplicating or shipping any build artifact, QA the assembled BUNDLE itself, '
305
+ + 'not the local workspace — a file that looks like a byte-identical duplicate in the '
306
+ + 'workspace (e.g. a sidecar opened by name at runtime) can be load-bearing in the packaged '
307
+ + 'output even though `cmp` shows it identical to another file.',
308
+ trigger: TRIGGERS.SHIP.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
309
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
310
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (cost a broken release, 2026-07-15)`,
311
+ },
312
+ L32: {
313
+ statement: 'A verification check built only from the instances you already found can only '
314
+ + 're-confirm those instances — it is structurally incapable of revealing what you missed. '
315
+ + 'Enumerate and instrument by MECHANISM (what CAN cause the problem, at every scope/depth) '
316
+ + 'instead, and verify by the effect the user cares about, never by the cause you touched.',
317
+ trigger: TRIGGERS.WRITE_CODE.key, enforcement: ENFORCEMENT.INJECT, severity: 'high',
318
+ origin: ORIGIN.USER_STATED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
319
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (7 failures in one session, 2026-07-14)`,
320
+ },
321
+ L33: {
322
+ statement: 'Kling renders exactly the loudness a soundscape prompt describes — a prompt naming '
323
+ + 'only delicate sounds comes back near-silent; always name at least one sound with body/'
324
+ + "presence, and verify each clip's audio by looking at the waveform/spectrogram, never by a "
325
+ + 'single dB number. If a clip is empty, re-prompt — never amplify an empty track, which only '
326
+ + 'raises the noise floor.',
327
+ trigger: TRIGGERS.CLAIM_DONE.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
328
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
329
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (measured 2026-07-13)`,
330
+ },
331
+ L34: {
332
+ statement: 'AgentDB (ADR-174) is two tiers: `memory_entries` is a raw recording tier, and '
333
+ + '`memory distill` mines it into episodes/patterns/causal edges on a per-namespace cursor. '
334
+ + '`causal_edges` are weak co-occurrence by design (may rank retrieval, may NOT justify '
335
+ + "action), and only real execution outcomes or a budgeted judge can promote a pattern — 'zero "
336
+ + "promoted' usually means starved input, not a broken gate. Know this before reporting "
337
+ + 'AgentDB/distillation as broken.',
338
+ trigger: TRIGGERS.RECOMMEND_ARCH.key, enforcement: ENFORCEMENT.CHECKLIST, severity: 'normal',
339
+ origin: ORIGIN.IMPORTED, status: STATUS.RATIFIED, projects: ['ruvnet-brain'],
340
+ ratifiedBy: `agentdb-migration:${MIGRATION_MARK} (ADR-174, 2026-07-13)`,
341
+ },
342
+ };
343
+
344
+ // key -> { store, decision, target } | { store, decision, id } (id must have a matching NEW_META entry)
345
+ const MANUAL_MAP = {
346
+ // ── Store B (global, 12 untagged rows lesson-bridge cannot place) ──
347
+ 'lesson-read-whole-chain-bisect-first': { store: 'B', decision: 'NEW', id: 'L18',
348
+ why: 'No existing entry covers "read the whole chain / bisect first" (Non-Negotiable #3, 2026-09-15).' },
349
+ 'lesson-check-source-never-answer-from-memory': { store: 'B', decision: 'MERGE', target: 'L02-check-before-you-assert',
350
+ why: 'Same rule as L02 (check a live source before asserting a fact) — Non-Negotiable #2, adds 2026-09-15 evidence.' },
351
+ 'lesson-instruction-is-a-contract': { store: 'B', decision: 'NEW', id: 'L19',
352
+ why: 'No existing entry covers instruction-as-contract / "I will" as commitment (Non-Negotiable #1, 2026-09-15).' },
353
+ 'lesson-working-means-a-human-received-it': { store: 'B', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
354
+ why: 'Same rule as L01: "working" must be verified via a real outcome channel, never an exit code or a green check.' },
355
+ 'lesson-free-tier-guards-must-sum-not-sample': { store: 'B', decision: 'NEW', id: 'L20',
356
+ why: 'No existing entry covers billing/quota guard aggregation correctness.' },
357
+ 'lesson-price-the-baa-before-choosing-a-host': { store: 'B', decision: 'NEW', id: 'L21',
358
+ why: 'No existing entry covers pricing compliance artifacts as part of a hosting decision.' },
359
+ 'lesson-review-transport-wrappers-for-negotiation-effects': { store: 'B', decision: 'NEW', id: 'L22',
360
+ why: 'No existing entry covers reviewing transport wrappers for client/server negotiation effects.' },
361
+ 'lesson-success-signal-from-system-of-record': { store: 'B', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
362
+ why: 'Same rule as L01: success must come from a re-read of the system of record, never a pipe/exit-code artifact.' },
363
+ 'lesson-ruflo-mcp-memory-different-store': { store: 'B', decision: 'MERGE', target: 'G-capture-and-enforcement-must-share-a-store',
364
+ why: 'Concrete instance of the exact rule already stated by G-capture-and-enforcement-must-share-a-store.' },
365
+ 'lesson-spusbdatatype-empty-use-ioreg': { store: 'B', decision: 'NEW', id: 'L23',
366
+ why: 'No existing entry covers this machine-specific USB detection trap.' },
367
+ 'lesson-parallel-window-playbook': { store: 'B', decision: 'NEW', id: 'L24',
368
+ why: 'No existing entry covers concurrent-worktree git safety.' },
369
+ 'lesson-inferred-cause-is-fabrication': { store: 'B', decision: 'MERGE', target: 'G-fabrication-feels-like-recall',
370
+ why: 'Fuller/earlier narrative version of the exact rule already bridged as G-fabrication-feels-like-recall (same opening line).' },
371
+
372
+ // ── Store C (project, 35 untagged rows across default/lessons/ruvnet-brain namespaces — the task
373
+ // brief's estimate of "5 keys" undercounted this store; 45 lesson-* rows exist live, 10 already
374
+ // bridged as P-*, 35 handled here) ──
375
+ 'lesson-claude-p-context-shedding-20260914': { store: 'C', decision: 'NEW', id: 'L25',
376
+ why: 'No existing entry covers claude -p context-shedding flags/cost.' },
377
+ 'lesson-claude-p-context-cost-20260914': { store: 'C', decision: 'MERGE', target: 'L25',
378
+ why: 'Same measurement as lesson-claude-p-context-shedding-20260914 (earlier snapshot of the same finding).' },
379
+ 'lesson-digest-cannot-see-ancestry-1789374': { store: 'C', decision: 'MERGE', target: 'G-green-ci-on-a-subset-proves-nothing',
380
+ why: 'Same mechanism: a verifier run without the flag that enables the real check reports green while checking less than assumed.' },
381
+ 'lesson-docs-only-merges-still-gate-1789350': { store: 'C', decision: 'MERGE', target: 'G-green-ci-on-a-subset-proves-nothing',
382
+ why: 'Same mechanism: running only the tests an authoring agent cited (a subset) and treating it as full-suite green.' },
383
+ 'lesson-try-catch-parse-is-not-validation-1789348': { store: 'C', decision: 'NEW', id: 'L26',
384
+ why: 'No existing entry covers this specific validation-depth coding pattern.' },
385
+ 'lesson-release-completion-is-live-20260823': { store: 'C', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
386
+ why: 'Same rule as L01: a release is not done until the live surface is verified, never the push/CI signal.' },
387
+ 'lesson-build-update-means-shipped-20260821': { store: 'C', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
388
+ why: 'Same rule as L01, applied to the release rail: one surface updated is not "shipped".' },
389
+ 'lesson-release-blocked-stale-txn-1787241710': { store: 'C', decision: 'NEW', id: 'L27',
390
+ why: 'No existing entry covers this specific release-transaction runbook fact.' },
391
+ 'lesson-agentdb-root-cause-FIXED-1787241183': { store: 'C', decision: 'NEW', id: 'L28',
392
+ why: 'No existing entry covers the Node-ABI/better-sqlite3 durability root cause.' },
393
+ 'lesson-agentdb-durability-root-cause-1787215055': { store: 'C', decision: 'MERGE', target: 'L28',
394
+ why: 'Earlier snapshot of the same root-cause investigation as lesson-agentdb-root-cause-FIXED-1787241183.' },
395
+ 'lesson-automation-cannot-ack-itself': { store: 'C', decision: 'NEW', id: 'L29',
396
+ why: 'No existing entry covers self-satisfiable monitoring predicates (explicitly flagged in its own text as not yet promoted).' },
397
+ 'lesson-ruvnet-wins-disagreements-1784676096': { store: 'C', decision: 'MERGE', target: 'G-assume-ruv-is-right-then-find-the-piece',
398
+ why: 'Same rule already bridged as G-assume-ruv-is-right-then-find-the-piece, earlier phrasing.' },
399
+ 'lesson-raison-detre-capability-advocacy-1784672810': { store: 'C', decision: 'MERGE', target: 'L30',
400
+ why: 'Supporting proof case for the same product-thesis rule as lesson-brain-must-advocate-not-answer-1784672438.' },
401
+ 'lesson-brain-must-advocate-not-answer-1784672438': { store: 'C', decision: 'NEW', id: 'L30',
402
+ why: 'No existing entry covers proactive capability advocacy as the product thesis.' },
403
+ 'lesson-learning-loop-must-be-on-by-default-1784661439': { store: 'C', decision: 'MERGE', target: 'L30',
404
+ why: 'Same "installed but dormant is a defect" rule as lesson-brain-must-advocate-not-answer-1784672438, restated.' },
405
+ 'lesson-shipped-means-walk-every-channel-1784075400': { store: 'C', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
406
+ why: 'Same rule as L01: never say shipped/fixed/works without walking the actual user path.' },
407
+ 'lesson-assumptions-equal-failure-1784345758515': { store: 'C', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
408
+ why: 'Same rule as L01, stated as its own absolute: an assumption about live state is a failure, not a shortcut.' },
409
+ 'lesson-verify-all-release-channels-1784345360076': { store: 'C', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
410
+ why: 'Same rule as L01, applied across every release channel independently.' },
411
+ 'lesson-narrative-version-sacrosanct-1784322539404': { store: 'C', decision: 'MERGE', target: 'L05-version-is-the-update-signal',
412
+ why: 'Same rule L05 already states explicitly ("the release narrative is updated to match").' },
413
+ 'lesson-proof-not-fake-1784316621498': { store: 'C', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
414
+ why: 'Same rule as L01: a status must be derived from a verifiable artifact, never asserted/hardcoded.' },
415
+ 'lesson-big-passages-is-required-not-dup-1784082256000': { store: 'C', decision: 'NEW', id: 'L31',
416
+ why: 'No existing entry covers QA-the-bundle-not-the-workspace.' },
417
+ 'lesson-capture-lessons-not-transcripts': { store: 'C', decision: 'MERGE', target: 'P-capture-outcomes-not-prompts',
418
+ why: 'Same "curated signal vs raw telemetry" rule already bridged as P-capture-outcomes-not-prompts.' },
419
+ 'lesson-telemetry-drowns-signal': { store: 'C', decision: 'MERGE', target: 'P-capture-outcomes-not-prompts',
420
+ why: 'Same "curated signal vs raw telemetry" rule already bridged as P-capture-outcomes-not-prompts.' },
421
+ 'lesson-verify-by-mechanism': { store: 'C', decision: 'MERGE', target: 'L32',
422
+ why: 'Same rule as lesson-verify-by-mechanism-not-instance, shorter form, different worked example.' },
423
+ 'lesson-never-cp-live-db': { store: 'C', decision: 'MERGE', target: 'P-never-cp-a-live-database',
424
+ why: 'Same rule already bridged as P-never-cp-a-live-database, shorter form.' },
425
+ 'lesson-npx-disease': { store: 'C', decision: 'MERGE', target: 'L32',
426
+ why: 'The worked example cited inside lesson-verify-by-mechanism-not-instance\'s own text; same mechanism-not-instance rule.' },
427
+ 'lesson-npx-disease-1784075245': { store: 'C', decision: 'MERGE', target: 'L32',
428
+ why: 'Duplicate of lesson-npx-disease under a different key/namespace; same mechanism-not-instance rule.' },
429
+ 'lesson-inventory-before-you-buy': { store: 'C', decision: 'MERGE', target: 'P-apply-what-you-just-researched',
430
+ why: 'Same "don\'t default to the tool already in hand, inventory what exists first" rule as P-apply-what-you-just-researched.' },
431
+ 'lesson-verify-by-mechanism-not-instance': { store: 'C', decision: 'NEW', id: 'L32',
432
+ why: 'No existing entry covers verify-by-mechanism vs verify-by-instance; the fullest statement of this repeated (2x independent) lesson.' },
433
+ 'lesson-kling-sound-presence': { store: 'C', decision: 'NEW', id: 'L33',
434
+ why: 'No existing entry covers Kling audio-presence verification.' },
435
+ 'lesson-version-is-the-update-signal': { store: 'C', decision: 'MERGE', target: 'L05-version-is-the-update-signal',
436
+ why: 'Same rule L05 already states; this is the original project-side statement of it.' },
437
+ 'lesson-feedback-tier-first-promotion': { store: 'C', decision: 'MERGE', target: 'L01-verify-with-a-capable-channel',
438
+ why: 'Same "never fabricate a success signal" family as L01 — blanket --success true fabricates gold labels.' },
439
+ 'lesson-adr174-two-tier-memory': { store: 'C', decision: 'NEW', id: 'L34',
440
+ why: 'No existing entry covers AgentDB\'s two-tier memory_entries/distill architecture.' },
441
+ 'lesson-write-path-gate-adr-0012': { store: 'C', decision: 'MERGE', target: 'G-always-search-ruvnet-first',
442
+ why: 'Documents the enforcement mechanism (ground-before-write.sh) for the rule G-always-search-ruvnet-first already states.' },
443
+ 'lesson-never-impersonate-ruv-tools': { store: 'C', decision: 'MERGE', target: 'L06-use-the-real-tool',
444
+ why: 'Same rule as L06: search for rUv\'s version before hand-rolling a capability, and disclose a hand-roll as one.' },
445
+
446
+ // Appeared live in BOTH stores under the SAME key (createdAt 2026-09-26, during this migration's
447
+ // own research window — the store is actively written by concurrent sessions/hooks, exactly what
448
+ // the refuse-on-unmapped guard below exists to catch rather than silently miss). `store: 'either'`
449
+ // means this decision applies no matter which store the row was read from — see buildPlan().
450
+ 'lesson-blast-radius-machine-wide-changes': { store: 'either', decision: 'MERGE', target: 'L07-blast-radius-not-social-comfort',
451
+ why: 'Same "gate on blast radius" rule as L07, elaborated for the machine-wide-surface case (cross-project verification + Tier A Opus pre-review). Appears in both stores under the same key.' },
452
+ };
453
+
454
+ // ── Decide + apply ───────────────────────────────────────────────────────────────────────────────
455
+
456
+ /** For a source row not covered by MANUAL_MAP, is it already accounted for under the deterministic
457
+ * lesson-bridge id (G-<slug> for global, P-<slug> for project)? Detected by full-text presence of
458
+ * the row's own key inside that candidate lesson's evidence — the same fact lesson-bridge's own
459
+ * lessonFromRow() bakes into evidence, so this is not a guess, it is reading what is already there. */
460
+ function autoDetect(row, storeLabel, lessonsById) {
461
+ const slug = row.key.replace(/^lesson-/, '');
462
+ const prefix = storeLabel === 'B' ? 'G-' : 'P-';
463
+ const candidateId = prefix + slug;
464
+ const candidate = lessonsById.get(candidateId);
465
+ if (candidate && alreadyCites(candidate, row.key)) {
466
+ return { decision: 'MERGE', target: candidateId, why: `Already bridged by lesson-bridge.mjs as ${candidateId} (evidence cites this exact key).` };
467
+ }
468
+ // Fallback: the key might already be cited under a DIFFERENT id (e.g. a prior manual merge, or a
469
+ // human hand-edit) even without the deterministic id existing. Scan every lesson once.
470
+ for (const l of lessonsById.values()) {
471
+ if (alreadyCites(l, row.key)) {
472
+ return { decision: 'MERGE', target: l.id, why: `Already cited in ${l.id}'s evidence.` };
473
+ }
474
+ }
475
+ return null;
476
+ }
477
+
478
+ function buildPlan(rows, storeLabel, lessonsById) {
479
+ const plan = [];
480
+ const unmapped = [];
481
+ for (const row of rows) {
482
+ const auto = autoDetect(row, storeLabel, lessonsById);
483
+ const manual = MANUAL_MAP[row.key];
484
+ if (auto) {
485
+ plan.push({ row, storeLabel, decision: 'MERGE', target: auto.target, why: auto.why, alreadyDone: true });
486
+ continue;
487
+ }
488
+ if (manual && (manual.store === storeLabel || manual.store === 'either')) {
489
+ if (manual.decision === 'NEW') {
490
+ plan.push({ row, storeLabel, decision: 'NEW', target: manual.id, why: manual.why });
491
+ } else {
492
+ plan.push({ row, storeLabel, decision: 'MERGE', target: manual.target, why: manual.why });
493
+ }
494
+ continue;
495
+ }
496
+ unmapped.push(row);
497
+ }
498
+ return { plan, unmapped };
499
+ }
500
+
501
+ function projectFor(storeLabel) { return storeLabel === 'B' ? 'global' : PROJECT_LABEL; }
502
+ function dbFor(storeLabel) { return storeLabel === 'B' ? GLOBAL_DB : PROJECT_DB; }
503
+
504
+ /** Apply one plan entry against the in-memory lessons array (mutated in place, id-keyed). Returns
505
+ * 'written' | 'skipped-idempotent' | 'created'. */
506
+ function applyEntry(entry, lessonsById, order) {
507
+ const { row, storeLabel, decision, target } = entry;
508
+ const label = projectFor(storeLabel);
509
+ const dbPath = dbFor(storeLabel);
510
+ const citation = citationFor(label, dbPath, row);
511
+
512
+ if (decision === 'NEW') {
513
+ const existing = lessonsById.get(target);
514
+ if (existing) {
515
+ if (alreadyCites(existing, row.key)) return 'skipped-idempotent';
516
+ existing.evidence.push(citation);
517
+ return 'written';
518
+ }
519
+ const meta = NEW_META[target];
520
+ if (!meta) throw new Error(`No NEW_META entry for id ${target} (key ${row.key})`);
521
+ const lesson = makeLesson({
522
+ id: target,
523
+ statement: meta.statement,
524
+ trigger: meta.trigger,
525
+ enforcement: meta.enforcement,
526
+ severity: meta.severity,
527
+ origin: meta.origin,
528
+ status: meta.status,
529
+ ratifiedBy: meta.ratifiedBy,
530
+ projects: meta.projects || (storeLabel === 'B' ? [] : ['ruvnet-brain']),
531
+ evidence: [citation],
532
+ });
533
+ lessonsById.set(target, lesson);
534
+ order.push(target);
535
+ return 'created';
536
+ }
537
+
538
+ // MERGE
539
+ const existing = lessonsById.get(target);
540
+ if (!existing) throw new Error(`MERGE target ${target} not found for key ${row.key} — check MANUAL_MAP / processing order`);
541
+ if (alreadyCites(existing, row.key)) return 'skipped-idempotent';
542
+ existing.evidence.push(citation);
543
+ return 'written';
544
+ }
545
+
546
+ function main() {
547
+ const argv = process.argv.slice(2);
548
+ const apply = argv.includes('--apply');
549
+ const dryRun = argv.includes('--dry-run') || !apply;
550
+ const json = argv.includes('--json');
551
+
552
+ const before = loadLessons();
553
+ const lessonsById = new Map(before.map((l) => [l.id, { ...l, evidence: [...l.evidence] }]));
554
+ const order = []; // ids created during this run, for stable NEW-first processing already guaranteed by two passes
555
+
556
+ const rowsB = readStoreB();
557
+ const rowsC = readStoreC();
558
+
559
+ const { plan: planB, unmapped: unmappedB } = buildPlan(rowsB, 'B', lessonsById);
560
+ const { plan: planC, unmapped: unmappedC } = buildPlan(rowsC, 'C', lessonsById);
561
+ const unmapped = [...unmappedB, ...unmappedC];
562
+
563
+ if (unmapped.length) {
564
+ console.error(`\n REFUSING: ${unmapped.length} lesson-* row(s) matched neither an already-bridged id nor MANUAL_MAP.`);
565
+ console.error(' Losing a row silently is the one failure this script exists to prevent. Add a decision for:');
566
+ for (const r of unmapped) console.error(` ${r.key}`);
567
+ process.exit(1);
568
+ }
569
+
570
+ // Pass 1: NEW (creates canonical ids other MERGEs in this same run may target).
571
+ // Pass 2: MERGE (targets are now resolvable whether pre-existing or freshly created).
572
+ const allEntries = [...planB, ...planC];
573
+ const news = allEntries.filter((e) => e.decision === 'NEW');
574
+ const merges = allEntries.filter((e) => e.decision === 'MERGE');
575
+
576
+ const results = [];
577
+ for (const e of news) results.push({ ...e, result: dryRun ? (lessonsById.has(e.target) && alreadyCites(lessonsById.get(e.target), e.row.key) ? 'skipped-idempotent' : (lessonsById.has(e.target) ? 'written' : 'created')) : applyEntry(e, lessonsById, order) });
578
+ for (const e of merges) results.push({ ...e, result: dryRun ? (lessonsById.has(e.target) && alreadyCites(lessonsById.get(e.target), e.row.key) ? 'skipped-idempotent' : 'written') : applyEntry(e, lessonsById, order) });
579
+
580
+ // ── Report ──
581
+ const table = results
582
+ .sort((a, b) => (a.storeLabel === b.storeLabel ? a.row.key.localeCompare(b.row.key) : a.storeLabel.localeCompare(b.storeLabel)))
583
+ .map((e) => ({
584
+ store: e.storeLabel, key: e.row.key, decision: e.decision, target: e.target,
585
+ result: e.result, why: e.why,
586
+ }));
587
+
588
+ if (json) {
589
+ console.log(JSON.stringify({ mode: dryRun ? 'dry-run' : 'apply', totalSourceRows: rowsB.length + rowsC.length,
590
+ storeB: rowsB.length, storeC: rowsC.length, before: before.length, table }, null, 2));
591
+ } else {
592
+ console.log(`\n lesson-migrate-agentdb.mjs — ${dryRun ? 'DRY RUN (nothing written)' : 'APPLY'}`);
593
+ console.log(` Store B (global, ${GLOBAL_DB} ns=${GLOBAL_NS}): ${rowsB.length} lesson-* row(s)`);
594
+ console.log(` Store C (project, ${PROJECT_DB}, all namespaces): ${rowsC.length} lesson-* row(s)`);
595
+ console.log(` Total source rows: ${rowsB.length + rowsC.length}\n`);
596
+ console.log(' # store key'.padEnd(58) + 'decision target'.padEnd(28) + 'result');
597
+ console.log(' ' + '-'.repeat(110));
598
+ table.forEach((row, i) => {
599
+ console.log(` ${String(i + 1).padStart(3)} ${row.store.padEnd(5)} ${row.key.padEnd(52)} `
600
+ + `${row.decision.padEnd(9)} ${String(row.target).padEnd(20)} ${row.result}`);
601
+ });
602
+ console.log('\n why (one line per row):');
603
+ table.forEach((row, i) => console.log(` ${String(i + 1).padStart(3)}. ${row.key} -> ${row.target}: ${row.why}`));
604
+ }
605
+
606
+ if (dryRun) {
607
+ if (!json) console.log(`\n DRY RUN — nothing written. Re-run with --apply to write ${results.filter((r) => r.result !== 'skipped-idempotent').length} change(s).\n`);
608
+ return;
609
+ }
610
+
611
+ // ── Real write, through the store's own locked/atomic/backed-up updater ──
612
+ updateLessons((current) => {
613
+ const currentById = new Map(current.map((l) => [l.id, l]));
614
+ for (const id of lessonsById.keys()) {
615
+ const updated = lessonsById.get(id);
616
+ if (currentById.has(id)) {
617
+ // Existing lesson: statement/trigger/etc untouched; only evidence may have grown.
618
+ const idx = current.findIndex((l) => l.id === id);
619
+ if (updated.evidence.length !== current[idx].evidence.length) {
620
+ current[idx] = makeLesson({ ...current[idx], evidence: updated.evidence });
621
+ }
622
+ } else {
623
+ current.push(updated);
624
+ }
625
+ }
626
+ return current;
627
+ });
628
+
629
+ const after = loadLessons();
630
+ console.log(`\n APPLIED: store ${before.length} -> ${after.length} lesson(s) `
631
+ + `(${after.length - before.length} new, ${results.filter((r) => r.result === 'written').length} evidence line(s) added, `
632
+ + `${results.filter((r) => r.result === 'skipped-idempotent').length} already present)\n`);
633
+ }
634
+
635
+ main();