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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- 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 */
|