@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/dist/lock.js ADDED
@@ -0,0 +1,158 @@
1
+ // ADR-0040 (v2): a vfkb-native advisory lock for the read-decide-append critical
2
+ // section. Held INTERNALLY by the engine around operations that read state before
3
+ // writing (supersede, transition, fluid edits, provenance re-stamps) — never something
4
+ // callers must remember to acquire. Scoped to one VFKB_DATA_DIR.
5
+ //
6
+ // Mechanism (ADR-0013: no native dep, so no real flock): a plain lockfile taken with
7
+ // O_EXCL exclusive-create at <brain>/.lock, carrying {pid, at, session_id} so
8
+ // contention is observable (ADR-0039's registry tells you who). Staleness: a holder
9
+ // whose pid is dead, or older than STALE_MS, is broken (a crashed process must not
10
+ // wedge every future engine op). Acquisition is a bounded retry loop; on timeout the
11
+ // engine proceeds WITHOUT the lock (fail-open: liveness over strictness — the same
12
+ // posture as the hooks; the pathological case is a >5s hold, which staleness already
13
+ // bounds at 10s).
14
+ //
15
+ // Re-entrancy: node is single-threaded, so a process-local depth counter suffices —
16
+ // should engine ops ever compose in-process, the inner op runs under the outer
17
+ // acquisition instead of deadlocking on its own lockfile.
18
+ //
19
+ // Release is OWNER-CHECKED (review gate F1): the holder file carries a unique token;
20
+ // release unlinks only if the file still holds OUR token. Without this, a holder whose
21
+ // critical section outlives STALE_MS would unlink the NEXT holder's fresh lock and
22
+ // admit an unbounded chain of overlapping writers.
23
+ import { openSync, closeSync, writeSync, readFileSync, unlinkSync, mkdirSync, statSync } from 'node:fs';
24
+ import { randomBytes } from 'node:crypto';
25
+ import { join } from 'node:path';
26
+ import { brainDir } from './storage.js';
27
+ const STALE_MS = 10_000;
28
+ const ACQUIRE_TIMEOUT_MS = 5_000;
29
+ const RETRY_MS = 25;
30
+ let depth = 0; // process-local re-entrancy (single-threaded)
31
+ function sleepSync(ms) {
32
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
33
+ }
34
+ function pidAlive(pid) {
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ }
39
+ catch (err) {
40
+ // EPERM = the process EXISTS but belongs to another user (shared brain dirs) —
41
+ // that is an alive holder, not a dead one (review gate F2).
42
+ return err.code === 'EPERM';
43
+ }
44
+ }
45
+ // Test-only injectable pause (ADR-0040 DoD): called by the engine INSIDE the critical
46
+ // section, between the read and the append, so a test can deterministically force two
47
+ // processes' read-decide-append sequences to overlap in time. No-op unless the env
48
+ // knob is set; never set it outside a test.
49
+ export function testHoldForRace() {
50
+ const ms = Number(process.env.VFKB_TEST_LOCK_HOLD_MS || 0);
51
+ if (ms > 0)
52
+ sleepSync(ms);
53
+ }
54
+ export function withBrainLock(fn) {
55
+ // Escape hatch for the DoD's must-fail arm ONLY (proves the race is real).
56
+ if (process.env.VFKB_LOCK_DISABLED === '1')
57
+ return fn();
58
+ if (depth > 0) {
59
+ depth++;
60
+ try {
61
+ return fn();
62
+ }
63
+ finally {
64
+ depth--;
65
+ }
66
+ }
67
+ const lockPath = join(brainDir(), '.lock');
68
+ const started = Date.now();
69
+ let contended = false;
70
+ // Unique per-acquisition token: release only unlinks a lock that is still OURS.
71
+ const ourContent = JSON.stringify({
72
+ pid: process.pid,
73
+ at: new Date().toISOString(),
74
+ session_id: process.env.KB_SESSION_ID,
75
+ token: randomBytes(6).toString('hex'),
76
+ });
77
+ for (;;) {
78
+ try {
79
+ mkdirSync(brainDir(), { recursive: true });
80
+ const fd = openSync(lockPath, 'wx'); // O_EXCL: atomic exclusive create
81
+ writeSync(fd, ourContent);
82
+ closeSync(fd);
83
+ break; // acquired
84
+ }
85
+ catch (err) {
86
+ if (err.code !== 'EEXIST')
87
+ throw err;
88
+ // Judge the holder. CAREFUL: a lockfile can legitimately be caught in the
89
+ // microsecond window between its exclusive-create and its content write —
90
+ // unparseable content is NOT evidence of staleness. Fall back to the file's
91
+ // mtime: a fresh-but-empty lock is waited on; only a demonstrably OLD or
92
+ // dead-holder lock is broken.
93
+ let holder;
94
+ let raw = '';
95
+ try {
96
+ raw = readFileSync(lockPath, 'utf8');
97
+ holder = JSON.parse(raw);
98
+ }
99
+ catch {
100
+ holder = undefined;
101
+ }
102
+ let age;
103
+ if (holder?.at) {
104
+ age = Date.now() - Date.parse(holder.at);
105
+ }
106
+ else {
107
+ try {
108
+ age = Date.now() - statSync(lockPath).mtimeMs;
109
+ }
110
+ catch {
111
+ continue; // lock vanished between EEXIST and stat → retry acquisition now
112
+ }
113
+ }
114
+ const dead = typeof holder?.pid === 'number' && !pidAlive(holder.pid);
115
+ if (dead || age > STALE_MS) {
116
+ // Confirm before breaking: re-read; only unlink if the content is still the
117
+ // same lock we judged stale (shrinks the window where a waiter could break a
118
+ // JUST-acquired fresh lock; not perfectly atomic without flock — accepted,
119
+ // documented ADR-0013 constraint).
120
+ try {
121
+ if (readFileSync(lockPath, 'utf8') !== raw)
122
+ continue;
123
+ unlinkSync(lockPath);
124
+ }
125
+ catch {
126
+ /* raced with the holder's own release — loop retries either way */
127
+ }
128
+ process.stderr.write(`vfkb: broke a stale brain lock (pid ${holder?.pid ?? '?'}${holder?.session_id ? `, session ${holder.session_id}` : ''})\n`);
129
+ continue;
130
+ }
131
+ if (Date.now() - started > ACQUIRE_TIMEOUT_MS) {
132
+ process.stderr.write(`vfkb: brain lock busy >${ACQUIRE_TIMEOUT_MS}ms (pid ${holder?.pid ?? '?'}${holder?.session_id ? `, session ${holder.session_id}` : ''}) — proceeding without the lock (fail-open)\n`);
133
+ return fn();
134
+ }
135
+ contended = true;
136
+ sleepSync(RETRY_MS);
137
+ }
138
+ }
139
+ if (contended) {
140
+ process.stderr.write('vfkb: brain lock acquired after contention\n');
141
+ }
142
+ depth = 1;
143
+ try {
144
+ return fn();
145
+ }
146
+ finally {
147
+ depth = 0;
148
+ try {
149
+ // Owner-checked release (F1): if a waiter judged us stale and took over, the
150
+ // file now carries THEIR token — leave it alone rather than freeing their lock.
151
+ if (readFileSync(lockPath, 'utf8') === ourContent)
152
+ unlinkSync(lockPath);
153
+ }
154
+ catch {
155
+ /* already broken as stale by a waiter — nothing to release */
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,38 @@
1
+ // FR-4 (ADR-0030) — the brain↔engine version stamp: .vfkb/manifest.json.
2
+ //
3
+ // A small COMMITTED file (ADR-0030 locks it as committed, distinct from the
4
+ // derived/gitignored index-meta.json) recording which engine a brain targets, so a
5
+ // consumer can't silently bind to a stale/incompatible engine. Engine-written only
6
+ // (the write-gate applies); never hand-edited.
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { SCHEMA_VERSION, ENGINE_VERSION, ENGINE_COMMIT } from './version.js';
10
+ export function manifestPath(brainDir) {
11
+ return join(brainDir, 'manifest.json');
12
+ }
13
+ export function currentManifest() {
14
+ return { schema_version: SCHEMA_VERSION, engine_version: ENGINE_VERSION, engine_commit: ENGINE_COMMIT };
15
+ }
16
+ export function readManifest(brainDir) {
17
+ const p = manifestPath(brainDir);
18
+ if (!existsSync(p))
19
+ return undefined;
20
+ try {
21
+ return JSON.parse(readFileSync(p, 'utf8'));
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ // Write the stamp if absent or stale. Returns 'created' | 'updated' | 'skipped'.
28
+ export function writeManifest(brainDir) {
29
+ const p = manifestPath(brainDir);
30
+ const existed = existsSync(p);
31
+ const cur = readManifest(brainDir);
32
+ const next = currentManifest();
33
+ if (cur && JSON.stringify(cur) === JSON.stringify(next))
34
+ return 'skipped';
35
+ mkdirSync(dirname(p), { recursive: true });
36
+ writeFileSync(p, JSON.stringify(next, null, 2) + '\n');
37
+ return existed ? 'updated' : 'created';
38
+ }
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ // vfkb MCP server — the cross-harness PULL baseline (D5a / ADR-0015). A tight set
3
+ // of scoped tools over the same engine the auto-layer faces use. Uses the OFFICIAL
4
+ // @modelcontextprotocol/sdk (verified contract — not a hand-rolled JSON-RPC). The
5
+ // engine stays zero-dep; this face opts into the SDK.
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { z } from 'zod';
9
+ import { addEntry, deriveTrust, readAll, renderContext, renderContextMap, renderResume, supersede, transitionDecision, } from './engine.js';
10
+ import { query, queryExplained } from './read.js';
11
+ import { brainDir, defaultProject } from './storage.js';
12
+ import { ENGINE_VERSION } from './version.js';
13
+ // Default page size for the read tools. An MCP tool result has a hard token
14
+ // budget; an unbounded `query()` returns the whole candidate pool (up to ~200
15
+ // full entries → ~130k chars), which overflows and errors the call. Cap the
16
+ // pull-face here so a broad query degrades to "freshest relevant top-N", never
17
+ // a hard error. Callers can still ask for a larger explicit `limit`.
18
+ const SEARCH_DEFAULT_LIMIT = 25;
19
+ const ENTRY_TYPE = z.enum(['fact', 'decision', 'gotcha', 'pattern', 'link']);
20
+ const ZONE = z.enum(['incoming', 'established', 'archive']);
21
+ const STATUS = z.enum(['proposed', 'accepted', 'deprecated', 'superseded']);
22
+ const ROLE = z.enum(['architect', 'pm', 'executor', 'judge', 'human', 'init', 'import']);
23
+ function line(e) {
24
+ const adr = typeof e.adr_no === 'number' ? ` ADR-${String(e.adr_no).padStart(4, '0')}` : '';
25
+ const st = e.status ? `/${e.status}` : '';
26
+ // ADR-0042 §3: a structural contradiction reference is surfaced on every read line.
27
+ const contra = e.refs?.contradicts?.length ? ` ⚔ contradicts ${e.refs.contradicts.join(',')}` : '';
28
+ return `${e.id} [${e.type} ${deriveTrust(e.author.role)}/${e.provenance.status}${st}${adr}]${contra} ${e.text}`;
29
+ }
30
+ function text(s) {
31
+ return { content: [{ type: 'text', text: s }] };
32
+ }
33
+ // RFC-002: render an empty search as a cause-distinguished, honest no-match. The
34
+ // engine returns structured truth (the diagnosis); this face turns it into words +
35
+ // the agent contract — an empty result is NOT a licence to answer from model priors.
36
+ function renderNoMatch(d, q) {
37
+ const subject = q ? `“${q}”` : 'that filter';
38
+ const contract = 'No recorded entry was found — do NOT present model-prior knowledge as if it were recorded. Say none was found, or rephrase/broaden.';
39
+ switch (d.reason) {
40
+ case 'empty_topic':
41
+ return `(no matches) — nothing recorded matches ${subject}. ${contract}`;
42
+ case 'no_match': {
43
+ const b = d.belowFloor;
44
+ const hint = b
45
+ ? ` Closest below the relevance floor (LOW CONFIDENCE — matched ${b.matched}/${b.queryTerms} terms, NOT a confirmed answer): ${line(b.entry)}`
46
+ : '';
47
+ return `(no matches) — recorded entries share wording with ${subject} but none cleared the relevance floor.${hint} ${contract}`;
48
+ }
49
+ case 'all_filtered': {
50
+ const parts = Object.entries(d.filteredOut ?? {})
51
+ .map(([k, v]) => `${v} ${k}`)
52
+ .join(', ');
53
+ const stale = (d.filteredOut?.stale ?? 0) + (d.filteredOut?.superseded ?? 0);
54
+ const staleNote = stale
55
+ ? ` ${stale} match(es) exist but are STALE/SUPERSEDED — the recorded knowledge here is out of date; pass include_stale/include_superseded to inspect it.`
56
+ : '';
57
+ return `(no matches) — ${d.candidates} candidate(s) matched ${subject} but were filtered out (${parts}).${staleNote} ${contract}`;
58
+ }
59
+ }
60
+ }
61
+ function tags(csv) {
62
+ return csv ? csv.split(',').map((t) => t.trim()).filter(Boolean) : undefined;
63
+ }
64
+ // In the fleet, who wrote an entry must be stamped by the HARNESS, not self-reported
65
+ // by the model (VERIFIED = observed, not asserted — applied to provenance). When the
66
+ // pod sets VFKB_ROLE, it is authoritative and overrides any model-supplied `role`.
67
+ // Outside the fleet (no env), the tool param / per-tool default applies as before.
68
+ function envRole() {
69
+ const p = ROLE.safeParse(process.env.VFKB_ROLE);
70
+ return p.success ? p.data : undefined;
71
+ }
72
+ // Render defaults resolve the real project name via the shared derivation
73
+ // (VFKB_PROJECT, else the brain dir's owning repo, else $CLAUDE_PROJECT_DIR/cwd)
74
+ // — the engine-wide successor of this server's Track 9 Q0 local fix.
75
+ const projectName = defaultProject;
76
+ const server = new McpServer({ name: 'vfkb', version: ENGINE_VERSION });
77
+ server.registerTool('kb_search', {
78
+ description: 'Search and filter project knowledge. Returns the freshest relevant entries (stale/superseded excluded by default). ' +
79
+ 'Pass verified=true to get ONLY human-verified knowledge (excludes unverified agent-authored entries) — the trust filter. ' +
80
+ 'An empty result is reported as an honest, cause-distinguished no-match (nothing recorded / below relevance floor / all matches filtered as stale) — it means no recorded entry was found, NOT a licence to answer from model priors as if recorded.',
81
+ inputSchema: {
82
+ text: z.string().optional().describe('free-text query'),
83
+ type: ENTRY_TYPE.optional(),
84
+ zone: ZONE.optional(),
85
+ status: STATUS.optional().describe('effective decision status'),
86
+ tags: z.string().optional().describe('comma-separated; entry must have ALL'),
87
+ author_role: ROLE.optional(),
88
+ verified: z
89
+ .boolean()
90
+ .optional()
91
+ .describe('true → return ONLY verified knowledge (provenance verified); excludes unverified/agent-authored entries'),
92
+ limit: z.number().int().positive().optional(),
93
+ include_stale: z.boolean().optional(),
94
+ include_superseded: z.boolean().optional(),
95
+ },
96
+ }, async (a) => {
97
+ const { results, diagnosis } = queryExplained({
98
+ text: a.text,
99
+ type: a.type,
100
+ zone: a.zone,
101
+ status: a.status,
102
+ tags: tags(a.tags),
103
+ authorRole: a.author_role,
104
+ verifiedOnly: a.verified,
105
+ limit: a.limit ?? SEARCH_DEFAULT_LIMIT,
106
+ includeStale: a.include_stale,
107
+ includeSuperseded: a.include_superseded,
108
+ });
109
+ return text(results.length ? results.map(line).join('\n') : renderNoMatch(diagnosis, a.text));
110
+ });
111
+ server.registerTool('kb_list', {
112
+ description: 'List entries by structural filter (no text search).',
113
+ inputSchema: {
114
+ type: ENTRY_TYPE.optional(),
115
+ zone: ZONE.optional(),
116
+ limit: z.number().int().positive().optional(),
117
+ },
118
+ }, async (a) => {
119
+ const r = query({ type: a.type, zone: a.zone, limit: a.limit ?? SEARCH_DEFAULT_LIMIT });
120
+ return text(r.length ? r.map(line).join('\n') : '(empty)');
121
+ });
122
+ server.registerTool('kb_get', {
123
+ description: 'Fetch a single entry by id (full JSON).',
124
+ inputSchema: { id: z.string() },
125
+ }, async ({ id }) => {
126
+ const e = readAll().find((x) => x.id === id);
127
+ return text(e ? JSON.stringify(e, null, 2) : `no such entry: ${id}`);
128
+ });
129
+ server.registerTool('kb_map', {
130
+ description: 'The derived Context Map — what knowledge exists and how to navigate it.',
131
+ inputSchema: {},
132
+ }, async () => text(renderContextMap()));
133
+ server.registerTool('kb_context', {
134
+ description: "The project context document — the agent's first read. Orients you to the project: " +
135
+ 'its job-to-be-done, architecture, tech profile, conventions, the load-bearing decisions, ' +
136
+ 'and links. Read this BEFORE acting on an unfamiliar project. Assembled from the authored ' +
137
+ 'context spine + the live Constitution/Map/decisions (always current).',
138
+ inputSchema: {},
139
+ }, async () => text(renderContext(projectName())));
140
+ server.registerTool('kb_add', {
141
+ description: 'Add an entry. Fluid types (fact/gotcha/pattern/link) are editable; decisions are immutable (supersede to change). New decisions default to status=proposed (an RFC).',
142
+ inputSchema: {
143
+ type: ENTRY_TYPE,
144
+ text: z.string(),
145
+ why: z.string().optional().describe('rationale; stored structurally AND folded into the text as a "Why: …" line (esp. for decisions)'),
146
+ tags: z.string().optional().describe('comma-separated'),
147
+ path: z
148
+ .string()
149
+ .optional()
150
+ .describe('link target (path or URL) for type=link — folded into the text as "<text> → <path>" ' +
151
+ '(link entries have no structural target field); rejected for other types'),
152
+ contradicts: z.string().optional().describe('comma-separated ids of entries this one contradicts (structural reference, ADR-0042)'),
153
+ role: ROLE.optional().describe('author role; defaults to executor (agent)'),
154
+ status: STATUS.optional().describe('decision family only'),
155
+ constitutional: z.boolean().optional().describe('decision family only (ADR-0008)'),
156
+ },
157
+ }, async (a) => {
158
+ if (a.path !== undefined && a.type !== 'link') {
159
+ throw new Error(`kb_add: 'path' is only valid with type=link (got type=${a.type})`);
160
+ }
161
+ if (a.path !== undefined && !a.path.trim()) {
162
+ // An empty target would silently record a link pointing nowhere — the
163
+ // exact instance-5 failure this parameter exists to prevent.
164
+ throw new Error("kb_add: 'path' must be a non-empty path or URL");
165
+ }
166
+ const body = a.path !== undefined ? `${a.text.trim()} → ${a.path.trim()}` : a.text;
167
+ const e = addEntry(a.type, body, {
168
+ role: envRole() ?? a.role ?? 'executor',
169
+ why: a.why,
170
+ tags: tags(a.tags),
171
+ contradicts: tags(a.contradicts),
172
+ status: a.status,
173
+ constitutional: a.constitutional,
174
+ });
175
+ return text(`added ${line(e)}`);
176
+ });
177
+ server.registerTool('kb_supersede', {
178
+ description: 'Supersede a decision with a new one (additive edge; the old is never edited and stops being injected).',
179
+ inputSchema: {
180
+ old_id: z.string(),
181
+ text: z.string(),
182
+ why: z
183
+ .string()
184
+ .optional()
185
+ .describe('rationale for the new decision; stored structurally AND folded into its text as a "Why: …" line'),
186
+ role: ROLE.optional(),
187
+ },
188
+ }, async (a) => {
189
+ const e = supersede(a.old_id, a.text, { role: envRole() ?? a.role ?? 'architect', why: a.why });
190
+ return text(`superseded ${a.old_id} -> ${line(e)}`);
191
+ });
192
+ server.registerTool('kb_transition', {
193
+ description: 'Transition a decision through its lifecycle (proposed -> accepted -> deprecated). Content is preserved; `superseded` is set by kb_supersede, not here.',
194
+ inputSchema: { id: z.string(), status: z.enum(['proposed', 'accepted', 'deprecated']) },
195
+ }, async (a) => {
196
+ const e = transitionDecision(a.id, a.status);
197
+ return text(`transitioned ${line(e)}`);
198
+ });
199
+ server.registerTool('kb_resume', {
200
+ description: 'Session-continuity resume (ADR-0020): the prior session’s derived digest (what was added/superseded/injected/captured — recomputed from the brain, so never stale) + the live knowledge bundle. Pull this to see where the last session left off.',
201
+ inputSchema: {},
202
+ }, async () => text(renderResume(projectName())));
203
+ async function main() {
204
+ const transport = new StdioServerTransport();
205
+ await server.connect(transport);
206
+ // stdout is the protocol channel — never write to it. Log to stderr.
207
+ // brainDir() resolves VFKB_DATA_DIR (canonical) → VFKB_DIR (deprecated alias) → ~/.vfkb.
208
+ process.stderr.write(`vfkb MCP server up (v${ENGINE_VERSION}, brain: ${brainDir()})\n`);
209
+ }
210
+ main().catch((err) => {
211
+ process.stderr.write(`vfkb MCP fatal: ${err}\n`);
212
+ process.exit(1);
213
+ });
@@ -0,0 +1,117 @@
1
+ // vfkb Pi face — the in-process TS extension (ADR-0015 cross-harness auto-layer).
2
+ // One engine, two faces: this is the Pi side; src/cli.ts + hooks are the Claude
3
+ // Code side. Same engine.ts underneath (LSP — every tier calls the same code).
4
+ //
5
+ // Contracts copied verbatim from the verified mykb spike (src/extension/*):
6
+ // before_agent_start -> { systemPrompt } : APPEND the bundle (Tier A inject)
7
+ // context -> { messages:[{role:'custom',...}] } : per-turn (Tier C, Pi-only)
8
+ // tool_call -> { toolName, input } : gate brain writes (pre-execution block)
9
+ // tool_execution_end -> { toolName, result, isError } : capture WITH result (Tier B, D-iv)
10
+ // session_shutdown -> git save
11
+ import { mkdirSync } from 'node:fs';
12
+ import { brainDir, captureToolCall, currentInjectableIds, renderContextDelta, renderResume, } from './engine.js';
13
+ import { SessionState } from './session.js';
14
+ import { defaultProject } from './storage.js';
15
+ import { isBrainWrite, GATING_REASON } from './gating.js';
16
+ import { save } from './git.js';
17
+ function project() {
18
+ return defaultProject();
19
+ }
20
+ // Reduce a pi tool result (AgentToolResult-ish: { content:[{text}], details } | string |
21
+ // object) to a bounded text summary for capture. classifyToolOutcome caps length; here we
22
+ // just extract the most useful string.
23
+ function resultText(r) {
24
+ if (r == null)
25
+ return '';
26
+ if (typeof r === 'string')
27
+ return r;
28
+ if (typeof r === 'object') {
29
+ const o = r;
30
+ if (Array.isArray(o.content)) {
31
+ return o.content
32
+ .map((c) => (c && typeof c.text === 'string' ? c.text : ''))
33
+ .join(' ')
34
+ .trim();
35
+ }
36
+ return JSON.stringify(o);
37
+ }
38
+ return String(r);
39
+ }
40
+ export default function (pi) {
41
+ mkdirSync(brainDir(), { recursive: true });
42
+ const session = SessionState.load(); // isolated by KB_SESSION_ID (L4)
43
+ pi.on('session_start', async () => {
44
+ mkdirSync(brainDir(), { recursive: true });
45
+ });
46
+ // Tier A — session-start injection. The payload is the RESUME render (ADR-0020 pt 5):
47
+ // the prior-session continuity digest + the live knowledge bundle, both derived at
48
+ // render time. Parity with the claude SessionStart hook (`hook session-start`), which
49
+ // already injects renderResume — the pi half previously injected only the bundle, so
50
+ // cross-session continuity was undelivered here. Mark the bundle's entries injected so
51
+ // the per-turn delta won't repeat them.
52
+ pi.on('before_agent_start', async (...args) => {
53
+ const e = args[0] || {};
54
+ const current = e.systemPrompt || '';
55
+ const resume = renderResume(project(), session);
56
+ session.markInjected(currentInjectableIds());
57
+ session.save();
58
+ return { systemPrompt: current + '\n\n' + resume };
59
+ });
60
+ // Tier C — per-turn delta (Pi-only). Inject ONLY entries new since the last turn
61
+ // (session-deduped); skip when nothing changed.
62
+ pi.on('context', async (...args) => {
63
+ const event = args[0];
64
+ const messages = event?.messages ?? [];
65
+ const delta = renderContextDelta(session, project());
66
+ session.save();
67
+ if (!delta)
68
+ return undefined; // no-op turn
69
+ return { messages: [{ role: 'custom', content: delta }, ...messages] };
70
+ });
71
+ // Tool-gating (pre-execution). Gate brain-file writes here — the block MUST happen
72
+ // before the tool runs, so a blocked write never reaches tool_execution_end and is
73
+ // never captured. Capture itself moved to tool_execution_end (D-iv): tool_call has no
74
+ // result, so capturing here recorded every failure as capture:ok and the distiller
75
+ // could never act on a LIVE pi failure (the 2026-06-27 finding).
76
+ pi.on('tool_call', async (...args) => {
77
+ const e = args[0] || {};
78
+ if (isBrainWrite(e.toolName, e.input)) {
79
+ return { block: true, reason: GATING_REASON }; // refuse direct brain edits
80
+ }
81
+ return undefined;
82
+ });
83
+ // Correlate start→end by toolCallId so the capture keeps the call's INPUT (the end event
84
+ // carries result+isError but not args). Bounded: entries removed on end.
85
+ const pendingArgs = new Map();
86
+ pi.on('tool_execution_start', async (...args) => {
87
+ const e = args[0] || {};
88
+ if (e.toolCallId)
89
+ pendingArgs.set(e.toolCallId, e.args);
90
+ return undefined;
91
+ });
92
+ // Tier-B capture (D-iv) — fires AFTER the tool runs, with the result + pi's authoritative
93
+ // isError flag. Feed classifyToolOutcome the isError signal so a real failed call records
94
+ // as capture:error → the distiller turns it into a candidate gotcha (live auto-distill).
95
+ pi.on('tool_execution_end', async (...args) => {
96
+ const e = args[0] || {};
97
+ if (!e.toolName)
98
+ return undefined;
99
+ const input = e.toolCallId ? pendingArgs.get(e.toolCallId) : undefined;
100
+ if (e.toolCallId)
101
+ pendingArgs.delete(e.toolCallId);
102
+ const summary = resultText(e.result);
103
+ const tool_result = e.isError ? { isError: true, error: summary } : { isError: false, result: summary };
104
+ captureToolCall({ tool_name: e.toolName, tool_input: input, tool_result, call_id: e.toolCallId });
105
+ return undefined;
106
+ });
107
+ // Persist + commit the brain at session end.
108
+ pi.on('session_shutdown', async () => {
109
+ session.save();
110
+ try {
111
+ save('vfkb: session changes', 'agent');
112
+ }
113
+ catch {
114
+ /* non-git brain or git unavailable → skip */
115
+ }
116
+ });
117
+ }
@@ -0,0 +1,94 @@
1
+ // vfkb pi MCP bridge — gives pi genuine MCP capability (on par with Claude Code).
2
+ //
3
+ // pi ships NO MCP by design (its README: "build an extension that adds MCP support").
4
+ // This is that extension: it reads a Claude-compatible mcpServers config, connects to
5
+ // each (stdio) MCP server with the official @modelcontextprotocol/sdk client, lists
6
+ // their tools, and registers each as a native pi tool named `mcp__<server>__<tool>`
7
+ // (matching Claude Code's naming) that proxies to the server.
8
+ //
9
+ // Config (env VFKB_MCP_CONFIG = path to JSON), Claude-compatible:
10
+ // { "mcpServers": { "vfkb": { "command": "node", "args": ["…/dist/mcp-server.js"],
11
+ // "env": { "VFKB_DIR": "…" } } } }
12
+ //
13
+ // Load it like any pi extension: pi -e dist/pi-mcp-bridge.js
14
+ //
15
+ // Connection model: CONNECT-PER-CALL. We connect once at load only to list tools (then
16
+ // close that connection), and each tool `execute` opens → calls → closes its own
17
+ // connection. This guarantees no MCP child process / stdio pipe lingers to keep pi's
18
+ // event loop alive — so `pi -p` exits cleanly (a persistent connection made it hang).
19
+ // Top-level await is safe: pi loads extensions via `await jiti.import(...)`, so module
20
+ // evaluation (incl. discovery) completes before the default export is invoked.
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
23
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
24
+ async function connect(spec) {
25
+ const transport = new StdioClientTransport({
26
+ command: spec.command,
27
+ args: spec.args ?? [],
28
+ env: { ...process.env, ...(spec.env ?? {}) },
29
+ });
30
+ const client = new Client({ name: 'vfkb-pi-bridge', version: '0.0.0' });
31
+ await client.connect(transport);
32
+ return client;
33
+ }
34
+ function toContent(r) {
35
+ const content = (r.content ?? []).map((c) => ({
36
+ type: 'text',
37
+ text: typeof c.text === 'string' ? c.text : JSON.stringify(c),
38
+ }));
39
+ return { content: content.length ? content : [{ type: 'text', text: '(no content)' }], details: {} };
40
+ }
41
+ function readConfig() {
42
+ const p = process.env.VFKB_MCP_CONFIG;
43
+ if (!p || !existsSync(p))
44
+ return {};
45
+ try {
46
+ return (JSON.parse(readFileSync(p, 'utf8')).mcpServers ?? {});
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ async function discover() {
53
+ const defs = [];
54
+ for (const [name, spec] of Object.entries(readConfig())) {
55
+ let client;
56
+ try {
57
+ client = await connect(spec);
58
+ const { tools } = await client.listTools();
59
+ for (const t of tools) {
60
+ const toolName = t.name;
61
+ defs.push({
62
+ name: `mcp__${name}__${toolName}`,
63
+ label: toolName,
64
+ description: t.description ?? `MCP tool ${toolName} on ${name}`,
65
+ parameters: t.inputSchema ?? { type: 'object', properties: {} },
66
+ // connect-per-call: open, call, close — nothing lingers.
67
+ execute: async (_id, params) => {
68
+ const c = await connect(spec);
69
+ try {
70
+ return toContent((await c.callTool({ name: toolName, arguments: params ?? {} })));
71
+ }
72
+ finally {
73
+ await c.close().catch(() => { });
74
+ }
75
+ },
76
+ });
77
+ }
78
+ process.stderr.write(`vfkb-pi-bridge: bridged '${name}' (${tools.length} tools)\n`);
79
+ }
80
+ catch (e) {
81
+ process.stderr.write(`vfkb-pi-bridge: failed to bridge '${name}': ${e.message}\n`);
82
+ }
83
+ finally {
84
+ await client?.close().catch(() => { }); // close the discovery connection
85
+ }
86
+ }
87
+ return defs;
88
+ }
89
+ // Top-level await: completes before pi invokes the default export (loader awaits import).
90
+ const DEFS = await discover();
91
+ export default function (pi) {
92
+ for (const d of DEFS)
93
+ pi.registerTool(d);
94
+ }
@@ -0,0 +1,6 @@
1
+ // Minimal Pi extension types. The real types come from
2
+ // @mariozechner/pi-coding-agent at runtime; these stubs are copied from the
3
+ // VERIFIED mykb spike contract (src/extension/pi-types.ts + hooks/*), NOT
4
+ // re-derived — mykb L7: hand-written Pi stubs were wrong and silently dropped
5
+ // injection for weeks. Only the surface vfkb's Phase-0 face uses is included.
6
+ export {};