@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/read.js ADDED
@@ -0,0 +1,112 @@
1
+ // vfkb read layer (Phase 3). Filtered/searched retrieval — the D5c filters
2
+ // (status/zone/type/tags/author.role) over the storage kernel, with the ADR-0012
3
+ // tiered reranker applied. Consumed by the CLI and (Phase 4) the MCP read tools.
4
+ import { effectiveStatus, heuristicCompare, isInjectable, readAll, rerank, supersededIds, } from './engine.js';
5
+ import { queryTermCount, selectIndex } from './index-store.js';
6
+ // RFC-001: an explicit text query keeps only candidates that match at least this
7
+ // fraction of the query's distinct terms. Default = 1/3 with a `>=` test, so a
8
+ // 1–2 term query reduces to the existing score>0 (1/1, 1/2 ≥ 1/3) and a 3-term
9
+ // query still admits a single strong match (1/3 ≥ 1/3), while the "1 common term
10
+ // out of 8" noise that buried the devops-kb answer (1/8 < 1/3) is dropped.
11
+ // Conservative by design: it can only remove genuine non-matches, never reorder or
12
+ // drop a real top hit. Injection/listing carry no text → no floor (ADR-0016).
13
+ export const DEFAULT_MIN_TERM_RATIO = 1 / 3;
14
+ function arr(v) {
15
+ if (v === undefined)
16
+ return undefined;
17
+ return Array.isArray(v) ? v : [v];
18
+ }
19
+ // The single pipeline. `query` returns just the entries (unchanged contract);
20
+ // `queryExplained` returns the entries plus, when empty, why (RFC-002).
21
+ function run(opts = {}) {
22
+ const all = readAll();
23
+ const superseded = supersededIds(all);
24
+ // Stage 1 (ADR-0012): term-overlap candidates when text is given, else all.
25
+ // Capture the relevance score per id so Stage 2 can keep it as the primary
26
+ // sort key for search (without a query there is no score → score 0 → the
27
+ // heuristic order stands, which is what list/injection want).
28
+ const hasText = !!(opts.text && opts.text.trim());
29
+ const scored = hasText ? selectIndex().searchScored(opts.text, 200) : [];
30
+ // RFC-001 relevance floor: drop candidates matching < minTermRatio of the query's
31
+ // distinct terms, before Stage 2 / the limit. Applies only to explicit search;
32
+ // listing/injection have no text and skip this entirely.
33
+ const minRatio = opts.minTermRatio ?? DEFAULT_MIN_TERM_RATIO;
34
+ const qTerms = hasText ? queryTermCount(opts.text) : 0;
35
+ const floored = hasText && qTerms > 0 && minRatio > 0
36
+ ? scored.filter((s) => s.matched / qTerms >= minRatio)
37
+ : scored;
38
+ const scoreOf = new Map(floored.map((s) => [s.entry.id, s.score]));
39
+ const candidates = hasText ? floored.map((s) => s.entry) : all;
40
+ const types = arr(opts.type);
41
+ const zones = arr(opts.zone);
42
+ const statuses = arr(opts.status);
43
+ const roles = arr(opts.authorRole);
44
+ // Tally the reason each candidate is dropped (first failing reason wins — same
45
+ // keep/drop decision as before, now also classified for RFC-002).
46
+ const filteredOut = {};
47
+ const filtered = candidates.filter((e) => {
48
+ let reason = null;
49
+ if (types && !types.includes(e.type))
50
+ reason = 'type';
51
+ else if (zones && !zones.includes(e.zone))
52
+ reason = 'zone';
53
+ else if (roles && !roles.includes(e.author.role))
54
+ reason = 'role';
55
+ else if (opts.verifiedOnly && e.provenance.status !== 'verified')
56
+ reason = 'provenance';
57
+ else if (opts.tags && !opts.tags.every((t) => e.tags.includes(t)))
58
+ reason = 'tags';
59
+ else if (statuses) {
60
+ const eff = effectiveStatus(e, superseded);
61
+ if (!eff || !statuses.includes(eff))
62
+ reason = 'status';
63
+ }
64
+ // Supersession edge and freshness are handled separately from the D5c filters
65
+ // so the include_* flags are independent.
66
+ if (!reason && superseded.has(e.id) && !opts.includeSuperseded)
67
+ reason = 'superseded';
68
+ if (!reason && !opts.includeStale && !isInjectable(e))
69
+ reason = 'stale'; // freshness gate
70
+ if (reason) {
71
+ filteredOut[reason] = (filteredOut[reason] ?? 0) + 1;
72
+ return false;
73
+ }
74
+ return true;
75
+ });
76
+ // Stage 2 (ADR-0012): for an explicit text query, RELEVANCE is the primary key
77
+ // and the heuristic tier (type/trust/recency) is only the tiebreak among
78
+ // equally-relevant entries — so a relevant entry is never buried by a fresher,
79
+ // higher-trust, but barely-relevant one. Without a query, fall back to the pure
80
+ // heuristic order (the injection bundle / `kb_list` ordering).
81
+ const ranked = hasText
82
+ ? [...filtered].sort((a, b) => {
83
+ const s = (scoreOf.get(b.id) ?? 0) - (scoreOf.get(a.id) ?? 0);
84
+ return s !== 0 ? s : heuristicCompare(a, b);
85
+ })
86
+ : rerank(filtered);
87
+ const results = opts.limit ? ranked.slice(0, opts.limit) : ranked;
88
+ if (results.length > 0)
89
+ return { results };
90
+ // Empty (RFC-002): classify deterministically from the stage counts.
91
+ let diagnosis;
92
+ if (candidates.length > 0) {
93
+ // Candidates survived text + floor but the filters / ADR-0005 gate removed them.
94
+ diagnosis = { reason: 'all_filtered', candidates: candidates.length, filteredOut };
95
+ }
96
+ else if (hasText && scored.length > 0) {
97
+ // There were lexical matches, but none cleared the relevance floor (near-miss).
98
+ const b = scored[0];
99
+ diagnosis = { reason: 'no_match', candidates: 0, belowFloor: { entry: b.entry, matched: b.matched, queryTerms: qTerms } };
100
+ }
101
+ else {
102
+ // No lexical overlap at all (or a structural query with nothing in scope).
103
+ diagnosis = { reason: 'empty_topic', candidates: 0 };
104
+ }
105
+ return { results, diagnosis };
106
+ }
107
+ export function query(opts = {}) {
108
+ return run(opts).results;
109
+ }
110
+ export function queryExplained(opts = {}) {
111
+ return run(opts);
112
+ }
@@ -0,0 +1,36 @@
1
+ // No-secrets write-time lint (D6e). The brain is git-committed (low-trust), so
2
+ // secrets must never land in it. Named, high-signal patterns (low false-positive) —
3
+ // not generic entropy. Checked at addEntry; explicit adds throw, passive captures
4
+ // skip (handled by the caller).
5
+ const PATTERNS = [
6
+ { kind: 'private-key-block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
7
+ { kind: 'aws-access-key-id', re: /\bAKIA[0-9A-Z]{16}\b/ },
8
+ { kind: 'github-token', re: /\bghp_[A-Za-z0-9]{36}\b/ },
9
+ { kind: 'github-pat', re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/ },
10
+ { kind: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
11
+ { kind: 'gcp-api-key', re: /\bAIza[0-9A-Za-z\-_]{35}\b/ },
12
+ // Azure storage account key (the base64 value in a connection string / SAS) — the
13
+ // highest-likelihood secret for this Azure-ops substrate. AccountKey isn't an
14
+ // api[_-]?key, so the generic assigned-secret rule below misses it.
15
+ { kind: 'azure-storage-key', re: /\bAccountKey=[A-Za-z0-9+/]{30,}={0,2}/ },
16
+ { kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/\-]{20,}=*\b/ },
17
+ {
18
+ kind: 'assigned-secret',
19
+ re: /\b(?:api[_-]?key|secret|token|password|passwd|pwd)\b\s*[:=]\s*['"]?[A-Za-z0-9_\-./+]{16,}/i,
20
+ },
21
+ ];
22
+ export function detectSecrets(text) {
23
+ const hits = [];
24
+ for (const p of PATTERNS)
25
+ if (p.re.test(text))
26
+ hits.push({ kind: p.kind });
27
+ return hits;
28
+ }
29
+ export function assertNoSecrets(text) {
30
+ const hits = detectSecrets(text);
31
+ if (hits.length > 0) {
32
+ // Never echo the offending text — just the matched kind(s).
33
+ throw new Error(`refusing to store: looks like a secret (${hits.map((h) => h.kind).join(', ')}). ` +
34
+ `The brain is git-committed — keep secrets out (D6e).`);
35
+ }
36
+ }
@@ -0,0 +1,183 @@
1
+ // Session-end continuity — GAP 2 (RFC-011 / ADR-0033): auto-commit the brain so
2
+ // `/exit` is safe by default. The committed brain (`.vfkb/entries.jsonl`, ADR-0019)
3
+ // ships INSIDE the surrounding project repo, so this is NOT git.ts:save() (which
4
+ // runs a standalone brain repo with `git add -A`). Here we commit ONE pathspec into
5
+ // the project repo, on the CURRENT branch, NEVER on the default branch (decision
6
+ // 34f2f2da: branch + PR-first), and we must never sweep in the operator's other
7
+ // staged files (so a pathspec-`--only` commit, not a bare `git commit`).
8
+ //
9
+ // Fail-open throughout: a SessionEnd hook cannot block exit and must never throw.
10
+ import { execFileSync } from 'node:child_process';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join, isAbsolute } from 'node:path';
13
+ import { addEntry } from './engine.js';
14
+ const realGit = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
15
+ // Repo-relative path to the brain file, POSIX-normalized. git's `HEAD:<path>` tree
16
+ // lookup needs forward slashes even on Windows (path.join yields `\` there) — without
17
+ // this, the lookup fails, headCount→0, and every existing entry is treated as "new".
18
+ // Matches the convention in stop-reminder.ts (relative(...).replace(/\\/g,'/')).
19
+ export function brainEntriesRelPath(dataDir) {
20
+ return join(dataDir, 'entries.jsonl').replace(/\\/g, '/');
21
+ }
22
+ function tryGit(git, args, cwd) {
23
+ try {
24
+ return git(args, cwd).trim();
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ // Count added lines (≈ new JSONL entries) for the brain file, working tree + index.
31
+ function countAdded(git, cwd, path) {
32
+ let added = 0;
33
+ for (const base of [['diff'], ['diff', '--cached']]) {
34
+ const out = tryGit(git, [...base, '--numstat', '--', path], cwd);
35
+ if (!out)
36
+ continue;
37
+ for (const line of out.split('\n')) {
38
+ const n = Number(line.split('\t')[0]);
39
+ if (Number.isFinite(n))
40
+ added += n;
41
+ }
42
+ }
43
+ return added;
44
+ }
45
+ // Entries appended to the brain since HEAD (append-only, ADR-0019): the lines beyond
46
+ // the committed line count — the same git-HEAD-delta signal stop-reminder.ts uses, so
47
+ // it needs NO session state (KB_SESSION_ID is unset in the live wiring — gotcha e8f324dc).
48
+ function newEntriesSinceHead(git, cwd, repoRelEntries, absEntries) {
49
+ let lines;
50
+ try {
51
+ lines = readFileSync(absEntries, 'utf8').split('\n').filter(Boolean);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ let headCount = 0;
57
+ const head = tryGit(git, ['show', `HEAD:${repoRelEntries}`], cwd);
58
+ if (head !== null)
59
+ headCount = head.split('\n').filter(Boolean).length;
60
+ return lines
61
+ .slice(headCount)
62
+ .map((l) => {
63
+ try {
64
+ return JSON.parse(l);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ })
70
+ .filter((e) => e !== null);
71
+ }
72
+ const isHandoff = (e) => (e.tags ?? []).some((t) => t === 'handoff' || t === 'next');
73
+ const oneLine = (s) => (s || '').replace(/\s+/g, ' ').trim();
74
+ // GAP 1 (B2 floor, ADR-0033 follow-on): when a session recorded knowledge but left no
75
+ // explicit handoff, write a minimal, honest fallback that ENUMERATES the session's new
76
+ // entries — deterministic (no transcript NLP), so a fresh clone always gets a committed
77
+ // pointer to the session's contribution. Surfaces via the knowledge bundle (it is a
78
+ // committed `fact`, independent of session records). Tagged `auto` to distinguish it
79
+ // from an agent-authored handoff. Writes through the engine (correct envelope); the
80
+ // engine resolves the brain via VFKB_DATA_DIR, so point it at the resolved dir for the call.
81
+ function writeAutoHandoff(absBrain, fresh, sessionId) {
82
+ const CAP = 12;
83
+ const list = fresh
84
+ .slice(0, CAP)
85
+ .map((e) => `${e.id ?? '?'} [${e.type ?? '?'}] ${oneLine(e.text ?? '').slice(0, 70)}`)
86
+ .join('; ');
87
+ const more = fresh.length > CAP ? ` (+${fresh.length - CAP} more)` : '';
88
+ const n = fresh.length;
89
+ const text = `Auto-handoff (session-end): no explicit handoff was recorded, but this session added ` +
90
+ `${n} brain entr${n === 1 ? 'y' : 'ies'} since the last commit — ${list}${more}. ` +
91
+ 'Next session: review these and record an explicit `next:` if continuing.';
92
+ const prev = process.env.VFKB_DATA_DIR;
93
+ process.env.VFKB_DATA_DIR = absBrain;
94
+ try {
95
+ addEntry('fact', text, {
96
+ role: 'executor',
97
+ zone: 'established',
98
+ tags: ['handoff', 'next', 'auto'],
99
+ sessionId, // ADR-0039: attribute the fallback handoff to the ending session
100
+ });
101
+ }
102
+ finally {
103
+ if (prev === undefined)
104
+ delete process.env.VFKB_DATA_DIR;
105
+ else
106
+ process.env.VFKB_DATA_DIR = prev;
107
+ }
108
+ }
109
+ // The repo's default branch (e.g. "main"/"master"), best-effort; falls back to "main".
110
+ function defaultBranch(git, cwd) {
111
+ const ref = tryGit(git, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], cwd);
112
+ if (ref && ref.startsWith('origin/'))
113
+ return ref.slice('origin/'.length);
114
+ return 'main';
115
+ }
116
+ export function runSessionEnd(opts = {}) {
117
+ const git = opts.git ?? realGit;
118
+ const cwd = opts.cwd || process.cwd();
119
+ const dataDir = opts.dataDir || process.env.VFKB_DATA_DIR || process.env.VFKB_DIR || '.vfkb';
120
+ const sessionId = opts.sessionId ?? process.env.KB_SESSION_ID;
121
+ const entries = brainEntriesRelPath(dataDir);
122
+ try {
123
+ // 1) Must be inside a git work tree (committed-brain model). Otherwise no-op.
124
+ if (tryGit(git, ['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') {
125
+ return { committed: false, reason: 'not-a-repo' };
126
+ }
127
+ // 2) Nothing staged or unstaged for the brain file → nothing to do (silent).
128
+ const status = tryGit(git, ['status', '--porcelain', '--', entries], cwd);
129
+ if (!status)
130
+ return { committed: false, reason: 'brain-clean' };
131
+ const tag = sessionId ? `, session ${sessionId.slice(0, 8)}` : '';
132
+ // 3) Branch guard — never commit on a detached HEAD or the default branch.
133
+ const branch = tryGit(git, ['symbolic-ref', '--short', '-q', 'HEAD'], cwd) || '';
134
+ const def = defaultBranch(git, cwd);
135
+ if (!branch) {
136
+ const added = countAdded(git, cwd, entries);
137
+ return {
138
+ committed: false,
139
+ reason: 'detached-head',
140
+ added,
141
+ systemMessage: `vfkb: ${added} new brain entr${added === 1 ? 'y' : 'ies'} uncommitted (detached HEAD) — check out a branch and commit to preserve continuity.`,
142
+ };
143
+ }
144
+ if (branch === def || branch === 'main' || branch === 'master') {
145
+ const added = countAdded(git, cwd, entries);
146
+ return {
147
+ committed: false,
148
+ reason: 'on-default-branch',
149
+ branch,
150
+ added,
151
+ systemMessage: `vfkb: ${added} new brain entr${added === 1 ? 'y' : 'ies'} on \`${branch}\` left uncommitted — branch + commit to preserve continuity (vfkb never auto-commits the default branch).`,
152
+ };
153
+ }
154
+ // 4) GAP 1 (B2 floor): guarantee a committed handoff exists. If the session left no
155
+ // handoff/next entry among its new-since-HEAD entries, write a fallback that
156
+ // enumerates them — BEFORE the commit, so it ships in the same commit.
157
+ const absBrain = isAbsolute(dataDir) ? dataDir : join(cwd, dataDir);
158
+ const fresh = newEntriesSinceHead(git, cwd, entries, join(absBrain, 'entries.jsonl'));
159
+ let autoHandoff = false;
160
+ if (fresh.length > 0 && !fresh.some(isHandoff)) {
161
+ try {
162
+ writeAutoHandoff(absBrain, fresh, sessionId);
163
+ autoHandoff = true;
164
+ }
165
+ catch {
166
+ /* handoff is best-effort; never let it block the commit */
167
+ }
168
+ }
169
+ // 5) Pathspec-scoped commit. Stage ONLY the brain file (leaves any other staged
170
+ // files alone), then commit with `--only` so even pre-staged files are NOT
171
+ // swept into this auto-commit.
172
+ const added = countAdded(git, cwd, entries);
173
+ const message = `chore(brain): session-end auto-commit (${added} new entr${added === 1 ? 'y' : 'ies'}${tag})`;
174
+ git(['add', '--', entries], cwd);
175
+ // `-m` MUST precede `--`; everything after `--` is treated as a pathspec.
176
+ git(['commit', '-o', '-m', message, '--', entries], cwd);
177
+ return { committed: true, reason: 'committed', branch, added, message, autoHandoff };
178
+ }
179
+ catch {
180
+ // Any git failure (no identity, hook rejection, …) → fail-open, never block exit.
181
+ return { committed: false, reason: 'error' };
182
+ }
183
+ }
@@ -0,0 +1,149 @@
1
+ // GAP-2 deterministic gate (RFC-011 / ADR-0033, ADR-0029 proof for the auto-commit
2
+ // mechanism): the SessionEnd hook auto-commits the brain into the SURROUNDING repo,
3
+ // on a topic branch, scoped to entries.jsonl, NEVER on the default branch, and never
4
+ // sweeping in the operator's pre-staged files. Runs against a real throwaway git repo
5
+ // (the mechanism is git behaviour, so we exercise real git — not a mock).
6
+ import { describe, it, expect, beforeEach } from 'vitest';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { runSessionEnd, brainEntriesRelPath } from './session-end.js';
12
+ let repo;
13
+ const git = (args, cwd = repo) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
14
+ const ENTRIES = '.vfkb/entries.jsonl';
15
+ function addEntry(line = '{"id":"x","type":"fact","text":"t"}') {
16
+ appendFileSync(join(repo, ENTRIES), line + '\n');
17
+ }
18
+ // A handoff-tagged entry suppresses the B2 auto-handoff — use it to keep the
19
+ // GAP-2 commit-mechanics tests isolated from the GAP-1 (B2) behaviour.
20
+ const HANDOFF_ENTRY = '{"id":"h","type":"fact","text":"next: do the thing","tags":["handoff"]}';
21
+ const readEntries = () => readFileSync(join(repo, ENTRIES), 'utf8')
22
+ .split('\n')
23
+ .filter(Boolean)
24
+ .map((l) => JSON.parse(l));
25
+ beforeEach(() => {
26
+ repo = mkdtempSync(join(tmpdir(), 'vfkb-se-'));
27
+ git(['init', '-q', '-b', 'main']);
28
+ git(['config', 'user.name', 'Test']);
29
+ git(['config', 'user.email', 'test@example.com']);
30
+ git(['config', 'commit.gpgsign', 'false']);
31
+ mkdirSync(join(repo, '.vfkb'), { recursive: true });
32
+ writeFileSync(join(repo, ENTRIES), '');
33
+ writeFileSync(join(repo, 'README.md'), '# repo\n');
34
+ git(['add', '-A']);
35
+ git(['commit', '-q', '-m', 'init']);
36
+ });
37
+ describe('brainEntriesRelPath — POSIX normalization for git HEAD: lookup', () => {
38
+ it('passes posix paths through unchanged', () => {
39
+ expect(brainEntriesRelPath('.vfkb')).toBe('.vfkb/entries.jsonl');
40
+ });
41
+ it('normalizes backslashes to forward slashes (the Windows git HEAD: bug)', () => {
42
+ // simulates a win32 path.join result / a backslash-containing dataDir
43
+ expect(brainEntriesRelPath('sub\\.vfkb')).toBe('sub/.vfkb/entries.jsonl');
44
+ });
45
+ });
46
+ describe('SessionEnd auto-commit (GAP 2)', () => {
47
+ it('commits ONLY entries.jsonl on a topic branch, attribution-free message', () => {
48
+ git(['checkout', '-q', '-b', 'feat/work']);
49
+ addEntry(HANDOFF_ENTRY); // handoff present → no B2 auto-handoff, keeps added=1
50
+ const r = runSessionEnd({ cwd: repo, dataDir: '.vfkb', sessionId: 'abcd1234efgh' });
51
+ expect(r.committed).toBe(true);
52
+ expect(r.reason).toBe('committed');
53
+ expect(r.branch).toBe('feat/work');
54
+ expect(r.added).toBe(1);
55
+ // The HEAD commit touches only the brain file.
56
+ const files = git(['show', '--name-only', '--pretty=format:', 'HEAD']).split('\n').filter(Boolean);
57
+ expect(files).toEqual([ENTRIES]);
58
+ const msg = git(['log', '-1', '--pretty=%B']);
59
+ expect(msg).toContain('chore(brain): session-end auto-commit');
60
+ expect(msg).toContain('session abcd1234'); // short id
61
+ // No AI attribution anywhere in the message.
62
+ expect(msg.toLowerCase()).not.toContain('claude');
63
+ expect(msg).not.toContain('Co-Authored-By');
64
+ expect(msg).not.toContain('🤖');
65
+ });
66
+ it('NEVER commits on the default branch (main) — warns instead', () => {
67
+ addEntry(); // still on main
68
+ const head = git(['rev-parse', 'HEAD']);
69
+ const r = runSessionEnd({ cwd: repo, dataDir: '.vfkb' });
70
+ expect(r.committed).toBe(false);
71
+ expect(r.reason).toBe('on-default-branch');
72
+ expect(r.systemMessage).toContain('main');
73
+ expect(r.systemMessage).toContain('branch + commit');
74
+ expect(git(['rev-parse', 'HEAD'])).toBe(head); // no new commit
75
+ expect(git(['status', '--porcelain', '--', ENTRIES])).not.toBe(''); // still dirty
76
+ });
77
+ it('does NOT sweep in the operator\'s pre-staged files (pathspec --only)', () => {
78
+ git(['checkout', '-q', '-b', 'feat/work']);
79
+ // Operator has staged their own file for a separate commit.
80
+ writeFileSync(join(repo, 'src.txt'), 'operator work\n');
81
+ git(['add', 'src.txt']);
82
+ addEntry(HANDOFF_ENTRY); // isolate from B2
83
+ const r = runSessionEnd({ cwd: repo, dataDir: '.vfkb' });
84
+ expect(r.committed).toBe(true);
85
+ // The auto-commit must contain ONLY entries.jsonl, not src.txt.
86
+ const files = git(['show', '--name-only', '--pretty=format:', 'HEAD']).split('\n').filter(Boolean);
87
+ expect(files).toEqual([ENTRIES]);
88
+ // src.txt is still staged (untouched), not committed.
89
+ expect(git(['diff', '--cached', '--name-only'])).toBe('src.txt');
90
+ });
91
+ it('is a no-op when the brain is clean', () => {
92
+ git(['checkout', '-q', '-b', 'feat/work']);
93
+ const head = git(['rev-parse', 'HEAD']);
94
+ const r = runSessionEnd({ cwd: repo, dataDir: '.vfkb' });
95
+ expect(r.committed).toBe(false);
96
+ expect(r.reason).toBe('brain-clean');
97
+ expect(git(['rev-parse', 'HEAD'])).toBe(head);
98
+ });
99
+ it('warns and does not commit on a detached HEAD', () => {
100
+ git(['checkout', '-q', '-b', 'feat/work']);
101
+ addEntry();
102
+ git(['add', '-A']);
103
+ git(['commit', '-q', '-m', 'pin']);
104
+ git(['checkout', '-q', '--detach']);
105
+ addEntry();
106
+ const r = runSessionEnd({ cwd: repo, dataDir: '.vfkb' });
107
+ expect(r.committed).toBe(false);
108
+ expect(r.reason).toBe('detached-head');
109
+ expect(r.systemMessage).toContain('detached HEAD');
110
+ });
111
+ it('is a no-op (not-a-repo) outside a git work tree', () => {
112
+ const bare = mkdtempSync(join(tmpdir(), 'vfkb-nogit-'));
113
+ mkdirSync(join(bare, '.vfkb'), { recursive: true });
114
+ writeFileSync(join(bare, ENTRIES), '{"id":"x"}\n');
115
+ const r = runSessionEnd({ cwd: bare, dataDir: '.vfkb' });
116
+ expect(r.committed).toBe(false);
117
+ expect(r.reason).toBe('not-a-repo');
118
+ });
119
+ // GAP 1 (B2 deterministic floor): when a session records knowledge but no handoff,
120
+ // SessionEnd writes a fallback handoff enumerating the new entries, then commits it.
121
+ it('writes & commits an auto-handoff when the session left none (B2)', () => {
122
+ git(['checkout', '-q', '-b', 'feat/work']);
123
+ addEntry('{"id":"k1","type":"decision","text":"chose approach X over Y"}');
124
+ addEntry('{"id":"k2","type":"gotcha","text":"watch out for Z"}');
125
+ const r = runSessionEnd({ cwd: repo, dataDir: '.vfkb' });
126
+ expect(r.committed).toBe(true);
127
+ expect(r.autoHandoff).toBe(true);
128
+ const entries = readEntries();
129
+ const auto = entries.find((e) => (e.tags ?? []).includes('auto'));
130
+ expect(auto).toBeDefined();
131
+ expect(auto.tags).toEqual(expect.arrayContaining(['handoff', 'next', 'auto']));
132
+ // It enumerates the session's real entries (so a fresh clone has a pointer).
133
+ expect(auto.text).toContain('k1');
134
+ expect(auto.text).toContain('k2');
135
+ // It is COMMITTED (survives a fresh clone), in a commit touching only the brain.
136
+ const files = git(['show', '--name-only', '--pretty=format:', 'HEAD']).split('\n').filter(Boolean);
137
+ expect(files).toEqual([ENTRIES]);
138
+ expect(git(['status', '--porcelain', '--', ENTRIES])).toBe(''); // fully committed
139
+ });
140
+ it('does NOT write an auto-handoff when the session already recorded one (B2)', () => {
141
+ git(['checkout', '-q', '-b', 'feat/work']);
142
+ addEntry('{"id":"k1","type":"fact","text":"some work"}');
143
+ addEntry(HANDOFF_ENTRY); // agent authored a real handoff this session
144
+ const r = runSessionEnd({ cwd: repo, dataDir: '.vfkb' });
145
+ expect(r.committed).toBe(true);
146
+ expect(r.autoHandoff).toBe(false);
147
+ expect(readEntries().some((e) => (e.tags ?? []).includes('auto'))).toBe(false);
148
+ });
149
+ });
@@ -0,0 +1,157 @@
1
+ // Per-session state, isolated by session id (mykb L4: a single global pointer
2
+ // let concurrent sessions clobber each other). State lives under <brain>/.sessions/
3
+ // <id>.json — under the brain mount (survives container restart), NOT /tmp. Without
4
+ // a session id, state is in-memory only (single-session default; nothing to clobber).
5
+ //
6
+ // ADR-0039 (v2 session backbone): the id normally comes from the HARNESS — Claude Code
7
+ // delivers `session_id` on every hook's stdin JSON, and the hooks thread it here via
8
+ // effectiveSessionId(). KB_SESSION_ID is an optional OVERRIDE (for harnesses that
9
+ // can't supply stdin the same way), not the only path. Verified 2026-07-06 (CLI
10
+ // v2.1.201): the stdin id is stable across `claude -p --resume` turns of one
11
+ // conversation, so records keyed on it accumulate correctly.
12
+ //
13
+ // ADR-0020 (session-continuity): each session's file is ONE record in an append-only
14
+ // log (per-session-id → never clobbered across sessions). The record carries the
15
+ // SIGNALS of the session (injected/captured ids, turn count, timestamps, an optional
16
+ // asserted operator note + asserted caller signals) — NOT a prose summary. The resume
17
+ // DIGEST is DERIVED from these signals against the live brain at render time
18
+ // (engine.renderResume), so it cannot go stale.
19
+ import { spawnSync } from 'node:child_process';
20
+ import { storageBackend } from './backend.js';
21
+ function now() {
22
+ return new Date().toISOString();
23
+ }
24
+ // ADR-0039: resolve the effective session id for a hook invocation.
25
+ // KB_SESSION_ID (when set) OVERRIDES the harness-supplied stdin id.
26
+ export function effectiveSessionId(payloadId) {
27
+ return process.env.KB_SESSION_ID || payloadId || undefined;
28
+ }
29
+ // Best-effort git branch at session start (identity surface, ADR-0039). The hooks run
30
+ // at the session cwd (the repo); outside a work tree this is undefined, never a throw.
31
+ function currentBranch() {
32
+ try {
33
+ const r = spawnSync('git', ['symbolic-ref', '--short', '-q', 'HEAD'], {
34
+ encoding: 'utf8',
35
+ timeout: 2000,
36
+ });
37
+ const b = (r.stdout || '').trim();
38
+ return r.status === 0 && b ? b : undefined;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ export class SessionState {
45
+ data;
46
+ injected = new Set();
47
+ captured = new Set();
48
+ persisted; // false → ephemeral, in-memory only
49
+ sessionId;
50
+ constructor(persisted, sessionId) {
51
+ this.persisted = persisted && !!sessionId;
52
+ this.sessionId = sessionId;
53
+ const ts = now();
54
+ this.data = {
55
+ sessionId,
56
+ startedAt: ts,
57
+ lastAt: ts,
58
+ turnCount: 0,
59
+ injectedIds: [],
60
+ capturedIds: [],
61
+ // Identity surface (ADR-0039) — stamped at record creation, best-effort.
62
+ agentRole: process.env.VFKB_AGENT_ROLE || undefined,
63
+ agentLabel: process.env.VFKB_AGENT_LABEL || undefined,
64
+ branch: currentBranch(),
65
+ pid: process.pid,
66
+ };
67
+ const raw = this.persisted && sessionId ? storageBackend().readSessionRecord(sessionId) : null;
68
+ if (raw !== null) {
69
+ try {
70
+ const loaded = JSON.parse(raw);
71
+ this.data = {
72
+ sessionId,
73
+ startedAt: loaded.startedAt ?? ts,
74
+ lastAt: loaded.lastAt ?? ts,
75
+ turnCount: loaded.turnCount ?? 0,
76
+ injectedIds: loaded.injectedIds ?? [],
77
+ capturedIds: loaded.capturedIds ?? [],
78
+ note: loaded.note,
79
+ signals: loaded.signals,
80
+ // preserve the CREATION-time identity; never restamp on later loads
81
+ agentRole: loaded.agentRole,
82
+ agentLabel: loaded.agentLabel,
83
+ branch: loaded.branch,
84
+ pid: loaded.pid,
85
+ };
86
+ this.injected = new Set(this.data.injectedIds);
87
+ this.captured = new Set(this.data.capturedIds);
88
+ }
89
+ catch {
90
+ /* corrupt session file → start fresh */
91
+ }
92
+ }
93
+ }
94
+ static load(sessionId = process.env.KB_SESSION_ID) {
95
+ if (!sessionId)
96
+ return new SessionState(false); // ephemeral, in-memory only
97
+ return new SessionState(true, sessionId); // keyed storage via the backend (ADR-0044)
98
+ }
99
+ // The CONCURRENT-SESSION REGISTRY (ADR-0039 §4): every persisted session record
100
+ // against this brain, newest-first by lastAt. This is the surface other mechanisms
101
+ // consult to ask "which other sessions are/were active against this brain" —
102
+ // e.g. ADR-0040's lock logs its holder against it, and a future contradiction
103
+ // check can scope "concurrent" by [startedAt, lastAt] overlap. Append-only: one
104
+ // file per session id, never a shared mutable singleton.
105
+ static records() {
106
+ const be = storageBackend();
107
+ const out = [];
108
+ for (const id of be.listSessionIds()) {
109
+ try {
110
+ const raw = be.readSessionRecord(id);
111
+ if (raw !== null)
112
+ out.push(JSON.parse(raw));
113
+ }
114
+ catch {
115
+ /* skip a corrupt record */
116
+ }
117
+ }
118
+ return out.sort((a, b) => (b.lastAt ?? '').localeCompare(a.lastAt ?? ''));
119
+ }
120
+ isInjected(id) {
121
+ return this.injected.has(id);
122
+ }
123
+ markInjected(ids) {
124
+ for (const id of ids)
125
+ this.injected.add(id);
126
+ }
127
+ recordCaptured(id) {
128
+ this.captured.add(id);
129
+ }
130
+ get capturedIds() {
131
+ return [...this.captured];
132
+ }
133
+ setNote(text) {
134
+ this.data.note = text;
135
+ }
136
+ addSignal(label, value) {
137
+ (this.data.signals ??= []).push({ label, value });
138
+ }
139
+ bumpTurn() {
140
+ this.data.turnCount++;
141
+ }
142
+ get turnCount() {
143
+ return this.data.turnCount;
144
+ }
145
+ get startedAt() {
146
+ return this.data.startedAt;
147
+ }
148
+ save() {
149
+ if (!this.persisted || !this.sessionId)
150
+ return; // ephemeral
151
+ this.data.sessionId = this.sessionId;
152
+ this.data.lastAt = now();
153
+ this.data.injectedIds = [...this.injected];
154
+ this.data.capturedIds = [...this.captured];
155
+ storageBackend().writeSessionRecord(this.sessionId, JSON.stringify(this.data));
156
+ }
157
+ }