@viloforge/vfkb 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ // ADR-0032 — env var rename with back-compat: VFKB_DATA_DIR is canonical,
2
+ // VFKB_DIR is a kept-working deprecated alias. (The bundle-var alias
3
+ // VFKB_BUNDLE_DIR/VFKB_HOME is covered by src/bootstrap.test.ts + doctor.test.ts.)
4
+ import { describe, it, expect, afterEach } from 'vitest';
5
+ import { brainDir } from './storage.js';
6
+ const saved = { data: process.env.VFKB_DATA_DIR, dir: process.env.VFKB_DIR };
7
+ const restore = (k, v) => v === undefined ? delete process.env[k] : (process.env[k] = v);
8
+ afterEach(() => {
9
+ restore('VFKB_DATA_DIR', saved.data);
10
+ restore('VFKB_DIR', saved.dir);
11
+ });
12
+ describe('env var back-compat (ADR-0032)', () => {
13
+ it('VFKB_DATA_DIR is canonical and takes precedence over VFKB_DIR', () => {
14
+ process.env.VFKB_DATA_DIR = '/canonical';
15
+ process.env.VFKB_DIR = '/legacy';
16
+ expect(brainDir()).toBe('/canonical');
17
+ });
18
+ it('VFKB_DIR still resolves the brain as a deprecated alias', () => {
19
+ delete process.env.VFKB_DATA_DIR;
20
+ process.env.VFKB_DIR = '/legacy';
21
+ expect(brainDir()).toBe('/legacy');
22
+ });
23
+ });
package/dist/export.js ADDED
@@ -0,0 +1,371 @@
1
+ // ADR-0047 (accepts RFC-022) — brain export projections: `vfkb export agents-md` +
2
+ // `vfkb export okf`. One projection core, two render targets. The output is a pure
3
+ // function of the brain's content: the "as-of" moment is max(entry.updated) — never
4
+ // the wall clock — the tree rewrite never reads previously emitted output, ordering
5
+ // is total (heuristicCompare + id tiebreak), and filenames derive from the entry id
6
+ // alone. The ADR-0046 ratchet is the publish filter for BOTH targets.
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { deriveConstitution, effectiveStatus, heuristicCompare, isDecisionFamily, isInjectable, readAll, supersededIds, } from './engine.js';
10
+ import { defaultProject, isTombstone, readContextSpine, readRecords } from './storage.js';
11
+ import { normalizeEntry } from './validate.js';
12
+ // Stamped into every emitted file; the scoped sweep deletes ONLY files carrying it,
13
+ // and refuses a non-empty target that has none (a one-flag `--out` mistake must
14
+ // hard-fail, not destroy hand-written content).
15
+ export const GENERATED_MARKER = 'generated by vfkb export';
16
+ // The sweep/guard check is LINE-ANCHORED to the emitted shape — a hand-written doc
17
+ // that merely QUOTES the marker phrase mid-sentence (RFC-022 itself does) must
18
+ // neither defeat the refusal nor enter the deletion set.
19
+ const MARKER_LINE_RE = /^<!-- generated by vfkb export /m;
20
+ // Export-sized budget for the AGENTS.md digest (a cold-agent digest, not a dump).
21
+ export const EXPORT_BUDGET_CHARS = 40_000;
22
+ const TYPE_DIR = {
23
+ fact: 'facts',
24
+ gotcha: 'gotchas',
25
+ pattern: 'patterns',
26
+ decision: 'decisions',
27
+ link: 'links',
28
+ };
29
+ // --- the shared projection core -------------------------------------------------
30
+ // asOf = max(entry.updated) across the live brain — the projection's clock.
31
+ function asOfDate(entries) {
32
+ let max = '';
33
+ for (const e of entries)
34
+ if (e.updated > max)
35
+ max = e.updated;
36
+ return (max || '0000-01-01').slice(0, 10);
37
+ }
38
+ // Total, stable ordering: heuristicCompare (tier → trust → recency) with an explicit
39
+ // id tiebreak — same-millisecond writes exist, and V8 sort stability is not a contract.
40
+ function exportCompare(a, b) {
41
+ return heuristicCompare(a, b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
42
+ }
43
+ // The four-clause export predicate (ADR-0046's ratchet, RFC-022 §Target 2).
44
+ // isInjectable evaluated at asOf carries clauses 2–4 (superseded/deprecated,
45
+ // zone ≠ archive, stale/expired provenance, validity window open at asOf); the
46
+ // ratchet adds verified-only and accepted-only-for-decisions on top. It is
47
+ // deliberately STRICTER than live injection (ADR-0005 keeps unverified-labelled
48
+ // injection for sessions; publishing is a different trust boundary).
49
+ function exportEligible(e, asOf, superseded) {
50
+ if (!isInjectable(e, asOf, superseded))
51
+ return false;
52
+ if (e.provenance.status !== 'verified')
53
+ return false;
54
+ if (isDecisionFamily(e.type) && effectiveStatus(e, superseded) !== 'accepted')
55
+ return false;
56
+ return true;
57
+ }
58
+ // Record-level publish-grade check for the log.md derivation: was THIS historical
59
+ // record's state publish-grade on its own terms? No superseded set (supersession is
60
+ // a current-state fact, not a property of the old record) and no validity clause
61
+ // (expiry is how an entry LEAVES the published view, which is what the log records).
62
+ function recordWasPublishGrade(e) {
63
+ if (e.zone === 'archive')
64
+ return false;
65
+ if (e.provenance.status !== 'verified')
66
+ return false;
67
+ if (isDecisionFamily(e.type) && e.status !== 'accepted')
68
+ return false;
69
+ return true;
70
+ }
71
+ function firstSentence(text, max = 120) {
72
+ const oneLine = text.split('\n')[0];
73
+ const dot = oneLine.indexOf('. ');
74
+ const s = dot > 0 ? oneLine.slice(0, dot + 1) : oneLine;
75
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
76
+ }
77
+ const yamlStr = (s) => JSON.stringify(s);
78
+ function projectionInputs() {
79
+ const all = readAll();
80
+ const superseded = supersededIds(all);
81
+ const asOf = asOfDate(all);
82
+ const eligible = all.filter((e) => exportEligible(e, asOf, superseded)).sort(exportCompare);
83
+ return { all, superseded, asOf, eligible };
84
+ }
85
+ // --- the scoped sweep ------------------------------------------------------------
86
+ function listFiles(dir) {
87
+ const out = [];
88
+ const walk = (d) => {
89
+ for (const name of readdirSync(d)) {
90
+ const p = join(d, name);
91
+ if (statSync(p).isDirectory())
92
+ walk(p);
93
+ else
94
+ out.push(p);
95
+ }
96
+ };
97
+ walk(dir);
98
+ return out;
99
+ }
100
+ function isGenerated(path) {
101
+ if (!path.endsWith('.md'))
102
+ return false;
103
+ try {
104
+ return MARKER_LINE_RE.test(readFileSync(path, 'utf8'));
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ // Rewrite-preparation for the OKF tree: delete ONLY generated-marker files (then any
111
+ // dirs left empty); refuse outright when a non-empty target carries no generated
112
+ // files at all — that is a wrong `--out`, not a previous export.
113
+ function prepareTree(dir) {
114
+ if (!existsSync(dir)) {
115
+ mkdirSync(dir, { recursive: true });
116
+ return;
117
+ }
118
+ const files = listFiles(dir);
119
+ if (files.length === 0)
120
+ return;
121
+ const generated = files.filter(isGenerated);
122
+ if (generated.length === 0) {
123
+ throw new Error(`vfkb export: refusing to write into non-empty ${dir} — it contains no generated files; point --out at an empty directory or a previous export target`);
124
+ }
125
+ for (const f of generated)
126
+ rmSync(f);
127
+ // remove dirs the sweep emptied (bottom-up), leaving any dir still holding foreign files
128
+ const dirs = [];
129
+ const collect = (d) => {
130
+ for (const name of readdirSync(d)) {
131
+ const p = join(d, name);
132
+ if (statSync(p).isDirectory()) {
133
+ collect(p);
134
+ dirs.push(p);
135
+ }
136
+ }
137
+ };
138
+ collect(dir);
139
+ for (const d of dirs.reverse()) {
140
+ if (readdirSync(d).length === 0)
141
+ rmSync(d, { recursive: true });
142
+ }
143
+ }
144
+ // --- target 1: AGENTS.md ----------------------------------------------------------
145
+ export function exportAgentsMd(opts = {}) {
146
+ const { superseded, asOf, eligible } = projectionInputs();
147
+ const project = opts.project ?? defaultProject();
148
+ const budget = opts.budget ?? EXPORT_BUDGET_CHARS;
149
+ const out = opts.out ?? join(process.cwd(), 'AGENTS.md');
150
+ if (existsSync(out) && !MARKER_LINE_RE.test(readFileSync(out, 'utf8'))) {
151
+ throw new Error(`vfkb export: refusing to overwrite ${out} — it is not a previous export (no generated marker)`);
152
+ }
153
+ // Constitution leads (ADR-0008) — but the ratchet still binds: a constitutional
154
+ // decision below the publish bar does not ship.
155
+ const constitution = deriveConstitution().filter((e) => exportEligible(e, asOf, superseded));
156
+ const constitutionalIds = new Set(constitution.map((c) => c.id));
157
+ const knowledge = eligible.filter((e) => !constitutionalIds.has(e.id));
158
+ let head = `<!-- ${GENERATED_MARKER} agents-md; regenerate with \`vfkb export agents-md\`, do not hand-edit (as-of ${asOf}) -->\n` +
159
+ `# ${project} — agent context (generated from the vfkb brain)\n\n`;
160
+ if (constitution.length > 0) {
161
+ head += '## Constitution (always applies)\n';
162
+ for (const c of constitution) {
163
+ const n = typeof c.adr_no === 'number' ? `ADR-${String(c.adr_no).padStart(4, '0')} ` : '';
164
+ head += `- [${n}constitutional] ${c.text}\n`;
165
+ }
166
+ head += '\n';
167
+ }
168
+ // Export-variant map: counts over the PUBLISHED subset only (unpublished entries
169
+ // leak neither counts nor tag strings), no live-session affordances.
170
+ const published = [...constitution, ...knowledge];
171
+ const byType = new Map();
172
+ const tagCounts = new Map();
173
+ let accepted = 0;
174
+ for (const e of published) {
175
+ byType.set(e.type, (byType.get(e.type) ?? 0) + 1);
176
+ for (const t of e.tags)
177
+ tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
178
+ if (isDecisionFamily(e.type))
179
+ accepted++;
180
+ }
181
+ const typesLine = [...byType.entries()]
182
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
183
+ .map(([t, n]) => `${t} ${n}`)
184
+ .join(' · ');
185
+ const topTags = [...tagCounts.entries()]
186
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
187
+ .slice(0, 8)
188
+ .map(([t, n]) => `${t}(${n})`)
189
+ .join(' ');
190
+ head += '## Map\n';
191
+ head += `${published.length} published entr${published.length === 1 ? 'y' : 'ies'}${typesLine ? ` · ${typesLine}` : ''}\n`;
192
+ head += `decisions: ${accepted} accepted (${constitution.length} constitutional)\n`;
193
+ if (topTags)
194
+ head += `top tags: ${topTags}\n`;
195
+ head += '\n';
196
+ const spine = readContextSpine();
197
+ if (spine)
198
+ head += `## Context (authored spine)\n${spine.trim()}\n\n`;
199
+ head += '## Knowledge (verified, published)\n';
200
+ let body = '';
201
+ let dropped = 0;
202
+ for (const e of knowledge) {
203
+ const line = `- [${e.type}] ${e.text}\n`;
204
+ if (head.length + body.length + line.length > budget) {
205
+ dropped++;
206
+ continue;
207
+ }
208
+ body += line;
209
+ }
210
+ if (dropped > 0)
211
+ body += `<!-- ${dropped} entries omitted for the ${budget}-char budget -->\n`;
212
+ mkdirSync(dirname(out), { recursive: true });
213
+ writeFileSync(out, head + body);
214
+ return { path: out };
215
+ }
216
+ // --- target 2: the OKF v0.1 bundle -------------------------------------------------
217
+ function okfDoc(e, asOf, exportedDirs) {
218
+ const title = firstSentence(e.text);
219
+ const fm = ['---'];
220
+ fm.push(`type: ${e.type}`);
221
+ fm.push(`title: ${yamlStr(title)}`);
222
+ fm.push(`description: ${yamlStr(title)}`);
223
+ if (e.tags.length > 0)
224
+ fm.push(`tags: ${JSON.stringify(e.tags)}`);
225
+ fm.push(`timestamp: ${e.updated}`);
226
+ if (e.type === 'link') {
227
+ const url = /https?:\/\/[^\s)"'\]]+/.exec(e.text)?.[0]?.replace(/[.,;:]+$/, '');
228
+ if (url)
229
+ fm.push(`resource: ${yamlStr(url)}`); // never fabricated — only a real URL
230
+ }
231
+ fm.push(`generated_by: vfkb export okf`);
232
+ fm.push('---');
233
+ let body = `<!-- ${GENERATED_MARKER} okf; regenerate with \`vfkb export okf\`, do not hand-edit (as-of ${asOf}) -->\n\n`;
234
+ body += `${e.text.trim()}\n`;
235
+ const citations = [];
236
+ for (const rel of e.refs?.related ?? []) {
237
+ const dir = exportedDirs.get(rel);
238
+ citations.push(dir ? `- [/${dir}/${rel}.md](/${dir}/${rel}.md)` : `- related entry ${rel}`);
239
+ }
240
+ for (const f of e.refs?.files ?? [])
241
+ citations.push(`- ${f}`);
242
+ if (e.refs?.commit)
243
+ citations.push(`- commit ${e.refs.commit}`);
244
+ if (citations.length > 0)
245
+ body += `\n# Citations\n${citations.join('\n')}\n`;
246
+ return `${fm.join('\n')}\n${body}`;
247
+ }
248
+ // log.md — a pure function of the brain's RAW record history (readRecords-level).
249
+ // The materialized LWW view cannot prove an entry was ever publish-grade (a
250
+ // verified→stale restamp shows only `stale`; a tombstoned entry vanishes), so the
251
+ // departure set is: some raw record was publish-grade AND the id is not exported now.
252
+ function deriveLog(eligibleIds, records, live) {
253
+ const byId = new Map();
254
+ for (const r of records) {
255
+ if (!r || typeof r.id !== 'string')
256
+ continue;
257
+ const list = byId.get(r.id) ?? [];
258
+ list.push(r);
259
+ byId.set(r.id, list);
260
+ }
261
+ const liveById = new Map(live.map((e) => [e.id, e]));
262
+ const successorOf = new Map();
263
+ for (const e of live)
264
+ if (e.refs?.supersedes)
265
+ successorOf.set(e.refs.supersedes, e);
266
+ const departures = [];
267
+ for (const [id, recs] of byId) {
268
+ if (eligibleIds.has(id))
269
+ continue;
270
+ // earliest (min-updated) publish-grade record — set-deterministic, no reliance
271
+ // on file order even across hypothetical record reorderings
272
+ let grade;
273
+ for (const r of recs) {
274
+ if (isTombstone(r))
275
+ continue;
276
+ const n = normalizeEntry(r);
277
+ if (n.ok && recordWasPublishGrade(n.entry) && (!grade || n.entry.updated < grade.updated)) {
278
+ grade = n.entry;
279
+ }
280
+ }
281
+ if (!grade)
282
+ continue; // never publish-grade → not a departure
283
+ const cur = liveById.get(id);
284
+ let at;
285
+ let reason;
286
+ if (!cur) {
287
+ const tomb = recs.filter(isTombstone).sort((a, b) => b.updated.localeCompare(a.updated))[0];
288
+ at = tomb?.updated ?? grade.updated;
289
+ reason = 'deleted (tombstoned)';
290
+ }
291
+ else if (successorOf.has(id)) {
292
+ at = successorOf.get(id).updated;
293
+ reason = `superseded by ${successorOf.get(id).id}`;
294
+ }
295
+ else if (cur.provenance.status !== 'verified') {
296
+ at = cur.updated;
297
+ reason = `provenance re-stamped ${cur.provenance.status}`;
298
+ }
299
+ else if (cur.zone === 'archive') {
300
+ at = cur.updated;
301
+ reason = 'archived';
302
+ }
303
+ else if (cur.status === 'deprecated') {
304
+ at = cur.updated;
305
+ reason = 'deprecated';
306
+ }
307
+ else if (cur.validity.valid_until) {
308
+ at = cur.validity.valid_until;
309
+ reason = `validity window closed ${cur.validity.valid_until.slice(0, 10)}`;
310
+ }
311
+ else {
312
+ at = cur.updated;
313
+ reason = 'no longer eligible';
314
+ }
315
+ departures.push({
316
+ at,
317
+ id,
318
+ line: `- ${at.slice(0, 10)} — ${id} [${grade.type}] ${yamlStr(firstSentence(grade.text, 80))} — ${reason}`,
319
+ });
320
+ }
321
+ departures.sort((a, b) => a.at.localeCompare(b.at) || (a.id < b.id ? -1 : 1));
322
+ return departures.map((d) => d.line);
323
+ }
324
+ export function exportOkf(opts = {}) {
325
+ const { superseded, asOf, eligible } = projectionInputs();
326
+ const project = opts.project ?? defaultProject();
327
+ const dir = opts.out ?? join(process.cwd(), '.okf');
328
+ prepareTree(dir);
329
+ const marker = `<!-- ${GENERATED_MARKER} okf; regenerate with \`vfkb export okf\`, do not hand-edit (as-of ${asOf}) -->`;
330
+ const files = [];
331
+ const write = (rel, content) => {
332
+ const p = join(dir, rel);
333
+ mkdirSync(dirname(p), { recursive: true });
334
+ writeFileSync(p, content);
335
+ files.push(rel);
336
+ };
337
+ const exportedDirs = new Map(eligible.map((e) => [e.id, TYPE_DIR[e.type]]));
338
+ const byDir = new Map();
339
+ for (const e of eligible) {
340
+ const d = TYPE_DIR[e.type];
341
+ const list = byDir.get(d) ?? [];
342
+ list.push(e);
343
+ byDir.set(d, list);
344
+ }
345
+ for (const [d, entries] of [...byDir.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
346
+ for (const e of entries)
347
+ write(`${d}/${e.id}.md`, okfDoc(e, asOf, exportedDirs));
348
+ const lines = entries.map((e) => `- [${firstSentence(e.text, 80).replace(/\]/g, '\\]')}](/${d}/${e.id}.md)`);
349
+ write(`${d}/index.md`, `${marker}\n\n# ${d}\n\n${lines.join('\n')}\n`);
350
+ }
351
+ const rootLines = [...byDir.entries()]
352
+ .sort((a, b) => a[0].localeCompare(b[0]))
353
+ .map(([d, entries]) => `- [${d}/](/${d}/index.md) — ${entries.length} document${entries.length === 1 ? '' : 's'}`);
354
+ write('index.md', `${marker}\n\n# ${project} — OKF bundle (generated from the vfkb brain, as-of ${asOf})\n\n` +
355
+ `${rootLines.join('\n') || '(no published entries)'}\n\n` +
356
+ `See [log.md](/log.md) for entries that have left the published view.\n`);
357
+ const logLines = deriveLog(new Set(eligible.map((e) => e.id)), readRecords(), readAll());
358
+ write('log.md', `${marker}\n\n# log — departures from the published view\n\n` +
359
+ `${logLines.join('\n') || '(none)'}\n`);
360
+ return { dir, files: files.sort() };
361
+ }
362
+ // CLI dispatch (`vfkb export <target> [--out <path>]`).
363
+ export function runExport(target, opts = {}) {
364
+ if (target === 'agents-md')
365
+ return `wrote ${exportAgentsMd(opts).path}`;
366
+ if (target === 'okf') {
367
+ const r = exportOkf(opts);
368
+ return `wrote ${r.files.length} files → ${r.dir}`;
369
+ }
370
+ throw new Error('usage: vfkb export <agents-md|okf> [--out <path>]');
371
+ }
package/dist/gating.js ADDED
@@ -0,0 +1,34 @@
1
+ // Tool-gating (mykb L10 mitigation / D7c). Block harness file-write tools from
2
+ // editing the brain JSONL directly — all writes MUST go through the engine (sole
3
+ // writer, D4a; keeps the index/freshness invariants and the no-secrets lint).
4
+ // Harness-agnostic: handles Claude Code ('Write'/'Edit'/'MultiEdit') and Pi
5
+ // ('write'/'edit') tool names + their path aliases.
6
+ import { resolve } from 'node:path';
7
+ import { brainDir } from './storage.js';
8
+ const WRITE_TOOLS = new Set([
9
+ 'write',
10
+ 'edit',
11
+ 'multiedit',
12
+ 'notebookedit',
13
+ 'create',
14
+ 'str_replace_editor',
15
+ ]);
16
+ function extractPath(input) {
17
+ if (!input)
18
+ return undefined;
19
+ const p = input.file_path ?? input.path ?? input.filePath ?? input.notebook_path;
20
+ return typeof p === 'string' ? p : undefined;
21
+ }
22
+ // Is this tool call a direct write into the brain directory?
23
+ export function isBrainWrite(toolName, input, brain = brainDir()) {
24
+ if (!toolName || !WRITE_TOOLS.has(toolName.toLowerCase()))
25
+ return false;
26
+ const p = extractPath(input);
27
+ if (!p)
28
+ return false;
29
+ const abs = resolve(p);
30
+ const root = resolve(brain);
31
+ return abs === root || abs.startsWith(root + '/');
32
+ }
33
+ export const GATING_REASON = 'vfkb: edit the brain via the engine/CLI/MCP, not by writing files directly ' +
34
+ '(keeps the index, freshness, and no-secrets invariants).';
package/dist/git.js ADDED
@@ -0,0 +1,43 @@
1
+ // Brain git lifecycle (Phase 6). The per-project brain is its own git history;
2
+ // the engine commits it (attributed to the writing role). Pure Node stdlib + the
3
+ // git binary (no library dep).
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { brainDir } from './storage.js';
8
+ function git(args, cwd) {
9
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
10
+ }
11
+ function ensureRepo(brain) {
12
+ if (!existsSync(join(brain, '.git'))) {
13
+ git(['init', '-q'], brain);
14
+ }
15
+ }
16
+ // Commit all brain changes. `role` attributes the commit (author.role, D4a).
17
+ // Returns committed:false when there is nothing to commit (idempotent).
18
+ export function save(message = 'vfkb: update', role = 'engine', brain = brainDir()) {
19
+ ensureRepo(brain);
20
+ git(['add', '-A'], brain);
21
+ const status = git(['status', '--porcelain'], brain).trim();
22
+ if (status.length === 0)
23
+ return { committed: false, message: 'nothing to commit' };
24
+ git([
25
+ '-c',
26
+ `user.name=vfkb (${role})`,
27
+ '-c',
28
+ 'user.email=vfkb@vilosource.local',
29
+ 'commit',
30
+ '-q',
31
+ '-m',
32
+ message,
33
+ ], brain);
34
+ return { committed: true, message };
35
+ }
36
+ export function saveAndPush(message = 'vfkb: update', role = 'engine', brain = brainDir()) {
37
+ const r = save(message, role, brain);
38
+ // Push only if a remote is configured (otherwise a local-only brain).
39
+ const remotes = git(['remote'], brain).trim();
40
+ if (remotes.length > 0)
41
+ git(['push', '-q'], brain);
42
+ return r;
43
+ }
package/dist/import.js ADDED
@@ -0,0 +1,99 @@
1
+ // FR-3 (ADR-0030) — `vfkb import`: bring existing knowledge across so "migrate a
2
+ // project to vfkb" is a real verb, not a clean-slate restart. All imports route
3
+ // through the engine (the write-gate applies) and are stamped role=import (Trust
4
+ // 'import') + an `imported` tag — the mapping is explicitly LOSSY (ADR-0030).
5
+ //
6
+ // --from-mykb <areaDir|name> map a mykb area's *.jsonl into vfkb envelopes
7
+ // --from-adr <dir> one `link` per ADR markdown file (default docs/adr)
8
+ // --from-markdown <file> attach a historical doc as a referenced source
9
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { basename, extname, join } from 'node:path';
12
+ import { addEntry } from './engine.js';
13
+ const MYKB_FILES = {
14
+ 'decisions.jsonl': 'decision',
15
+ 'facts.jsonl': 'fact',
16
+ 'gotchas.jsonl': 'gotcha',
17
+ 'patterns.jsonl': 'pattern',
18
+ 'links.jsonl': 'link',
19
+ };
20
+ function stamp(type, text, tags, verified) {
21
+ const e = addEntry(type, text, {
22
+ role: 'import',
23
+ tags: ['imported', ...tags.filter((t) => t !== 'imported')],
24
+ provStatus: verified ? 'verified' : 'unverified',
25
+ });
26
+ return { id: e.id, type: e.type, text: e.text };
27
+ }
28
+ // mykb envelope -> vfkb text (lossy: area/zone/created/rejected fold or drop).
29
+ function mykbText(type, e) {
30
+ const parts = [String(e.text ?? '').trim()];
31
+ if (type === 'decision' && e.why)
32
+ parts.push(`Why: ${e.why}`);
33
+ if (type === 'decision' && e.rejected)
34
+ parts.push(`Rejected: ${e.rejected}`);
35
+ if (type === 'gotcha' && e.resolution)
36
+ parts.push(`Resolution: ${e.resolution}`);
37
+ if (type === 'link' && e.url)
38
+ return `${parts[0]} → ${e.url}`;
39
+ return parts.filter(Boolean).join('\n\n');
40
+ }
41
+ export function resolveMykbArea(nameOrDir) {
42
+ if (existsSync(nameOrDir) && statSync(nameOrDir).isDirectory())
43
+ return nameOrDir;
44
+ return join(homedir(), '.mykb', 'areas', nameOrDir);
45
+ }
46
+ export function fromMykb(areaDir) {
47
+ if (!existsSync(areaDir))
48
+ throw new Error(`mykb area not found: ${areaDir}`);
49
+ const out = [];
50
+ for (const [file, type] of Object.entries(MYKB_FILES)) {
51
+ const path = join(areaDir, file);
52
+ if (!existsSync(path))
53
+ continue;
54
+ for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
55
+ const trimmed = line.trim();
56
+ if (!trimmed)
57
+ continue;
58
+ let e;
59
+ try {
60
+ e = JSON.parse(trimmed);
61
+ }
62
+ catch {
63
+ continue; // skip a malformed line, don't abort the migration
64
+ }
65
+ const text = mykbText(type, e);
66
+ if (!text)
67
+ continue;
68
+ const verified = e?.provenance?.status === 'verified';
69
+ out.push(stamp(type, text, Array.isArray(e.tags) ? e.tags : [], verified));
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+ function mdTitle(path) {
75
+ try {
76
+ const heading = readFileSync(path, 'utf8').split(/\r?\n/).find((l) => l.startsWith('# '));
77
+ if (heading)
78
+ return heading.replace(/^#\s+/, '').trim();
79
+ }
80
+ catch { }
81
+ return basename(path, extname(path));
82
+ }
83
+ export function fromAdr(dir = 'docs/adr') {
84
+ if (!existsSync(dir))
85
+ throw new Error(`ADR dir not found: ${dir}`);
86
+ const out = [];
87
+ for (const file of readdirSync(dir).sort()) {
88
+ if (extname(file) !== '.md' || /readme/i.test(file))
89
+ continue;
90
+ const rel = join(dir, file);
91
+ out.push(stamp('link', `${mdTitle(rel)} → ${rel}`, ['adr'], false));
92
+ }
93
+ return out;
94
+ }
95
+ export function fromMarkdown(file) {
96
+ if (!existsSync(file))
97
+ throw new Error(`markdown file not found: ${file}`);
98
+ return [stamp('link', `${mdTitle(file)} → ${file}`, ['doc'], false)];
99
+ }
@@ -0,0 +1,54 @@
1
+ // FR-3 (ADR-0030) inner gate — `vfkb import` maps mykb / ADRs / markdown into vfkb
2
+ // envelopes through the engine (role=import, lossy), skipping malformed input.
3
+ import { describe, it, expect, beforeEach } from 'vitest';
4
+ import { mkdtempSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fromMykb, fromAdr, fromMarkdown } from './import.js';
8
+ import { readAll } from './engine.js';
9
+ beforeEach(() => {
10
+ process.env.VFKB_DIR = mkdtempSync(join(tmpdir(), 'vfkb-import-'));
11
+ });
12
+ const jl = (...objs) => objs.map((o) => JSON.stringify(o)).join('\n') + '\n';
13
+ describe('vfkb import (FR-3)', () => {
14
+ it('maps a mykb area into vfkb envelopes (lossy), folding why/resolution/url', () => {
15
+ const area = mkdtempSync(join(tmpdir(), 'mykb-area-'));
16
+ writeFileSync(join(area, 'decisions.jsonl'), jl({ type: 'decision', text: 'D1', tags: ['arch'], why: 'because', provenance: { status: 'verified' } }));
17
+ writeFileSync(join(area, 'gotchas.jsonl'), jl({ type: 'gotcha', text: 'G1', resolution: 'fixed it' }));
18
+ writeFileSync(join(area, 'links.jsonl'), jl({ type: 'link', text: 'L1', url: 'https://x/y' }));
19
+ writeFileSync(join(area, 'facts.jsonl'), 'not json\n' + jl({ type: 'fact', text: 'F1' }));
20
+ const res = fromMykb(area);
21
+ expect(res.length).toBe(4); // the malformed line is skipped
22
+ const all = readAll();
23
+ const decision = all.find((e) => e.type === 'decision');
24
+ expect(decision.text).toContain('D1');
25
+ expect(decision.text).toContain('Why: because');
26
+ expect(decision.author.role).toBe('import');
27
+ expect(decision.tags).toContain('imported');
28
+ expect(decision.provenance.status).toBe('verified'); // mykb verified preserved
29
+ expect(all.find((e) => e.type === 'gotcha').text).toContain('Resolution: fixed it');
30
+ expect(all.find((e) => e.type === 'link').text).toBe('L1 → https://x/y');
31
+ expect(all.find((e) => e.type === 'fact').provenance.status).toBe('unverified');
32
+ });
33
+ it('from-adr creates one link per ADR markdown, skipping README', () => {
34
+ const dir = mkdtempSync(join(tmpdir(), 'adr-'));
35
+ writeFileSync(join(dir, 'ADR-0001-foo.md'), '# ADR-0001: Foo decision\n\nbody');
36
+ writeFileSync(join(dir, 'ADR-0002-bar.md'), '# ADR-0002: Bar decision\n');
37
+ writeFileSync(join(dir, 'README.md'), '# index');
38
+ const res = fromAdr(dir);
39
+ expect(res.length).toBe(2);
40
+ const all = readAll();
41
+ expect(all.every((e) => e.type === 'link' && e.author.role === 'import')).toBe(true);
42
+ expect(all.find((e) => e.text.includes('Foo decision')).text).toContain('ADR-0001-foo.md');
43
+ });
44
+ it('from-markdown attaches a single referenced source', () => {
45
+ const f = join(mkdtempSync(join(tmpdir(), 'md-')), 'NOTES.md');
46
+ writeFileSync(f, '# Historical notes\n\nstuff');
47
+ const res = fromMarkdown(f);
48
+ expect(res.length).toBe(1);
49
+ expect(readAll()[0].text).toBe(`Historical notes → ${f}`);
50
+ });
51
+ it('throws a clear error on a missing source', () => {
52
+ expect(() => fromAdr('/no/such/dir')).toThrow(/ADR dir not found/);
53
+ });
54
+ });