@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/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/doctor.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // or inconsistent wiring, and the dual-clone drift signal (a brain last stamped by
4
4
  // a different engine build). Deterministic; unit-tested (the inner gate per ADR-0023).
5
5
  import { execFileSync } from 'node:child_process';
6
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
- import { join, dirname } from 'node:path';
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
7
+ import { join, dirname, relative, resolve, isAbsolute } from 'node:path';
8
8
  import { SCHEMA_VERSION, ENGINE_VERSION, ENGINE_COMMIT } from './version.js';
9
9
  import { journalStatus } from './journal.js';
10
10
  import { readManifest } from './manifest.js';
@@ -313,12 +313,81 @@ export async function checkNpmCurrency(opts) {
313
313
  clearTimeout(timer);
314
314
  }
315
315
  }
316
+ // #186 — what OLD (pre-plugin) vfkb wiring actually is: a hook command that
317
+ // invokes the ENGINE's hook dispatcher, either through the committed bootstrap
318
+ // (ADR-0031) or by naming a hook subcommand directly.
319
+ //
320
+ // The predicate used to be `JSON.stringify(hook).includes('vfkb')`, which
321
+ // classified ANY hook mentioning the string as old wiring — including the
322
+ // ADR-0059 INACTIVE guard (`.claude/vfkb-guard.mjs`) that the plugin's own
323
+ // MIGRATION_GUIDE tells every consumer to commit. So a correctly migrated repo
324
+ // was told to delete its own INACTIVE detector, and real double wiring was
325
+ // buried in a warning consumers had learned to ignore.
326
+ const HOOK_SUBCOMMANDS = ['session-start', 'pre-tool-use', 'post-tool-use', 'stop', 'session-end'];
327
+ export function isEngineWiring(hookJson) {
328
+ // Compare against the LITERAL command text, not its JSON encoding: a tab in the
329
+ // command is the two characters \\t once stringified, and a quoted subcommand is
330
+ // \\". Un-escaping first means the separator rule below is about real whitespace
331
+ // and quoting rather than about JSON spelling. (Review of PR #216, minor 5: the
332
+ // previous `\\?` sat before the whitespace, where JSON never puts a backslash.)
333
+ const text = hookJson.replace(/\\[tnr]/g, ' ').replace(/\\(.)/g, '$1');
334
+ if (/bootstrap\.mjs/.test(text))
335
+ return true;
336
+ return new RegExp(`\\bhook["'\\s]+(${HOOK_SUBCOMMANDS.join('|')})\\b`).test(text);
337
+ }
338
+ // #212 — a sentinel commit means "this build does not know its own identity"
339
+ // (version.ts's honest `dev` fallback on the tsc/dist path), NOT "a different
340
+ // engine stamped this brain". Comparing a sentinel against a real sha is not a
341
+ // drift observation, it is an absence of one.
342
+ //
343
+ // The old predicate exempted a dev RUNNING engine but not a dev MANIFEST, so a
344
+ // brain stamped by a dist-path build (e.g. the documented `node dist/cli.js`
345
+ // fallback, which is how ViloGate's manifest got `"dev"`) reported drift forever
346
+ // against every real-sha engine.
347
+ export function engineDrift(manifestCommit, runningCommit) {
348
+ if (!manifestCommit || manifestCommit === 'dev')
349
+ return false;
350
+ if (!runningCommit || runningCommit === 'dev')
351
+ return false;
352
+ return manifestCommit !== runningCommit;
353
+ }
354
+ // #206 — the work tree that actually governs a path, or undefined outside a repo.
355
+ function repoToplevel(root) {
356
+ try {
357
+ return execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], {
358
+ encoding: 'utf8',
359
+ stdio: ['ignore', 'pipe', 'ignore'],
360
+ }).trim();
361
+ }
362
+ catch {
363
+ return undefined;
364
+ }
365
+ }
366
+ // Containment on path BOUNDARIES: `/repo` must not be judged to contain
367
+ // `/repo-other/...`. Both sides are realpath'd so a symlinked brain dir (or a
368
+ // /tmp that resolves to /private/tmp) compares honestly rather than by spelling.
369
+ function isUnder(parent, child) {
370
+ if (!parent)
371
+ return false;
372
+ const real = (p) => {
373
+ try {
374
+ return realpathSync(p);
375
+ }
376
+ catch {
377
+ return resolve(p);
378
+ }
379
+ };
380
+ const rel = relative(real(parent), real(child));
381
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
382
+ }
316
383
  export function runDoctor(opts) {
317
384
  const { root, brainDir, env } = opts;
385
+ // The identity we compare the brain's stamp against (injectable — see DoctorOpts).
386
+ const runningCommit = opts.engineCommit ?? ENGINE_COMMIT;
318
387
  const checks = [];
319
388
  const add = (name, status, detail) => checks.push({ name, status, detail });
320
389
  // 1. Engine identity (info).
321
- add('engine', 'ok', `version ${ENGINE_VERSION} · commit ${ENGINE_COMMIT} · schema v${SCHEMA_VERSION}`);
390
+ add('engine', 'ok', `version ${ENGINE_VERSION} · commit ${runningCommit} · schema v${SCHEMA_VERSION}`);
322
391
  // Read the project settings once (used by plugin detection, hooks checks, and
323
392
  // project-consistency below), and detect ADR-0045 plugin wiring up front — it changes
324
393
  // what "healthy" means for the wiring checks AND which advice is safe to give
@@ -330,9 +399,19 @@ export function runDoctor(opts) {
330
399
  // 2. Brain ↔ engine compat (the load-bearing check).
331
400
  const mf = readManifest(brainDir);
332
401
  if (!mf) {
333
- add('brain manifest', 'warn', plugin
334
- ? `no manifest.json in ${brainDir} it will be stamped on the next write`
335
- : `no manifest.json in ${brainDir} run \`vfkb init\` (or it will be stamped on next write)`);
402
+ add('brain manifest', 'warn',
403
+ // #188 the ordinary write path (addEntry appendRecord backend.append)
404
+ // never touches manifest.json. `writeManifest` has exactly two callers:
405
+ // `vfkb init` (init.ts) and the broadcast heal, which fires only when the
406
+ // manifest is ABSENT (broadcast.ts). Saying "on the next write" claimed
407
+ // behaviour the code does not have.
408
+ // On a PLUGIN-wired repo the message must not prescribe `vfkb init` — that
409
+ // advice is what scaffolds double wiring (issue #77), and the invariant is
410
+ // asserted in doctor.test.ts. A plugin-born brain simply has no manifest
411
+ // until a cross-repo broadcast heals it (vfkb#193).
412
+ plugin
413
+ ? `no manifest.json in ${brainDir} — plugin-born brains have none until a cross-repo broadcast heals it (vfkb#193); the ordinary write path never creates one`
414
+ : `no manifest.json in ${brainDir} — run \`vfkb init\` to stamp it (the ordinary write path never creates one)`);
336
415
  }
337
416
  else if (typeof mf.schema_version !== 'number') {
338
417
  add('brain manifest', 'warn', 'manifest has no numeric schema_version');
@@ -344,10 +423,19 @@ export function runDoctor(opts) {
344
423
  add('brain↔engine compat', 'warn', `brain schema v${mf.schema_version} is older than engine v${SCHEMA_VERSION} — migration may be needed`);
345
424
  }
346
425
  else {
347
- add('brain↔engine compat', 'ok', `schema v${mf.schema_version} matches`);
426
+ // #212 — a sentinel stamp is suppressed as DRIFT (it is not drift), but it must
427
+ // not become invisible: the brain's provenance genuinely is unrecorded, and the
428
+ // stamping half of #212 (a build that does not know its commit should not write
429
+ // one) is still open. Reported in the detail rather than as a new warn — it is a
430
+ // true statement, but not one the consumer can act on, and this PR exists to stop
431
+ // doctor crying wolf.
432
+ const sentinel = mf.engine_commit === 'dev'
433
+ ? ` · stamped by a build that did not know its own commit ("dev") — provenance unknown (#212)`
434
+ : '';
435
+ add('brain↔engine compat', 'ok', `schema v${mf.schema_version} matches${sentinel}`);
348
436
  // Drift signal: same schema but a different engine build last stamped the brain.
349
- if (mf.engine_commit && ENGINE_COMMIT !== 'dev' && mf.engine_commit !== ENGINE_COMMIT) {
350
- add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${ENGINE_COMMIT} — possible dual-clone drift`);
437
+ if (engineDrift(mf.engine_commit, runningCommit)) {
438
+ add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${runningCommit} — possible dual-clone drift`);
351
439
  }
352
440
  }
353
441
  // 2b. Durable-capture journal (ADR-0064): what would recovery do, and is a
@@ -367,7 +455,12 @@ export function runDoctor(opts) {
367
455
  // has no .journal/ gitignore line — its next `git add .vfkb` would COMMIT
368
456
  // the wal (a tracked journal dies by the same reset --hard, RFC-034
369
457
  // Alternatives; and a committed wal defeats §4 redaction).
370
- if (existsSync(join(brainDir, '.journal'))) {
458
+ // #206 — judge only a journal the repo actually governs. With the brain on
459
+ // the default ~/.vfkb tier (VFKB_DATA_DIR outside the work tree),
460
+ // `check-ignore` exits 128 ("path outside work tree"), which the catch-all
461
+ // below read as plain "not ignored" — so doctor advised editing a .gitignore
462
+ // that does not govern that path at all.
463
+ if (existsSync(join(brainDir, '.journal')) && isUnder(repoToplevel(root), join(brainDir, '.journal'))) {
371
464
  try {
372
465
  execFileSync('git', ['-C', root, 'check-ignore', '-q', join(brainDir, '.journal', 'wal.jsonl')], {
373
466
  stdio: 'ignore',
@@ -430,7 +523,7 @@ export function runDoctor(opts) {
430
523
  // 5. Hooks wiring (same plugin logic as check 4).
431
524
  const hooks = settings?.hooks ?? {};
432
525
  const expected = ['SessionStart', 'PreToolUse', 'Stop', 'SessionEnd'];
433
- const have = expected.filter((e) => JSON.stringify(hooks[e] ?? '').includes('vfkb'));
526
+ const have = expected.filter((e) => isEngineWiring(JSON.stringify(hooks[e] ?? '')));
434
527
  if (plugin && have.length > 0) {
435
528
  add('.claude/settings.json', 'warn', `vfkb hooks present ALONGSIDE the plugin (double wiring) — remove them; the plugin's hooks are primary (ADR-0045)`);
436
529
  }