@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,101 @@
1
+ // Pluggable read index (ADR-0013). v1 default = pure-JS in-memory, rebuilt from
2
+ // JSONL (source of truth). A better-sqlite3 FTS5 backend is an OPTIONAL future
3
+ // implementation behind this same interface — the engine never hard-depends on it.
4
+ //
5
+ // Freshness (ADR-0014): the index compares a content-derived token before serving
6
+ // and rebuilds on mismatch (rebuild-on-doubt — cheap because in-memory + small).
7
+ import { contentHash, materialize, readMeta, readRecords } from './storage.js';
8
+ // Light suffix stripping (NOT a full Porter stemmer) so a natural-language query
9
+ // term lexically matches the stored wording: hanging/hangs -> hang,
10
+ // silently/silent -> silent. Found load-bearing by the devops-kb live turn — the
11
+ // agent phrased "hanging silent" while the entry said "hangs silently", so the
12
+ // relevant gotcha scored ~0 and was never surfaced. Conservative: min stem length
13
+ // 3, longest suffix first; mismatches it can't resolve (running/runs) are accepted
14
+ // for v1 — the real robustness fix is the deferred semantic reranker (ADR-0012).
15
+ function stem(t) {
16
+ for (const suf of ['ing', 'ed', 'ly', 'es', 's']) {
17
+ if (t.length - suf.length >= 3 && t.endsWith(suf))
18
+ return t.slice(0, -suf.length);
19
+ }
20
+ return t;
21
+ }
22
+ function tokenize(s) {
23
+ return s
24
+ .toLowerCase()
25
+ .split(/[^a-z0-9]+/)
26
+ .filter((t) => t.length > 1)
27
+ .map(stem);
28
+ }
29
+ // Distinct stemmed terms in a query — the denominator for RFC-001's relevance
30
+ // floor (matched / queryTermCount). Shares the tokenizer/stemmer with searchScored
31
+ // so the floor and the search agree on what a "term" is.
32
+ export function queryTermCount(query) {
33
+ return new Set(tokenize(query)).size;
34
+ }
35
+ export class InMemoryIndex {
36
+ entries = [];
37
+ token = '';
38
+ constructor() {
39
+ this.rebuild();
40
+ }
41
+ rebuild() {
42
+ this.entries = materialize(readRecords());
43
+ this.token = contentHash(this.entries);
44
+ }
45
+ // Rebuild iff the persisted content token differs from what we hold.
46
+ // The meta hash is authoritative when present; else recompute from JSONL
47
+ // (covers a git pull that changed entries.jsonl but not the sidecar).
48
+ ensureFresh() {
49
+ const persisted = readMeta()?.content_hash ?? contentHash();
50
+ if (persisted !== this.token)
51
+ this.rebuild();
52
+ }
53
+ all() {
54
+ this.ensureFresh();
55
+ return this.entries;
56
+ }
57
+ get(id) {
58
+ this.ensureFresh();
59
+ return this.entries.find((e) => e.id === id);
60
+ }
61
+ // Stage-1 relevance: stemmed term-overlap count over text + tags — NOT BM25
62
+ // (no IDF, no length normalization; the score is # of entry tokens matching a
63
+ // query term). Adequate as a candidate signal at per-project scale; semantic
64
+ // ranking is the deferred EmbeddingReranker (ADR-0012/0016). The envelope-aware
65
+ // Heuristic reranker is a separate Stage-2 applied to these candidates.
66
+ searchScored(query, k = 30) {
67
+ this.ensureFresh();
68
+ const terms = new Set(tokenize(query));
69
+ if (terms.size === 0)
70
+ return [];
71
+ return this.entries
72
+ .map((entry) => {
73
+ // tags may be absent on legacy or externally-projected entries (e.g. vfwb's
74
+ // lossy projection into .vfkb) — never let a tagless entry crash search.
75
+ const hay = tokenize(entry.text + ' ' + (entry.tags ?? []).join(' '));
76
+ let score = 0;
77
+ const hit = new Set();
78
+ for (const t of hay)
79
+ if (terms.has(t)) {
80
+ score++;
81
+ hit.add(t);
82
+ }
83
+ return { entry, score, matched: hit.size };
84
+ })
85
+ .filter((x) => x.score > 0)
86
+ .sort((a, b) => b.score - a.score || b.entry.updated.localeCompare(a.entry.updated))
87
+ .slice(0, k);
88
+ }
89
+ search(query, k = 30) {
90
+ return this.searchScored(query, k).map((x) => x.entry);
91
+ }
92
+ freshnessToken() {
93
+ return this.token;
94
+ }
95
+ }
96
+ // ADR-0013: select the index backend. v1 always returns the pure-JS in-memory
97
+ // index (zero native deps). A future SQLite/FTS5 backend would be auto-detected
98
+ // here and fall back to in-memory if the native module is absent.
99
+ export function selectIndex() {
100
+ return new InMemoryIndex();
101
+ }
package/dist/init.js ADDED
@@ -0,0 +1,264 @@
1
+ // FR-1 (ADR-0030) — `vfkb init [project]`: idempotently scaffold a CONSUMER repo
2
+ // so a session there runs on vfkb automatically. Writes the auto-layer wiring in
3
+ // the portable `$VFKB_HOME` form (FR-2), the .gitignore stanza, an empty brain,
4
+ // and a parameterized "how we track work HERE" snippet. Re-running is safe: each
5
+ // piece is created-if-absent / merged-if-present, and a brain is NEVER clobbered.
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { basename, join } from 'node:path';
8
+ import { writeManifest } from './manifest.js';
9
+ const AGENTS_MARKER = '<!-- vfkb:how-we-track-work -->';
10
+ const BOOTSTRAP_REL = '.vfkb/bin/bootstrap.mjs';
11
+ // The wiring entry-point is a COMMITTED, RELATIVE bootstrap (ADR-0031) — always
12
+ // resolvable in any clone. The bootstrap resolves the real engine via $VFKB_HOME
13
+ // at runtime and degrades gracefully (a clear banner, never a crash/blocked write)
14
+ // when it is unset. This also avoids depending on `${VFKB_HOME}` arg-expansion.
15
+ function mcpConfig(project) {
16
+ return {
17
+ command: 'node',
18
+ args: [BOOTSTRAP_REL, 'mcp'],
19
+ env: { VFKB_DATA_DIR: '.vfkb', VFKB_PROJECT: project },
20
+ };
21
+ }
22
+ // Anchor hook paths to the project root Claude Code injects into the hook env
23
+ // ($CLAUDE_PROJECT_DIR), defaulting to the CWD-relative form when it is unset so the
24
+ // wiring never regresses. Hooks run in the SESSION's cwd — which is NOT guaranteed to
25
+ // be the repo root (it follows `cd`) — so the bare-relative `node .vfkb/bin/bootstrap.mjs`
26
+ // threw MODULE_NOT_FOUND the moment a session cd'd out of the root (issue #22, ADR-0035).
27
+ // $CLAUDE_PROJECT_DIR is CWD-independent (empirically verified) and hook commands are
28
+ // shell-run, so `${VAR:-.}` parameter-expansion resolves. (The MCP server in .mcp.json
29
+ // stays relative — it is spawned once at session start with cwd=root, not re-invoked.)
30
+ function hookCommand(project, sub) {
31
+ const root = '${CLAUDE_PROJECT_DIR:-.}';
32
+ return `VFKB_DATA_DIR=${root}/.vfkb VFKB_PROJECT=${project} node ${root}/${BOOTSTRAP_REL} cli hook ${sub}`;
33
+ }
34
+ // The bootstrap source, written verbatim into the consumer repo. Self-contained
35
+ // (no engine import — it must run even when the engine is unresolvable). Bump
36
+ // BOOTSTRAP_VERSION on a change so `vfkb init` upgrades an older one in place.
37
+ const BOOTSTRAP_VERSION = 2;
38
+ const BOOTSTRAP_SRC = `#!/usr/bin/env node
39
+ // vfkb engine bootstrap (ADR-0031) — vfkb-bootstrap-version: ${BOOTSTRAP_VERSION}
40
+ // Committed at a RELATIVE path so it is always resolvable. Resolves the real
41
+ // engine via $VFKB_BUNDLE_DIR at runtime; degrades GRACEFULLY (clear, actionable
42
+ // message; never breaks the session) when it is unset or the bundles are missing.
43
+ // DO NOT hand-edit — regenerated by \`vfkb init\`.
44
+ import { existsSync } from 'node:fs';
45
+ import { spawnSync } from 'node:child_process';
46
+ import { join } from 'node:path';
47
+
48
+ const mode = process.argv[2] === 'mcp' ? 'mcp' : 'cli';
49
+ const passthrough = process.argv.slice(3);
50
+ // VFKB_BUNDLE_DIR is canonical; VFKB_HOME is a kept-working deprecated alias.
51
+ const home = process.env.VFKB_BUNDLE_DIR || process.env.VFKB_HOME;
52
+ const engine = mode === 'mcp' ? 'vfkb-mcp.mjs' : 'vfkb.mjs';
53
+ const enginePath = home ? join(home, engine) : '';
54
+
55
+ const FIX =
56
+ 'vfkb is INACTIVE: VFKB_BUNDLE_DIR is not set (or its bundles are missing). ' +
57
+ 'Fix: build the bundles in the vfkb repo (\\\`npm run build:bundles\\\`) and ' +
58
+ '\\\`export VFKB_BUNDLE_DIR=/path/to/vfkb/dist/bundles\\\` (see docs/CONSUMER-ONBOARDING.md). ' +
59
+ 'Then run \\\`vfkb doctor\\\` to verify.';
60
+
61
+ if (!home || !existsSync(enginePath)) {
62
+ // SessionStart: inform the user via the injection channel (a valid hook payload).
63
+ if (mode === 'cli' && passthrough[1] === 'session-start') {
64
+ process.stdout.write(JSON.stringify({
65
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '⚠️ ' + FIX },
66
+ }));
67
+ process.exit(0);
68
+ }
69
+ // PreToolUse / Stop / MCP: note it, but NEVER block writes or crash the turn.
70
+ process.stderr.write('vfkb-bootstrap: ' + FIX + '\\n');
71
+ process.exit(0);
72
+ }
73
+
74
+ // Resolved — run the real engine transparently (stdio passed through).
75
+ const r = spawnSync('node', [enginePath, ...passthrough], { stdio: 'inherit' });
76
+ process.exit(r.status == null ? 0 : r.status);
77
+ `;
78
+ function settingsHooks(project) {
79
+ return {
80
+ SessionStart: [{ hooks: [{ type: 'command', command: hookCommand(project, 'session-start') }] }],
81
+ PreToolUse: [
82
+ { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: hookCommand(project, 'pre-tool-use') }] },
83
+ ],
84
+ Stop: [{ hooks: [{ type: 'command', command: hookCommand(project, 'stop') }] }],
85
+ SessionEnd: [{ hooks: [{ type: 'command', command: hookCommand(project, 'session-end') }] }],
86
+ };
87
+ }
88
+ function agentsSnippet(project) {
89
+ return `${AGENTS_MARKER}
90
+ ## How we track work HERE — vfkb
91
+
92
+ This repo uses **vfkb** as its knowledge substrate (project \`${project}\`). Knowledge is recorded
93
+ **deliberately, through the engine** — never by hand-editing \`.vfkb/\` (a PreToolUse hook gates that).
94
+
95
+ - **Session start** injects the resume digest + knowledge bundle automatically (SessionStart hook).
96
+ - **Record knowledge** with the \`mcp__vfkb__kb_add\` tool (or \`node .vfkb/bin/bootstrap.mjs cli add …\`):
97
+ \`decision\`, \`fact\`, \`gotcha\`, \`pattern\`, \`link\` — put a decision's rationale in its text.
98
+ **Capture load-bearing decisions immediately — don't defer.**
99
+ - Only \`.vfkb/entries.jsonl\`, \`.vfkb/manifest.json\`, and \`.vfkb/bin/\` are committed;
100
+ \`.vfkb/index-meta.json\`, \`.sessions/\`, \`.signals/\` are derived/gitignored.
101
+
102
+ Two env vars: **\`VFKB_DATA_DIR\`** = this repo's brain (\`.vfkb\`, set by the wiring) · **\`VFKB_BUNDLE_DIR\`**
103
+ = the shared vfkb engine bundles — set it once per machine, e.g. \`export VFKB_BUNDLE_DIR=/path/to/vfkb/dist/bundles\`.
104
+ If it is unset, a session-start banner tells you; run \`vfkb doctor\` to check.
105
+ `;
106
+ }
107
+ function readJson(path) {
108
+ if (!existsSync(path))
109
+ return undefined;
110
+ try {
111
+ return JSON.parse(readFileSync(path, 'utf8'));
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ function writeJson(path, value) {
118
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
119
+ }
120
+ function eventHasVfkb(arr) {
121
+ return JSON.stringify(arr ?? '').includes(BOOTSTRAP_REL);
122
+ }
123
+ export function initProject(root, opts = {}) {
124
+ const project = opts.project || basename(root) || 'project';
125
+ const changes = [];
126
+ // 1. Empty brain — NEVER clobber an existing one.
127
+ const brainDir = join(root, '.vfkb');
128
+ const entries = join(brainDir, 'entries.jsonl');
129
+ if (!existsSync(entries)) {
130
+ mkdirSync(brainDir, { recursive: true });
131
+ writeFileSync(entries, '');
132
+ changes.push({ path: '.vfkb/entries.jsonl', action: 'created' });
133
+ }
134
+ else {
135
+ changes.push({ path: '.vfkb/entries.jsonl', action: 'skipped' });
136
+ }
137
+ // 1b. Brain↔engine version stamp (FR-4) — committed, engine-written.
138
+ changes.push({ path: '.vfkb/manifest.json', action: writeManifest(brainDir) });
139
+ // 1c. The committed bootstrap entry-point (ADR-0031) — write if absent or stale.
140
+ {
141
+ const binDir = join(brainDir, 'bin');
142
+ const path = join(binDir, 'bootstrap.mjs');
143
+ const existed = existsSync(path);
144
+ const same = existed && readFileSync(path, 'utf8') === BOOTSTRAP_SRC;
145
+ if (same) {
146
+ changes.push({ path: BOOTSTRAP_REL, action: 'skipped' });
147
+ }
148
+ else {
149
+ mkdirSync(binDir, { recursive: true });
150
+ writeFileSync(path, BOOTSTRAP_SRC);
151
+ changes.push({ path: BOOTSTRAP_REL, action: existed ? 'updated' : 'created' });
152
+ }
153
+ }
154
+ // 2. .mcp.json — register the vfkb MCP server (merge, keep other servers).
155
+ {
156
+ const path = join(root, '.mcp.json');
157
+ const existed = existsSync(path);
158
+ const cfg = readJson(path) ?? {};
159
+ cfg.mcpServers = cfg.mcpServers ?? {};
160
+ const desired = mcpConfig(project);
161
+ const same = JSON.stringify(cfg.mcpServers.vfkb) === JSON.stringify(desired);
162
+ if (same) {
163
+ changes.push({ path: '.mcp.json', action: 'skipped' });
164
+ }
165
+ else {
166
+ cfg.mcpServers.vfkb = desired;
167
+ writeJson(path, cfg);
168
+ changes.push({ path: '.mcp.json', action: existed ? 'updated' : 'created' });
169
+ }
170
+ }
171
+ // 3. .claude/settings.json — the four hooks (merge per-event; don't duplicate).
172
+ // Drop any pre-existing vfkb entry and re-append the current form: this UPGRADES an
173
+ // older CWD-relative hook to the anchored one (issue #22) while keeping user hooks.
174
+ {
175
+ const dir = join(root, '.claude');
176
+ const path = join(dir, 'settings.json');
177
+ const existed = existsSync(path);
178
+ const cfg = readJson(path) ?? {};
179
+ cfg.hooks = cfg.hooks ?? {};
180
+ const want = settingsHooks(project);
181
+ let touched = false;
182
+ for (const event of Object.keys(want)) {
183
+ const raw = cfg.hooks[event];
184
+ const cur = Array.isArray(raw) ? raw : raw ? [raw] : [];
185
+ const others = cur.filter((e) => !eventHasVfkb(e)); // keep non-vfkb (user) hooks
186
+ const desired = [...others, ...want[event]];
187
+ if (JSON.stringify(cur) === JSON.stringify(desired))
188
+ continue; // already current form
189
+ cfg.hooks[event] = desired;
190
+ touched = true;
191
+ }
192
+ if (touched) {
193
+ mkdirSync(dir, { recursive: true });
194
+ writeJson(path, cfg);
195
+ changes.push({ path: '.claude/settings.json', action: existed ? 'updated' : 'created' });
196
+ }
197
+ else {
198
+ changes.push({ path: '.claude/settings.json', action: 'skipped' });
199
+ }
200
+ }
201
+ // 4. .gitignore — the derived/operational stanza (append once).
202
+ {
203
+ const path = join(root, '.gitignore');
204
+ const lines = ['.vfkb/index-meta.json', '.vfkb/.sessions/', '.vfkb/.signals/'];
205
+ const existed = existsSync(path);
206
+ const cur = existed ? readFileSync(path, 'utf8') : '';
207
+ const missing = lines.filter((l) => !cur.split(/\r?\n/).includes(l));
208
+ if (missing.length === 0) {
209
+ changes.push({ path: '.gitignore', action: 'skipped' });
210
+ }
211
+ else {
212
+ const prefix = cur && !cur.endsWith('\n') ? '\n' : '';
213
+ const block = `${prefix}${cur ? '\n' : ''}# vfkb — derived/operational (only .vfkb/entries.jsonl is committed)\n${missing.join('\n')}\n`;
214
+ writeFileSync(path, cur + block);
215
+ changes.push({ path: '.gitignore', action: existed ? 'updated' : 'created' });
216
+ }
217
+ }
218
+ // 4b. .gitattributes — merge=union for the append-only brain (ADR-0041), so
219
+ // parallel branches that both appended entries union-merge instead of conflicting.
220
+ // Append once; never touches the consumer's other attributes. (GitHub's server-side
221
+ // merge ignores it — a local `git merge` remains the documented fallback, ADR-0041.)
222
+ {
223
+ const path = join(root, '.gitattributes');
224
+ const line = '.vfkb/entries.jsonl merge=union';
225
+ const existed = existsSync(path);
226
+ const cur = existed ? readFileSync(path, 'utf8') : '';
227
+ if (cur.split(/\r?\n/).includes(line)) {
228
+ changes.push({ path: '.gitattributes', action: 'skipped' });
229
+ }
230
+ else {
231
+ const prefix = cur && !cur.endsWith('\n') ? '\n' : '';
232
+ const block = `${prefix}${cur ? '\n' : ''}# vfkb — the append-only brain unions across branches (ADR-0041)\n${line}\n`;
233
+ writeFileSync(path, cur + block);
234
+ changes.push({ path: '.gitattributes', action: existed ? 'updated' : 'created' });
235
+ }
236
+ }
237
+ // 5. AGENTS.md — the parameterized "how we track work HERE" snippet (append once).
238
+ {
239
+ const path = join(root, 'AGENTS.md');
240
+ const existed = existsSync(path);
241
+ const cur = existed ? readFileSync(path, 'utf8') : '';
242
+ if (cur.includes(AGENTS_MARKER)) {
243
+ changes.push({ path: 'AGENTS.md', action: 'skipped' });
244
+ }
245
+ else {
246
+ const sep = cur && !cur.endsWith('\n') ? '\n\n' : cur ? '\n' : '';
247
+ writeFileSync(path, cur + sep + agentsSnippet(project));
248
+ changes.push({ path: 'AGENTS.md', action: existed ? 'updated' : 'created' });
249
+ }
250
+ }
251
+ return changes;
252
+ }
253
+ // The one step init cannot do for you (printed by the CLI).
254
+ export function approvalNotice(project) {
255
+ return [
256
+ `vfkb wired for project "${project}".`,
257
+ '',
258
+ 'Next (one-time, manual):',
259
+ ' 1. Set $VFKB_BUNDLE_DIR once per machine to the vfkb bundles dir, e.g.:',
260
+ ' export VFKB_BUNDLE_DIR=/path/to/vfkb/dist/bundles # (run `npm run build:bundles` in the vfkb repo)',
261
+ ' 2. Start `claude` in this repo and APPROVE the project MCP server + hooks when prompted (once).',
262
+ ' 3. Commit the wiring + the empty brain: git add .mcp.json .claude .gitignore .gitattributes .vfkb AGENTS.md',
263
+ ].join('\n');
264
+ }
@@ -0,0 +1,148 @@
1
+ // FR-1 (ADR-0030) inner gate — `vfkb init` scaffolds a consumer repo correctly
2
+ // and is IDEMPOTENT (re-running changes nothing, never clobbers a brain, never
3
+ // duplicates the .gitignore stanza or the AGENTS.md snippet). The agent-driven
4
+ // consumer-onboarding L4 scenario is the capability-level DoD (ADR-0029).
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { initProject } from './init.js';
11
+ let root;
12
+ beforeEach(() => {
13
+ root = mkdtempSync(join(tmpdir(), 'vfkb-init-'));
14
+ });
15
+ const read = (p) => readFileSync(join(root, p), 'utf8');
16
+ describe('vfkb init (FR-1)', () => {
17
+ it('scaffolds the portable wiring, gitignore stanza, empty brain, and snippet', () => {
18
+ const changes = initProject(root, { project: 'demo' });
19
+ const actions = Object.fromEntries(changes.map((c) => [c.path, c.action]));
20
+ expect(actions['.vfkb/entries.jsonl']).toBe('created');
21
+ expect(actions['.vfkb/manifest.json']).toBe('created');
22
+ expect(actions['.mcp.json']).toBe('created');
23
+ expect(actions['.claude/settings.json']).toBe('created');
24
+ expect(actions['.vfkb/bin/bootstrap.mjs']).toBe('created');
25
+ expect(actions['.gitignore']).toBe('created');
26
+ expect(actions['AGENTS.md']).toBe('created');
27
+ // .mcp.json — via the committed relative bootstrap (ADR-0031), project from the arg.
28
+ const mcp = JSON.parse(read('.mcp.json'));
29
+ expect(mcp.mcpServers.vfkb.args).toEqual(['.vfkb/bin/bootstrap.mjs', 'mcp']);
30
+ expect(mcp.mcpServers.vfkb.env).toEqual({ VFKB_DATA_DIR: '.vfkb', VFKB_PROJECT: 'demo' });
31
+ // .claude/settings.json — the three hooks, via the bootstrap, no relative dist/ path.
32
+ const settings = JSON.parse(read('.claude/settings.json'));
33
+ expect(Object.keys(settings.hooks).sort()).toEqual([
34
+ 'PreToolUse',
35
+ 'SessionEnd',
36
+ 'SessionStart',
37
+ 'Stop',
38
+ ]);
39
+ expect(settings.hooks.SessionEnd[0].hooks[0].command).toContain('cli hook session-end');
40
+ const blob = JSON.stringify(settings);
41
+ expect(blob).toContain('.vfkb/bin/bootstrap.mjs cli hook');
42
+ expect(blob).toContain('VFKB_PROJECT=demo');
43
+ expect(blob).not.toContain('dist/cli.js');
44
+ expect(settings.hooks.PreToolUse[0].matcher).toBe('Write|Edit|MultiEdit');
45
+ // issue #22 / ADR-0035 — hooks anchor to $CLAUDE_PROJECT_DIR (CWD-independent),
46
+ // NOT a bare CWD-relative path that breaks when the session cd's out of the root.
47
+ expect(blob).toContain('${CLAUDE_PROJECT_DIR:-.}/.vfkb/bin/bootstrap.mjs');
48
+ expect(blob).toContain('VFKB_DATA_DIR=${CLAUDE_PROJECT_DIR:-.}/.vfkb');
49
+ expect(blob).not.toContain('node .vfkb/bin/bootstrap.mjs'); // the old bare-relative form
50
+ // the bootstrap is a committed, self-contained guard.
51
+ const boot = read('.vfkb/bin/bootstrap.mjs');
52
+ expect(boot).toContain('VFKB_BUNDLE_DIR');
53
+ expect(boot).not.toContain("from './"); // no engine import — must run standalone
54
+ // version stamp (FR-4).
55
+ expect(JSON.parse(read('.vfkb/manifest.json')).schema_version).toBe(1);
56
+ // empty brain + gitignore stanza + snippet marker.
57
+ expect(read('.vfkb/entries.jsonl')).toBe('');
58
+ expect(read('.gitignore')).toContain('.vfkb/.sessions/');
59
+ expect(read('AGENTS.md')).toContain('How we track work HERE');
60
+ });
61
+ it('defaults the project name to the directory basename', () => {
62
+ initProject(root, {});
63
+ const mcp = JSON.parse(read('.mcp.json'));
64
+ expect(mcp.mcpServers.vfkb.env.VFKB_PROJECT).toBe(root.split('/').pop());
65
+ });
66
+ it('is idempotent — a second run changes nothing and does not duplicate', () => {
67
+ initProject(root, { project: 'demo' });
68
+ const second = initProject(root, { project: 'demo' });
69
+ expect(second.every((c) => c.action === 'skipped')).toBe(true);
70
+ // gitignore stanza appears exactly once.
71
+ const gi = read('.gitignore');
72
+ expect(gi.split('.vfkb/.sessions/').length - 1).toBe(1);
73
+ // snippet marker appears exactly once.
74
+ const agents = read('AGENTS.md');
75
+ expect(agents.split('vfkb:how-we-track-work').length - 1).toBe(1);
76
+ });
77
+ it('emits the ADR-0041 merge=union attribute for the brain, append-once (V2-3 consumer follow-up)', () => {
78
+ const changes = initProject(root, { project: 'demo' });
79
+ expect(changes.find((c) => c.path === '.gitattributes')?.action).toBe('created');
80
+ expect(read('.gitattributes')).toContain('.vfkb/entries.jsonl merge=union');
81
+ // appends to an existing .gitattributes without touching its content…
82
+ writeFileSync(join(root, '.gitattributes'), '*.png binary\n');
83
+ const again = initProject(root, { project: 'demo' });
84
+ expect(again.find((c) => c.path === '.gitattributes')?.action).toBe('updated');
85
+ const ga = read('.gitattributes');
86
+ expect(ga).toContain('*.png binary');
87
+ expect(ga.split('.vfkb/entries.jsonl merge=union').length - 1).toBe(1);
88
+ // …and is idempotent once present.
89
+ const third = initProject(root, { project: 'demo' });
90
+ expect(third.find((c) => c.path === '.gitattributes')?.action).toBe('skipped');
91
+ });
92
+ it('never clobbers an existing brain', () => {
93
+ initProject(root, { project: 'demo' });
94
+ writeFileSync(join(root, '.vfkb', 'entries.jsonl'), '{"id":"keep"}\n');
95
+ const again = initProject(root, { project: 'demo' });
96
+ expect(again.find((c) => c.path === '.vfkb/entries.jsonl')?.action).toBe('skipped');
97
+ expect(read('.vfkb/entries.jsonl')).toContain('keep');
98
+ });
99
+ it('merges into an existing .mcp.json without dropping other servers', () => {
100
+ writeFileSync(join(root, '.mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'x' } } }));
101
+ initProject(root, { project: 'demo' });
102
+ const mcp = JSON.parse(read('.mcp.json'));
103
+ expect(mcp.mcpServers.other).toEqual({ command: 'x' });
104
+ expect(mcp.mcpServers.vfkb).toBeDefined();
105
+ });
106
+ it('upgrades an existing CWD-relative vfkb hook to the anchored form, keeping user hooks (issue #22)', () => {
107
+ const dir = join(root, '.claude');
108
+ mkdirSync(dir, { recursive: true });
109
+ writeFileSync(join(dir, 'settings.json'), JSON.stringify({
110
+ hooks: {
111
+ Stop: [
112
+ { hooks: [{ type: 'command', command: 'echo user-hook' }] }, // a user's own hook — must survive
113
+ { hooks: [{ type: 'command', command: 'VFKB_DATA_DIR=.vfkb VFKB_PROJECT=demo node .vfkb/bin/bootstrap.mjs cli hook stop' }] },
114
+ ],
115
+ },
116
+ }));
117
+ const changes = initProject(root, { project: 'demo' });
118
+ expect(changes.find((c) => c.path === '.claude/settings.json')?.action).toBe('updated');
119
+ const settings = JSON.parse(read('.claude/settings.json'));
120
+ const cmds = settings.hooks.Stop.map((e) => e.hooks[0].command);
121
+ expect(cmds).toContain('echo user-hook'); // user hook preserved
122
+ expect(cmds.some((c) => c.includes('${CLAUDE_PROJECT_DIR:-.}/.vfkb/bin/bootstrap.mjs cli hook stop'))).toBe(true);
123
+ expect(cmds).not.toContain('VFKB_DATA_DIR=.vfkb VFKB_PROJECT=demo node .vfkb/bin/bootstrap.mjs cli hook stop'); // old form gone
124
+ // and re-running is now idempotent on the upgraded form
125
+ const again = initProject(root, { project: 'demo' });
126
+ expect(again.find((c) => c.path === '.claude/settings.json')?.action).toBe('skipped');
127
+ });
128
+ it('the emitted hook resolves from a foreign CWD; the old bare-relative form does not (issue #22 DoD)', () => {
129
+ initProject(root, { project: 'demo' });
130
+ const settings = JSON.parse(read('.claude/settings.json'));
131
+ const anchored = settings.hooks.SessionStart[0].hooks[0].command;
132
+ const foreign = mkdtempSync(join(tmpdir(), 'vfkb-cwd-')); // a dir that is NOT the repo root
133
+ const env = { ...process.env, CLAUDE_PROJECT_DIR: root };
134
+ delete env.VFKB_BUNDLE_DIR; // force the bootstrap's graceful INACTIVE path (no engine needed)
135
+ delete env.VFKB_HOME;
136
+ // Anchored form: the bootstrap is FOUND despite the foreign CWD -> emits the
137
+ // SessionStart INACTIVE payload and exits 0.
138
+ const ok = spawnSync('sh', ['-c', anchored], { cwd: foreign, env, encoding: 'utf8' });
139
+ expect(ok.status).toBe(0);
140
+ expect(ok.stdout).toContain('INACTIVE');
141
+ // Contrast (proves the bug + that this gate CAN fail): the old bare-relative form
142
+ // from the same foreign CWD -> MODULE_NOT_FOUND, non-zero exit.
143
+ const bare = 'VFKB_DATA_DIR=.vfkb VFKB_PROJECT=demo node .vfkb/bin/bootstrap.mjs cli hook session-start';
144
+ const bad = spawnSync('sh', ['-c', bare], { cwd: foreign, env, encoding: 'utf8' });
145
+ expect(bad.status).not.toBe(0);
146
+ expect(bad.stderr).toMatch(/Cannot find module|MODULE_NOT_FOUND/);
147
+ });
148
+ });