@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/broadcast.js +19 -8
- package/dist/bundles/vfkb-mcp.mjs +15 -1
- package/dist/bundles/vfkb.mjs +223 -112
- package/dist/cli.js +43 -4
- package/dist/docs-committed-brain-files.test.js +416 -0
- package/dist/doctor.js +104 -11
- package/dist/engine.js +52 -1
- package/dist/hook-stdin-failopen.test.js +217 -0
- package/dist/init.js +47 -6
- package/dist/init.test.js +70 -1
- package/dist/manifest.js +40 -4
- package/package.json +1 -1
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',
|
|
54
|
-
//
|
|
55
|
-
setTimeout(
|
|
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
|
-
|
|
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 ${
|
|
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',
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
|
350
|
-
add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${
|
|
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
|
-
|
|
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] ?? '')
|
|
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
|
}
|