@viloforge/vfkb 0.4.1 → 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/cli.js CHANGED
@@ -43,16 +43,44 @@ function packageVersion() {
43
43
  return ENGINE_VERSION;
44
44
  }
45
45
  }
46
+ // The hook stdin-read convention (issue #214). A hook must NEVER wedge a tool
47
+ // call — it fails open or it does not ship. Two things are required for that,
48
+ // and the original guard only did the first:
49
+ //
50
+ // 1. RESOLVE on a deadline, so a writer that never closes stdin still yields
51
+ // whatever arrived (the pre-existing 2s watchdog did this correctly).
52
+ // 2. RELEASE stdin once settled, so the PROCESS can actually exit. A live
53
+ // 'data' listener refs the pipe and keeps the event loop alive — MEASURED
54
+ // 2026-07-18: `dist/cli.js hook pre-tool-use` wrote its allow decision
55
+ // `{}` at 2s and then hung until SIGKILL at 10s. The right answer,
56
+ // delivered by a process the harness is still waiting on, is still a stall.
57
+ //
58
+ // Claude Code cancels a `command` hook only at its default 600s timeout
59
+ // (code.claude.com/docs/en/hooks), and what it does on expiry — allow or deny —
60
+ // is NOT documented. So the wedge is up to ten minutes and its outcome unknown;
61
+ // neither is acceptable for a PreToolUse gate.
62
+ const STDIN_WATCHDOG_MS = 2000;
46
63
  function readStdin() {
47
64
  return new Promise((resolve) => {
48
65
  let data = '';
49
66
  if (process.stdin.isTTY)
50
67
  return resolve('');
68
+ let settled = false;
69
+ const finish = () => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ // Stop holding the event loop open: without this the hook produces the
74
+ // correct output and still never exits (clause 2 above).
75
+ process.stdin.pause();
76
+ process.stdin.unref?.();
77
+ resolve(data);
78
+ };
51
79
  process.stdin.setEncoding('utf8');
52
80
  process.stdin.on('data', (c) => (data += c));
53
- process.stdin.on('end', () => resolve(data));
54
- // Guard: if nothing arrives, don't hang forever.
55
- setTimeout(() => resolve(data), 2000).unref?.();
81
+ process.stdin.on('end', finish);
82
+ process.stdin.on('error', finish); // a broken pipe must fail open too
83
+ setTimeout(finish, STDIN_WATCHDOG_MS).unref?.();
56
84
  });
57
85
  }
58
86
  // Positional flag lookup for the HOOK subcommands only — hooks stay fail-open
@@ -158,7 +186,18 @@ async function dispatch() {
158
186
  let failed = 0;
159
187
  for (const r of results) {
160
188
  if (r.ok) {
161
- process.stdout.write(`written\t${r.target}\t${r.id}\t${r.posture}${r.healed ? '\t(manifest healed — brain was wired but manifest-less, vfkb#193)' : ''}\n`);
189
+ // #212 — `healed` (manifest was ABSENT, #193) and `upgraded` (manifest
190
+ // was PRESENT but of unknown provenance, "dev") are different repairs
191
+ // and must read differently: reporting an upgrade as a heal tells the
192
+ // operator the manifest was missing when it never was, misdirecting
193
+ // diagnosis to #193. An unrendered `upgraded` is the same defect in
194
+ // silent form — the audit signal simply never reaches the operator.
195
+ const repair = r.healed
196
+ ? '\t(manifest healed — brain was wired but manifest-less, vfkb#193)'
197
+ : r.upgraded
198
+ ? '\t(manifest provenance upgraded — engine identity was unknown ("dev"), now the running sha, vfkb#212)'
199
+ : '';
200
+ process.stdout.write(`written\t${r.target}\t${r.id}\t${r.posture}${repair}\n`);
162
201
  }
163
202
  else {
164
203
  failed++;
@@ -0,0 +1,416 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { initProject } from './init.js';
7
+ /**
8
+ * Issue #220 — "only .vfkb/entries.jsonl is committed" is FALSE.
9
+ *
10
+ * `.vfkb/manifest.json` is committed BY DESIGN (ADR-0030: the brain↔engine
11
+ * version stamp, explicitly distinct from the derived, gitignored
12
+ * `index-meta.json`). The false sentence caused a real defect: the natural
13
+ * response to an untracked `manifest.json` is to gitignore it, and ViloGate
14
+ * ended up the only consumer with NO engine stamp at all.
15
+ *
16
+ * These guards assert at the altitude of the SHIPPED artifacts — the doc files
17
+ * a reader actually reads, and **git itself** for ground truth — not a helper.
18
+ *
19
+ * HISTORICAL documents are deliberately out of scope. `docs/adr/` and
20
+ * `docs/rfc/` are decided, immutable records (ADR-0001); the sentence was TRUE
21
+ * when RFC-011 was written (manifest.json arrived with ADR-0030, `.journal/`
22
+ * with ADR-0064). Editing a decided document to match present reality is the
23
+ * opposite of the immutability norm. CHANGELOG.md is a historical log too.
24
+ */
25
+ const REPO = join(__dirname, '..');
26
+ /** Docs a reader/agent consults for CURRENT truth. Excludes decided records. */
27
+ const LIVE_DOCS = [
28
+ 'CLAUDE.md',
29
+ 'README.md',
30
+ 'docs/CONSUMER-ONBOARDING.md',
31
+ 'docs/CONSUMER-ONBOARDING-PROMPT.md',
32
+ 'docs/RUNBOOK-claude-code-integration.md',
33
+ ];
34
+ const read = (p) => readFileSync(join(REPO, p), 'utf8');
35
+ /** The full derived/operational set that must be gitignored. */
36
+ const DERIVED = [
37
+ 'index-meta.json',
38
+ '.sessions/',
39
+ '.signals/',
40
+ '.journal/',
41
+ '.lock',
42
+ ];
43
+ describe('the committed-vs-gitignored brain contract in live docs (issue #220)', () => {
44
+ it('GROUND TRUTH: git tracks entries.jsonl AND manifest.json under .vfkb/', () => {
45
+ const tracked = execFileSync('git', ['ls-files', '.vfkb/'], { cwd: REPO, encoding: 'utf8' })
46
+ .split('\n')
47
+ .filter(Boolean)
48
+ .sort();
49
+ // If this ever changes, the docs below are describing a different world and
50
+ // this whole test file must be revisited rather than the docs patched.
51
+ expect(tracked).toEqual(['.vfkb/entries.jsonl', '.vfkb/manifest.json']);
52
+ });
53
+ it.each(LIVE_DOCS)('%s does not repeat the false "only entries.jsonl" claim', (doc) => {
54
+ const text = read(doc);
55
+ // Matches "Only `.vfkb/entries.jsonl` is committed", "only entries.jsonl is
56
+ // committed", "`entries.jsonl` is committed, the rest is gitignored", etc.
57
+ const FALSE_CLAIM = /\b(only|Only)\b[^.\n]{0,40}`?\.?(vfkb\/)?entries\.jsonl`?[^.\n]{0,40}\bis committed\b/;
58
+ const REST_IS_IGNORED = /`entries\.jsonl`\s+is committed,\s*the rest is gitignored/;
59
+ expect(text, `${doc}: the false "only entries.jsonl is committed" claim`).not.toMatch(FALSE_CLAIM);
60
+ expect(text, `${doc}: "the rest is gitignored" is flatly false`).not.toMatch(REST_IS_IGNORED);
61
+ });
62
+ /**
63
+ * Split a markdown doc into STATEMENTS a reader consumes as one unit: a
64
+ * bullet / table row / code line, plus its indented continuation lines.
65
+ *
66
+ * Checking the whole document is too coarse to be a guard: a doc can drop
67
+ * manifest.json from its `git add` line and still pass, because the name
68
+ * survives in some unrelated paragraph. (Observed — three mutations passed a
69
+ * document-wide check.) The statement is the altitude a reader copies from.
70
+ */
71
+ function statements(text) {
72
+ const out = [];
73
+ let inFence = false;
74
+ let open = false; // is the last statement still accepting continuations?
75
+ // Unwrap blockquotes first — CONSUMER-ONBOARDING-PROMPT.md is one long `>`
76
+ // quote, and without this every wrapped continuation line looks like a new
77
+ // block, splitting one bullet into fragments and reporting a complete list
78
+ // as partial.
79
+ for (const raw of text.replace(/^> ?/gm, '').split('\n')) {
80
+ if (/^\s*```/.test(raw)) {
81
+ inFence = !inFence;
82
+ out.push(raw);
83
+ open = false;
84
+ continue;
85
+ }
86
+ // Inside a fence every line stands alone — a `git add` line is exactly
87
+ // what a reader copies, so it must carry the whole truth by itself.
88
+ if (inFence) {
89
+ out.push(raw);
90
+ open = false;
91
+ continue;
92
+ }
93
+ if (raw.trim() === '') {
94
+ open = false;
95
+ continue;
96
+ }
97
+ // Block starters: bullet, ordered item, table row, heading, blockquote
98
+ // bullet. Each begins a new statement.
99
+ const startsBlock = /^\s*(?:[-*+]\s|\d+\.\s|\||#{1,6}\s)/.test(raw);
100
+ if (open && !startsBlock)
101
+ out[out.length - 1] += ' ' + raw.trim();
102
+ else
103
+ out.push(raw.trim());
104
+ open = true;
105
+ }
106
+ return out;
107
+ }
108
+ /** A statement that tells the reader something is committed. */
109
+ const CLAIMS_COMMITTED = /\bgit add\b|\bis committed\b|\bare committed\b|\*\*Committed\b|\bCommitted (from|\(|:)/;
110
+ it.each(LIVE_DOCS.filter((d) => d !== 'README.md'))('%s names manifest.json in EVERY statement that says what is committed', (doc) => {
111
+ // Consecutive `git add` lines are ONE instruction to the reader — the
112
+ // correct form splits manifest.json onto its own guarded line (it may not
113
+ // exist on a plugin-born brain), so judging each line alone is wrong.
114
+ const merged = [];
115
+ for (const s of statements(read(doc))) {
116
+ const prev = merged[merged.length - 1];
117
+ if (prev !== undefined && /^\s*git add\b/.test(s) && /^\s*git add\b/.test(prev))
118
+ merged[merged.length - 1] = prev + ' ; ' + s.trim();
119
+ else
120
+ merged.push(s);
121
+ }
122
+ const relevant = merged.filter((s) => /entries\.jsonl/.test(s) && CLAIMS_COMMITTED.test(s));
123
+ // If none matched, the guard has silently stopped guarding.
124
+ expect(relevant.length, `${doc}: no committed-brain statement found to check`).toBeGreaterThan(0);
125
+ for (const s of relevant) {
126
+ expect(s, `${doc}: this statement tells the reader what is committed but omits manifest.json:\n ${s.trim()}`).toMatch(/manifest\.json/);
127
+ // Naming manifest.json somewhere in the statement is not enough — the
128
+ // statement must not ALSO scope the committed set to entries.jsonh
129
+ // alone. "`entries.jsonl` only (…manifest.json…)" named it and still
130
+ // told the reader the wrong thing (mutation M4 stayed green).
131
+ // Tight window so the legitimate "written only by `vfkb init`" nearby
132
+ // is not caught.
133
+ expect(s, `${doc}: this statement scopes the committed set to entries.jsonl alone:\n ${s.trim()}`).not.toMatch(/(?<![-\w])only\b[^.\n]{0,25}entries\.jsonl|entries\.jsonl[^.\n]{0,25}(?<![-\w])only\b/i);
134
+ }
135
+ });
136
+ it.each([
137
+ 'docs/CONSUMER-ONBOARDING.md',
138
+ 'docs/CONSUMER-ONBOARDING-PROMPT.md',
139
+ 'docs/RUNBOOK-claude-code-integration.md',
140
+ ])('%s enumerates the COMPLETE derived/gitignored set', (doc) => {
141
+ const text = read(doc);
142
+ for (const p of DERIVED) {
143
+ expect(text, `${doc} omits the derived path ${p}`).toContain(p);
144
+ }
145
+ });
146
+ /**
147
+ * A reader copies a fenced gitignore block wholesale. Checking the document
148
+ * for the path names is too coarse — a block can drop `.lock` and still pass
149
+ * because the name survives in a prose bullet elsewhere (observed: mutation
150
+ * M9 stayed green against the document-wide check).
151
+ */
152
+ /**
153
+ * Pure extraction so BOTH the per-doc guard and the non-vacuity floor can
154
+ * call it. It used to be a module-level array populated by an earlier
155
+ * `it.each`, which made the floor depend on test execution ORDER — under
156
+ * `-t`, `--shard`, or a `.only` it failed spuriously, and a gate that blocks
157
+ * honest work is a defect in its own right.
158
+ */
159
+ function gitignoreBlocks(doc) {
160
+ // Consume ANY language tag. Matching only ```/```gitignore mispairs the
161
+ // fences (a ```sh open is skipped, so the block boundaries invert) and the
162
+ // filter below then silently keeps nothing — a vacuous test. Verified
163
+ // non-empty by the count assertion at the end of this file.
164
+ const text = read(doc).replace(/^> ?/gm, ''); // unwrap blockquoted fences
165
+ return ([...text.matchAll(/```[A-Za-z]*\n([\s\S]*?)```/g)]
166
+ .map((m) => m[1])
167
+ // A gitignore block: every non-comment line is a bare path, and at least
168
+ // one is a .vfkb path. Commands (git add …, node …) are not.
169
+ .filter((b) => /^\s*\.vfkb\/\S+\s*$/m.test(b) &&
170
+ b
171
+ .split('\n')
172
+ .filter((l) => l.trim() && !l.trim().startsWith('#'))
173
+ .every((l) => /^\s*\S+\s*$/.test(l) && !/[$=]|^\s*(git|node|npm|claude)\b/.test(l))));
174
+ }
175
+ it.each(LIVE_DOCS)('%s: every copyable .vfkb gitignore block is COMPLETE and correct', (doc) => {
176
+ for (const b of gitignoreBlocks(doc)) {
177
+ for (const p of DERIVED)
178
+ expect(b, `${doc}: gitignore block omits ${p}:\n${b}`).toContain(p);
179
+ expect(b, `${doc}: a gitignore block ignores the COMMITTED manifest.json:\n${b}`).not.toMatch(/manifest\.json/);
180
+ expect(b, `${doc}: a gitignore block ignores the COMMITTED entries.jsonl:\n${b}`).not.toMatch(/entries\.jsonl/);
181
+ }
182
+ });
183
+ /**
184
+ * The catch-all. Not every ignore list is a fenced block — the onboarding
185
+ * PROMPT carries one as a `#` comment inside a bash fence, which the fence
186
+ * guard skips (mutation M12 stayed green). Rule: any statement that
187
+ * enumerates two or more derived paths is presenting "the set", so it must
188
+ * present the WHOLE set. A partial list is how `.journal/` and `.lock` went
189
+ * missing from 10 consumer repos.
190
+ *
191
+ * KNOWN FALSE POSITIVE (accepted, and cheap to work around): a statement that
192
+ * legitimately discusses a SUBSET for some other reason — e.g. "`.sessions/`
193
+ * and `.signals/` are per-machine" — is rejected too, because the rule cannot
194
+ * tell "here is the ignore set" from "here are two of its members". Scoping it
195
+ * to statements containing the word "gitignore" was tried and is worse: the
196
+ * PROMPT's list lives on a bare `# .vfkb/…` line whose gating word is on the
197
+ * PREVIOUS line, so the scoped rule let mutation M12 through. Blocking an
198
+ * honest sentence is a defect (review charge step 6) — the escape hatch is to
199
+ * split such a sentence, or add it to an exemption list here with a reason.
200
+ * The scope is 5 curated docs, so the blast radius is small and visible.
201
+ */
202
+ it.each(LIVE_DOCS)('%s: no statement enumerates a PARTIAL derived set', (doc) => {
203
+ for (const s of statements(read(doc))) {
204
+ const present = DERIVED.filter((p) => s.includes(p));
205
+ if (present.length < 2)
206
+ continue;
207
+ const missing = DERIVED.filter((p) => !s.includes(p));
208
+ expect(missing, `${doc}: partial derived set — missing ${missing.join(', ')}:\n ${s}`).toEqual([]);
209
+ }
210
+ });
211
+ /**
212
+ * The correction must not overshoot into a SECOND false claim.
213
+ * `writeManifest` has exactly two call sites — `vfkb init` (src/init.ts) and
214
+ * the cross-repo broadcast heal (src/broadcast.ts). The ordinary write path
215
+ * never creates a manifest, so a PLUGIN-born brain legitimately has none
216
+ * (vfkb#193), and `doctor` reports that as a warn. An onboarding doc that
217
+ * says "commit manifest.json" unconditionally hands the plugin reader a
218
+ * `git add` that fails with "pathspec did not match".
219
+ */
220
+ it('the two writeManifest call sites are still exactly init + broadcast', () => {
221
+ // Scan EVERY src/*.ts — a hardcoded candidate list can only notice a caller
222
+ // disappearing, never one being ADDED, which is the direction that falsifies
223
+ // the docs' "exactly two callers" claim. (That was the bug: the old filter's
224
+ // own comment said "if this list grows…" over a list that could not grow.)
225
+ const files = execFileSync('git', ['ls-files', 'src/*.ts'], { cwd: REPO, encoding: 'utf8' })
226
+ .split('\n')
227
+ .filter((f) => f && !f.endsWith('.test.ts'));
228
+ expect(files.length, 'no src files scanned — the guard would pass vacuously').toBeGreaterThan(10);
229
+ // Strip line comments first — grepping the raw text counts a COMMENTED-OUT
230
+ // call as a live call site (mutation M16 stayed green against that).
231
+ const callers = files.filter((f) => /writeManifest\s*\(/.test(read(f)
232
+ .split('\n')
233
+ .filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l))
234
+ .join('\n')));
235
+ // If this list grows, the "plugin-born brains have none" caveat in the docs
236
+ // may have stopped being true and must be re-checked against the new caller.
237
+ expect(callers.sort()).toEqual(['src/broadcast.ts', 'src/init.ts', 'src/manifest.ts']);
238
+ });
239
+ /**
240
+ * POLARITY. The guards above check that manifest.json is NAMED and that fenced
241
+ * ignore blocks do not list it — but a prose sentence is what a reader acts on,
242
+ * and flipping "never gitignore `manifest.json`" to "always gitignore
243
+ * `manifest.json`" satisfies every one of them. That single flip reintroduces
244
+ * the exact ViloGate defect this file exists to prevent, so the polarity of the
245
+ * rule is itself the thing to assert.
246
+ *
247
+ * The negation must sit ADJACENT to the verb (only markdown emphasis or a short
248
+ * "a reason to" may intervene). A loose "somewhere in the sentence there is a
249
+ * 'not'" test passes on "…is not one — gitignoring it is fine", which is the
250
+ * failure it is meant to catch.
251
+ */
252
+ const NEVER_GITIGNORE = /\b(?:never|not)\b[\s*_`]{0,6}(?:a reason to\s+)?gitignor/i;
253
+ /**
254
+ * Fires only when the gitignore VERB is applied to manifest.json — the two are
255
+ * within 30 characters of each other. Merely co-occurring in a long sentence is
256
+ * not the claim (`init` "writes … `manifest.json` … and the `.gitignore`
257
+ * stanza" is honest, as is "`manifest.json` … is committed; the derived paths
258
+ * are gitignored"). `(?<!\.)` drops the FILENAME `.gitignore`, which is a noun.
259
+ *
260
+ * The window is `[^\n]` and NOT `[^.\n]`: excluding dots looked tidier but the
261
+ * path a doc actually writes is `.vfkb/manifest.json`, whose own dots ended the
262
+ * window early — the flip "do NOT gitignore .vfkb/manifest.json" → "do
263
+ * gitignore .vfkb/manifest.json" stayed GREEN against the dot-excluding form.
264
+ */
265
+ const IGNORES_MANIFEST = /(?<!\.)gitignor\w*[^\n]{0,30}manifest\.json|manifest\.json[^\n]{0,30}(?<!\.)gitignor/i;
266
+ it.each(LIVE_DOCS)('%s: no statement tells the reader to gitignore manifest.json', (doc) => {
267
+ for (const s of statements(read(doc))) {
268
+ if (!IGNORES_MANIFEST.test(s))
269
+ continue;
270
+ expect(s, `${doc}: this statement pairs manifest.json with gitignoring and is NOT negated — ` +
271
+ `it reads as an instruction to ignore the committed engine stamp:\n ${s}`).toMatch(NEVER_GITIGNORE);
272
+ }
273
+ });
274
+ // README.md is a project overview, not a wiring doc — it never enumerates the
275
+ // ignore contract, so requiring the rule there would block honest prose. The
276
+ // other four are exactly the docs a consumer acts on.
277
+ it.each(LIVE_DOCS.filter((d) => d !== 'README.md'))('%s: states the never-gitignore-manifest.json rule outright', (doc) => {
278
+ const stated = statements(read(doc)).filter((s) => /manifest\.json/.test(s) && NEVER_GITIGNORE.test(s));
279
+ expect(stated.length, `${doc}: the load-bearing rule "never gitignore manifest.json" is absent. ` +
280
+ `Naming manifest.json on a committed list is not enough — the reader's mistake ` +
281
+ `is ignoring it, so the doc must say not to.`).toBeGreaterThan(0);
282
+ });
283
+ /**
284
+ * A reader who skims to the copyable gitignore fence never reads the prose
285
+ * above it. The correction is only delivered if it sits where they land, so
286
+ * every fence must be followed by the rule. This is what makes the sentence
287
+ * after each block undeletable — deleting it, or the RUNBOOK's whole
288
+ * committed-vs-gitignored section, previously left the suite green.
289
+ */
290
+ it.each(LIVE_DOCS)('%s: every .vfkb gitignore fence is followed by the rule', (doc) => {
291
+ const lines = read(doc).replace(/^> ?/gm, '').split('\n');
292
+ for (const b of gitignoreBlocks(doc)) {
293
+ const firstPath = b.split('\n').find((l) => l.trim().startsWith('.vfkb/')).trim();
294
+ const at = lines.findIndex((l) => l.trim() === firstPath);
295
+ expect(at, `${doc}: could not locate the block in the file`).toBeGreaterThan(-1);
296
+ // Find the closing fence, then look at the prose immediately after it.
297
+ let close = at;
298
+ while (close < lines.length && !/^\s*```\s*$/.test(lines[close]))
299
+ close++;
300
+ const after = lines.slice(close + 1, close + 13).join('\n');
301
+ expect(after, `${doc}: the gitignore block a reader copies is not followed by ` +
302
+ `"never gitignore manifest.json". A skimmer copies the block and never sees ` +
303
+ `the warning:\n${after}`).toMatch(NEVER_GITIGNORE);
304
+ expect(after, `${doc}: the post-block warning does not name manifest.json`).toMatch(/manifest\.json/);
305
+ }
306
+ });
307
+ /**
308
+ * The RUNBOOK's canonical reference block. Every guard above is document-wide
309
+ * or fence-anchored, so this whole section could be DELETED and the suite
310
+ * stayed green — the RUNBOOK's §6 fence and §0 table independently satisfied
311
+ * the document-wide `toContain` checks. That is the "commenting out every
312
+ * emitted path still passed" failure mode: the section is a deliverable, so
313
+ * assert it exists and that its BODY carries the contract.
314
+ */
315
+ it('the RUNBOOK keeps its "Committed vs gitignored" section, correct and complete', () => {
316
+ const text = read('docs/RUNBOOK-claude-code-integration.md');
317
+ const HEADING = '### Committed vs gitignored in `.vfkb/`';
318
+ const at = text.indexOf(HEADING);
319
+ expect(at, `the RUNBOOK's "${HEADING}" section is gone`).toBeGreaterThan(-1);
320
+ // Body = up to the next heading of any level.
321
+ const rest = text.slice(at + HEADING.length);
322
+ const end = rest.search(/\n#{1,6} |\n---\s*\n/);
323
+ const body = end === -1 ? rest : rest.slice(0, end);
324
+ expect(body.length, 'the section heading survives but its body is empty').toBeGreaterThan(200);
325
+ const committed = body.split(/\*\*Gitignored/)[0];
326
+ expect(committed, 'the section no longer lists manifest.json as committed').toMatch(/manifest\.json/);
327
+ expect(committed, 'the section no longer lists entries.jsonl as committed').toMatch(/entries\.jsonl/);
328
+ for (const p of DERIVED)
329
+ expect(body, `the section's gitignored list omits ${p}`).toContain(p);
330
+ expect(body, 'the section no longer carries the never-gitignore rule').toMatch(NEVER_GITIGNORE);
331
+ });
332
+ /**
333
+ * SCOPE. LIVE_DOCS is a curated list, so the false claim could be reintroduced
334
+ * in any doc outside it with no CI signal. Sweep every tracked markdown file
335
+ * instead, minus the decided/immutable records (ADR-0001) and the changelog,
336
+ * where the sentence was TRUE when written.
337
+ */
338
+ it('no tracked markdown outside the decided records repeats the false claim', () => {
339
+ const docs = execFileSync('git', ['ls-files', '*.md'], { cwd: REPO, encoding: 'utf8' })
340
+ .split('\n')
341
+ .filter((f) => f && !f.startsWith('docs/adr/') && !f.startsWith('docs/rfc/') && f !== 'CHANGELOG.md');
342
+ expect(docs.length, 'no markdown scanned — vacuous').toBeGreaterThan(5);
343
+ const FALSE_CLAIM = /\b(only|Only)\b[^.\n]{0,40}`?\.?(vfkb\/)?entries\.jsonl`?[^.\n]{0,40}\bis committed\b/;
344
+ // Statement-level, so a sentence that QUOTES the claim in order to call it
345
+ // false — the roadmap's record of the defect — is a description, not a
346
+ // repetition. Anything asserting it as fact carries no such disclaimer.
347
+ const offenders = docs.flatMap((f) => statements(read(f))
348
+ .filter((s) => FALSE_CLAIM.test(s) && !/\bfalse\b/i.test(s) && !/#220\b/.test(s))
349
+ .map((s) => `${f}: ${s.slice(0, 120)}`));
350
+ expect(offenders, 'these statements repeat "only entries.jsonl is committed"').toEqual([]);
351
+ });
352
+ /**
353
+ * The ENGINE is an emitter of this contract too: `vfkb init` writes an
354
+ * AGENTS.md stanza into every newly-wired consumer repo. It shipped the same
355
+ * partial-derived-set defect the docs had (3 of 5 paths), so a consumer's own
356
+ * agent instructions taught the mistake this PR corrects everywhere else.
357
+ * Asserted against the FILE init actually writes, not the source template.
358
+ */
359
+ it('vfkb init writes an AGENTS.md stanza with the complete, correct contract', () => {
360
+ const root = mkdtempSync(join(tmpdir(), 'vfkb-agents-'));
361
+ try {
362
+ initProject(root, { project: 'probe' });
363
+ const agents = readFileSync(join(root, 'AGENTS.md'), 'utf8');
364
+ for (const p of DERIVED)
365
+ expect(agents, `init's AGENTS.md omits the derived path ${p}`).toContain(p);
366
+ expect(agents, "init's AGENTS.md omits the committed manifest.json").toContain('manifest.json');
367
+ expect(agents, "init's AGENTS.md does not carry the never-gitignore rule").toMatch(NEVER_GITIGNORE);
368
+ for (const s of statements(agents)) {
369
+ if (!IGNORES_MANIFEST.test(s))
370
+ continue;
371
+ expect(s, `init's AGENTS.md tells the agent to gitignore manifest.json:\n ${s}`).toMatch(NEVER_GITIGNORE);
372
+ }
373
+ }
374
+ finally {
375
+ rmSync(root, { recursive: true, force: true });
376
+ }
377
+ });
378
+ it.each(['docs/CONSUMER-ONBOARDING.md', 'docs/CONSUMER-ONBOARDING-PROMPT.md'])('%s does not promise manifest.json unconditionally exists', (doc) => {
379
+ const text = read(doc);
380
+ // It must carry the caveat...
381
+ expect(text, `${doc}: no plugin-born-brain caveat for manifest.json`).toMatch(/plugin-born|if present|whenever it exists|have none/i);
382
+ // ...and no bare `git add` may list manifest.json as a required pathspec
383
+ // without an escape (|| true / 2>/dev/null), which is what fails on a
384
+ // plugin-wired repo.
385
+ for (const s of statements(text)) {
386
+ if (!/^\s*git add\b/.test(s) || !/manifest\.json/.test(s))
387
+ continue;
388
+ expect(s, `${doc}: this git add fails on a plugin-born brain:\n ${s}`).toMatch(/\|\|\s*true|2>\/dev\/null/);
389
+ }
390
+ });
391
+ // The check above is a loop over a filtered list — if the filter matches
392
+ // nothing it passes vacuously (it did, on the first draft: a fence-pairing
393
+ // bug left 0 blocks and 3 mutations stayed green). This asserts the guard
394
+ // actually had something to guard.
395
+ it('the gitignore-block guard is not vacuous — it saw real blocks', () => {
396
+ const seen = LIVE_DOCS.flatMap(gitignoreBlocks);
397
+ expect(seen.length).toBeGreaterThanOrEqual(2);
398
+ });
399
+ it("this repo's own .gitignore ignores every derived path and NOT the committed ones", () => {
400
+ const gi = read('.gitignore');
401
+ for (const p of DERIVED)
402
+ expect(gi, `.gitignore omits ${p}`).toContain(`.vfkb/${p}`);
403
+ expect(gi).not.toMatch(/^\.vfkb\/manifest\.json\s*$/m);
404
+ expect(gi).not.toMatch(/^\.vfkb\/entries\.jsonl\s*$/m);
405
+ });
406
+ it('doctor.ts does not enumerate a stale partial gitignore set in prose', () => {
407
+ // Strip comment markers + collapse whitespace so a claim that spans a
408
+ // wrapped comment still matches (the stale one did).
409
+ const src = read('src/doctor.ts').replace(/^\s*\/\/ ?/gm, '').replace(/\s+/g, ' ');
410
+ // The old comment said only `.signals/` (plus .sessions/ and
411
+ // index-meta.json) is in init's stanza — 3 of 5, stale since ADR-0064.
412
+ expect(src).not.toMatch(/plus \.sessions\/ and index-meta\.json/);
413
+ // And it must name the two paths ADR-0064 / the lock added.
414
+ expect(src).toMatch(/\.journal\//);
415
+ });
416
+ });
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