@viloforge/vfkb 0.2.1
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/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/args.js +82 -0
- package/dist/backend.js +115 -0
- package/dist/bootstrap.test.js +64 -0
- package/dist/bundles/vfkb-mcp.mjs +32139 -0
- package/dist/bundles/vfkb.mjs +17583 -0
- package/dist/bundles.test.js +177 -0
- package/dist/cli.js +635 -0
- package/dist/counters.js +61 -0
- package/dist/curator.js +87 -0
- package/dist/distiller.js +116 -0
- package/dist/doctor.js +479 -0
- package/dist/doctor.test.js +179 -0
- package/dist/engine.js +668 -0
- package/dist/env-compat.test.js +23 -0
- package/dist/export.js +371 -0
- package/dist/gating.js +34 -0
- package/dist/git.js +43 -0
- package/dist/import.js +99 -0
- package/dist/import.test.js +54 -0
- package/dist/index-store.js +101 -0
- package/dist/init.js +264 -0
- package/dist/init.test.js +148 -0
- package/dist/lock.js +158 -0
- package/dist/manifest.js +38 -0
- package/dist/mcp-server.js +213 -0
- package/dist/pi-extension.js +117 -0
- package/dist/pi-mcp-bridge.js +94 -0
- package/dist/pi-types.js +6 -0
- package/dist/read.js +112 -0
- package/dist/secrets.js +36 -0
- package/dist/session-end.js +183 -0
- package/dist/session-end.test.js +149 -0
- package/dist/session.js +157 -0
- package/dist/stop-reminder.js +130 -0
- package/dist/storage.js +145 -0
- package/dist/types.js +6 -0
- package/dist/validate.js +83 -0
- package/dist/version.js +32 -0
- package/package.json +51 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Pluggable read index (ADR-0013). v1 default = pure-JS in-memory, rebuilt from
|
|
2
|
+
// JSONL (source of truth). A better-sqlite3 FTS5 backend is an OPTIONAL future
|
|
3
|
+
// implementation behind this same interface — the engine never hard-depends on it.
|
|
4
|
+
//
|
|
5
|
+
// Freshness (ADR-0014): the index compares a content-derived token before serving
|
|
6
|
+
// and rebuilds on mismatch (rebuild-on-doubt — cheap because in-memory + small).
|
|
7
|
+
import { contentHash, materialize, readMeta, readRecords } from './storage.js';
|
|
8
|
+
// Light suffix stripping (NOT a full Porter stemmer) so a natural-language query
|
|
9
|
+
// term lexically matches the stored wording: hanging/hangs -> hang,
|
|
10
|
+
// silently/silent -> silent. Found load-bearing by the devops-kb live turn — the
|
|
11
|
+
// agent phrased "hanging silent" while the entry said "hangs silently", so the
|
|
12
|
+
// relevant gotcha scored ~0 and was never surfaced. Conservative: min stem length
|
|
13
|
+
// 3, longest suffix first; mismatches it can't resolve (running/runs) are accepted
|
|
14
|
+
// for v1 — the real robustness fix is the deferred semantic reranker (ADR-0012).
|
|
15
|
+
function stem(t) {
|
|
16
|
+
for (const suf of ['ing', 'ed', 'ly', 'es', 's']) {
|
|
17
|
+
if (t.length - suf.length >= 3 && t.endsWith(suf))
|
|
18
|
+
return t.slice(0, -suf.length);
|
|
19
|
+
}
|
|
20
|
+
return t;
|
|
21
|
+
}
|
|
22
|
+
function tokenize(s) {
|
|
23
|
+
return s
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.split(/[^a-z0-9]+/)
|
|
26
|
+
.filter((t) => t.length > 1)
|
|
27
|
+
.map(stem);
|
|
28
|
+
}
|
|
29
|
+
// Distinct stemmed terms in a query — the denominator for RFC-001's relevance
|
|
30
|
+
// floor (matched / queryTermCount). Shares the tokenizer/stemmer with searchScored
|
|
31
|
+
// so the floor and the search agree on what a "term" is.
|
|
32
|
+
export function queryTermCount(query) {
|
|
33
|
+
return new Set(tokenize(query)).size;
|
|
34
|
+
}
|
|
35
|
+
export class InMemoryIndex {
|
|
36
|
+
entries = [];
|
|
37
|
+
token = '';
|
|
38
|
+
constructor() {
|
|
39
|
+
this.rebuild();
|
|
40
|
+
}
|
|
41
|
+
rebuild() {
|
|
42
|
+
this.entries = materialize(readRecords());
|
|
43
|
+
this.token = contentHash(this.entries);
|
|
44
|
+
}
|
|
45
|
+
// Rebuild iff the persisted content token differs from what we hold.
|
|
46
|
+
// The meta hash is authoritative when present; else recompute from JSONL
|
|
47
|
+
// (covers a git pull that changed entries.jsonl but not the sidecar).
|
|
48
|
+
ensureFresh() {
|
|
49
|
+
const persisted = readMeta()?.content_hash ?? contentHash();
|
|
50
|
+
if (persisted !== this.token)
|
|
51
|
+
this.rebuild();
|
|
52
|
+
}
|
|
53
|
+
all() {
|
|
54
|
+
this.ensureFresh();
|
|
55
|
+
return this.entries;
|
|
56
|
+
}
|
|
57
|
+
get(id) {
|
|
58
|
+
this.ensureFresh();
|
|
59
|
+
return this.entries.find((e) => e.id === id);
|
|
60
|
+
}
|
|
61
|
+
// Stage-1 relevance: stemmed term-overlap count over text + tags — NOT BM25
|
|
62
|
+
// (no IDF, no length normalization; the score is # of entry tokens matching a
|
|
63
|
+
// query term). Adequate as a candidate signal at per-project scale; semantic
|
|
64
|
+
// ranking is the deferred EmbeddingReranker (ADR-0012/0016). The envelope-aware
|
|
65
|
+
// Heuristic reranker is a separate Stage-2 applied to these candidates.
|
|
66
|
+
searchScored(query, k = 30) {
|
|
67
|
+
this.ensureFresh();
|
|
68
|
+
const terms = new Set(tokenize(query));
|
|
69
|
+
if (terms.size === 0)
|
|
70
|
+
return [];
|
|
71
|
+
return this.entries
|
|
72
|
+
.map((entry) => {
|
|
73
|
+
// tags may be absent on legacy or externally-projected entries (e.g. vfwb's
|
|
74
|
+
// lossy projection into .vfkb) — never let a tagless entry crash search.
|
|
75
|
+
const hay = tokenize(entry.text + ' ' + (entry.tags ?? []).join(' '));
|
|
76
|
+
let score = 0;
|
|
77
|
+
const hit = new Set();
|
|
78
|
+
for (const t of hay)
|
|
79
|
+
if (terms.has(t)) {
|
|
80
|
+
score++;
|
|
81
|
+
hit.add(t);
|
|
82
|
+
}
|
|
83
|
+
return { entry, score, matched: hit.size };
|
|
84
|
+
})
|
|
85
|
+
.filter((x) => x.score > 0)
|
|
86
|
+
.sort((a, b) => b.score - a.score || b.entry.updated.localeCompare(a.entry.updated))
|
|
87
|
+
.slice(0, k);
|
|
88
|
+
}
|
|
89
|
+
search(query, k = 30) {
|
|
90
|
+
return this.searchScored(query, k).map((x) => x.entry);
|
|
91
|
+
}
|
|
92
|
+
freshnessToken() {
|
|
93
|
+
return this.token;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// ADR-0013: select the index backend. v1 always returns the pure-JS in-memory
|
|
97
|
+
// index (zero native deps). A future SQLite/FTS5 backend would be auto-detected
|
|
98
|
+
// here and fall back to in-memory if the native module is absent.
|
|
99
|
+
export function selectIndex() {
|
|
100
|
+
return new InMemoryIndex();
|
|
101
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// FR-1 (ADR-0030) — `vfkb init [project]`: idempotently scaffold a CONSUMER repo
|
|
2
|
+
// so a session there runs on vfkb automatically. Writes the auto-layer wiring in
|
|
3
|
+
// the portable `$VFKB_HOME` form (FR-2), the .gitignore stanza, an empty brain,
|
|
4
|
+
// and a parameterized "how we track work HERE" snippet. Re-running is safe: each
|
|
5
|
+
// piece is created-if-absent / merged-if-present, and a brain is NEVER clobbered.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
|
+
import { writeManifest } from './manifest.js';
|
|
9
|
+
const AGENTS_MARKER = '<!-- vfkb:how-we-track-work -->';
|
|
10
|
+
const BOOTSTRAP_REL = '.vfkb/bin/bootstrap.mjs';
|
|
11
|
+
// The wiring entry-point is a COMMITTED, RELATIVE bootstrap (ADR-0031) — always
|
|
12
|
+
// resolvable in any clone. The bootstrap resolves the real engine via $VFKB_HOME
|
|
13
|
+
// at runtime and degrades gracefully (a clear banner, never a crash/blocked write)
|
|
14
|
+
// when it is unset. This also avoids depending on `${VFKB_HOME}` arg-expansion.
|
|
15
|
+
function mcpConfig(project) {
|
|
16
|
+
return {
|
|
17
|
+
command: 'node',
|
|
18
|
+
args: [BOOTSTRAP_REL, 'mcp'],
|
|
19
|
+
env: { VFKB_DATA_DIR: '.vfkb', VFKB_PROJECT: project },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// Anchor hook paths to the project root Claude Code injects into the hook env
|
|
23
|
+
// ($CLAUDE_PROJECT_DIR), defaulting to the CWD-relative form when it is unset so the
|
|
24
|
+
// wiring never regresses. Hooks run in the SESSION's cwd — which is NOT guaranteed to
|
|
25
|
+
// be the repo root (it follows `cd`) — so the bare-relative `node .vfkb/bin/bootstrap.mjs`
|
|
26
|
+
// threw MODULE_NOT_FOUND the moment a session cd'd out of the root (issue #22, ADR-0035).
|
|
27
|
+
// $CLAUDE_PROJECT_DIR is CWD-independent (empirically verified) and hook commands are
|
|
28
|
+
// shell-run, so `${VAR:-.}` parameter-expansion resolves. (The MCP server in .mcp.json
|
|
29
|
+
// stays relative — it is spawned once at session start with cwd=root, not re-invoked.)
|
|
30
|
+
function hookCommand(project, sub) {
|
|
31
|
+
const root = '${CLAUDE_PROJECT_DIR:-.}';
|
|
32
|
+
return `VFKB_DATA_DIR=${root}/.vfkb VFKB_PROJECT=${project} node ${root}/${BOOTSTRAP_REL} cli hook ${sub}`;
|
|
33
|
+
}
|
|
34
|
+
// The bootstrap source, written verbatim into the consumer repo. Self-contained
|
|
35
|
+
// (no engine import — it must run even when the engine is unresolvable). Bump
|
|
36
|
+
// BOOTSTRAP_VERSION on a change so `vfkb init` upgrades an older one in place.
|
|
37
|
+
const BOOTSTRAP_VERSION = 2;
|
|
38
|
+
const BOOTSTRAP_SRC = `#!/usr/bin/env node
|
|
39
|
+
// vfkb engine bootstrap (ADR-0031) — vfkb-bootstrap-version: ${BOOTSTRAP_VERSION}
|
|
40
|
+
// Committed at a RELATIVE path so it is always resolvable. Resolves the real
|
|
41
|
+
// engine via $VFKB_BUNDLE_DIR at runtime; degrades GRACEFULLY (clear, actionable
|
|
42
|
+
// message; never breaks the session) when it is unset or the bundles are missing.
|
|
43
|
+
// DO NOT hand-edit — regenerated by \`vfkb init\`.
|
|
44
|
+
import { existsSync } from 'node:fs';
|
|
45
|
+
import { spawnSync } from 'node:child_process';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
|
|
48
|
+
const mode = process.argv[2] === 'mcp' ? 'mcp' : 'cli';
|
|
49
|
+
const passthrough = process.argv.slice(3);
|
|
50
|
+
// VFKB_BUNDLE_DIR is canonical; VFKB_HOME is a kept-working deprecated alias.
|
|
51
|
+
const home = process.env.VFKB_BUNDLE_DIR || process.env.VFKB_HOME;
|
|
52
|
+
const engine = mode === 'mcp' ? 'vfkb-mcp.mjs' : 'vfkb.mjs';
|
|
53
|
+
const enginePath = home ? join(home, engine) : '';
|
|
54
|
+
|
|
55
|
+
const FIX =
|
|
56
|
+
'vfkb is INACTIVE: VFKB_BUNDLE_DIR is not set (or its bundles are missing). ' +
|
|
57
|
+
'Fix: build the bundles in the vfkb repo (\\\`npm run build:bundles\\\`) and ' +
|
|
58
|
+
'\\\`export VFKB_BUNDLE_DIR=/path/to/vfkb/dist/bundles\\\` (see docs/CONSUMER-ONBOARDING.md). ' +
|
|
59
|
+
'Then run \\\`vfkb doctor\\\` to verify.';
|
|
60
|
+
|
|
61
|
+
if (!home || !existsSync(enginePath)) {
|
|
62
|
+
// SessionStart: inform the user via the injection channel (a valid hook payload).
|
|
63
|
+
if (mode === 'cli' && passthrough[1] === 'session-start') {
|
|
64
|
+
process.stdout.write(JSON.stringify({
|
|
65
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '⚠️ ' + FIX },
|
|
66
|
+
}));
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
// PreToolUse / Stop / MCP: note it, but NEVER block writes or crash the turn.
|
|
70
|
+
process.stderr.write('vfkb-bootstrap: ' + FIX + '\\n');
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Resolved — run the real engine transparently (stdio passed through).
|
|
75
|
+
const r = spawnSync('node', [enginePath, ...passthrough], { stdio: 'inherit' });
|
|
76
|
+
process.exit(r.status == null ? 0 : r.status);
|
|
77
|
+
`;
|
|
78
|
+
function settingsHooks(project) {
|
|
79
|
+
return {
|
|
80
|
+
SessionStart: [{ hooks: [{ type: 'command', command: hookCommand(project, 'session-start') }] }],
|
|
81
|
+
PreToolUse: [
|
|
82
|
+
{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: hookCommand(project, 'pre-tool-use') }] },
|
|
83
|
+
],
|
|
84
|
+
Stop: [{ hooks: [{ type: 'command', command: hookCommand(project, 'stop') }] }],
|
|
85
|
+
SessionEnd: [{ hooks: [{ type: 'command', command: hookCommand(project, 'session-end') }] }],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function agentsSnippet(project) {
|
|
89
|
+
return `${AGENTS_MARKER}
|
|
90
|
+
## How we track work HERE — vfkb
|
|
91
|
+
|
|
92
|
+
This repo uses **vfkb** as its knowledge substrate (project \`${project}\`). Knowledge is recorded
|
|
93
|
+
**deliberately, through the engine** — never by hand-editing \`.vfkb/\` (a PreToolUse hook gates that).
|
|
94
|
+
|
|
95
|
+
- **Session start** injects the resume digest + knowledge bundle automatically (SessionStart hook).
|
|
96
|
+
- **Record knowledge** with the \`mcp__vfkb__kb_add\` tool (or \`node .vfkb/bin/bootstrap.mjs cli add …\`):
|
|
97
|
+
\`decision\`, \`fact\`, \`gotcha\`, \`pattern\`, \`link\` — put a decision's rationale in its text.
|
|
98
|
+
**Capture load-bearing decisions immediately — don't defer.**
|
|
99
|
+
- Only \`.vfkb/entries.jsonl\`, \`.vfkb/manifest.json\`, and \`.vfkb/bin/\` are committed;
|
|
100
|
+
\`.vfkb/index-meta.json\`, \`.sessions/\`, \`.signals/\` are derived/gitignored.
|
|
101
|
+
|
|
102
|
+
Two env vars: **\`VFKB_DATA_DIR\`** = this repo's brain (\`.vfkb\`, set by the wiring) · **\`VFKB_BUNDLE_DIR\`**
|
|
103
|
+
= the shared vfkb engine bundles — set it once per machine, e.g. \`export VFKB_BUNDLE_DIR=/path/to/vfkb/dist/bundles\`.
|
|
104
|
+
If it is unset, a session-start banner tells you; run \`vfkb doctor\` to check.
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
107
|
+
function readJson(path) {
|
|
108
|
+
if (!existsSync(path))
|
|
109
|
+
return undefined;
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function writeJson(path, value) {
|
|
118
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
|
|
119
|
+
}
|
|
120
|
+
function eventHasVfkb(arr) {
|
|
121
|
+
return JSON.stringify(arr ?? '').includes(BOOTSTRAP_REL);
|
|
122
|
+
}
|
|
123
|
+
export function initProject(root, opts = {}) {
|
|
124
|
+
const project = opts.project || basename(root) || 'project';
|
|
125
|
+
const changes = [];
|
|
126
|
+
// 1. Empty brain — NEVER clobber an existing one.
|
|
127
|
+
const brainDir = join(root, '.vfkb');
|
|
128
|
+
const entries = join(brainDir, 'entries.jsonl');
|
|
129
|
+
if (!existsSync(entries)) {
|
|
130
|
+
mkdirSync(brainDir, { recursive: true });
|
|
131
|
+
writeFileSync(entries, '');
|
|
132
|
+
changes.push({ path: '.vfkb/entries.jsonl', action: 'created' });
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
changes.push({ path: '.vfkb/entries.jsonl', action: 'skipped' });
|
|
136
|
+
}
|
|
137
|
+
// 1b. Brain↔engine version stamp (FR-4) — committed, engine-written.
|
|
138
|
+
changes.push({ path: '.vfkb/manifest.json', action: writeManifest(brainDir) });
|
|
139
|
+
// 1c. The committed bootstrap entry-point (ADR-0031) — write if absent or stale.
|
|
140
|
+
{
|
|
141
|
+
const binDir = join(brainDir, 'bin');
|
|
142
|
+
const path = join(binDir, 'bootstrap.mjs');
|
|
143
|
+
const existed = existsSync(path);
|
|
144
|
+
const same = existed && readFileSync(path, 'utf8') === BOOTSTRAP_SRC;
|
|
145
|
+
if (same) {
|
|
146
|
+
changes.push({ path: BOOTSTRAP_REL, action: 'skipped' });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
mkdirSync(binDir, { recursive: true });
|
|
150
|
+
writeFileSync(path, BOOTSTRAP_SRC);
|
|
151
|
+
changes.push({ path: BOOTSTRAP_REL, action: existed ? 'updated' : 'created' });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// 2. .mcp.json — register the vfkb MCP server (merge, keep other servers).
|
|
155
|
+
{
|
|
156
|
+
const path = join(root, '.mcp.json');
|
|
157
|
+
const existed = existsSync(path);
|
|
158
|
+
const cfg = readJson(path) ?? {};
|
|
159
|
+
cfg.mcpServers = cfg.mcpServers ?? {};
|
|
160
|
+
const desired = mcpConfig(project);
|
|
161
|
+
const same = JSON.stringify(cfg.mcpServers.vfkb) === JSON.stringify(desired);
|
|
162
|
+
if (same) {
|
|
163
|
+
changes.push({ path: '.mcp.json', action: 'skipped' });
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
cfg.mcpServers.vfkb = desired;
|
|
167
|
+
writeJson(path, cfg);
|
|
168
|
+
changes.push({ path: '.mcp.json', action: existed ? 'updated' : 'created' });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// 3. .claude/settings.json — the four hooks (merge per-event; don't duplicate).
|
|
172
|
+
// Drop any pre-existing vfkb entry and re-append the current form: this UPGRADES an
|
|
173
|
+
// older CWD-relative hook to the anchored one (issue #22) while keeping user hooks.
|
|
174
|
+
{
|
|
175
|
+
const dir = join(root, '.claude');
|
|
176
|
+
const path = join(dir, 'settings.json');
|
|
177
|
+
const existed = existsSync(path);
|
|
178
|
+
const cfg = readJson(path) ?? {};
|
|
179
|
+
cfg.hooks = cfg.hooks ?? {};
|
|
180
|
+
const want = settingsHooks(project);
|
|
181
|
+
let touched = false;
|
|
182
|
+
for (const event of Object.keys(want)) {
|
|
183
|
+
const raw = cfg.hooks[event];
|
|
184
|
+
const cur = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
185
|
+
const others = cur.filter((e) => !eventHasVfkb(e)); // keep non-vfkb (user) hooks
|
|
186
|
+
const desired = [...others, ...want[event]];
|
|
187
|
+
if (JSON.stringify(cur) === JSON.stringify(desired))
|
|
188
|
+
continue; // already current form
|
|
189
|
+
cfg.hooks[event] = desired;
|
|
190
|
+
touched = true;
|
|
191
|
+
}
|
|
192
|
+
if (touched) {
|
|
193
|
+
mkdirSync(dir, { recursive: true });
|
|
194
|
+
writeJson(path, cfg);
|
|
195
|
+
changes.push({ path: '.claude/settings.json', action: existed ? 'updated' : 'created' });
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
changes.push({ path: '.claude/settings.json', action: 'skipped' });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// 4. .gitignore — the derived/operational stanza (append once).
|
|
202
|
+
{
|
|
203
|
+
const path = join(root, '.gitignore');
|
|
204
|
+
const lines = ['.vfkb/index-meta.json', '.vfkb/.sessions/', '.vfkb/.signals/'];
|
|
205
|
+
const existed = existsSync(path);
|
|
206
|
+
const cur = existed ? readFileSync(path, 'utf8') : '';
|
|
207
|
+
const missing = lines.filter((l) => !cur.split(/\r?\n/).includes(l));
|
|
208
|
+
if (missing.length === 0) {
|
|
209
|
+
changes.push({ path: '.gitignore', action: 'skipped' });
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
const prefix = cur && !cur.endsWith('\n') ? '\n' : '';
|
|
213
|
+
const block = `${prefix}${cur ? '\n' : ''}# vfkb — derived/operational (only .vfkb/entries.jsonl is committed)\n${missing.join('\n')}\n`;
|
|
214
|
+
writeFileSync(path, cur + block);
|
|
215
|
+
changes.push({ path: '.gitignore', action: existed ? 'updated' : 'created' });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// 4b. .gitattributes — merge=union for the append-only brain (ADR-0041), so
|
|
219
|
+
// parallel branches that both appended entries union-merge instead of conflicting.
|
|
220
|
+
// Append once; never touches the consumer's other attributes. (GitHub's server-side
|
|
221
|
+
// merge ignores it — a local `git merge` remains the documented fallback, ADR-0041.)
|
|
222
|
+
{
|
|
223
|
+
const path = join(root, '.gitattributes');
|
|
224
|
+
const line = '.vfkb/entries.jsonl merge=union';
|
|
225
|
+
const existed = existsSync(path);
|
|
226
|
+
const cur = existed ? readFileSync(path, 'utf8') : '';
|
|
227
|
+
if (cur.split(/\r?\n/).includes(line)) {
|
|
228
|
+
changes.push({ path: '.gitattributes', action: 'skipped' });
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const prefix = cur && !cur.endsWith('\n') ? '\n' : '';
|
|
232
|
+
const block = `${prefix}${cur ? '\n' : ''}# vfkb — the append-only brain unions across branches (ADR-0041)\n${line}\n`;
|
|
233
|
+
writeFileSync(path, cur + block);
|
|
234
|
+
changes.push({ path: '.gitattributes', action: existed ? 'updated' : 'created' });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// 5. AGENTS.md — the parameterized "how we track work HERE" snippet (append once).
|
|
238
|
+
{
|
|
239
|
+
const path = join(root, 'AGENTS.md');
|
|
240
|
+
const existed = existsSync(path);
|
|
241
|
+
const cur = existed ? readFileSync(path, 'utf8') : '';
|
|
242
|
+
if (cur.includes(AGENTS_MARKER)) {
|
|
243
|
+
changes.push({ path: 'AGENTS.md', action: 'skipped' });
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const sep = cur && !cur.endsWith('\n') ? '\n\n' : cur ? '\n' : '';
|
|
247
|
+
writeFileSync(path, cur + sep + agentsSnippet(project));
|
|
248
|
+
changes.push({ path: 'AGENTS.md', action: existed ? 'updated' : 'created' });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return changes;
|
|
252
|
+
}
|
|
253
|
+
// The one step init cannot do for you (printed by the CLI).
|
|
254
|
+
export function approvalNotice(project) {
|
|
255
|
+
return [
|
|
256
|
+
`vfkb wired for project "${project}".`,
|
|
257
|
+
'',
|
|
258
|
+
'Next (one-time, manual):',
|
|
259
|
+
' 1. Set $VFKB_BUNDLE_DIR once per machine to the vfkb bundles dir, e.g.:',
|
|
260
|
+
' export VFKB_BUNDLE_DIR=/path/to/vfkb/dist/bundles # (run `npm run build:bundles` in the vfkb repo)',
|
|
261
|
+
' 2. Start `claude` in this repo and APPROVE the project MCP server + hooks when prompted (once).',
|
|
262
|
+
' 3. Commit the wiring + the empty brain: git add .mcp.json .claude .gitignore .gitattributes .vfkb AGENTS.md',
|
|
263
|
+
].join('\n');
|
|
264
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// FR-1 (ADR-0030) inner gate — `vfkb init` scaffolds a consumer repo correctly
|
|
2
|
+
// and is IDEMPOTENT (re-running changes nothing, never clobbers a brain, never
|
|
3
|
+
// duplicates the .gitignore stanza or the AGENTS.md snippet). The agent-driven
|
|
4
|
+
// consumer-onboarding L4 scenario is the capability-level DoD (ADR-0029).
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { initProject } from './init.js';
|
|
11
|
+
let root;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
root = mkdtempSync(join(tmpdir(), 'vfkb-init-'));
|
|
14
|
+
});
|
|
15
|
+
const read = (p) => readFileSync(join(root, p), 'utf8');
|
|
16
|
+
describe('vfkb init (FR-1)', () => {
|
|
17
|
+
it('scaffolds the portable wiring, gitignore stanza, empty brain, and snippet', () => {
|
|
18
|
+
const changes = initProject(root, { project: 'demo' });
|
|
19
|
+
const actions = Object.fromEntries(changes.map((c) => [c.path, c.action]));
|
|
20
|
+
expect(actions['.vfkb/entries.jsonl']).toBe('created');
|
|
21
|
+
expect(actions['.vfkb/manifest.json']).toBe('created');
|
|
22
|
+
expect(actions['.mcp.json']).toBe('created');
|
|
23
|
+
expect(actions['.claude/settings.json']).toBe('created');
|
|
24
|
+
expect(actions['.vfkb/bin/bootstrap.mjs']).toBe('created');
|
|
25
|
+
expect(actions['.gitignore']).toBe('created');
|
|
26
|
+
expect(actions['AGENTS.md']).toBe('created');
|
|
27
|
+
// .mcp.json — via the committed relative bootstrap (ADR-0031), project from the arg.
|
|
28
|
+
const mcp = JSON.parse(read('.mcp.json'));
|
|
29
|
+
expect(mcp.mcpServers.vfkb.args).toEqual(['.vfkb/bin/bootstrap.mjs', 'mcp']);
|
|
30
|
+
expect(mcp.mcpServers.vfkb.env).toEqual({ VFKB_DATA_DIR: '.vfkb', VFKB_PROJECT: 'demo' });
|
|
31
|
+
// .claude/settings.json — the three hooks, via the bootstrap, no relative dist/ path.
|
|
32
|
+
const settings = JSON.parse(read('.claude/settings.json'));
|
|
33
|
+
expect(Object.keys(settings.hooks).sort()).toEqual([
|
|
34
|
+
'PreToolUse',
|
|
35
|
+
'SessionEnd',
|
|
36
|
+
'SessionStart',
|
|
37
|
+
'Stop',
|
|
38
|
+
]);
|
|
39
|
+
expect(settings.hooks.SessionEnd[0].hooks[0].command).toContain('cli hook session-end');
|
|
40
|
+
const blob = JSON.stringify(settings);
|
|
41
|
+
expect(blob).toContain('.vfkb/bin/bootstrap.mjs cli hook');
|
|
42
|
+
expect(blob).toContain('VFKB_PROJECT=demo');
|
|
43
|
+
expect(blob).not.toContain('dist/cli.js');
|
|
44
|
+
expect(settings.hooks.PreToolUse[0].matcher).toBe('Write|Edit|MultiEdit');
|
|
45
|
+
// issue #22 / ADR-0035 — hooks anchor to $CLAUDE_PROJECT_DIR (CWD-independent),
|
|
46
|
+
// NOT a bare CWD-relative path that breaks when the session cd's out of the root.
|
|
47
|
+
expect(blob).toContain('${CLAUDE_PROJECT_DIR:-.}/.vfkb/bin/bootstrap.mjs');
|
|
48
|
+
expect(blob).toContain('VFKB_DATA_DIR=${CLAUDE_PROJECT_DIR:-.}/.vfkb');
|
|
49
|
+
expect(blob).not.toContain('node .vfkb/bin/bootstrap.mjs'); // the old bare-relative form
|
|
50
|
+
// the bootstrap is a committed, self-contained guard.
|
|
51
|
+
const boot = read('.vfkb/bin/bootstrap.mjs');
|
|
52
|
+
expect(boot).toContain('VFKB_BUNDLE_DIR');
|
|
53
|
+
expect(boot).not.toContain("from './"); // no engine import — must run standalone
|
|
54
|
+
// version stamp (FR-4).
|
|
55
|
+
expect(JSON.parse(read('.vfkb/manifest.json')).schema_version).toBe(1);
|
|
56
|
+
// empty brain + gitignore stanza + snippet marker.
|
|
57
|
+
expect(read('.vfkb/entries.jsonl')).toBe('');
|
|
58
|
+
expect(read('.gitignore')).toContain('.vfkb/.sessions/');
|
|
59
|
+
expect(read('AGENTS.md')).toContain('How we track work HERE');
|
|
60
|
+
});
|
|
61
|
+
it('defaults the project name to the directory basename', () => {
|
|
62
|
+
initProject(root, {});
|
|
63
|
+
const mcp = JSON.parse(read('.mcp.json'));
|
|
64
|
+
expect(mcp.mcpServers.vfkb.env.VFKB_PROJECT).toBe(root.split('/').pop());
|
|
65
|
+
});
|
|
66
|
+
it('is idempotent — a second run changes nothing and does not duplicate', () => {
|
|
67
|
+
initProject(root, { project: 'demo' });
|
|
68
|
+
const second = initProject(root, { project: 'demo' });
|
|
69
|
+
expect(second.every((c) => c.action === 'skipped')).toBe(true);
|
|
70
|
+
// gitignore stanza appears exactly once.
|
|
71
|
+
const gi = read('.gitignore');
|
|
72
|
+
expect(gi.split('.vfkb/.sessions/').length - 1).toBe(1);
|
|
73
|
+
// snippet marker appears exactly once.
|
|
74
|
+
const agents = read('AGENTS.md');
|
|
75
|
+
expect(agents.split('vfkb:how-we-track-work').length - 1).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
it('emits the ADR-0041 merge=union attribute for the brain, append-once (V2-3 consumer follow-up)', () => {
|
|
78
|
+
const changes = initProject(root, { project: 'demo' });
|
|
79
|
+
expect(changes.find((c) => c.path === '.gitattributes')?.action).toBe('created');
|
|
80
|
+
expect(read('.gitattributes')).toContain('.vfkb/entries.jsonl merge=union');
|
|
81
|
+
// appends to an existing .gitattributes without touching its content…
|
|
82
|
+
writeFileSync(join(root, '.gitattributes'), '*.png binary\n');
|
|
83
|
+
const again = initProject(root, { project: 'demo' });
|
|
84
|
+
expect(again.find((c) => c.path === '.gitattributes')?.action).toBe('updated');
|
|
85
|
+
const ga = read('.gitattributes');
|
|
86
|
+
expect(ga).toContain('*.png binary');
|
|
87
|
+
expect(ga.split('.vfkb/entries.jsonl merge=union').length - 1).toBe(1);
|
|
88
|
+
// …and is idempotent once present.
|
|
89
|
+
const third = initProject(root, { project: 'demo' });
|
|
90
|
+
expect(third.find((c) => c.path === '.gitattributes')?.action).toBe('skipped');
|
|
91
|
+
});
|
|
92
|
+
it('never clobbers an existing brain', () => {
|
|
93
|
+
initProject(root, { project: 'demo' });
|
|
94
|
+
writeFileSync(join(root, '.vfkb', 'entries.jsonl'), '{"id":"keep"}\n');
|
|
95
|
+
const again = initProject(root, { project: 'demo' });
|
|
96
|
+
expect(again.find((c) => c.path === '.vfkb/entries.jsonl')?.action).toBe('skipped');
|
|
97
|
+
expect(read('.vfkb/entries.jsonl')).toContain('keep');
|
|
98
|
+
});
|
|
99
|
+
it('merges into an existing .mcp.json without dropping other servers', () => {
|
|
100
|
+
writeFileSync(join(root, '.mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'x' } } }));
|
|
101
|
+
initProject(root, { project: 'demo' });
|
|
102
|
+
const mcp = JSON.parse(read('.mcp.json'));
|
|
103
|
+
expect(mcp.mcpServers.other).toEqual({ command: 'x' });
|
|
104
|
+
expect(mcp.mcpServers.vfkb).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
it('upgrades an existing CWD-relative vfkb hook to the anchored form, keeping user hooks (issue #22)', () => {
|
|
107
|
+
const dir = join(root, '.claude');
|
|
108
|
+
mkdirSync(dir, { recursive: true });
|
|
109
|
+
writeFileSync(join(dir, 'settings.json'), JSON.stringify({
|
|
110
|
+
hooks: {
|
|
111
|
+
Stop: [
|
|
112
|
+
{ hooks: [{ type: 'command', command: 'echo user-hook' }] }, // a user's own hook — must survive
|
|
113
|
+
{ hooks: [{ type: 'command', command: 'VFKB_DATA_DIR=.vfkb VFKB_PROJECT=demo node .vfkb/bin/bootstrap.mjs cli hook stop' }] },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
const changes = initProject(root, { project: 'demo' });
|
|
118
|
+
expect(changes.find((c) => c.path === '.claude/settings.json')?.action).toBe('updated');
|
|
119
|
+
const settings = JSON.parse(read('.claude/settings.json'));
|
|
120
|
+
const cmds = settings.hooks.Stop.map((e) => e.hooks[0].command);
|
|
121
|
+
expect(cmds).toContain('echo user-hook'); // user hook preserved
|
|
122
|
+
expect(cmds.some((c) => c.includes('${CLAUDE_PROJECT_DIR:-.}/.vfkb/bin/bootstrap.mjs cli hook stop'))).toBe(true);
|
|
123
|
+
expect(cmds).not.toContain('VFKB_DATA_DIR=.vfkb VFKB_PROJECT=demo node .vfkb/bin/bootstrap.mjs cli hook stop'); // old form gone
|
|
124
|
+
// and re-running is now idempotent on the upgraded form
|
|
125
|
+
const again = initProject(root, { project: 'demo' });
|
|
126
|
+
expect(again.find((c) => c.path === '.claude/settings.json')?.action).toBe('skipped');
|
|
127
|
+
});
|
|
128
|
+
it('the emitted hook resolves from a foreign CWD; the old bare-relative form does not (issue #22 DoD)', () => {
|
|
129
|
+
initProject(root, { project: 'demo' });
|
|
130
|
+
const settings = JSON.parse(read('.claude/settings.json'));
|
|
131
|
+
const anchored = settings.hooks.SessionStart[0].hooks[0].command;
|
|
132
|
+
const foreign = mkdtempSync(join(tmpdir(), 'vfkb-cwd-')); // a dir that is NOT the repo root
|
|
133
|
+
const env = { ...process.env, CLAUDE_PROJECT_DIR: root };
|
|
134
|
+
delete env.VFKB_BUNDLE_DIR; // force the bootstrap's graceful INACTIVE path (no engine needed)
|
|
135
|
+
delete env.VFKB_HOME;
|
|
136
|
+
// Anchored form: the bootstrap is FOUND despite the foreign CWD -> emits the
|
|
137
|
+
// SessionStart INACTIVE payload and exits 0.
|
|
138
|
+
const ok = spawnSync('sh', ['-c', anchored], { cwd: foreign, env, encoding: 'utf8' });
|
|
139
|
+
expect(ok.status).toBe(0);
|
|
140
|
+
expect(ok.stdout).toContain('INACTIVE');
|
|
141
|
+
// Contrast (proves the bug + that this gate CAN fail): the old bare-relative form
|
|
142
|
+
// from the same foreign CWD -> MODULE_NOT_FOUND, non-zero exit.
|
|
143
|
+
const bare = 'VFKB_DATA_DIR=.vfkb VFKB_PROJECT=demo node .vfkb/bin/bootstrap.mjs cli hook session-start';
|
|
144
|
+
const bad = spawnSync('sh', ['-c', bare], { cwd: foreign, env, encoding: 'utf8' });
|
|
145
|
+
expect(bad.status).not.toBe(0);
|
|
146
|
+
expect(bad.stderr).toMatch(/Cannot find module|MODULE_NOT_FOUND/);
|
|
147
|
+
});
|
|
148
|
+
});
|