@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vilosource
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,290 @@
1
+ # vfkb — ViloForge KnowledgeBase
2
+
3
+ > Decision-grade, git-native memory for AI coding agents.
4
+ > *They remember what was said. vfkb remembers what your project decided — with rationale, lifecycle, and receipts.*
5
+
6
+ [![review-gate](https://github.com/vilosource/vfkb/actions/workflows/review-gate.yml/badge.svg)](https://github.com/vilosource/vfkb/actions/workflows/review-gate.yml)
7
+ [![test](https://github.com/vilosource/vfkb/actions/workflows/test.yml/badge.svg)](https://github.com/vilosource/vfkb/actions/workflows/test.yml)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
+ [![Node](https://img.shields.io/badge/node-%E2%89%A520-brightgreen.svg)](package.json)
10
+
11
+ vfkb is a **per-project knowledge substrate for coding agents**: an append-only JSONL brain
12
+ (`.vfkb/entries.jsonl`) that lives *inside your repo*, travels with every clone and branch, and is
13
+ injected into agent sessions automatically. One TypeScript engine — **zero runtime dependencies**
14
+ (Node stdlib; only the MCP server adds `@modelcontextprotocol/sdk` + `zod`) — exposed through
15
+ three faces: **Claude Code hooks**, an in-process **[Pi](https://github.com/earendil-works/pi)
16
+ extension**, and a **9-tool MCP server** any MCP client can pull from.
17
+
18
+ This repo dogfoods itself: [`.vfkb/`](.vfkb/) is vfkb's own committed brain — 200+ entries of the
19
+ decisions, gotchas and handoffs that built it.
20
+
21
+ ---
22
+
23
+ ## Contents
24
+
25
+ - [Why vfkb](#why-vfkb)
26
+ - [How it compares](#how-it-compares)
27
+ - [Install](#install)
28
+ - [Quick start](#quick-start)
29
+ - [How it works](#how-it-works)
30
+ - [Proving it works](#proving-it-works)
31
+ - [Key concepts](#key-concepts)
32
+ - [Requirements](#requirements)
33
+ - [Documentation](#documentation)
34
+ - [Project status](#project-status)
35
+ - [Lineage & acknowledgements](#lineage--acknowledgements)
36
+ - [License](#license)
37
+
38
+ ---
39
+
40
+ ## Why vfkb
41
+
42
+ AI coding agents start every session from zero. The architect that plans your payment system knows
43
+ nothing about your payment system. An executor discovers "money needs Postgres `NUMERIC`, not
44
+ float," fixes it, ships — and the next task makes the same mistake, because the lesson was never
45
+ written anywhere an agent will read. The human ends up being the only shared memory between
46
+ agents, which makes the human the bottleneck.
47
+
48
+ Most "AI memory" products attack this by remembering **conversations** — extract facts from chat,
49
+ embed them, retrieve them semantically. vfkb attacks a different problem: preserving a project's
50
+ **engineering judgment**. That demands properties conversation-recall systems don't have:
51
+
52
+ | | Chat history | `CLAUDE.md` / `AGENTS.md` | Hosted memory platforms | Vector RAG | **vfkb** |
53
+ |--------------------------------------------------------|:---:|:---:|:---:|:---:|:---:|
54
+ | Structured entries (type, tags, provenance) | ✗ | ✗ | ~ | ~ | **✓** |
55
+ | Decision lifecycle (immutable, supersede-only, status) | ✗ | ✗ | ✗ | ✗ | **✓** |
56
+ | Trust model: operator-verified ≠ agent-asserted | ✗ | ✗ | ✗ | ✗ | **✓** |
57
+ | Stale / superseded knowledge filtered out at inject | ✗ | ✗ | ~ | ✗ | **✓** |
58
+ | Git-native: diffable, branchable, reviewed in PRs | ✗ | ✓ | ✗ | ✗ | **✓** |
59
+ | Auto-injected at session start (no retrieval round-trip)| ✗ | ✓ (always on) | ~ | ✗ | **✓** |
60
+ | Deterministic & offline — no embeddings, server, or API keys | ✓ | ✓ | ✗ | ✗ | **✓** |
61
+ | Says "no recorded entry" instead of guessing | ✗ | ✗ | ✗ | ✗ | **✓** |
62
+
63
+ Two things follow from the git-native choice that no external memory store can offer:
64
+
65
+ 1. **Memory rides code review.** Knowledge changes are diffs; a wrong "fact" is caught in a PR like
66
+ a wrong line of code. Branches carry their own knowledge, and concurrent sessions merge
67
+ conflict-free (`merge=union` on the append-only log, plus a cross-process lock).
68
+ 2. **Memory has provenance you can audit.** Every entry records who wrote it (human / agent /
69
+ import), whether it was verified, when it expires, and — for decisions — *why*, what it
70
+ superseded, and its lifecycle status. Trust is **derived**, never self-declared.
71
+
72
+ ## How it compares
73
+
74
+ Feature-level differences against the systems we studied, per their public docs as of mid-2026 —
75
+ these are good tools solving a *different* problem:
76
+
77
+ - **[mem0](https://github.com/mem0ai/mem0)** — a universal memory layer: an LLM extraction
78
+ pipeline over conversations, stored in your choice of ~20 vector stores or a managed platform,
79
+ retrieved semantically. Optimized for user/session personalization at scale. vfkb stores what a
80
+ *human or agent deliberately recorded* about a *project* — no extraction step, no embeddings, no
81
+ hosted service, and agent-written entries are explicitly second-class until verified.
82
+ - **[Zep / Graphiti](https://github.com/getzep/graphiti)** — a temporal knowledge graph where
83
+ facts carry validity windows; strong at "what was true when." vfkb's validity is explicit rather
84
+ than inferred (validity windows, `stale`/`expired` provenance, and supersede chains an operator
85
+ can read as an ADR log), and it needs no graph service — the brain is a text file in your repo.
86
+ - **[Letta (MemGPT)](https://github.com/letta-ai/letta)** — an agent runtime with OS-style memory
87
+ tiers managed by the agent itself. vfkb is the opposite shape: not a runtime, but a substrate
88
+ *under* whatever harness you already run (Claude Code, Pi, anything speaking MCP).
89
+ - **[MemPalace](https://github.com/MemPalace/mempalace)** — local-first, verbatim conversation
90
+ storage with scoped semantic search. Shares vfkb's local-first stance; differs on what's worth
91
+ keeping (verbatim transcripts vs. curated, typed judgment) and on retrieval (embeddings vs.
92
+ deterministic lexical search with an honest no-match).
93
+ - **`CLAUDE.md` / `AGENTS.md` files** — the right instinct (project knowledge in the repo), but
94
+ flat prose: no types, no provenance, no lifecycle, no queries, always fully in context. vfkb
95
+ *generates* an `AGENTS.md` projection from the brain (`vfkb export agents-md`) so you can have
96
+ both.
97
+
98
+ ## Install
99
+
100
+ ### From source
101
+
102
+ ```bash
103
+ git clone https://github.com/vilosource/vfkb.git
104
+ cd vfkb
105
+ npm install
106
+ npm run build # tsc → dist/ (no native modules)
107
+ npm test # vitest — 265 deterministic tests
108
+
109
+ node dist/cli.js --help # the CLI
110
+ node dist/mcp-server.js # the MCP server (stdio)
111
+ ```
112
+
113
+ ### As a Claude Code plugin (the auto-layer)
114
+
115
+ The [vfkb-claude-plugin](https://github.com/vilosource/vfkb-claude-plugin) bundles the engine,
116
+ MCP server, and hooks so a Claude Code session runs on vfkb automatically — session-start
117
+ injection, brain-write gating, end-of-turn decision reminders, and session-end auto-commit.
118
+ See that repo for install instructions and its current, mechanically-tracked
119
+ [delivery status](https://github.com/vilosource/vfkb-claude-plugin/blob/main/DELIVERY-STATUS.json).
120
+
121
+ ### As an MCP server (any harness)
122
+
123
+ Point any MCP client at `dist/mcp-server.js` with `VFKB_DATA_DIR` set to the brain directory:
124
+
125
+ ```json
126
+ { "mcpServers": { "vfkb": { "command": "node", "args": ["dist/mcp-server.js"],
127
+ "env": { "VFKB_DATA_DIR": ".vfkb" } } } }
128
+ ```
129
+
130
+ Nine tools: `kb_add` · `kb_get` · `kb_list` · `kb_map` · `kb_context` · `kb_search` ·
131
+ `kb_supersede` · `kb_transition` · `kb_resume`. For Pi there is also
132
+ `dist/pi-mcp-bridge.js`, an extension that gives Pi Claude-compatible MCP support
133
+ (`mcp__<server>__<tool>` naming).
134
+
135
+ ## Quick start
136
+
137
+ ```bash
138
+ alias vfkb='VFKB_DATA_DIR=.vfkb node /path/to/vfkb/dist/cli.js'
139
+
140
+ vfkb init # scaffold this repo as a vfkb consumer (.vfkb/ + wiring)
141
+
142
+ # Record knowledge — deliberate, typed, attributed
143
+ vfkb add fact "The API gateway strips the X-Request-Id header" --tag networking --role human
144
+ vfkb add gotcha "pgbouncer in transaction mode breaks LISTEN/NOTIFY" --tag database --role human
145
+ vfkb add decision "Use NUMERIC for money columns" \
146
+ --why "float rounding lost cents in reconciliation" --role human
147
+ vfkb add link "Payment flow design" "docs/design/payments.md" --role human
148
+
149
+ # Query it
150
+ vfkb list --tag networking --limit 5 # newest 5 entries carrying the tag
151
+ vfkb search "money columns" --type decision --verified
152
+ vfkb map # what knowledge exists, at a glance
153
+ vfkb context # the project context doc — an agent's first read
154
+
155
+ # Session continuity
156
+ vfkb resume # what the last session did + the live knowledge bundle
157
+ vfkb resume-note "next: wire the ALB target group"
158
+
159
+ # Lifecycle — decisions are immutable; supersede, never edit
160
+ vfkb supersede <id> "Use NUMERIC(19,4) for money columns" --why "scale fixed by audit" --role human
161
+
162
+ # Health + delivery
163
+ vfkb doctor # brain/engine/wiring diagnosis, incl. plugin staleness
164
+ vfkb save # commit the brain (it ships with your repo)
165
+ vfkb export agents-md # generate an AGENTS.md projection for non-vfkb tooling
166
+ ```
167
+
168
+ Unknown or repeated flags **error loudly** — silent argument drops are treated as bugs here
169
+ (see [issue #95](https://github.com/vilosource/vfkb/issues/95) for the class this killed).
170
+
171
+ ## How it works
172
+
173
+ ### The entry envelope
174
+
175
+ The atomic unit is a typed entry: **fact**, **decision**, **gotcha**, **pattern**, or **link** —
176
+ with tags, a lifecycle **zone** (`incoming` → `established` → `archive`), **provenance**
177
+ (`verified` / `unverified` / `stale` / `expired`, plus a validity window), and an author role from
178
+ which **trust is derived** (operator / agent / import). Decisions additionally carry rationale
179
+ (`why`), a status (`proposed` → `accepted` → `deprecated` / `superseded`), optional
180
+ **constitutional** weight (always injected, leads every session), and supersede references — an
181
+ ADR log the engine can execute.
182
+
183
+ ### The storage kernel
184
+
185
+ One append-only JSONL file, materialized last-write-wins with tombstones. No database, no
186
+ server, no native modules. `merge=union` in `.gitattributes` makes concurrent branch writes
187
+ merge conflict-free by construction; a cross-process lock serializes the read-decide-append
188
+ critical section for same-machine concurrency. The search index is derived and rebuildable —
189
+ the log is the only source of truth.
190
+
191
+ ### Injection, not retrieval
192
+
193
+ At session start the harness face injects a **resume digest** (what the last session added,
194
+ superseded, captured — *recomputed from the brain*, never a stale summary) plus a **knowledge
195
+ bundle**: constitutional decisions first, then the pinned handoff, then relevance-filtered
196
+ entries. Stale, expired, superseded, and archived knowledge is filtered out *before* the agent
197
+ sees it. On-demand search is a deterministic two-stage lexical retriever with a relevance floor
198
+ and an honest empty result: `NO-MATCH` with a cause ("no recorded entry" vs. "all matches
199
+ stale"), because a memory that guesses is worse than no memory.
200
+
201
+ ### Guardrails
202
+
203
+ - **Write-time no-secrets lint** — planted credentials are rejected at `add`, not found in review.
204
+ - **Tool gating** — the Claude Code `PreToolUse` hook denies direct edits to `.vfkb/`, forcing
205
+ writes through the engine (and its lint, lock, and envelope validation).
206
+ - **Hooks fail open** — a malformed payload or crash never wedges the host session.
207
+ - **Session-end auto-commit** — the brain is committed on exit (topic branches only, pathspec-
208
+ scoped, never `main`), so `/exit` doesn't lose the day's knowledge.
209
+
210
+ ## Proving it works
211
+
212
+ Unit tests prove the modules; they cannot prove an agent *behaves better because of vfkb*. So
213
+ every user-facing capability ships behind an **agent-driven scenario harness** (L4): a real agent
214
+ (Pi/DeepSeek and Claude Code arms) runs in a dockerized sandbox against the real surface, scored
215
+ on **observable effects** — the agent's output or the brain's state, never self-report — and
216
+ always against a **contrast arm** (no memory, or a naive flat-dump memory) so the win is shown to
217
+ be *caused* by vfkb. A capability is DEMONSTRATED at ≥2/3 trials, and the records are committed
218
+ under [`scenarios/records/`](scenarios/records/). A proof that cannot fail proves nothing — every
219
+ harness carries an arm that can go red, and several have (that's how they earn trust).
220
+
221
+ The same standard applies to the project's own process: every implementation PR carries an
222
+ adversarial review record ([`reviews/`](reviews/)) verified by a deterministic CI gate, and the
223
+ project's Definition of Done ([ADR-0050](docs/adr/ADR-0050-l4-dod-constitutional-brake.md),
224
+ [ADR-0051](docs/adr/ADR-0051-delivery-honesty.md)) forbids calling anything "done" that hasn't
225
+ been *observed* working — a rule vfkb's own brain enforces on every session it boots.
226
+
227
+ ## Key concepts
228
+
229
+ | Term | Meaning |
230
+ |------|---------|
231
+ | **Brain** | The per-project knowledge store — `.vfkb/entries.jsonl`, committed with the repo. |
232
+ | **Entry** | Atomic unit: fact, decision, gotcha, pattern, or link — typed, tagged, attributed. |
233
+ | **Decision family** | Immutable entries with lifecycle status and supersede chains; an executable ADR log. `proposed` decisions are RFCs. |
234
+ | **Constitutional** | A decision injected at the top of *every* session — the project's non-negotiables. |
235
+ | **Zone** | Lifecycle stage: `incoming` (unproven) → `established` (trusted set) → `archive`. |
236
+ | **Provenance** | Verification state (`verified`/`unverified`/`stale`/`expired`) + validity window + origin. |
237
+ | **Trust** | Derived from author role: operator / agent / import. Agents cannot self-promote. |
238
+ | **Handoff** | A tagged fact carrying session-to-session continuity; pinned in the resume render. |
239
+ | **Curator** | Deliberate promotion/archive/merge operations — deltas only, never text rewrites. |
240
+ | **L4 scenario** | An agent-driven, sandboxed, contrast-armed proof that a capability changes real agent behavior. |
241
+
242
+ ## Requirements
243
+
244
+ - **Node.js ≥ 20**
245
+ - **git** (the brain rides your repo)
246
+ - No native modules, no database, no API keys. Runtime deps: `@modelcontextprotocol/sdk` + `zod`
247
+ (MCP server only — the engine itself is stdlib).
248
+
249
+ ## Documentation
250
+
251
+ - [`docs/DESIGN.md`](docs/DESIGN.md) — engineering design; [`docs/FEATURES.md`](docs/FEATURES.md)
252
+ — the verified feature reference (every feature marked BUILT / PARTIAL / GATED).
253
+ - [`docs/adr/`](docs/adr/) — 52 architecture decision records (Nygard format, immutable);
254
+ [`docs/rfc/`](docs/rfc/) — 24 RFCs. The project practices what it stores: decisions before code.
255
+ - [`docs/STATUS-AND-ROADMAP.md`](docs/STATUS-AND-ROADMAP.md) — north star;
256
+ [`docs/H4-DEVELOPMENT-ROADMAP.md`](docs/H4-DEVELOPMENT-ROADMAP.md) — execution authority.
257
+
258
+ ## Project status
259
+
260
+ **Alpha, used in anger daily.** The v1 per-project tier and the v2 storage/session backbone are
261
+ built and shipped; 265 deterministic tests plus the L4 scenario suite. vfkb develops itself on its
262
+ own brain (200+ committed entries) — every session that builds vfkb resumes from vfkb. Public API,
263
+ CLI surface, and storage schema may still move before 1.0. Part of the
264
+ [ViloForge](https://github.com/vilosource) ecosystem: [vfwb](https://github.com/vilosource/vfwb)
265
+ (the planning workbench that grounds against vfkb) is in design.
266
+
267
+ ## Lineage & acknowledgements
268
+
269
+ vfkb is the third generation of one idea — *agents deserve a project memory with provenance*:
270
+
271
+ - **[OSB — OpenSecondBrain](https://github.com/vilosource/osb)** (Go) proved the knowledge model
272
+ (facts/decisions/gotchas/patterns with provenance) on Claude Code via file conventions and
273
+ adapter prompts.
274
+ - **[mykb](https://github.com/vilosource/mykb)** (TypeScript, [Pi](https://github.com/earendil-works/pi))
275
+ proved mechanical enforcement: three-tier context delivery, tool gating, and the JSONL+SQLite
276
+ hybrid — with storage prior art from
277
+ [Engram](https://github.com/Gentleman-Programming/engram) and
278
+ [Beads](https://github.com/gastownhall/beads).
279
+ - **vfkb** is the greenfield reimplementation for fleet use: pure-stdlib engine, harness-portable
280
+ faces (hooks / extension / MCP), the decision family, derived trust, and the L4 evidence
281
+ culture.
282
+
283
+ We also studied [MemPalace](https://github.com/MemPalace/mempalace) — its local-first,
284
+ zero-API-call stance is the right one, and vfkb shares it while betting on curated judgment over
285
+ verbatim recall. Thanks to Mario Zechner's [Pi](https://github.com/earendil-works/pi) for the
286
+ harness extensibility the Pi face builds on.
287
+
288
+ ## License
289
+
290
+ [MIT](LICENSE) © vilosource
package/dist/args.js ADDED
@@ -0,0 +1,82 @@
1
+ // Strict CLI argument parsing (issue #95 — the silent-flag family).
2
+ // Every verb declares its flags; anything unknown or repeated ERRORS instead of
3
+ // being silently ignored. The `hook` subcommands are intentionally NOT parsed
4
+ // through this — the harness hook contract is fail-open (a hook must never
5
+ // wedge a session over an argv quirk).
6
+ /** A user-facing argv mistake — the CLI prints the message + usage and exits 1. */
7
+ export class UsageError extends Error {
8
+ }
9
+ export function parseArgs(verb, args, spec) {
10
+ const positionals = [];
11
+ const flags = new Map();
12
+ for (let i = 0; i < args.length; i++) {
13
+ const a = args[i];
14
+ if (!a.startsWith('--')) {
15
+ positionals.push(a);
16
+ continue;
17
+ }
18
+ const name = a.slice(2);
19
+ const kind = spec[name];
20
+ if (!kind) {
21
+ throw new UsageError(`unknown flag --${name} for '${verb}'${didYouMean(name, spec)}${knownFlags(spec)}`);
22
+ }
23
+ if (flags.has(name)) {
24
+ throw new UsageError(`repeated flag --${name} for '${verb}' — give it once` +
25
+ (kind === 'value' ? ` (use a comma-separated value for multiples, e.g. --${name} a,b)` : ''));
26
+ }
27
+ if (kind === 'boolean') {
28
+ flags.set(name, true);
29
+ continue;
30
+ }
31
+ const next = args[i + 1];
32
+ if (next === undefined || next.startsWith('--')) {
33
+ if (kind === 'optional-value') {
34
+ flags.set(name, true);
35
+ continue;
36
+ }
37
+ throw new UsageError(`flag --${name} for '${verb}' requires a value`);
38
+ }
39
+ flags.set(name, next);
40
+ i++;
41
+ }
42
+ return { verb, positionals, flags };
43
+ }
44
+ /** String value of a flag, if given with one. */
45
+ export function flagValue(p, name) {
46
+ const v = p.flags.get(name);
47
+ return typeof v === 'string' ? v : undefined;
48
+ }
49
+ /** Comma-separated flag → trimmed non-empty list (undefined when absent).
50
+ * A given-but-empty value (--tag "" / --tag ,,,) ERRORS — it would otherwise
51
+ * silently mean "no filter" on list and "untagged" on add (the #95 class). */
52
+ export function flagList(p, name) {
53
+ const v = flagValue(p, name);
54
+ if (v === undefined)
55
+ return undefined;
56
+ const items = v.split(',').map((t) => t.trim()).filter(Boolean);
57
+ if (items.length === 0) {
58
+ throw new UsageError(`flag --${name} for '${p.verb}' must be a non-empty comma-separated list (got '${v}')`);
59
+ }
60
+ return items;
61
+ }
62
+ /** Positive-integer flag; errors on anything else (silent NaN was the old behavior). */
63
+ export function flagInt(p, name) {
64
+ const v = flagValue(p, name);
65
+ if (v === undefined)
66
+ return undefined;
67
+ if (!/^\d+$/.test(v) || Number(v) < 1) {
68
+ throw new UsageError(`flag --${name} for '${p.verb}' must be a positive integer (got '${v}')`);
69
+ }
70
+ return Number(v);
71
+ }
72
+ function didYouMean(name, spec) {
73
+ // The observed trap: --tags for --tag (four real entries landed untagged).
74
+ const singular = name.replace(/s$/, '');
75
+ if (singular !== name && spec[singular])
76
+ return ` — did you mean --${singular} a,b (comma-separated)?`;
77
+ return '';
78
+ }
79
+ function knownFlags(spec) {
80
+ const names = Object.keys(spec);
81
+ return names.length ? ` (known: ${names.map((n) => `--${n}`).join(', ')})` : ' (it takes no flags)';
82
+ }
@@ -0,0 +1,115 @@
1
+ // ADR-0044 (v2): the storage-backend interface — the seam the engine's persistence
2
+ // flows through instead of assuming a local file directly. Exactly ONE implementation
3
+ // ships in v2: JSONL-on-disk, matching ADR-0019 byte-for-byte (zero behavior change —
4
+ // the DoD is the full pre-existing suite passing unchanged). A second (hosted) backend
5
+ // is explicitly NOT decided here; `setStorageBackend` is the future opt-in door.
6
+ //
7
+ // Layering: engine.ts → storage.ts (backend-AGNOSTIC policy: LWW materialization,
8
+ // ADR-0042 normalization, ADR-0014 meta derivation) → this interface (transport:
9
+ // where records/spine/meta/session records physically live, and what "exclusive"
10
+ // means there — a lockfile here, a transaction in a hypothetical hosted backend).
11
+ // Git-layer consumers (git.ts, gating.ts, stop-reminder.ts, session-end.ts) stay
12
+ // file-based BY DESIGN: they exist because the brain is a committed file (ADR-0019);
13
+ // a backend without that property simply doesn't wire them.
14
+ // KNOWN HOLE (review gate, deliberate): counters.ts (.signals telemetry) is
15
+ // engine-owned but still direct-fs — it joins the seam in whatever follow-up first
16
+ // needs it behind one (a second backend); until then signals are local-disk-only.
17
+ //
18
+ // The method surface was shaped by the shipped v2 code, per RFC-019's sequencing
19
+ // intent: records (the kernel), spine/meta (ADR-0025/0014), session records
20
+ // (ADR-0039's registry), withExclusive (ADR-0040's critical section).
21
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { withBrainLock } from './lock.js';
24
+ // One shared env resolver (review gate F3): the JSONL file, the lock, and the
25
+ // git layer must never drift onto different directories. Call-time-only cycle
26
+ // with storage.ts — safe (nothing dereferences at module init).
27
+ import { brainDir } from './storage.js';
28
+ // --- the one shipped implementation: JSONL on disk (ADR-0019) ---
29
+ const dataDir = () => brainDir();
30
+ const safeKey = (id) => id.replace(/[^A-Za-z0-9_-]/g, '_');
31
+ class JsonlFsBackend {
32
+ name = 'jsonl-fs';
33
+ location() {
34
+ return dataDir();
35
+ }
36
+ file() {
37
+ return join(dataDir(), 'entries.jsonl');
38
+ }
39
+ append(rec) {
40
+ mkdirSync(dataDir(), { recursive: true });
41
+ appendFileSync(this.file(), JSON.stringify(rec) + '\n', 'utf8');
42
+ }
43
+ readAllRaw() {
44
+ const f = this.file();
45
+ if (!existsSync(f))
46
+ return { records: [], malformed: [] };
47
+ const records = [];
48
+ const malformed = [];
49
+ const lines = readFileSync(f, 'utf8').split('\n');
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const l = lines[i];
52
+ if (l.trim().length === 0)
53
+ continue;
54
+ try {
55
+ records.push(JSON.parse(l));
56
+ }
57
+ catch (err) {
58
+ // ADR-0042: one corrupt line must not crash every read of the whole brain.
59
+ malformed.push({ line: i + 1, issue: `unparseable JSON: ${err.message}`, raw: l.slice(0, 200) });
60
+ }
61
+ }
62
+ return { records, malformed };
63
+ }
64
+ readMetaRaw() {
65
+ const f = join(dataDir(), 'index-meta.json');
66
+ return existsSync(f) ? readFileSync(f, 'utf8') : null;
67
+ }
68
+ writeMetaRaw(json) {
69
+ mkdirSync(dataDir(), { recursive: true });
70
+ writeFileSync(join(dataDir(), 'index-meta.json'), json, 'utf8');
71
+ }
72
+ readSpine() {
73
+ const p = this.spinePath();
74
+ return existsSync(p) ? readFileSync(p, 'utf8').trim() : null;
75
+ }
76
+ writeSpine(content) {
77
+ mkdirSync(dataDir(), { recursive: true });
78
+ writeFileSync(this.spinePath(), content);
79
+ }
80
+ spinePath() {
81
+ return join(dataDir(), 'context.md');
82
+ }
83
+ listSessionIds() {
84
+ const dir = join(dataDir(), '.sessions');
85
+ if (!existsSync(dir))
86
+ return [];
87
+ return readdirSync(dir)
88
+ .filter((f) => f.endsWith('.json'))
89
+ .map((f) => f.slice(0, -'.json'.length));
90
+ }
91
+ readSessionRecord(id) {
92
+ const f = join(dataDir(), '.sessions', `${safeKey(id)}.json`);
93
+ return existsSync(f) ? readFileSync(f, 'utf8') : null;
94
+ }
95
+ writeSessionRecord(id, json) {
96
+ const dir = join(dataDir(), '.sessions');
97
+ mkdirSync(dir, { recursive: true });
98
+ writeFileSync(join(dir, `${safeKey(id)}.json`), json, 'utf8');
99
+ }
100
+ withExclusive(fn) {
101
+ return withBrainLock(fn);
102
+ }
103
+ }
104
+ const jsonl = new JsonlFsBackend();
105
+ let current = jsonl;
106
+ export function storageBackend() {
107
+ return current;
108
+ }
109
+ // The future opt-in door (a hosted/alternate backend per project) and the test seam.
110
+ // Returns the previous backend so callers can restore it.
111
+ export function setStorageBackend(b) {
112
+ const prev = current;
113
+ current = b;
114
+ return prev;
115
+ }
@@ -0,0 +1,64 @@
1
+ // ADR-0031 inner gate — the committed bootstrap guard resolves the engine via
2
+ // $VFKB_HOME and DEGRADES GRACEFULLY when it is unset: SessionStart informs the
3
+ // user (a valid hook payload), PreToolUse/Stop never block, and a resolved engine
4
+ // is run transparently. This is the guardrail for "VFKB_HOME not set".
5
+ import { describe, it, expect, beforeAll } from 'vitest';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { mkdtempSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { initProject } from './init.js';
11
+ let bootstrap;
12
+ let fakeHome;
13
+ beforeAll(() => {
14
+ const root = mkdtempSync(join(tmpdir(), 'vfkb-boot-'));
15
+ initProject(root, { project: 'demo' });
16
+ bootstrap = join(root, '.vfkb', 'bin', 'bootstrap.mjs');
17
+ // a fake $VFKB_HOME whose "engines" just echo their argv so we can see passthrough
18
+ fakeHome = mkdtempSync(join(tmpdir(), 'vfkb-fakehome-'));
19
+ const stub = 'console.log("ENGINE " + process.argv.slice(2).join(" "));';
20
+ writeFileSync(join(fakeHome, 'vfkb.mjs'), stub);
21
+ writeFileSync(join(fakeHome, 'vfkb-mcp.mjs'), stub);
22
+ });
23
+ function run(args, env) {
24
+ return spawnSync('node', [bootstrap, ...args], {
25
+ encoding: 'utf8',
26
+ env: { ...process.env, VFKB_HOME: undefined, VFKB_BUNDLE_DIR: undefined, ...env },
27
+ });
28
+ }
29
+ describe('bootstrap guard (ADR-0031)', () => {
30
+ it('VFKB_HOME unset + session-start: emits a clear INACTIVE banner (exit 0)', () => {
31
+ const r = run(['cli', 'hook', 'session-start'], { VFKB_HOME: undefined });
32
+ expect(r.status).toBe(0);
33
+ const payload = JSON.parse(r.stdout);
34
+ expect(payload.hookSpecificOutput.hookEventName).toBe('SessionStart');
35
+ expect(payload.hookSpecificOutput.additionalContext).toContain('VFKB_BUNDLE_DIR');
36
+ expect(payload.hookSpecificOutput.additionalContext).toContain('INACTIVE');
37
+ });
38
+ it('VFKB_HOME unset + pre-tool-use: never blocks (exit 0, no stdout payload)', () => {
39
+ const r = run(['cli', 'hook', 'pre-tool-use'], { VFKB_HOME: undefined });
40
+ expect(r.status).toBe(0);
41
+ expect(r.stdout.trim()).toBe('');
42
+ expect(r.stderr).toContain('VFKB_BUNDLE_DIR'); // informs on stderr
43
+ });
44
+ it('VFKB_HOME unset + mcp: exits cleanly (no crash)', () => {
45
+ const r = run(['mcp'], { VFKB_HOME: undefined });
46
+ expect(r.status).toBe(0);
47
+ });
48
+ it('VFKB_HOME set: runs the resolved engine transparently (passthrough)', () => {
49
+ const r = run(['cli', 'hook', 'session-start'], { VFKB_BUNDLE_DIR: fakeHome });
50
+ expect(r.status).toBe(0);
51
+ expect(r.stdout.trim()).toBe('ENGINE hook session-start');
52
+ });
53
+ it('VFKB_HOME still works as a deprecated alias (passthrough)', () => {
54
+ const r = run(['cli', 'hook', 'session-start'], { VFKB_HOME: fakeHome });
55
+ expect(r.status).toBe(0);
56
+ expect(r.stdout.trim()).toBe('ENGINE hook session-start');
57
+ });
58
+ it('VFKB_BUNDLE_DIR set but bundles missing: degrades like unset', () => {
59
+ const empty = mkdtempSync(join(tmpdir(), 'vfkb-empty-'));
60
+ const r = run(['cli', 'hook', 'session-start'], { VFKB_HOME: empty });
61
+ expect(r.status).toBe(0);
62
+ expect(JSON.parse(r.stdout).hookSpecificOutput.additionalContext).toContain('INACTIVE');
63
+ });
64
+ });