@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/broadcast.js +19 -8
- package/dist/bundles/vfkb-mcp.mjs +15 -1
- package/dist/bundles/vfkb.mjs +171 -104
- package/dist/cli.js +43 -4
- package/dist/docs-committed-brain-files.test.js +416 -0
- 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/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 {
|
|
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
|