session-orchestrator 3.19.0 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -0,0 +1,967 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * backfill-learnings-from-vault.mjs — reconstruct learning records that the
4
+ * store lost, from the vault mirror, and report what WOULD be restored.
5
+ *
6
+ * Issue #1017. `skills/evolve/SKILL.md` pruned `learnings.jsonl` by rewriting it
7
+ * with `>` and no archive append, so pruned records vanished from every local
8
+ * store. Their `learning-id` provenance pointers still sit in
9
+ * `.claude/rules/*.md`, pointing at nothing. The vault mirror
10
+ * (`<vault>/40-learnings/**`, written by `scripts/lib/vault-mirror/render-*.mjs`)
11
+ * kept a rendered copy — this tool reverses that rendering.
12
+ *
13
+ * DEFAULT IS DRY-RUN. Nothing is written unless `--apply` is passed, and even
14
+ * then the only write is an APPEND (`appendLearning`) of records that are
15
+ * absent from BOTH the store and the archive at apply time. This tool never
16
+ * rewrites the store (the rewrite path is what destroyed the data in the first
17
+ * place) and never writes to the vault, which is read-only input here.
18
+ *
19
+ * ── Honesty contract (the point of the tool) ────────────────────────────────
20
+ * A reconstruction that silently fills gaps is worse than a missing record,
21
+ * because it looks authoritative. So every field of every reconstructed record
22
+ * carries an ORIGIN label, reported per record (never aggregated):
23
+ *
24
+ * vault verbatim from the mirror note (body/frontmatter)
25
+ * rule-provenance verbatim from the rule's Provenance block / H1 — the
26
+ * reconcile engine copied these from the original record
27
+ * derived:<how> a LOSSY derivation (e.g. the mirror stores `created` as a
28
+ * DATE, so the time-of-day is gone and midnight UTC is used)
29
+ * absent not reconstructed; no value is invented. Where
30
+ * `validateLearning` applies its own default (scope /
31
+ * host_class / anonymized) that is labelled explicitly.
32
+ *
33
+ * `file_paths` is deliberately NOT reconstructed: the rule's `globs:` are a
34
+ * lossy projection of it, and inverting that projection would be a guess. The
35
+ * globs are quoted in the report as a hint only.
36
+ *
37
+ * Every candidate is gated through `validateLearning` from the schema SSOT
38
+ * (`scripts/lib/learnings/schema.mjs`). A record that would not validate is NOT
39
+ * a restore candidate and says so, per record.
40
+ *
41
+ * Restored records are stamped `_restored_from: 'vault'` + `_restored_at` +
42
+ * `_restored_fidelity`, so a later audit can always tell a reconstruction from
43
+ * a record that never left.
44
+ *
45
+ * Idempotence: dry-run is pure (reads only). `--apply` re-checks store+archive
46
+ * membership per record immediately before appending, so a second `--apply`
47
+ * appends nothing and a later dry-run reports 0 orphans.
48
+ *
49
+ * NOTE on discovery: every file here is read through `node:fs`, never grep — a
50
+ * single NUL byte makes a tracked file invisible to grep (silent skip, exit 1,
51
+ * no warning) and the counting is done in Node, so `grep -c`'s no-match exit-1
52
+ * double-print trap cannot apply.
53
+ *
54
+ * Usage:
55
+ * node scripts/backfill-learnings-from-vault.mjs [--json] [--apply]
56
+ * [--vault-dir PATH] [--rules-dir PATH] [--store PATH] [--archive PATH]
57
+ *
58
+ * Exit codes: 0 completed (dry-run or apply) · 1 usage/config error · 2 system error.
59
+ * Output: report on stdout, diagnostics on stderr.
60
+ *
61
+ * Exports (for tests): main, parseRuleProvenance, vaultSlugFor,
62
+ * parseVaultNote, indexVaultNotes, locateNote, reconstructRecord.
63
+ */
64
+
65
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
66
+ import { join, resolve, dirname, basename, relative } from 'node:path';
67
+ import { pathToFileURL } from 'node:url';
68
+
69
+ import { findProjectRoot, resolveInstructionFile, expandTilde } from './lib/common.mjs';
70
+ import { parseSessionConfig } from './lib/config.mjs';
71
+ import { subjectToSlug, parseFrontmatter } from './lib/vault-mirror/utils.mjs';
72
+ import { kebab } from './lib/learnings/kebab.mjs';
73
+ import { validateLearning } from './lib/learnings/schema.mjs';
74
+ import { appendLearning } from './lib/learnings/io.mjs';
75
+
76
+ const DEFAULT_RULES_DIR = '.claude/rules';
77
+ const DEFAULT_STORE = '.orchestrator/metrics/learnings.jsonl';
78
+ const DEFAULT_ARCHIVE = '.orchestrator/metrics/learnings-archive.jsonl';
79
+ const DEFAULT_VAULT_SUBDIR = '40-learnings';
80
+
81
+ /** Sentinel the vault mirror writes when the source record had no evidence. */
82
+ const MIRROR_EVIDENCE_SENTINEL = '(none recorded)';
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Pure helpers
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Derive the vault note slug for a v1 learning subject, mirroring
90
+ * `vault-mirror/process.mjs` (whitespace → hyphen, then `subjectToSlug`).
91
+ * This is the reverse-lookup key for finding a learning's mirror note.
92
+ *
93
+ * @param {string} subject
94
+ * @returns {string}
95
+ */
96
+ export function vaultSlugFor(subject) {
97
+ if (typeof subject !== 'string' || subject.trim() === '') return '';
98
+ return subjectToSlug(subject.trim().replace(/\s+/g, '-'));
99
+ }
100
+
101
+ /** Strip hyphens for a hyphenation-insensitive slug comparison. */
102
+ const dehyphen = (s) => String(s).replace(/-/g, '');
103
+
104
+ /**
105
+ * Extract the section of a markdown body between `startRe` and the next
106
+ * `## `-heading (or an HTML comment / end of input).
107
+ *
108
+ * @param {string} body
109
+ * @param {RegExp} startRe — must match the heading line
110
+ * @returns {string|null} trimmed section text, or null when the heading is absent
111
+ */
112
+ function sectionAfter(body, startRe) {
113
+ const m = body.match(startRe);
114
+ if (!m) return null;
115
+ const rest = body.slice(m.index + m[0].length);
116
+ const stop = rest.search(/\n(?:## |<!-- )/);
117
+ return (stop === -1 ? rest : rest.slice(0, stop)).trim();
118
+ }
119
+
120
+ /**
121
+ * Parse an auto-generated `.claude/rules/*.md` file into the provenance facts
122
+ * the reconcile engine transcribed from the original learning record.
123
+ *
124
+ * Returns null for hand-authored rules (no `## Provenance` block).
125
+ *
126
+ * @param {string} content — full file text
127
+ * @returns {{
128
+ * learningKey: string|null, learningId: string|null, sourceSession: string|null,
129
+ * confidence: number|null, expiresAt: string|null, subject: string|null,
130
+ * globs: string[], insight: string|null, evidence: string|null
131
+ * }|null}
132
+ */
133
+ export function parseRuleProvenance(content) {
134
+ if (!/^##\s+Provenance\s*$/m.test(content)) return null;
135
+
136
+ const pick = (re) => {
137
+ const m = content.match(re);
138
+ return m ? m[1].trim() : null;
139
+ };
140
+
141
+ const learningId = pick(/^- learning-id:\s*`([^`]*)`/m);
142
+ const learningKey = pick(/^- learning-key:\s*`([^`]*)`/m);
143
+ const sourceSession = pick(/^- source-session:\s*`([^`]*)`/m);
144
+ const confidenceRaw = pick(/^- confidence:\s*(.+)$/m);
145
+ const expiresAt = pick(/^- expires-at:\s*(.+)$/m);
146
+ const subject = pick(/^#\s+Auto-generated rule:\s*(.+)$/m);
147
+
148
+ const confidence =
149
+ confidenceRaw !== null && confidenceRaw !== '' && Number.isFinite(Number(confidenceRaw))
150
+ ? Number(confidenceRaw)
151
+ : null;
152
+
153
+ // `globs:` is a YAML block list — parseFrontmatter drops the `- "..."` items
154
+ // (no colon), so collect them here for the report's hint line.
155
+ const globs = [];
156
+ const fmEnd = content.indexOf('\n---', 3);
157
+ if (content.startsWith('---') && fmEnd !== -1) {
158
+ const fmLines = content.slice(3, fmEnd).split('\n');
159
+ let inGlobs = false;
160
+ for (const line of fmLines) {
161
+ if (/^globs:\s*$/.test(line)) {
162
+ inGlobs = true;
163
+ continue;
164
+ }
165
+ if (inGlobs) {
166
+ const item = line.match(/^\s+-\s*"?([^"]*)"?\s*$/);
167
+ if (item) {
168
+ globs.push(item[1]);
169
+ continue;
170
+ }
171
+ inGlobs = false;
172
+ }
173
+ }
174
+ }
175
+
176
+ // Body insight: between the H1 and the `## Evidence` heading.
177
+ const h1 = content.match(/^#\s+Auto-generated rule:.*$/m);
178
+ let insight = null;
179
+ if (h1) {
180
+ const afterH1 = content.slice(h1.index + h1[0].length);
181
+ const stop = afterH1.search(/\n## /);
182
+ insight = (stop === -1 ? afterH1 : afterH1.slice(0, stop)).trim() || null;
183
+ }
184
+ const evidence = sectionAfter(content, /^##\s+Evidence\s*$/m);
185
+
186
+ // The renderer writes its own placeholders when the source learning had no
187
+ // insight/evidence. Mapping them to null keeps them out of the cross-check as
188
+ // "not-possible" rather than counting a placeholder as a differing second copy.
189
+ const unplaceholder = (v, placeholder) => (v === placeholder ? null : v);
190
+
191
+ return {
192
+ learningKey,
193
+ learningId,
194
+ sourceSession,
195
+ confidence,
196
+ expiresAt,
197
+ subject,
198
+ globs,
199
+ insight: unplaceholder(insight, '(no insight recorded)'),
200
+ evidence: unplaceholder(evidence, '(no evidence recorded)'),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Parse a vault learning note (as rendered by `render-learnings.mjs`) back into
206
+ * the record fields it carries. Fields the renderer never wrote (`subject`,
207
+ * `file_paths`, `scope`, …) are simply absent — never guessed.
208
+ *
209
+ * @param {string} content
210
+ * @param {string} absPath
211
+ * @returns {object|null} null when the file has no parseable frontmatter
212
+ */
213
+ export function parseVaultNote(content, absPath) {
214
+ const fm = parseFrontmatter(content);
215
+ if (!fm) return null;
216
+
217
+ const bodyStart = content.indexOf('\n---', 3);
218
+ const body = bodyStart === -1 ? content : content.slice(bodyStart + 4);
219
+
220
+ const bullet = (label) => {
221
+ const m = body.match(new RegExp(`^-\\s+\\*\\*${label}:\\*\\*\\s*(.+)$`, 'm'));
222
+ return m ? m[1].trim() : null;
223
+ };
224
+
225
+ const rawConfidence = bullet('Confidence');
226
+ const sourceSessionRaw = fm.source_session ?? bullet('Source session');
227
+
228
+ return {
229
+ path: absPath,
230
+ slug: typeof fm.id === 'string' && fm.id !== '' ? fm.id : basename(absPath, '.md'),
231
+ fileStem: basename(absPath, '.md'),
232
+ noteType: fm.type ?? null,
233
+ sourceRepo: fm['source-repo'] ?? null,
234
+ generator: fm._generator ?? null,
235
+ type: bullet('Type'),
236
+ confidence:
237
+ rawConfidence !== null && Number.isFinite(Number(rawConfidence)) ? Number(rawConfidence) : null,
238
+ // Strip Obsidian wikilink brackets — the renderer emits `[[session-id]]`
239
+ // when the session note exists, plain text otherwise.
240
+ sourceSession:
241
+ typeof sourceSessionRaw === 'string'
242
+ ? sourceSessionRaw.replace(/^\[\[|\]\]$/g, '').trim() || null
243
+ : null,
244
+ created: fm.created ?? null,
245
+ expires: fm.expires ?? null,
246
+ insight: sectionAfter(body, /^##\s+Insight\s*$/m),
247
+ evidence: sectionAfter(body, /^##\s+Evidence\s*$/m),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Recursively index every learning note under `<vaultDir>/<subdir>`.
253
+ * Read via `node:fs` (not grep) so a NUL-corrupted note is still seen.
254
+ *
255
+ * @param {string} rootDir — absolute path of the learnings directory
256
+ * @returns {{ notes: object[], unreadable: string[] }}
257
+ */
258
+ export function indexVaultNotes(rootDir) {
259
+ const notes = [];
260
+ const unreadable = [];
261
+ if (!existsSync(rootDir)) return { notes, unreadable };
262
+
263
+ const entries = readdirSync(rootDir, { withFileTypes: true, recursive: true });
264
+ for (const ent of entries) {
265
+ if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
266
+ const abs = join(ent.parentPath ?? ent.path ?? rootDir, ent.name);
267
+ let content;
268
+ try {
269
+ content = readFileSync(abs, 'utf8');
270
+ } catch (err) {
271
+ unreadable.push(`${abs}: ${err.message}`);
272
+ continue;
273
+ }
274
+ const note = parseVaultNote(content, abs);
275
+ if (!note || note.noteType !== 'learning') continue;
276
+ note.relPath = relative(rootDir, abs);
277
+ notes.push(note);
278
+ }
279
+ return { notes, unreadable };
280
+ }
281
+
282
+ /**
283
+ * Locate the mirror note for one orphaned learning. Strategies are tried in
284
+ * descending fidelity order; the first that yields candidates wins.
285
+ *
286
+ * A strategy that yields MORE than one candidate is narrowed by `source-repo`
287
+ * and, failing that, reported as ambiguous — a silent pick would be exactly the
288
+ * "looks like it recovered something" failure this tool exists to avoid.
289
+ *
290
+ * @param {object[]} notes — from indexVaultNotes
291
+ * @param {{subject: string|null, keySlug: string|null, learningId: string|null, repoHint?: string|null}} q
292
+ * @returns {{note: object|null, strategy: string|null, ambiguous: object[]|null}}
293
+ */
294
+ export function locateNote(notes, { subject, keySlug, learningId, repoHint = null }) {
295
+ const strategies = [];
296
+ const subjSlug = subject ? vaultSlugFor(subject) : '';
297
+ if (subjSlug) {
298
+ strategies.push(['slug-from-rule-subject', (n) => n.slug === subjSlug || n.fileStem === subjSlug]);
299
+ }
300
+ if (keySlug) {
301
+ strategies.push(['slug-from-learning-key', (n) => n.slug === keySlug || n.fileStem === keySlug]);
302
+ }
303
+ if (subjSlug || keySlug) {
304
+ const targets = new Set([dehyphen(subjSlug), dehyphen(keySlug ?? '')].filter(Boolean));
305
+ strategies.push([
306
+ 'slug-hyphen-insensitive',
307
+ (n) => targets.has(dehyphen(n.slug)) || targets.has(dehyphen(n.fileStem)),
308
+ ]);
309
+ }
310
+ if (learningId) {
311
+ strategies.push(['note-named-by-learning-id', (n) => n.fileStem === learningId || n.slug === learningId]);
312
+ }
313
+
314
+ for (const [strategy, pred] of strategies) {
315
+ let hits = notes.filter(pred);
316
+ if (hits.length === 0) continue;
317
+ if (hits.length > 1 && repoHint) {
318
+ const narrowed = hits.filter((n) => n.sourceRepo === repoHint);
319
+ if (narrowed.length === 1) hits = narrowed;
320
+ }
321
+ if (hits.length > 1) return { note: null, strategy, ambiguous: hits };
322
+ return { note: hits[0], strategy, ambiguous: null };
323
+ }
324
+ return { note: null, strategy: null, ambiguous: null };
325
+ }
326
+
327
+ /**
328
+ * Convert a vault date (`YYYY-MM-DD`, the mirror's lossy `toDate` output) to an
329
+ * ISO timestamp at midnight UTC. Returns the input verbatim when it already
330
+ * carries a time component (then it is ORIGINAL, not derived).
331
+ *
332
+ * @param {string|null} value
333
+ * @returns {{iso: string|null, lossy: boolean}}
334
+ */
335
+ function dateToIso(value) {
336
+ if (typeof value !== 'string' || value.trim() === '') return { iso: null, lossy: false };
337
+ const v = value.trim();
338
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return { iso: `${v}T00:00:00.000Z`, lossy: true };
339
+ const ms = Date.parse(v);
340
+ if (Number.isFinite(ms)) return { iso: new Date(ms).toISOString(), lossy: false };
341
+ return { iso: null, lossy: false };
342
+ }
343
+
344
+ /**
345
+ * Reconstruct one learning record from its vault note + rule provenance, label
346
+ * every field's origin, and gate it through `validateLearning`.
347
+ *
348
+ * Never invents a value: a field that is in neither source stays absent, which
349
+ * makes the validator reject the record — the intended outcome.
350
+ *
351
+ * @param {{rule: object, note: object|null, now?: string}} args
352
+ * @returns {{record: object|null, fidelity: object, conflicts: string[], crossChecks: object, validates: boolean, validationError: string|null}}
353
+ */
354
+ export function reconstructRecord({ rule, note, now = new Date().toISOString() }) {
355
+ const fidelity = {};
356
+ const conflicts = [];
357
+ /**
358
+ * Per-field corroboration between the two independent copies (vault note vs
359
+ * rule provenance). Recorded EXPLICITLY so an empty `conflicts` list can
360
+ * never be read as "corroborated" when in truth no comparison was possible.
361
+ */
362
+ const crossChecks = {};
363
+ const crossCheck = (field, ruleVal, vaultVal) => {
364
+ if (ruleVal === null || ruleVal === undefined || ruleVal === '') {
365
+ crossChecks[field] = 'not-possible (rule side carries no value)';
366
+ } else if (vaultVal === null || vaultVal === undefined || vaultVal === '') {
367
+ crossChecks[field] = 'not-possible (vault side carries no value)';
368
+ } else {
369
+ crossChecks[field] = ruleVal === vaultVal ? 'match' : 'DIFFER';
370
+ }
371
+ return crossChecks[field];
372
+ };
373
+
374
+ if (!note) {
375
+ return {
376
+ record: null,
377
+ fidelity,
378
+ conflicts,
379
+ crossChecks,
380
+ validates: false,
381
+ validationError: 'no vault note located — nothing to reconstruct from',
382
+ };
383
+ }
384
+
385
+ /** @type {Record<string, unknown>} */
386
+ const rec = {};
387
+
388
+ // id — verbatim from the rule provenance (the engine copied the record's id).
389
+ if (rule.learningId) {
390
+ rec.id = rule.learningId;
391
+ fidelity.id = 'rule-provenance (verbatim learning-id)';
392
+ } else {
393
+ fidelity.id = 'absent (rule carries no learning-id)';
394
+ }
395
+
396
+ // type — from the mirror note body; cross-checked against the learning-key prefix.
397
+ // Reading the prefix AS the type is only sound because the key's type half is
398
+ // verbatim, never slugged (`learnings/kebab.mjs::learningKeyOf` decision 1) —
399
+ // the subject half below is the lossy one, which is why it can only ever be
400
+ // labelled `derived:learning-key-slug`.
401
+ const keyType = rule.learningKey ? rule.learningKey.split('/')[0] : null;
402
+ if (note.type) {
403
+ rec.type = note.type;
404
+ fidelity.type = 'vault (note body **Type:**)';
405
+ if (crossCheck('type', keyType, note.type) === 'DIFFER') {
406
+ conflicts.push(`type: vault=${note.type} vs learning-key prefix=${keyType} (vault wins)`);
407
+ }
408
+ } else if (keyType) {
409
+ rec.type = keyType;
410
+ fidelity.type = 'rule-provenance (learning-key prefix; note carried no Type bullet)';
411
+ } else {
412
+ fidelity.type = 'absent';
413
+ }
414
+
415
+ // subject — the rule H1 carries the original prose subject. Confirmed when it
416
+ // kebabs back to the learning-key's second segment; otherwise the key slug is
417
+ // used and labelled derived (a slug, not the original prose).
418
+ const keySubjectSlug = rule.learningKey ? rule.learningKey.split('/').slice(1).join('/') : null;
419
+ const subjectCheck = crossCheck(
420
+ 'subject',
421
+ keySubjectSlug,
422
+ rule.subject ? kebab(rule.subject) : null
423
+ );
424
+ if (rule.subject && subjectCheck === 'match') {
425
+ rec.subject = rule.subject;
426
+ fidelity.subject = 'rule-provenance (H1, confirmed against learning-key)';
427
+ } else if (rule.subject) {
428
+ rec.subject = rule.subject;
429
+ fidelity.subject = 'rule-provenance (H1, NOT confirmed against learning-key — may be a title alias)';
430
+ if (subjectCheck === 'DIFFER') {
431
+ conflicts.push(`subject: kebab(H1)=${kebab(rule.subject)} != learning-key subject=${keySubjectSlug}`);
432
+ }
433
+ } else if (keySubjectSlug) {
434
+ rec.subject = keySubjectSlug;
435
+ fidelity.subject = 'derived:learning-key-slug (original prose subject not recoverable)';
436
+ } else {
437
+ fidelity.subject = 'absent';
438
+ }
439
+
440
+ // insight — full text from the note; cross-checked against the rule body.
441
+ if (note.insight) {
442
+ rec.insight = note.insight;
443
+ fidelity.insight = 'vault (## Insight, full text)';
444
+ if (crossCheck('insight', rule.insight, note.insight) === 'DIFFER') {
445
+ conflicts.push('insight: vault text differs from the rule body text (vault wins)');
446
+ }
447
+ } else if (rule.insight) {
448
+ rec.insight = rule.insight;
449
+ fidelity.insight = 'rule-provenance (rule body; note had no ## Insight section)';
450
+ } else {
451
+ fidelity.insight = 'absent';
452
+ }
453
+
454
+ // evidence — the mirror writes a "(none recorded)" sentinel when the source
455
+ // record had none, so that sentinel must NOT be restored as content.
456
+ if (note.evidence && note.evidence !== MIRROR_EVIDENCE_SENTINEL) {
457
+ rec.evidence = note.evidence;
458
+ fidelity.evidence = 'vault (## Evidence, full text)';
459
+ if (crossCheck('evidence', rule.evidence, note.evidence) === 'DIFFER') {
460
+ conflicts.push('evidence: vault text differs from the rule body text (vault wins)');
461
+ }
462
+ } else if (note.evidence === MIRROR_EVIDENCE_SENTINEL) {
463
+ rec.evidence = '';
464
+ fidelity.evidence =
465
+ 'absent (mirror wrote the "(none recorded)" sentinel — the original evidence was empty; empty string used, schema requires the key)';
466
+ } else if (rule.evidence && rule.evidence !== '(no evidence recorded)') {
467
+ rec.evidence = rule.evidence;
468
+ fidelity.evidence = 'rule-provenance (rule body; note had no ## Evidence section)';
469
+ } else {
470
+ fidelity.evidence = 'absent (no evidence in either source)';
471
+ }
472
+
473
+ // confidence — the note body carries the exact value.
474
+ if (typeof note.confidence === 'number') {
475
+ rec.confidence = note.confidence;
476
+ fidelity.confidence = 'vault (note body **Confidence:**)';
477
+ if (crossCheck('confidence', rule.confidence, note.confidence) === 'DIFFER') {
478
+ conflicts.push(`confidence: vault=${note.confidence} vs rule=${rule.confidence} (vault wins)`);
479
+ }
480
+ } else if (typeof rule.confidence === 'number') {
481
+ rec.confidence = rule.confidence;
482
+ fidelity.confidence = 'rule-provenance (note carried no Confidence bullet)';
483
+ } else {
484
+ fidelity.confidence = 'absent';
485
+ }
486
+
487
+ // source_session
488
+ if (note.sourceSession) {
489
+ rec.source_session = note.sourceSession;
490
+ fidelity.source_session = 'vault (frontmatter source_session)';
491
+ if (crossCheck('source_session', rule.sourceSession, note.sourceSession) === 'DIFFER') {
492
+ conflicts.push(`source_session: vault=${note.sourceSession} vs rule=${rule.sourceSession} (vault wins)`);
493
+ }
494
+ } else if (rule.sourceSession) {
495
+ rec.source_session = rule.sourceSession;
496
+ fidelity.source_session = 'rule-provenance (note carried no source_session)';
497
+ } else {
498
+ fidelity.source_session = 'absent';
499
+ }
500
+
501
+ // created_at — the mirror stores a DATE; the time-of-day is gone for good.
502
+ const created = dateToIso(note.created);
503
+ if (created.iso) {
504
+ rec.created_at = created.iso;
505
+ fidelity.created_at = created.lossy
506
+ ? `derived:vault-date (${note.created} → midnight UTC; original time-of-day lost)`
507
+ : 'vault (full timestamp)';
508
+ } else {
509
+ fidelity.created_at = 'absent (no created date in the vault note)';
510
+ }
511
+
512
+ // expires_at — same lossy date treatment as created_at.
513
+ //
514
+ // The rule's `expires-at` is NOT a second copy of this field: the reconcile
515
+ // emitter RECOMPUTES a rule ACTIVATION WINDOW from created_at + the per-type
516
+ // TTL, with a floor (`emitter.mjs::computeExpiresAt`). A difference is
517
+ // therefore expected and is reported as information — never as a data
518
+ // conflict, and never used in preference to the vault value.
519
+ const expires = dateToIso(note.expires);
520
+ if (expires.iso) {
521
+ rec.expires_at = expires.iso;
522
+ fidelity.expires_at = expires.lossy
523
+ ? `derived:vault-date (${note.expires} → midnight UTC; original time-of-day lost)`
524
+ : 'vault (full timestamp)';
525
+ } else {
526
+ const fromRule = dateToIso(rule.expiresAt);
527
+ if (fromRule.iso) {
528
+ rec.expires_at = fromRule.iso;
529
+ fidelity.expires_at =
530
+ `derived:rule-activation-window (${rule.expiresAt} → midnight UTC) — NOT the record's original ` +
531
+ 'expires_at; the emitter recomputes this window from created_at + type TTL';
532
+ } else {
533
+ fidelity.expires_at = 'absent (no expiry in either source)';
534
+ }
535
+ }
536
+ if (!rule.expiresAt) {
537
+ crossChecks.expires_at_vs_rule_window = 'not-possible (rule carries no expires-at)';
538
+ } else if (!note.expires) {
539
+ crossChecks.expires_at_vs_rule_window = 'not-possible (vault note carries no expires)';
540
+ } else if (rule.expiresAt === note.expires) {
541
+ crossChecks.expires_at_vs_rule_window = 'match';
542
+ } else {
543
+ crossChecks.expires_at_vs_rule_window =
544
+ `differs (vault=${note.expires}, rule window=${rule.expiresAt}) — expected: the rule window is ` +
545
+ 'emitter-derived, not a copy of the record field; the vault value is used';
546
+ }
547
+
548
+ // schema_version — not recorded by the mirror. Stamped with the writer's
549
+ // current version and labelled as such (validateLearning accepts 0 or 1).
550
+ rec.schema_version = 1;
551
+ fidelity.schema_version = 'derived:writer-default (the mirror never recorded the original schema_version)';
552
+
553
+ // Deliberately NOT reconstructed.
554
+ fidelity.file_paths =
555
+ rule.globs.length > 0
556
+ ? `absent (NOT reconstructed — the rule globs [${rule.globs.join(', ')}] are a lossy projection of file_paths, not its inverse)`
557
+ : 'absent (no source)';
558
+ fidelity.scope = 'absent → validateLearning default "local" applied';
559
+ fidelity.host_class = 'absent → validateLearning default null applied';
560
+ fidelity.anonymized = 'absent → validateLearning default false applied';
561
+ fidelity.updated_at = 'absent (the mirror sets updated = created; carries no independent value)';
562
+
563
+ // Restore stamps — make a reconstruction distinguishable from an original.
564
+ rec._restored_from = 'vault';
565
+ rec._restored_at = now;
566
+ rec._restored_by = 'scripts/backfill-learnings-from-vault.mjs';
567
+ rec._restored_source_note = note.relPath ?? note.path;
568
+ // Store the origin CODE per field, not the report's prose — the record is a
569
+ // store line, not a document. The full wording stays in the report (and is
570
+ // reproducible from it), while the record keeps enough for a later audit to
571
+ // ask "where did this field come from?" without re-running the tool.
572
+ const code = (v) => String(v).split(' (')[0].split(' →')[0];
573
+ rec._restored_fidelity = Object.fromEntries(Object.entries(fidelity).map(([k, v]) => [k, code(v)]));
574
+ rec._restored_cross_checks = Object.fromEntries(
575
+ Object.entries(crossChecks).map(([k, v]) => [k, code(v)])
576
+ );
577
+
578
+ let validated = null;
579
+ let validationError = null;
580
+ try {
581
+ validated = validateLearning(rec);
582
+ } catch (err) {
583
+ validationError = err.message;
584
+ }
585
+
586
+ return {
587
+ record: validated,
588
+ fidelity,
589
+ conflicts,
590
+ crossChecks,
591
+ validates: validationError === null,
592
+ validationError,
593
+ };
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Store membership
598
+ // ---------------------------------------------------------------------------
599
+
600
+ /**
601
+ * Collect learning ids from a JSONL store. Counting happens in Node (never via
602
+ * `grep -c`, whose no-match exit-1 makes `|| echo 0` double-print).
603
+ *
604
+ * @param {string} filePath
605
+ * @returns {Set<string>}
606
+ */
607
+ function idsInStore(filePath) {
608
+ const ids = new Set();
609
+ if (!existsSync(filePath)) return ids;
610
+ const raw = readFileSync(filePath, 'utf8');
611
+ for (const line of raw.split('\n')) {
612
+ if (line.trim() === '') continue;
613
+ try {
614
+ const obj = JSON.parse(line);
615
+ if (obj && typeof obj.id === 'string' && obj.id !== '') ids.add(obj.id);
616
+ } catch {
617
+ // Malformed line — not an id source. Never rewritten by this tool.
618
+ }
619
+ }
620
+ return ids;
621
+ }
622
+
623
+ /** List `<store>.bak-*` siblings (a verbatim original beats any reconstruction). */
624
+ function backupPaths(storePath) {
625
+ const dir = dirname(storePath);
626
+ const base = basename(storePath);
627
+ let names;
628
+ try {
629
+ names = readdirSync(dir);
630
+ } catch {
631
+ // Missing dir, or a --store path whose parent is not a directory. Neither is
632
+ // fatal: the backup sweep is an enrichment, not a prerequisite.
633
+ return [];
634
+ }
635
+ return names
636
+ .filter((n) => n.startsWith(`${base}.bak-`))
637
+ .sort()
638
+ .map((n) => join(dir, n));
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // CLI
643
+ // ---------------------------------------------------------------------------
644
+
645
+ function printHelp() {
646
+ process.stdout.write(`backfill-learnings-from-vault — reconstruct lost learning records from the vault mirror
647
+
648
+ USAGE
649
+ node scripts/backfill-learnings-from-vault.mjs [options]
650
+
651
+ OPTIONS
652
+ --json machine-readable report on stdout
653
+ --apply APPEND the restorable records to the store (default: dry-run,
654
+ writes nothing). Never rewrites the store, never touches the vault.
655
+ --vault-dir PATH override vault-integration.vault-dir
656
+ --vault-subdir PATH learnings subdir inside the vault (default: ${DEFAULT_VAULT_SUBDIR})
657
+ --rules-dir PATH rules directory (default: ${DEFAULT_RULES_DIR})
658
+ --store PATH learnings store (default: ${DEFAULT_STORE})
659
+ --archive PATH learnings archive (default: ${DEFAULT_ARCHIVE})
660
+ -h, --help this text
661
+
662
+ EXIT CODES
663
+ 0 completed · 1 usage/config error · 2 system error
664
+ `);
665
+ }
666
+
667
+ function printHuman(report) {
668
+ const out = [];
669
+ const s = report.summary;
670
+ out.push(`Learning backfill from vault ${report.dry_run ? '(dry-run — nothing written)' : '(APPLY)'}`);
671
+ out.push(` rules dir : ${report.rules_dir}`);
672
+ out.push(` store : ${report.store}`);
673
+ out.push(` archive : ${report.archive}`);
674
+ out.push(` vault : ${report.vault_learnings_dir} (${s.vault_notes_indexed} learning notes indexed)`);
675
+ out.push('');
676
+ out.push(
677
+ `Rules with provenance: ${s.rules_with_provenance} · resolved in store: ${s.present_in_store} · in archive: ${s.present_in_archive} · recoverable verbatim from a .bak: ${s.recoverable_from_backup} · ORPHANED: ${s.orphans}`
678
+ );
679
+ out.push('');
680
+
681
+ for (const r of report.records) {
682
+ if (r.status !== 'orphan') continue;
683
+ const head = `[${r.index}/${s.orphans}] ${r.learning_id} ${r.learning_key ?? '(no key)'}`;
684
+ out.push(head);
685
+ out.push(` rule : ${r.rule_file}`);
686
+ if (r.vault_note) {
687
+ out.push(` vault note : ${r.vault_note} (match: ${r.match_strategy})`);
688
+ } else if (r.ambiguous_candidates) {
689
+ out.push(` vault note : AMBIGUOUS — ${r.ambiguous_candidates.length} candidates via ${r.match_strategy}:`);
690
+ for (const c of r.ambiguous_candidates) out.push(` - ${c}`);
691
+ } else {
692
+ out.push(' vault note : NOT FOUND — no note matched by subject slug, learning-key slug, or id');
693
+ }
694
+ out.push(` validates : ${r.validates ? 'yes' : `NO — ${r.validation_error}`}`);
695
+ out.push(` restorable : ${r.restorable ? 'yes' : 'NO'}`);
696
+ if (Object.keys(r.fidelity).length > 0) {
697
+ out.push(' fidelity :');
698
+ for (const [field, origin] of Object.entries(r.fidelity)) {
699
+ out.push(` ${field.padEnd(15)} ${origin}`);
700
+ }
701
+ }
702
+ // Cross-checks are printed BEFORE conflicts so an empty conflicts list is
703
+ // read together with the evidence that a comparison was actually possible.
704
+ const cc = Object.entries(r.cross_checks ?? {});
705
+ if (cc.length > 0) {
706
+ out.push(' cross-check: (vault copy vs rule-provenance copy)');
707
+ for (const [field, verdict] of cc) out.push(` ${field.padEnd(15)} ${verdict}`);
708
+ }
709
+ if (r.conflicts.length > 0) {
710
+ out.push(' conflicts :');
711
+ for (const c of r.conflicts) out.push(` ! ${c}`);
712
+ } else {
713
+ out.push(' conflicts : none');
714
+ }
715
+ out.push('');
716
+ }
717
+
718
+ const nonOrphan = report.records.filter((r) => r.status !== 'orphan');
719
+ if (nonOrphan.length > 0) {
720
+ out.push('Not orphaned (no reconstruction needed):');
721
+ for (const r of nonOrphan) {
722
+ out.push(` ${r.status.padEnd(24)} ${r.learning_id} [${r.found_in.join(', ')}]`);
723
+ }
724
+ out.push('');
725
+ }
726
+
727
+ out.push(
728
+ `RESTORABLE: ${s.restorable} of ${s.orphans} orphans` +
729
+ (s.restorable < s.orphans ? ` — ${s.orphans - s.restorable} NOT recoverable (see per-record reasons above)` : '')
730
+ );
731
+ if (report.dry_run) {
732
+ out.push('Dry-run: no file was written. Re-run with --apply to append the restorable records.');
733
+ } else {
734
+ out.push(`Applied: ${s.applied} appended · ${s.skipped_already_present} skipped (already present at apply time)`);
735
+ }
736
+ process.stdout.write(out.join('\n') + '\n');
737
+ }
738
+
739
+ /**
740
+ * @param {string[]} argv
741
+ * @param {{repoRoot?: string, vaultDir?: string, now?: string, hostPaths?: object}} [deps]
742
+ * @returns {Promise<{code: 0|1|2, report?: object}>}
743
+ */
744
+ export async function main(argv = [], deps = {}) {
745
+ let json = false;
746
+ let apply = false;
747
+ let help = false;
748
+ let vaultDirArg = deps.vaultDir ?? null;
749
+ let vaultSubdir = DEFAULT_VAULT_SUBDIR;
750
+ let rulesDir = DEFAULT_RULES_DIR;
751
+ let storeArg = DEFAULT_STORE;
752
+ let archiveArg = DEFAULT_ARCHIVE;
753
+
754
+ for (let i = 0; i < argv.length; i++) {
755
+ const a = argv[i];
756
+ if (a === '--json') json = true;
757
+ else if (a === '--apply') apply = true;
758
+ else if (a === '--dry-run') apply = false;
759
+ else if (a === '--help' || a === '-h') help = true;
760
+ else if (a === '--vault-dir') vaultDirArg = argv[++i];
761
+ else if (a === '--vault-subdir') vaultSubdir = argv[++i];
762
+ else if (a === '--rules-dir') rulesDir = argv[++i];
763
+ else if (a === '--store') storeArg = argv[++i];
764
+ else if (a === '--archive') archiveArg = argv[++i];
765
+ else {
766
+ process.stderr.write(`backfill-learnings-from-vault: unknown argument: ${a}\n`);
767
+ return { code: 1 };
768
+ }
769
+ }
770
+ if (help) {
771
+ printHelp();
772
+ return { code: 0 };
773
+ }
774
+ for (const [flag, value] of [
775
+ ['--vault-dir', vaultDirArg],
776
+ ['--vault-subdir', vaultSubdir],
777
+ ['--rules-dir', rulesDir],
778
+ ['--store', storeArg],
779
+ ['--archive', archiveArg],
780
+ ]) {
781
+ if (value === undefined || value === '') {
782
+ process.stderr.write(`backfill-learnings-from-vault: ${flag} requires a value.\n`);
783
+ return { code: 1 };
784
+ }
785
+ }
786
+
787
+ const root = deps.repoRoot ?? findProjectRoot();
788
+ const now = deps.now ?? new Date().toISOString();
789
+
790
+ // ── Resolve the vault dir (flag > Session Config) ────────────────────────
791
+ let vaultDir = vaultDirArg;
792
+ if (!vaultDir) {
793
+ const instr = resolveInstructionFile(root);
794
+ if (!instr) {
795
+ process.stderr.write(`backfill-learnings-from-vault: no CLAUDE.md/AGENTS.md at ${root}.\n`);
796
+ return { code: 1 };
797
+ }
798
+ try {
799
+ const config = parseSessionConfig(readFileSync(instr.path, 'utf8'), deps.hostPaths ? { hostPaths: deps.hostPaths } : undefined);
800
+ vaultDir = config?.['vault-integration']?.['vault-dir'];
801
+ } catch (err) {
802
+ process.stderr.write(`backfill-learnings-from-vault: failed to parse Session Config: ${err.message}\n`);
803
+ return { code: 2 };
804
+ }
805
+ }
806
+ if (!vaultDir || typeof vaultDir !== 'string' || vaultDir.trim() === '') {
807
+ process.stderr.write('backfill-learnings-from-vault: vault-integration.vault-dir is not configured — nothing to recover from.\n');
808
+ return { code: 1 };
809
+ }
810
+ vaultDir = expandTilde(vaultDir);
811
+
812
+ // `resolve` (not `join`) so an ABSOLUTE --store/--archive/--rules-dir wins over
813
+ // the repo root instead of being silently nested under it.
814
+ const rulesAbs = resolve(root, rulesDir);
815
+ const storeAbs = resolve(root, storeArg);
816
+ const archiveAbs = resolve(root, archiveArg);
817
+ const learningsAbs = resolve(vaultDir, vaultSubdir);
818
+
819
+ if (!existsSync(rulesAbs)) {
820
+ process.stderr.write(`backfill-learnings-from-vault: rules dir not found: ${rulesAbs}\n`);
821
+ return { code: 1 };
822
+ }
823
+ if (!existsSync(learningsAbs)) {
824
+ process.stderr.write(`backfill-learnings-from-vault: vault learnings dir not found: ${learningsAbs}\n`);
825
+ return { code: 1 };
826
+ }
827
+
828
+ // ── Load inputs ──────────────────────────────────────────────────────────
829
+ const storeIds = idsInStore(storeAbs);
830
+ const archiveIds = idsInStore(archiveAbs);
831
+ const backups = backupPaths(storeAbs).map((p) => ({ path: p, ids: idsInStore(p) }));
832
+ const { notes, unreadable } = indexVaultNotes(learningsAbs);
833
+ for (const u of unreadable) process.stderr.write(`backfill-learnings-from-vault: WARN unreadable vault note ${u}\n`);
834
+
835
+ const repoHint = basename(root);
836
+
837
+ const rules = readdirSync(rulesAbs)
838
+ .filter((n) => n.endsWith('.md'))
839
+ .sort()
840
+ .map((n) => ({ name: n, content: readFileSync(join(rulesAbs, n), 'utf8') }))
841
+ .map((f) => ({ name: f.name, prov: parseRuleProvenance(f.content) }))
842
+ .filter((f) => f.prov !== null && f.prov.learningId);
843
+
844
+ // ── Classify + reconstruct ───────────────────────────────────────────────
845
+ const records = [];
846
+ let orphanIndex = 0;
847
+ for (const { name, prov } of rules) {
848
+ const id = prov.learningId;
849
+ const foundIn = [];
850
+ if (storeIds.has(id)) foundIn.push('store');
851
+ if (archiveIds.has(id)) foundIn.push('archive');
852
+ for (const b of backups) if (b.ids.has(id)) foundIn.push(basename(b.path));
853
+
854
+ let status;
855
+ if (storeIds.has(id)) status = 'present-in-store';
856
+ else if (archiveIds.has(id)) status = 'present-in-archive';
857
+ else if (foundIn.length > 0) status = 'recoverable-verbatim-from-backup';
858
+ else status = 'orphan';
859
+
860
+ const base = {
861
+ learning_id: id,
862
+ learning_key: prov.learningKey,
863
+ rule_file: join(rulesDir, name),
864
+ status,
865
+ found_in: foundIn,
866
+ };
867
+
868
+ if (status !== 'orphan') {
869
+ records.push({ ...base, fidelity: {}, conflicts: [], validates: null, restorable: false, index: null });
870
+ continue;
871
+ }
872
+
873
+ orphanIndex += 1;
874
+ const keySubjectSlug = prov.learningKey ? prov.learningKey.split('/').slice(1).join('/') : null;
875
+ const { note, strategy, ambiguous } = locateNote(notes, {
876
+ subject: prov.subject,
877
+ keySlug: keySubjectSlug,
878
+ learningId: id,
879
+ repoHint,
880
+ });
881
+
882
+ const { record, fidelity, conflicts, crossChecks, validates, validationError } = reconstructRecord({
883
+ rule: prov,
884
+ note,
885
+ now,
886
+ });
887
+
888
+ records.push({
889
+ ...base,
890
+ index: orphanIndex,
891
+ vault_note: note ? note.relPath : null,
892
+ match_strategy: strategy,
893
+ ambiguous_candidates: ambiguous ? ambiguous.map((n) => n.relPath) : null,
894
+ validates,
895
+ validation_error: validationError,
896
+ restorable: validates,
897
+ fidelity,
898
+ cross_checks: crossChecks,
899
+ conflicts,
900
+ record,
901
+ });
902
+ }
903
+
904
+ // ── Apply (append-only, re-checked) ──────────────────────────────────────
905
+ let applied = 0;
906
+ let skippedAlreadyPresent = 0;
907
+ if (apply) {
908
+ for (const r of records) {
909
+ if (r.status !== 'orphan' || !r.restorable || !r.record) continue;
910
+ // Re-check membership at apply time — this is what makes a second
911
+ // --apply a no-op rather than a duplicate append.
912
+ if (idsInStore(storeAbs).has(r.learning_id) || idsInStore(archiveAbs).has(r.learning_id)) {
913
+ skippedAlreadyPresent += 1;
914
+ r.applied = false;
915
+ continue;
916
+ }
917
+ try {
918
+ await appendLearning(storeAbs, r.record);
919
+ applied += 1;
920
+ r.applied = true;
921
+ } catch (err) {
922
+ r.applied = false;
923
+ r.apply_error = err.message;
924
+ process.stderr.write(`backfill-learnings-from-vault: append failed for ${r.learning_id}: ${err.message}\n`);
925
+ }
926
+ }
927
+ }
928
+
929
+ const orphans = records.filter((r) => r.status === 'orphan');
930
+ const report = {
931
+ dry_run: !apply,
932
+ repo_root: root,
933
+ rules_dir: rulesDir,
934
+ store: storeArg,
935
+ archive: archiveArg,
936
+ vault_learnings_dir: learningsAbs,
937
+ summary: {
938
+ rules_with_provenance: rules.length,
939
+ vault_notes_indexed: notes.length,
940
+ present_in_store: records.filter((r) => r.status === 'present-in-store').length,
941
+ present_in_archive: records.filter((r) => r.status === 'present-in-archive').length,
942
+ recoverable_from_backup: records.filter((r) => r.status === 'recoverable-verbatim-from-backup').length,
943
+ orphans: orphans.length,
944
+ located_in_vault: orphans.filter((r) => r.vault_note).length,
945
+ restorable: orphans.filter((r) => r.restorable).length,
946
+ applied,
947
+ skipped_already_present: skippedAlreadyPresent,
948
+ },
949
+ records,
950
+ };
951
+
952
+ if (json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
953
+ else printHuman(report);
954
+
955
+ return { code: 0, report };
956
+ }
957
+
958
+ /* c8 ignore start — CLI entrypoint */
959
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
960
+ main(process.argv.slice(2))
961
+ .then(({ code }) => process.exit(code))
962
+ .catch((err) => {
963
+ process.stderr.write(`backfill-learnings-from-vault: ${err.stack ?? err.message}\n`);
964
+ process.exit(2);
965
+ });
966
+ }
967
+ /* c8 ignore stop */