@viloforge/vfkb 0.4.0 → 0.5.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/dist/engine.js CHANGED
@@ -4,7 +4,10 @@
4
4
  // the tiered Heuristic reranker (ADR-0012), the budgeted render (ADR-0015), capture.
5
5
  // Pure Node stdlib, ZERO runtime deps.
6
6
  import { randomBytes } from 'node:crypto';
7
- import { appendRecord, materialize, readRecords, lastMalformed, withExclusive, contextSpinePath, readContextSpine, writeContextSpine, defaultProject, } from './storage.js';
7
+ import { existsSync } from 'node:fs';
8
+ import { dirname, join, resolve as resolvePath } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { appendRecord, materialize, readRecords, lastMalformed, withExclusive, contextSpinePath, readContextSpine, writeContextSpine, defaultProject, brainDir, } from './storage.js';
8
11
  import { selectIndex } from './index-store.js';
9
12
  import { testHoldForRace } from './lock.js';
10
13
  import { assertNoSecrets } from './secrets.js';
@@ -396,6 +399,46 @@ export function latestCrossRepo(all = readAll(), today = nowIso().slice(0, 10),
396
399
  }
397
400
  return latest;
398
401
  }
402
+ /**
403
+ * ADR-0065 §1 — the CLI write path an agent should reach for when the MCP face
404
+ * errors or disappears.
405
+ *
406
+ * Resolved from THIS MODULE's own location, never `process.argv[1]`. argv[1] is
407
+ * the *entry* script, which is only the CLI on one of the three faces:
408
+ * - plugin bundles: dist/bundles/{vfkb.mjs, vfkb-mcp.mjs} — siblings
409
+ * - npm install: dist/{cli.js, mcp-server.js} — siblings
410
+ * - pi / embedded: argv[1] is the HOST's script entirely
411
+ * Using argv[1] emitted `node ".../dist/mcp-server.js" add …` on the npm MCP
412
+ * face — a command that starts a stdio server, blocks on stdin and writes
413
+ * nothing (observed; review of PR #217, blocking 1). The engine module always
414
+ * ships beside its own CLI face, so its directory is the stable anchor.
415
+ *
416
+ * Both paths are QUOTED — plugin cache paths can contain spaces, and a command
417
+ * truncating at the first space is the quiet-failure class this ADR exists to
418
+ * remove. The brain dir is ABSOLUTE-ised for the same reason: VFKB_DATA_DIR is
419
+ * commonly configured relative (".vfkb"), and a command pasted from another cwd
420
+ * would otherwise create a second brain in the wrong directory.
421
+ */
422
+ const CLI_FACES = ['vfkb.mjs', 'cli.js', 'cli.mjs'];
423
+ /** The CLI entry point shipped beside the engine, or undefined if none is found. */
424
+ export function resolveCliFace(engineDir) {
425
+ return CLI_FACES.map((f) => join(engineDir, f)).find((p) => existsSync(p));
426
+ }
427
+ export function renderCaptureFallback(engineDir = dirname(fileURLToPath(import.meta.url))) {
428
+ // engineDir is injectable for tests ONLY. Under vitest the engine runs from
429
+ // src/, where no BUILT cli face exists, so the default correctly yields the
430
+ // unresolved marker — the real-path guarantee is therefore asserted against
431
+ // the built artifact in test/mcp-write-loudness.test.ts, not here.
432
+ const cli = resolveCliFace(engineDir);
433
+ // Never emit a path we could not verify: a wrong command pasted mid-failure is
434
+ // worse than an honest gap (ADR-0051 §3 — say what is unknown, don't imply).
435
+ const target = cli
436
+ ? `node "${cli}"`
437
+ : `node <vfkb CLI not found beside the engine at ${engineDir} — locate vfkb.mjs or cli.js>`;
438
+ return (`capture fallback: if the kb_* tools error or disappear, the CLI still writes ` +
439
+ `(same engine, separate process; journaled per ADR-0064) —\n` +
440
+ ` VFKB_DATA_DIR="${resolvePath(brainDir())}" ${target} add <type> "…" [--tags a,b] [--why "…"]`);
441
+ }
399
442
  export function renderContextBundle(project = defaultProject(), budget = SESSION_BUDGET_CHARS) {
400
443
  const all = readAll();
401
444
  const today = nowIso().slice(0, 10);
@@ -444,6 +487,14 @@ export function renderContextBundle(project = defaultProject(), budget = SESSION
444
487
  }
445
488
  // Context Map (ADR-0006): the navigational index, always injected, never dropped.
446
489
  body += renderContextMap() + '\n\n';
490
+ // Capture fallback (ADR-0065 §1 ← RFC-035): the hooks and the MCP server are
491
+ // separate processes over the same engine bundle, so a dead MCP face does not
492
+ // imply a dead CLI — and with ADR-0064's journal the CLI write is exactly as
493
+ // durable. That fallback already exists but is undocumented at the moment of
494
+ // failure; an agent mid-loss has no reason to know it. Joins the never-dropped
495
+ // preamble, costing at most one lowest-ranked entry — the same trade the
496
+ // ADR-0049/ADR-0063 pins already made, stated as a decision, not a side effect.
497
+ body += renderCaptureFallback() + '\n\n';
447
498
  // Ranked lines are collected (not appended) so the omission note can evict
448
499
  // trailing lines when needed — under the old append-as-you-go shape, a body
449
500
  // that filled the budget exactly silently dropped the note itself, hiding
@@ -0,0 +1,217 @@
1
+ // Issue #214 — a hook whose stdin never closes must FAIL OPEN, not stall.
2
+ //
3
+ // MEASURED ground truth (2026-07-18, this worktree, before the fix):
4
+ // - `node dist/cli.js hook pre-tool-use` with a payload written but stdin
5
+ // never closed WROTE `{}` (readStdin's 2s watchdog fired and produced the
6
+ // right answer) and then NEVER EXITED — killed at 10s. The stdin 'data'
7
+ // listener keeps the event loop alive, so resolving the promise is not
8
+ // enough: the process still holds the harness.
9
+ // - `node scripts/hook-durable-claim-check.mjs` under the same condition
10
+ // wrote NOTHING and never exited (it only ever acts on 'end').
11
+ //
12
+ // Why that matters: Claude Code's default `command`-hook timeout is 600s
13
+ // (code.claude.com/docs/en/hooks). What the harness does when that expires
14
+ // (allow vs deny) is NOT documented — but a PreToolUse hook that pins a tool
15
+ // call for up to ten minutes violates the repo protocol either way.
16
+ //
17
+ // ALTITUDE: this drives the REAL shipped artifacts as child processes over a
18
+ // real pipe — the built `dist/cli.js` and the standalone hook script. It does
19
+ // not call readStdin() directly, because the bug is not in the promise, it is
20
+ // in whether the PROCESS terminates. A test of the helper would have stayed
21
+ // green through the entire defect.
22
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23
+ import { spawn } from 'node:child_process';
24
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { resolve, join } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
29
+ const cliPath = join(repoRoot, 'dist', 'cli.js');
30
+ const durableHook = join(repoRoot, 'scripts', 'hook-durable-claim-check.mjs');
31
+ // SANDBOX (ADR-0029 clause 1: "isolated from the live/dogfooded system").
32
+ // `hook post-tool-use` CAPTURES its payload into the brain, and the brain dir
33
+ // defaults to ~/.vfkb — the operator's REAL, git-tracked global brain. Without
34
+ // this every run of this file, and every RED mutation run a future engineer
35
+ // performs to re-verify the guard, appends junk `fact` entries to a committed
36
+ // file. That is not hypothetical: it was OBSERVED during review of this very
37
+ // PR (18 stray `/etc/hostname` entries, ~/.vfkb left dirty).
38
+ //
39
+ // This is self-enforcing, not a convention: the payload-survival test below
40
+ // asserts the entry lands in THIS dir, so dropping VFKB_DATA_DIR turns the
41
+ // sandbox brain empty and takes the suite RED.
42
+ let brainDir;
43
+ beforeAll(() => {
44
+ brainDir = mkdtempSync(join(tmpdir(), 'vfkb-stdin-failopen-'));
45
+ });
46
+ afterAll(() => {
47
+ if (brainDir)
48
+ rmSync(brainDir, { recursive: true, force: true });
49
+ });
50
+ /** Env for every spawned hook: the real harness vars, but a throwaway brain. */
51
+ function sandboxEnv() {
52
+ return {
53
+ ...process.env,
54
+ CLAUDE_PROJECT_DIR: repoRoot,
55
+ VFKB_DATA_DIR: brainDir,
56
+ };
57
+ }
58
+ // readStdin's watchdog is 2000ms. Allow generous headroom for process spawn +
59
+ // a loaded CI box, but stay far below anything a human would call a stall.
60
+ const MUST_EXIT_WITHIN_MS = 8000;
61
+ /**
62
+ * Spawn `cmd args`, WRITE the payload to stdin, and deliberately NEVER close
63
+ * stdin. This is the exact condition in issue #214: the writer stays attached.
64
+ * Resolves when the child exits, or reports exited:false after the deadline.
65
+ */
66
+ function probeWithUnclosedStdin(cmd, args, payload, deadlineMs = MUST_EXIT_WITHIN_MS) {
67
+ return new Promise((resolveP) => {
68
+ const t0 = Date.now();
69
+ const child = spawn(cmd, args, {
70
+ cwd: repoRoot,
71
+ env: sandboxEnv(),
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ });
74
+ let stdout = '';
75
+ child.stdout.on('data', (d) => (stdout += d));
76
+ child.stderr.on('data', () => { });
77
+ child.stdin.on('error', () => { });
78
+ child.stdin.write(payload); // NOTE: no .end() — stdin stays open forever.
79
+ let settled = false;
80
+ const kill = setTimeout(() => {
81
+ if (settled)
82
+ return;
83
+ settled = true;
84
+ child.kill('SIGKILL');
85
+ resolveP({ exited: false, code: null, stdout, elapsedMs: Date.now() - t0 });
86
+ }, deadlineMs);
87
+ child.on('exit', (code) => {
88
+ if (settled)
89
+ return;
90
+ settled = true;
91
+ clearTimeout(kill);
92
+ // Give stdout a tick to flush before reporting.
93
+ setTimeout(() => resolveP({ exited: true, code, stdout, elapsedMs: Date.now() - t0 }), 20);
94
+ });
95
+ });
96
+ }
97
+ const benignPayload = JSON.stringify({
98
+ session_id: 'stdin-failopen-test',
99
+ cwd: '/nonexistent-vfkb-probe',
100
+ tool_name: 'Read',
101
+ tool_input: { file_path: '/etc/hostname' },
102
+ });
103
+ describe('issue #214 — hooks fail open when stdin never closes', () => {
104
+ beforeAll(() => {
105
+ // The property under test is about the SHIPPED binary. If it is missing the
106
+ // test must fail loudly, never silently skip (a skipped guard is a blind one).
107
+ expect(existsSync(cliPath), `dist/cli.js missing — run \`npm run build\` (pretest does this)`).toBe(true);
108
+ expect(existsSync(durableHook)).toBe(true);
109
+ });
110
+ // The PreToolUse gate is the highest-stakes case: it sits between the agent
111
+ // and every Write/Edit, so a wedge here wedges the whole session.
112
+ it('dist/cli.js hook pre-tool-use TERMINATES and allows the call', async () => {
113
+ const r = await probeWithUnclosedStdin('node', [cliPath, 'hook', 'pre-tool-use'], benignPayload);
114
+ expect(r.exited, `hook pre-tool-use did not exit within ${MUST_EXIT_WITHIN_MS}ms with stdin held open ` +
115
+ `(stdout so far: ${JSON.stringify(r.stdout)}). A PreToolUse hook that never exits ` +
116
+ `pins the tool call until the harness's 600s cancel.`).toBe(true);
117
+ expect(r.code).toBe(0);
118
+ // Fail OPEN means EMITTING the allow shape, not merely declining to deny.
119
+ // Asserting only `not.toContain('deny')` is satisfied by empty stdout — the
120
+ // quiet-success trap (ADR-0051 clause 3). Assert the payload positively.
121
+ expect(r.stdout.trim()).toBe('{}');
122
+ }, 30000);
123
+ // Every other hook subcommand shares the same readStdin() convention, so the
124
+ // guard must hold uniformly — this is a convention fix, not a one-file patch.
125
+ for (const sub of ['post-tool-use', 'stop', 'session-end', 'session-start']) {
126
+ it(`dist/cli.js hook ${sub} TERMINATES with stdin held open`, async () => {
127
+ const r = await probeWithUnclosedStdin('node', [cliPath, 'hook', sub], benignPayload);
128
+ expect(r.exited, `hook ${sub} did not exit within ${MUST_EXIT_WITHIN_MS}ms with stdin held open ` +
129
+ `(stdout so far: ${JSON.stringify(r.stdout)})`).toBe(true);
130
+ expect(r.code).toBe(0);
131
+ // Terminating silently is NOT failing open — the harness gets no decision.
132
+ // Every hook subcommand emits a JSON object on the allow path.
133
+ expect(r.stdout.trim().startsWith('{'), `hook ${sub} exited but emitted no JSON decision (stdout: ${JSON.stringify(r.stdout)})`).toBe(true);
134
+ }, 30000);
135
+ }
136
+ // The cli.ts analogue of the durable-claim payload test below. Terminating is
137
+ // only half the contract: readStdin must resolve with the bytes that ARRIVED,
138
+ // not an empty string. A watchdog that fires and DISCARDS the buffer exits 0,
139
+ // emits `{}`, and passes every termination assertion above while silently
140
+ // making the hook inert — the quiet-success trap (ADR-0051 clause 3), and the
141
+ // exact asymmetry review finding R2 flagged (the .mjs side was guarded here,
142
+ // cli.ts was not). Mutation: `resolve(data)` -> `resolve('')` in finish().
143
+ //
144
+ // Observed at the shipped altitude: post-tool-use CAPTURES the payload into
145
+ // the brain, so the sandbox brain is the proof the bytes survived. This also
146
+ // pins the sandbox itself — with VFKB_DATA_DIR dropped, this file is empty.
147
+ it('post-tool-use still CAPTURES a payload whose stdin never closed (watchdog must not discard)', async () => {
148
+ const marker = `stdin-failopen-marker-${Date.now()}`;
149
+ const payload = JSON.stringify({
150
+ session_id: 'stdin-failopen-capture',
151
+ tool_name: 'Read',
152
+ tool_input: { file_path: `/probe/${marker}` },
153
+ });
154
+ const r = await probeWithUnclosedStdin('node', [cliPath, 'hook', 'post-tool-use'], payload);
155
+ expect(r.exited, 'post-tool-use did not exit with stdin held open').toBe(true);
156
+ const entriesPath = join(brainDir, 'entries.jsonl');
157
+ expect(existsSync(entriesPath), `no brain written at ${entriesPath} — the watchdog resolved but the payload was dropped, ` +
158
+ `so captureToolCall saw nothing. The hook is inert.`).toBe(true);
159
+ const brain = readFileSync(entriesPath, 'utf8');
160
+ expect(brain, `the captured entry does not carry the payload's marker — readStdin settled on the ` +
161
+ `deadline but discarded the buffered bytes.`).toContain(marker);
162
+ }, 30000);
163
+ // The standalone Brake reads stdin with the same await-until-'end' pattern and
164
+ // ships to this repo's own .claude/settings.json — issue #214's stated scope.
165
+ it('scripts/hook-durable-claim-check.mjs TERMINATES with stdin held open', async () => {
166
+ const r = await probeWithUnclosedStdin('node', [durableHook], benignPayload);
167
+ expect(r.exited, `durable-claim hook did not exit within ${MUST_EXIT_WITHIN_MS}ms with stdin held open`).toBe(true);
168
+ expect(r.code).toBe(0);
169
+ }, 30000);
170
+ // ...and it must still be able to SPEAK on a durable-artifact payload it only
171
+ // ever saw partially. A watchdog that fires but drops the payload would turn
172
+ // the Brake inert under load — the exact class of defect this repo keeps hitting.
173
+ it('durable-claim hook still emits its reminder from a payload whose stdin never closed', async () => {
174
+ const durablePayload = JSON.stringify({
175
+ tool_name: 'Bash',
176
+ tool_input: { command: 'gh issue create --title "x" --body "y"' },
177
+ });
178
+ const r = await probeWithUnclosedStdin('node', [durableHook], durablePayload);
179
+ expect(r.exited, 'durable-claim hook did not exit with stdin held open').toBe(true);
180
+ expect(r.stdout).toContain('DURABLE ARTIFACT');
181
+ expect(r.stdout).toContain('hookSpecificOutput');
182
+ }, 30000);
183
+ // CONTRAST ARM: the normal path (stdin properly closed) must be FAST and
184
+ // unaffected. If the watchdog became the only exit route, every hook would
185
+ // suddenly cost 2s — a regression the termination assertions alone miss.
186
+ it('the normal closed-stdin path stays fast (watchdog is not the exit route)', async () => {
187
+ const runClosed = async () => {
188
+ const t0 = Date.now();
189
+ const out = await new Promise((resolveP) => {
190
+ const child = spawn('node', [cliPath, 'hook', 'pre-tool-use'], {
191
+ cwd: repoRoot,
192
+ env: sandboxEnv(),
193
+ });
194
+ let s = '';
195
+ child.stdout.on('data', (d) => (s += d));
196
+ child.on('exit', () => resolveP(s));
197
+ child.stdin.end(benignPayload); // properly closed
198
+ });
199
+ return { out, elapsed: Date.now() - t0 };
200
+ };
201
+ let r = await runClosed();
202
+ // The bound MUST stay below STDIN_WATCHDOG_MS (2000ms) or it asserts nothing
203
+ // — the whole point is distinguishing "exited via 'end'" from "exited via the
204
+ // watchdog". So instead of loosening it past the thing it measures, absorb a
205
+ // single transient runner stall by retrying once (review finding R3). A build
206
+ // where the watchdog IS the only exit route takes ~2s on BOTH attempts, so
207
+ // this keeps the full mutation-detecting power.
208
+ if (r.elapsed >= 1900)
209
+ r = await runClosed();
210
+ // Positive assertion: it must actually EMIT the allow decision on the normal
211
+ // path. (Dropping the 'end' handler makes the process exit 0 fast and silent
212
+ // — mutation M4 — which an `not.toContain('deny')` check passes happily.)
213
+ expect(r.out.trim()).toBe('{}');
214
+ expect(r.elapsed, `closed-stdin hook took ${r.elapsed}ms on two consecutive attempts — the 2s watchdog ` +
215
+ `should NOT be the exit route`).toBeLessThan(1900);
216
+ }, 30000);
217
+ });
package/dist/init.js CHANGED
@@ -96,8 +96,9 @@ This repo uses **vfkb** as its knowledge substrate (project \`${project}\`). Kno
96
96
  - **Record knowledge** with the \`mcp__vfkb__kb_add\` tool (or \`node .vfkb/bin/bootstrap.mjs cli add …\`):
97
97
  \`decision\`, \`fact\`, \`gotcha\`, \`pattern\`, \`link\` — put a decision's rationale in its text.
98
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.
99
+ - Committed: \`.vfkb/entries.jsonl\`, \`.vfkb/manifest.json\` (the ADR-0030 engine stamp —
100
+ **never gitignore \`manifest.json\`**), and \`.vfkb/bin/\`. Derived/gitignored, all five:
101
+ \`.vfkb/index-meta.json\`, \`.vfkb/.sessions/\`, \`.vfkb/.signals/\`, \`.vfkb/.journal/\`, \`.vfkb/.lock\`.
101
102
 
102
103
  Two env vars: **\`VFKB_DATA_DIR\`** = this repo's brain (\`.vfkb\`, set by the wiring) · **\`VFKB_BUNDLE_DIR\`**
103
104
  = the shared vfkb engine bundles — set it once per machine, e.g. \`export VFKB_BUNDLE_DIR=/path/to/vfkb/dist/bundles\`.
@@ -201,16 +202,56 @@ export function initProject(root, opts = {}) {
201
202
  // 4. .gitignore — the derived/operational stanza (append once).
202
203
  {
203
204
  const path = join(root, '.gitignore');
204
- const lines = ['.vfkb/index-meta.json', '.vfkb/.sessions/', '.vfkb/.signals/', '.vfkb/.journal/'];
205
+ // Every derived/operational path. `.lock` is the ADR-0040 concurrency lock:
206
+ // transient, but a `git add .vfkb` timed against a concurrent write would
207
+ // commit it. Keep in step with vfkb's own .gitignore — a consumer whose
208
+ // ignore set is narrower than the engine's is a sweep waiting to happen
209
+ // (one ran across 10 repos on 2026-07-18 for exactly this).
210
+ const lines = [
211
+ '.vfkb/index-meta.json',
212
+ '.vfkb/.sessions/',
213
+ '.vfkb/.signals/',
214
+ '.vfkb/.journal/',
215
+ '.vfkb/.lock',
216
+ ];
217
+ // The header the stanza should carry. The OLD one claimed "only
218
+ // .vfkb/entries.jsonl is committed", which is FALSE — manifest.json is
219
+ // committed by design (ADR-0030, the brain↔engine stamp, distinct from the
220
+ // derived index-meta.json). That sentence caused a real defect: the natural
221
+ // response to an untracked manifest.json is to gitignore it, and one
222
+ // consumer ended up with no engine stamp at all.
223
+ const HEADER = [
224
+ '# vfkb — entries.jsonl + manifest.json (the ADR-0030 engine stamp) are committed;',
225
+ '# the vfkb paths below are derived/operational and stay out of git',
226
+ ];
227
+ // Any header this generator has ever emitted, so an upgrade HEALS the stanza
228
+ // in place. Appending a corrected block while leaving the old one alone left
229
+ // the false claim intact AND first in the file, contradicted by a second
230
+ // stanza below it — worse than not fixing it (review of PR #219, blocking 1).
231
+ const STALE_HEADER = /^#\s*vfkb\s*—\s*derived\/operational \(only \.vfkb\/entries\.jsonl is committed\)\s*$/;
205
232
  const existed = existsSync(path);
206
233
  const cur = existed ? readFileSync(path, 'utf8') : '';
207
- const missing = lines.filter((l) => !cur.split(/\r?\n/).includes(l));
208
- if (missing.length === 0) {
234
+ const curLines = cur.split(/\r?\n/);
235
+ const missing = lines.filter((l) => !curLines.includes(l));
236
+ const staleAt = curLines.findIndex((l) => STALE_HEADER.test(l));
237
+ if (missing.length === 0 && staleAt === -1) {
209
238
  changes.push({ path: '.gitignore', action: 'skipped' });
210
239
  }
240
+ else if (staleAt !== -1) {
241
+ // Heal: swap the stale header for the correct one and fold any missing
242
+ // paths into THAT stanza, so the file ends up with exactly one.
243
+ const out = [...curLines];
244
+ out.splice(staleAt, 1, ...HEADER);
245
+ let last = staleAt + HEADER.length - 1;
246
+ for (let i = last + 1; i < out.length && /^\.vfkb\//.test(out[i].trim()); i++)
247
+ last = i;
248
+ out.splice(last + 1, 0, ...missing);
249
+ writeFileSync(path, out.join('\n'));
250
+ changes.push({ path: '.gitignore', action: 'updated' });
251
+ }
211
252
  else {
212
253
  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`;
254
+ const block = `${prefix}${cur ? '\n' : ''}${HEADER.join('\n')}\n${missing.join('\n')}\n`;
214
255
  writeFileSync(path, cur + block);
215
256
  changes.push({ path: '.gitignore', action: existed ? 'updated' : 'created' });
216
257
  }
package/dist/init.test.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // consumer-onboarding L4 scenario is the capability-level DoD (ADR-0029).
5
5
  import { describe, it, expect, beforeEach } from 'vitest';
6
6
  import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
- import { spawnSync } from 'node:child_process';
7
+ import { spawnSync, execFileSync } from 'node:child_process';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { initProject } from './init.js';
@@ -58,6 +58,75 @@ describe('vfkb init (FR-1)', () => {
58
58
  expect(read('.gitignore')).toContain('.vfkb/.sessions/');
59
59
  expect(read('AGENTS.md')).toContain('How we track work HERE');
60
60
  });
61
+ // The generator, not the symptom. A 10-repo sweep (2026-07-18) added
62
+ // `.vfkb/.lock` and corrected a false comment across every consumer — and
63
+ // `vfkb init` would have re-introduced both on the next new repo. Fixing ten
64
+ // copies without fixing what emits them is a sweep you run again.
65
+ describe('the emitted .gitignore stanza is correct at the source', () => {
66
+ it('ignores every derived/operational path, including the lock', () => {
67
+ initProject(root, { project: 'demo' });
68
+ const gi = read('.gitignore');
69
+ for (const p of ['.vfkb/index-meta.json', '.vfkb/.sessions/', '.vfkb/.signals/', '.vfkb/.journal/', '.vfkb/.lock']) {
70
+ expect(gi, `missing ignore rule ${p}`).toContain(p);
71
+ }
72
+ });
73
+ it('does NOT ignore the two committed files', () => {
74
+ initProject(root, { project: 'demo' });
75
+ const gi = read('.gitignore')
76
+ .split(/\r?\n/)
77
+ .filter((l) => l.trim() && !l.trim().startsWith('#'));
78
+ // entries.jsonl is the brain; manifest.json is the ADR-0030 engine stamp.
79
+ expect(gi).not.toContain('.vfkb/entries.jsonl');
80
+ expect(gi).not.toContain('.vfkb/manifest.json');
81
+ expect(gi).not.toContain('.vfkb/');
82
+ });
83
+ // The assertion that can fail for the RIGHT reason. Substring checks match a
84
+ // COMMENTED-OUT path just as happily: prefixing every emitted path with '# '
85
+ // produced a stanza that ignores NOTHING and still passed them (review of
86
+ // PR #219, major 2). git is the only authority on what a .gitignore does.
87
+ it('git actually ignores the derived paths and NOT the committed ones', () => {
88
+ initProject(root, { project: 'demo' });
89
+ execFileSync('git', ['init', '-q'], { cwd: root });
90
+ const ignored = (p) => {
91
+ try {
92
+ execFileSync('git', ['check-ignore', '-q', p], { cwd: root, stdio: 'ignore' });
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ };
99
+ for (const p of ['.vfkb/.lock', '.vfkb/.journal/wal.jsonl', '.vfkb/index-meta.json', '.vfkb/.sessions/x', '.vfkb/.signals/y']) {
100
+ expect(ignored(p), `${p} should be ignored`).toBe(true);
101
+ }
102
+ // The brain and the ADR-0030 engine stamp MUST stay committable.
103
+ expect(ignored('.vfkb/entries.jsonl'), 'entries.jsonl must not be ignored').toBe(false);
104
+ expect(ignored('.vfkb/manifest.json'), 'manifest.json must not be ignored').toBe(false);
105
+ });
106
+ it('HEALS an old stanza in place instead of appending a contradictory one', () => {
107
+ // Every existing consumer takes this path; greenfield is the rare case.
108
+ writeFileSync(join(root, '.gitignore'), '# vfkb — derived/operational (only .vfkb/entries.jsonl is committed)\n' +
109
+ '.vfkb/index-meta.json\n.vfkb/.sessions/\n.vfkb/.signals/\n.vfkb/.journal/\n');
110
+ initProject(root, { project: 'demo' });
111
+ const gi = read('.gitignore');
112
+ expect(gi, 'the false claim must not survive').not.toMatch(/only \.vfkb\/entries\.jsonl is committed/i);
113
+ // Exactly ONE stanza — not the old one plus a corrected one below it.
114
+ expect(gi.split('derived/operational').length - 1).toBe(1);
115
+ expect(gi).toContain('.vfkb/.lock');
116
+ // and the healed stanza still works
117
+ execFileSync('git', ['init', '-q'], { cwd: root });
118
+ expect(() => execFileSync('git', ['check-ignore', '-q', '.vfkb/.lock'], { cwd: root, stdio: 'ignore' })).not.toThrow();
119
+ });
120
+ it('does not claim entries.jsonl is the ONLY committed file', () => {
121
+ // That comment was false and load-bearing: the natural response to an
122
+ // untracked manifest.json is to gitignore it, which is exactly how one
123
+ // consumer ended up with no engine stamp at all.
124
+ initProject(root, { project: 'demo' });
125
+ const gi = read('.gitignore');
126
+ expect(gi).not.toMatch(/only .vfkb\/entries\.jsonl is committed/i);
127
+ expect(gi).toMatch(/manifest\.json/);
128
+ });
129
+ });
61
130
  it('defaults the project name to the directory basename', () => {
62
131
  initProject(root, {});
63
132
  const mcp = JSON.parse(read('.mcp.json'));
package/dist/manifest.js CHANGED
@@ -10,8 +10,18 @@ import { SCHEMA_VERSION, ENGINE_VERSION, ENGINE_COMMIT } from './version.js';
10
10
  export function manifestPath(brainDir) {
11
11
  return join(brainDir, 'manifest.json');
12
12
  }
13
- export function currentManifest() {
14
- return { schema_version: SCHEMA_VERSION, engine_version: ENGINE_VERSION, engine_commit: ENGINE_COMMIT };
13
+ // #212 `dev` is version.ts's honest sentinel for "this build has no esbuild
14
+ // define, so it does not know its own commit" (the tsc/dist path, i.e. the
15
+ // documented `node dist/cli.js` fallback). A missing commit says the same thing.
16
+ export function isUnknownCommit(commit) {
17
+ return !commit || commit === 'dev';
18
+ }
19
+ export function currentManifest(opts = {}) {
20
+ return {
21
+ schema_version: SCHEMA_VERSION,
22
+ engine_version: opts.engineVersion ?? ENGINE_VERSION,
23
+ engine_commit: opts.engineCommit ?? ENGINE_COMMIT,
24
+ };
15
25
  }
16
26
  export function readManifest(brainDir) {
17
27
  const p = manifestPath(brainDir);
@@ -24,12 +34,38 @@ export function readManifest(brainDir) {
24
34
  return undefined;
25
35
  }
26
36
  }
37
+ // #212 — would writing the stamp materially IMPROVE this brain's provenance?
38
+ //
39
+ // True when there is no stamp at all, or when the stored identity is unknown and
40
+ // the running engine knows its own. A stored sha that merely DIFFERS is not a
41
+ // re-stamp trigger: that is drift, and reporting drift is doctor's job — silently
42
+ // overwriting the evidence would destroy the very signal doctor reads.
43
+ export function manifestNeedsStamp(brainDir, engineCommit = ENGINE_COMMIT) {
44
+ const cur = readManifest(brainDir);
45
+ if (!cur)
46
+ return true;
47
+ return isUnknownCommit(cur.engine_commit) && !isUnknownCommit(engineCommit);
48
+ }
27
49
  // Write the stamp if absent or stale. Returns 'created' | 'updated' | 'skipped'.
28
- export function writeManifest(brainDir) {
50
+ //
51
+ // #212 (stamping half) — engine identity is a PROVENANCE record, so it only ever
52
+ // ratchets UNKNOWN -> KNOWN. A build that does not know its own commit must not
53
+ // overwrite a brain that does: `vfkb init` calls this unconditionally, so a
54
+ // re-run from the tsc/dist path is a live downgrade path. A dev build may still
55
+ // CREATE a stamp (broadcast's heal depends on it, and a manifest's load-bearing
56
+ // field is schema_version); recording `dev` there is honest — it is a brain whose
57
+ // provenance genuinely is unknown, and doctor says exactly that.
58
+ export function writeManifest(brainDir, opts = {}) {
29
59
  const p = manifestPath(brainDir);
30
60
  const existed = existsSync(p);
31
61
  const cur = readManifest(brainDir);
32
- const next = currentManifest();
62
+ const next = currentManifest(opts);
63
+ if (cur && isUnknownCommit(next.engine_commit) && !isUnknownCommit(cur.engine_commit)) {
64
+ // Preserve the pair, not just the sha: a real commit carrying some other
65
+ // build's version number would be a fresh falsehood, not a preserved truth.
66
+ next.engine_commit = cur.engine_commit;
67
+ next.engine_version = cur.engine_version;
68
+ }
33
69
  if (cur && JSON.stringify(cur) === JSON.stringify(next))
34
70
  return 'skipped';
35
71
  mkdirSync(dirname(p), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viloforge/vfkb",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "ViloForge KnowledgeBase (vfkb) — per-project knowledge substrate for the ViloForge software factory. Greenfield TypeScript.",
5
5
  "type": "module",
6
6
  "main": "dist/engine.js",