@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/engine.js ADDED
@@ -0,0 +1,668 @@
1
+ // vfkb engine facade. The storage kernel lives in storage.ts (append-only JSONL,
2
+ // tombstones, LWW, content-hash freshness — ADR-0013/0014) and the read index in
3
+ // index-store.ts. This module is: schema defaults, the injection FILTER (ADR-0005),
4
+ // the tiered Heuristic reranker (ADR-0012), the budgeted render (ADR-0015), capture.
5
+ // Pure Node stdlib, ZERO runtime deps.
6
+ import { randomBytes } from 'node:crypto';
7
+ import { appendRecord, materialize, readRecords, lastMalformed, withExclusive, contextSpinePath, readContextSpine, writeContextSpine, defaultProject, } from './storage.js';
8
+ import { selectIndex } from './index-store.js';
9
+ import { testHoldForRace } from './lock.js';
10
+ import { assertNoSecrets } from './secrets.js';
11
+ import { tally } from './counters.js';
12
+ import { SessionState } from './session.js';
13
+ export { brainDir } from './storage.js';
14
+ export const SESSION_BUDGET_CHARS = 10_000; // Claude Code additionalContext cap (ADR-0015).
15
+ // Fluid types are editable (last-write-wins); the decision family is immutable
16
+ // (supersede-only) — ADR-0004. RFC = a `proposed` decision (ADR-0007),
17
+ // constitutional = a flagged decision (ADR-0008) — both are the `decision` type,
18
+ // not new types.
19
+ const FLUID_TYPES = new Set(['fact', 'gotcha', 'pattern', 'link']);
20
+ const DECISION_FAMILY = new Set(['decision']);
21
+ export function isDecisionFamily(type) {
22
+ return DECISION_FAMILY.has(type);
23
+ }
24
+ function nowIso() {
25
+ return new Date().toISOString();
26
+ }
27
+ function newId() {
28
+ return randomBytes(6).toString('hex');
29
+ }
30
+ // --- Derived trust (ADR-0011): NOT a stored field. ---
31
+ export function deriveTrust(role) {
32
+ if (role === 'human')
33
+ return 'operator';
34
+ if (role === 'init' || role === 'import')
35
+ return 'import';
36
+ return 'agent'; // architect | pm | executor | judge
37
+ }
38
+ // Fold a `--why`/`why=` rationale into the entry text (gotcha 91338268: the envelope
39
+ // has no `why` field, so rationale only persists in the text). No-op if blank or if a
40
+ // "Why:" line is already present. Matches the import convention (mykb why → "Why: …").
41
+ export function foldWhy(text, why) {
42
+ const w = (why ?? '').trim();
43
+ if (!w)
44
+ return text;
45
+ if (/(^|\n)\s*why:/i.test(text))
46
+ return text;
47
+ return `${text}\n\nWhy: ${w}`;
48
+ }
49
+ export function addEntry(type, text, opts = {}) {
50
+ text = foldWhy(text, opts.why);
51
+ assertNoSecrets(text); // no-secrets write-time lint (D6e) — throws on a planted secret
52
+ const role = opts.role ?? 'executor';
53
+ const ts = nowIso();
54
+ const entry = {
55
+ id: newId(),
56
+ type,
57
+ text,
58
+ tags: opts.tags ?? [],
59
+ zone: opts.zone ?? (deriveTrust(role) === 'operator' ? 'established' : 'incoming'),
60
+ author: { role },
61
+ refs: opts.supersedes || opts.contradicts?.length
62
+ ? {
63
+ supersedes: opts.supersedes,
64
+ contradicts: opts.contradicts?.length ? opts.contradicts : undefined,
65
+ }
66
+ : undefined,
67
+ why: opts.why?.trim() || undefined, // structural rationale (ADR-0042 §1)
68
+ provenance: {
69
+ status: opts.provStatus ?? (deriveTrust(role) === 'operator' ? 'verified' : 'unverified'),
70
+ date: ts,
71
+ origin: opts.origin,
72
+ },
73
+ validity: { valid_from: ts, valid_until: opts.validUntil },
74
+ // default a brand-new decision to `proposed` (an RFC, ADR-0007) unless told.
75
+ status: opts.status ?? (isDecisionFamily(type) ? 'proposed' : undefined),
76
+ constitutional: isDecisionFamily(type) ? opts.constitutional : undefined,
77
+ session_id: opts.sessionId ?? process.env.KB_SESSION_ID ?? undefined,
78
+ created: ts,
79
+ updated: ts,
80
+ };
81
+ appendRecord(entry);
82
+ return entry;
83
+ }
84
+ export function readAll() {
85
+ return materialize();
86
+ }
87
+ // Fluid-type edit (last-write-wins). Throws on the decision family (ADR-0004:
88
+ // decisions/RFC/constitutional are immutable — supersede, don't edit).
89
+ export function updateEntry(id, patch) {
90
+ return withExclusive(() => {
91
+ const cur = readAll().find((e) => e.id === id);
92
+ if (!cur)
93
+ throw new Error(`no such entry: ${id}`);
94
+ if (!FLUID_TYPES.has(cur.type)) {
95
+ throw new Error(`entry ${id} is a ${cur.type} (decision family) — immutable; supersede it, don't edit (ADR-0004)`);
96
+ }
97
+ const next = { ...cur, ...patch, updated: nowIso() };
98
+ appendRecord(next);
99
+ return next;
100
+ });
101
+ }
102
+ // Non-destructive provenance re-stamp (D-iii / ADR-0024). Flips the trust label
103
+ // (provenance.status) while leaving the entry's TEXT byte-identical — the curator's
104
+ // never-rewrite Brake stays intact. This is how corroborated promotion makes its trust
105
+ // elevation AGENT-OBSERVABLE (the ✓ glyph + the verified-only filter), not just a zone
106
+ // move. Deliberately metadata-only: it does not touch text/zone/status.
107
+ export function setProvenanceStatus(id, status) {
108
+ return withExclusive(() => {
109
+ const cur = readAll().find((e) => e.id === id);
110
+ if (!cur)
111
+ throw new Error(`no such entry: ${id}`);
112
+ const next = {
113
+ ...cur,
114
+ provenance: { ...cur.provenance, status },
115
+ updated: nowIso(),
116
+ };
117
+ appendRecord(next);
118
+ return next;
119
+ });
120
+ }
121
+ // Delete = additive tombstone (never rewrites; merge=union safe).
122
+ export function deleteEntry(id) {
123
+ appendRecord({ id, deleted: true, updated: nowIso() });
124
+ }
125
+ // Deterministic rebuild of the read index from JSONL (ADR-0014).
126
+ export function rebuild() {
127
+ const ix = selectIndex();
128
+ ix.rebuild();
129
+ return ix;
130
+ }
131
+ export function getIndex() {
132
+ return selectIndex();
133
+ }
134
+ export function buildContextMap() {
135
+ const all = readAll();
136
+ const superseded = supersededIds(all);
137
+ const byType = { fact: 0, decision: 0, gotcha: 0, pattern: 0, link: 0 };
138
+ const byZone = { incoming: 0, established: 0, archive: 0 };
139
+ const decisions = { accepted: 0, proposed: 0, superseded: 0, deprecated: 0, constitutional: 0 };
140
+ const tagCounts = new Map();
141
+ for (const e of all) {
142
+ byType[e.type]++;
143
+ byZone[e.zone]++;
144
+ for (const t of e.tags)
145
+ tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
146
+ if (isDecisionFamily(e.type)) {
147
+ const eff = effectiveStatus(e, superseded);
148
+ if (eff === 'accepted')
149
+ decisions.accepted++;
150
+ else if (eff === 'proposed')
151
+ decisions.proposed++;
152
+ else if (eff === 'superseded')
153
+ decisions.superseded++;
154
+ else if (eff === 'deprecated')
155
+ decisions.deprecated++;
156
+ if (e.constitutional && eff === 'accepted')
157
+ decisions.constitutional++;
158
+ }
159
+ }
160
+ const topTags = [...tagCounts.entries()]
161
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
162
+ .slice(0, 8)
163
+ .map(([tag, n]) => ({ tag, n }));
164
+ return { total: all.length, byType, byZone, decisions, topTags, malformed: lastMalformed().length };
165
+ }
166
+ export function renderContextMap(map = buildContextMap()) {
167
+ const types = Object.entries(map.byType)
168
+ .filter(([, n]) => n > 0)
169
+ .map(([t, n]) => `${t} ${n}`)
170
+ .join(' · ');
171
+ const d = map.decisions;
172
+ const decLine = `decisions: ${d.accepted} accepted (${d.constitutional} constitutional)` +
173
+ (d.proposed ? ` · ${d.proposed} proposed` : '') +
174
+ (d.superseded ? ` · ${d.superseded} superseded` : '') +
175
+ (d.deprecated ? ` · ${d.deprecated} deprecated` : '');
176
+ const tags = map.topTags.length ? map.topTags.map((t) => `${t.tag}(${t.n})`).join(' ') : '(none)';
177
+ return (`<vfkb-map>\n` +
178
+ `${map.total} entries · ${types} · zones: established ${map.byZone.established}/incoming ${map.byZone.incoming}\n` +
179
+ `${decLine}\n` +
180
+ `top tags: ${tags}\n` +
181
+ (map.malformed ? `⚠ ${map.malformed} malformed record${map.malformed === 1 ? '' : 's'} excluded (ADR-0042 — inspect the brain file)\n` : '') +
182
+ `pull more: search <terms> · filter by type/tag/status/author\n` +
183
+ `</vfkb-map>`);
184
+ }
185
+ // =========================================================================
186
+ // Decision family (ADR-0004/0007/0008/0009). Decisions are immutable in their
187
+ // CONTENT (text/rationale) — superseded, not edited. But lifecycle/identity
188
+ // fields the ENGINE manages (status, refs.supersedes, adr_no) may change via
189
+ // the dedicated operations below, which preserve the content verbatim.
190
+ // =========================================================================
191
+ // Supersede a decision with a new one (additive edge — the old is never edited).
192
+ // The old entry's effective status becomes `superseded`, derived from the edge.
193
+ // Read-decide-append → runs under the brain lock (ADR-0040): two concurrent
194
+ // supersedes of the same target must not both act on the pre-edge snapshot and
195
+ // fork the lineage — the second sees the first's edge and is rejected.
196
+ export function supersede(oldId, text, opts = {}) {
197
+ return withExclusive(() => {
198
+ const all = readAll();
199
+ const old = all.find((e) => e.id === oldId);
200
+ if (!old)
201
+ throw new Error(`no such entry: ${oldId}`);
202
+ if (!isDecisionFamily(old.type)) {
203
+ throw new Error(`entry ${oldId} is not a decision — fluid types are edited, not superseded`);
204
+ }
205
+ if (supersededIds(all).has(oldId)) {
206
+ throw new Error(`entry ${oldId} is already superseded — superseding it again would fork the lineage; ` +
207
+ 'supersede its live successor instead');
208
+ }
209
+ testHoldForRace(); // test-only seam between the read and the append (ADR-0040 DoD)
210
+ return addEntry('decision', text, {
211
+ role: opts.role ?? 'human',
212
+ why: opts.why,
213
+ tags: opts.tags,
214
+ status: opts.status ?? 'accepted',
215
+ constitutional: opts.constitutional ?? old.constitutional,
216
+ supersedes: oldId,
217
+ sessionId: opts.sessionId,
218
+ });
219
+ });
220
+ }
221
+ // Lifecycle transition (proposed→accepted→deprecated). Appends a new version with
222
+ // the SAME content but a new status. Cannot set `superseded` (that is edge-derived),
223
+ // and refuses to alter `text` (content-immutability — use supersede() instead).
224
+ export function transitionDecision(id, status) {
225
+ if (status === 'superseded') {
226
+ throw new Error('`superseded` is derived from a supersession edge — use supersede()');
227
+ }
228
+ return withExclusive(() => {
229
+ const cur = readAll().find((e) => e.id === id);
230
+ if (!cur)
231
+ throw new Error(`no such entry: ${id}`);
232
+ if (!isDecisionFamily(cur.type)) {
233
+ throw new Error(`entry ${id} is not a decision — use updateEntry() for fluid types`);
234
+ }
235
+ testHoldForRace();
236
+ const next = { ...cur, status, updated: nowIso() };
237
+ appendRecord(next);
238
+ return next;
239
+ });
240
+ }
241
+ // The set of ids superseded by some live entry (the supersession edges).
242
+ export function supersededIds(entries = readAll()) {
243
+ const s = new Set();
244
+ for (const e of entries)
245
+ if (e.refs?.supersedes)
246
+ s.add(e.refs.supersedes);
247
+ return s;
248
+ }
249
+ // Effective status folds the supersession edge in: an entry pointed at by a live
250
+ // supersession is `superseded` regardless of its own stored status.
251
+ export function effectiveStatus(e, superseded = supersededIds()) {
252
+ if (superseded.has(e.id))
253
+ return 'superseded';
254
+ return e.status;
255
+ }
256
+ // Stamp human ADR ordinals (ADR-0009). Assigns the next monotonic `adr_no` to live
257
+ // decision-family entries lacking one, in `created` order. Idempotent: already-
258
+ // stamped entries keep their number. Models the engine's merge-to-main step (main
259
+ // is serialized + sole-writer → monotonic, no collision).
260
+ export function stampOrdinals() {
261
+ const live = readAll();
262
+ let max = 0;
263
+ for (const e of live)
264
+ if (typeof e.adr_no === 'number' && e.adr_no > max)
265
+ max = e.adr_no;
266
+ // "Creation order" = append-log order (first appearance in the JSONL). This is
267
+ // the ground-truth write order, immune to same-millisecond `created` collisions.
268
+ const firstSeen = new Map();
269
+ readRecords().forEach((r, i) => {
270
+ if (!firstSeen.has(r.id))
271
+ firstSeen.set(r.id, i);
272
+ });
273
+ const unstamped = live
274
+ .filter((e) => isDecisionFamily(e.type) && typeof e.adr_no !== 'number')
275
+ .sort((a, b) => (firstSeen.get(a.id) ?? 0) - (firstSeen.get(b.id) ?? 0));
276
+ let stamped = 0;
277
+ for (const e of unstamped) {
278
+ appendRecord({ ...e, adr_no: ++max, updated: nowIso() });
279
+ stamped++;
280
+ }
281
+ return stamped;
282
+ }
283
+ // Derive the always-injected Constitution (ADR-0008): live, accepted, constitutional
284
+ // decisions that are not superseded/deprecated.
285
+ export function deriveConstitution() {
286
+ const live = readAll();
287
+ const superseded = supersededIds(live);
288
+ return live
289
+ .filter((e) => isDecisionFamily(e.type) &&
290
+ e.constitutional === true &&
291
+ effectiveStatus(e, superseded) === 'accepted')
292
+ .sort((a, b) => (a.adr_no ?? 1e9) - (b.adr_no ?? 1e9));
293
+ }
294
+ // --- Injection filter (ADR-0005 + ADR-0011 valid_until). Hard gate. ---
295
+ export function isInjectable(e, today = nowIso().slice(0, 10), superseded) {
296
+ if (e.zone === 'archive')
297
+ return false;
298
+ if (superseded?.has(e.id))
299
+ return false; // superseded by a live edge (ADR-0004)
300
+ if (e.status === 'deprecated' || e.status === 'superseded')
301
+ return false;
302
+ if (e.provenance.status === 'stale' || e.provenance.status === 'expired')
303
+ return false;
304
+ if (e.validity.valid_until && e.validity.valid_until.slice(0, 10) < today)
305
+ return false;
306
+ return true; // unverified entries ARE injected (labelled) per ADR-0005.
307
+ }
308
+ // --- Heuristic reranker (ADR-0012). Soft sort over the survivors of the filter. ---
309
+ const TYPE_WEIGHT = {
310
+ pattern: 5, // patterns + gotchas first (L3 tiered render)
311
+ gotcha: 5,
312
+ decision: 4,
313
+ fact: 2,
314
+ link: 1,
315
+ };
316
+ function withinTierScore(e) {
317
+ let s = 0;
318
+ if (deriveTrust(e.author.role) === 'operator')
319
+ s += 3; // operator-trust boost
320
+ if (e.provenance.status === 'verified')
321
+ s += 1;
322
+ return s;
323
+ }
324
+ // Tiered sort (L3): TYPE tier is the PRIMARY key (patterns/gotchas first), so
325
+ // trust/recency reorder *within* a tier but never lift a fact above a pattern.
326
+ // This is the correct order for the *injection* bundle and for non-text listing,
327
+ // where there is no query and thus no relevance signal. For an explicit text
328
+ // SEARCH, relevance is primary and this comparator is only the tiebreak among
329
+ // equally-relevant entries (ADR-0012 — the read layer threads the score through).
330
+ export function heuristicCompare(a, b) {
331
+ const tier = TYPE_WEIGHT[b.type] - TYPE_WEIGHT[a.type];
332
+ if (tier !== 0)
333
+ return tier;
334
+ const within = withinTierScore(b) - withinTierScore(a);
335
+ if (within !== 0)
336
+ return within;
337
+ return b.updated.localeCompare(a.updated); // recency tiebreak
338
+ }
339
+ export function rerank(entries) {
340
+ return [...entries].sort(heuristicCompare);
341
+ }
342
+ // --- Tier-A session-start bundle (ADR-0015), budgeted to 10k chars. ---
343
+ function trustGlyph(e) {
344
+ const t = deriveTrust(e.author.role);
345
+ const v = e.provenance.status === 'verified' ? '✓' : e.provenance.status === 'unverified' ? '⚠' : '';
346
+ return `${v}${t}`;
347
+ }
348
+ // --- Last-handoff pin (ADR-0049): the read side of Track 8 (ADR-0033). Selection is a
349
+ // filter, not an inference: newest-by-`updated` among injectable entries tagged
350
+ // handoff/next. A superseded/archived/expired handoff ages out via isInjectable.
351
+ const HANDOFF_PIN_CAP_CHARS = 2000; // bound the pinned section; the live 07-08 handoff ≈ 1.5k
352
+ export function latestHandoff(all = readAll(), today = nowIso().slice(0, 10), superseded = supersededIds(all)) {
353
+ let latest = null;
354
+ for (const e of all) {
355
+ if (!(e.tags.includes('handoff') || e.tags.includes('next')))
356
+ continue;
357
+ if (!isInjectable(e, today, superseded))
358
+ continue;
359
+ if (!latest) {
360
+ latest = e;
361
+ continue;
362
+ }
363
+ // Total order (adversarial-review finding): updated, then created, then append
364
+ // position — `>= 0` keeps the LATER entry on a full timestamp tie, which in an
365
+ // append-only log is the newest write. A strict `>` here silently favored the
366
+ // oldest entry on same-millisecond stamps (bulk adds, imports).
367
+ const cmp = e.updated.localeCompare(latest.updated) || e.created.localeCompare(latest.created);
368
+ if (cmp >= 0)
369
+ latest = e;
370
+ }
371
+ return latest;
372
+ }
373
+ export function renderContextBundle(project = defaultProject(), budget = SESSION_BUDGET_CHARS) {
374
+ const all = readAll();
375
+ const today = nowIso().slice(0, 10);
376
+ const superseded = supersededIds(all);
377
+ const injectable = rerank(all.filter((e) => isInjectable(e, today, superseded)));
378
+ const header = `<vfkb-context project="${project}">\n`;
379
+ const footer = `\n</vfkb-context>`;
380
+ let body = '';
381
+ // Constitution always leads (ADR-0008): accepted, constitutional, non-superseded
382
+ // decisions, injected first and never budget-dropped.
383
+ const constitution = deriveConstitution();
384
+ if (constitution.length > 0) {
385
+ body += '## Constitution (always applies)\n';
386
+ for (const c of constitution) {
387
+ const n = typeof c.adr_no === 'number' ? `ADR-${String(c.adr_no).padStart(4, '0')} ` : '';
388
+ body += `- [${n}constitutional] ${c.text}\n`;
389
+ }
390
+ body += '\n';
391
+ }
392
+ const constitutionalIds = new Set(constitution.map((c) => c.id));
393
+ // Last handoff (ADR-0049): pinned after the Constitution, never budget-dropped —
394
+ // otherwise the ADR-0012 type tiers budget-drop every fact under enough gotchas,
395
+ // and the Track 8 handoff is exactly the entry a fresh session cannot lose.
396
+ // Bounded pin (adversarial-review finding): a handoff is free text and may be
397
+ // machine-generated (the B2 fallback enumerates entries), so unlike the short
398
+ // curated Constitution it must not unbound the render — truncate at the cap and
399
+ // name the id so the rest stays one kb_get away. A constitutional handoff is
400
+ // already pinned in the Constitution section; don't render it twice.
401
+ const handoff = latestHandoff(all, today, superseded);
402
+ if (handoff && !constitutionalIds.has(handoff.id)) {
403
+ const text = handoff.text.length > HANDOFF_PIN_CAP_CHARS
404
+ ? `${handoff.text.slice(0, HANDOFF_PIN_CAP_CHARS)}… (truncated — kb_get ${handoff.id} for the rest)`
405
+ : handoff.text;
406
+ body += `## Last handoff\n- [${handoff.type} ${trustGlyph(handoff)}] ${text}\n\n`;
407
+ }
408
+ // Context Map (ADR-0006): the navigational index, always injected, never dropped.
409
+ body += renderContextMap() + '\n\n';
410
+ let dropped = 0;
411
+ for (const e of injectable) {
412
+ if (constitutionalIds.has(e.id))
413
+ continue; // already in the Constitution section
414
+ if (handoff && e.id === handoff.id)
415
+ continue; // already pinned in Last handoff
416
+ const line = `- [${e.type} ${trustGlyph(e)}] ${e.text}\n`;
417
+ if (header.length + body.length + line.length + footer.length > budget) {
418
+ dropped++;
419
+ continue;
420
+ }
421
+ body += line;
422
+ }
423
+ if (dropped > 0) {
424
+ const note = `<!-- ${dropped} lower-ranked entries omitted for the ${budget}-char budget -->\n`;
425
+ if (header.length + body.length + note.length + footer.length <= budget)
426
+ body += note;
427
+ }
428
+ return header + body + footer;
429
+ }
430
+ // --- Project context document (D-ii / ADR-0025 ← RFC-007): the agent's first read.
431
+ // An ASSEMBLED artifact: the AUTHORED narrative spine (<brain>/context.md — job-to-be-done,
432
+ // architecture, tech profile, conventions, Vision/Taste per ADR-0010) STITCHED with DERIVED
433
+ // sections vfkb already owns (Constitution ADR-0008, Context Map ADR-0006, load-bearing
434
+ // decisions, links). The derived half is rendered live so it never drifts into a stale
435
+ // hand-copy (the RFC-005 anti-drift lesson). Read ON-DEMAND via kb_context — NOT auto-injected
436
+ // (session-start keeps the budget-bounded map+resume; ADR-0015). No new entry type.
437
+ const CONTEXT_SPINE_SCAFFOLD = `# Project Context
438
+
439
+ ## Job-to-be-done
440
+ <what this project is — the job it does for its users>
441
+
442
+ ## Architecture
443
+ <the shape of the system: key components and how they fit>
444
+
445
+ ## Tech profile
446
+ <languages, frameworks, runtimes, datastores>
447
+
448
+ ## Conventions
449
+ <load-bearing conventions an agent must follow>
450
+
451
+ ## Vision / Taste
452
+ <the taste/voice this project holds to (ADR-0010)>
453
+ `;
454
+ // Scaffold the authored spine if absent (onboarding D-O8). Idempotent: never overwrites
455
+ // an existing spine.
456
+ export function initContextSpine() {
457
+ const p = contextSpinePath();
458
+ if (readContextSpine() !== null)
459
+ return { created: false, path: p };
460
+ writeContextSpine(CONTEXT_SPINE_SCAFFOLD);
461
+ return { created: true, path: p };
462
+ }
463
+ export function renderContext(project = defaultProject()) {
464
+ const all = readAll();
465
+ const sup = supersededIds(all);
466
+ const out = [`# ${project} — project context`, ''];
467
+ const spine = readContextSpine();
468
+ if (spine) {
469
+ out.push('<!-- authored spine (architect-maintained) -->', spine, '');
470
+ }
471
+ else {
472
+ out.push('_(no authored context spine yet — run `vfkb context init` to scaffold `context.md`. The derived sections below are always current.)_', '');
473
+ }
474
+ // Derived sections — stitched live from the brain so they never drift from a hand-copy.
475
+ const constitution = deriveConstitution();
476
+ if (constitution.length > 0) {
477
+ out.push('## Constitution (derived — always applies)');
478
+ for (const c of constitution) {
479
+ const n = typeof c.adr_no === 'number' ? `ADR-${String(c.adr_no).padStart(4, '0')} ` : '';
480
+ out.push(`- [${n}constitutional] ${c.text}`);
481
+ }
482
+ out.push('');
483
+ }
484
+ out.push('## Map (derived)', renderContextMap(), '');
485
+ const decisions = all.filter((e) => e.type === 'decision' && !e.constitutional && effectiveStatus(e, sup) === 'accepted');
486
+ if (decisions.length > 0) {
487
+ out.push('## Load-bearing decisions (derived)');
488
+ for (const d of decisions) {
489
+ const n = typeof d.adr_no === 'number' ? `ADR-${String(d.adr_no).padStart(4, '0')} ` : '';
490
+ out.push(`- [${n}decision] ${d.text}`);
491
+ }
492
+ out.push('');
493
+ }
494
+ const links = all.filter((e) => e.type === 'link' && isInjectable(e, undefined, sup));
495
+ if (links.length > 0) {
496
+ out.push('## Links & docs (derived)');
497
+ for (const l of links)
498
+ out.push(`- ${l.text}`);
499
+ out.push('');
500
+ }
501
+ return out.join('\n').trim() + '\n';
502
+ }
503
+ // --- Naive flat dump (NOT used in production). Represents a mykb-v1-style memory:
504
+ // all live entries in load/creation order, flat, NO supersession/stale filter,
505
+ // NO rerank, budget-cut from the end (so the newest can be dropped). Used only
506
+ // by the L4 scenario harness as the contrast baseline. ---
507
+ export function renderNaiveDump(project = defaultProject(), budget = SESSION_BUDGET_CHARS, limit) {
508
+ let entries = readAll()
509
+ .filter((e) => e.zone !== 'archive')
510
+ .sort((a, b) => a.created.localeCompare(b.created)); // oldest first (load order)
511
+ // A `limit` reproduces the Stark-FQDN incident faithfully: a load-order memory
512
+ // truncated to a token budget keeps the OLDER entries and drops the newest
513
+ // correction — exactly mykb v1's failure mode.
514
+ if (typeof limit === 'number')
515
+ entries = entries.slice(0, limit);
516
+ const header = `<context project="${project}">\n`;
517
+ const footer = `\n</context>`;
518
+ let body = '';
519
+ for (const e of entries) {
520
+ const line = `- ${e.text}\n`;
521
+ if (header.length + body.length + line.length + footer.length > budget)
522
+ break; // drops newest
523
+ body += line;
524
+ }
525
+ return header + body + footer;
526
+ }
527
+ // vfkb's own knowledge tools (bare `kb_*` or harness-namespaced `mcp__vfkb__*`).
528
+ // Capturing an agent reading/writing its own brain is self-pollution — the noise
529
+ // then gets re-surfaced by search/injection. Never capture these.
530
+ function isOwnKnowledgeTool(name) {
531
+ const n = name.toLowerCase();
532
+ return n.startsWith('kb_') || n.includes('vfkb');
533
+ }
534
+ const RESULT_SUMMARY_CAP = 120;
535
+ export function classifyToolOutcome(result) {
536
+ if (result === undefined || result === null)
537
+ return { outcome: 'ok', summary: '' };
538
+ if (typeof result === 'object') {
539
+ const r = result;
540
+ const exit = typeof r.exit_code === 'number' ? r.exit_code
541
+ : typeof r.exitCode === 'number' ? r.exitCode
542
+ : undefined;
543
+ const isErr = r.isError === true || r.error != null || (typeof r.stderr === 'string' && r.stderr.trim() !== '') ||
544
+ (exit !== undefined && exit !== 0);
545
+ const basis = r.error ?? r.stderr ?? r.message ?? r.result ?? r;
546
+ const summary = (typeof basis === 'string' ? basis : JSON.stringify(basis)).slice(0, RESULT_SUMMARY_CAP);
547
+ return { outcome: isErr ? 'error' : 'ok', summary };
548
+ }
549
+ const s = String(result).slice(0, RESULT_SUMMARY_CAP);
550
+ const isErr = /(^|\s)(error|failed|failure|exception|denied|traceback|refused)\b/i.test(s);
551
+ return { outcome: isErr ? 'error' : 'ok', summary: s };
552
+ }
553
+ export function captureToolCall(ev) {
554
+ if (!ev.tool_name)
555
+ return null;
556
+ if (isOwnKnowledgeTool(ev.tool_name))
557
+ return null;
558
+ const inputSummary = typeof ev.tool_input === 'object' && ev.tool_input
559
+ ? JSON.stringify(ev.tool_input).slice(0, 200)
560
+ : String(ev.tool_input ?? '');
561
+ const { outcome, summary } = classifyToolOutcome(ev.tool_result);
562
+ const text = `Tool ${ev.tool_name} invoked${inputSummary ? `: ${inputSummary}` : ''}` +
563
+ (summary ? ` → ${outcome}: ${summary}` : '');
564
+ try {
565
+ return addEntry('fact', text, {
566
+ role: 'executor',
567
+ tags: ['captured', `capture:${outcome}`],
568
+ provStatus: 'unverified',
569
+ origin: { kind: 'tool_call', tool: ev.tool_name, call_id: ev.call_id },
570
+ sessionId: ev.session_id,
571
+ });
572
+ }
573
+ catch {
574
+ // no-secrets lint (or any write error) → skip the capture, never crash the harness.
575
+ return null;
576
+ }
577
+ }
578
+ // Ids of entries currently eligible for injection (filter + supersession applied).
579
+ export function currentInjectableIds() {
580
+ const all = readAll();
581
+ const sup = supersededIds(all);
582
+ return all.filter((e) => isInjectable(e, undefined, sup)).map((e) => e.id);
583
+ }
584
+ // --- Per-turn delta injection (Tier C, Pi-only — ADR-0015). Inject only entries
585
+ // not already injected this session (dedup via SessionState, L4). Returns ''
586
+ // when there is nothing new this turn. ---
587
+ export function renderContextDelta(session, project = defaultProject()) {
588
+ const all = readAll();
589
+ const superseded = supersededIds(all);
590
+ const fresh = rerank(all.filter((e) => isInjectable(e, undefined, superseded))).filter((e) => !session.isInjected(e.id));
591
+ session.bumpTurn();
592
+ if (fresh.length === 0)
593
+ return '';
594
+ session.markInjected(fresh.map((e) => e.id));
595
+ const lines = fresh.map((e) => `- [${e.type} ${trustGlyph(e)}] ${e.text}`).join('\n');
596
+ return `<vfkb-context-delta project="${project}">\n${lines}\n</vfkb-context-delta>`;
597
+ }
598
+ // =========================================================================
599
+ // Session continuity (ADR-0020 / RFC-005) — vfkb's knowledge half of the
600
+ // vtf/vfkb seam. The resume DIGEST is DERIVED from a session record's signals
601
+ // against the LIVE brain at render time, never a stored prose blob — so it
602
+ // cannot go stale (the failure that opened this work). The "Resume" view is a
603
+ // Tier-A render: the prior-session digest + the live knowledge bundle, both
604
+ // re-derived from ground truth and budgeted to the 10k cap.
605
+ // =========================================================================
606
+ // Entries created within a session's [startedAt, lastAt] window — recomputed from
607
+ // the live brain, so a since-deleted/tombstoned entry never reappears (anti-stale).
608
+ function sessionWindow(all, from, to) {
609
+ const added = all.filter((e) => e.created >= from && e.created <= to);
610
+ const superseded = added.filter((e) => e.refs?.supersedes); // each new edge = one supersession in-window
611
+ return { added, superseded };
612
+ }
613
+ const DIGEST_LESSON_CAP = 5; // bound the learned-lessons section in the digest
614
+ // The auto-distilled `incoming` lessons of a session window (M3 / ADR-0020 Phase B):
615
+ // candidate gotchas the distiller wrote (tag `distilled`, still incoming = unverified).
616
+ // DERIVED from the live brain — a lesson later promoted (zone≠incoming) or archived drops
617
+ // out on the next render, so this can no more go stale than the M1 counts can.
618
+ function distilledLessons(added) {
619
+ return added
620
+ .filter((e) => e.zone === 'incoming' && e.tags.includes('distilled'))
621
+ .sort((a, b) => b.created.localeCompare(a.created)); // newest first
622
+ }
623
+ // Render a single session record as a derived digest. Observed facts (added/
624
+ // superseded counts + distilled candidate lessons) come from the live brain; the note +
625
+ // caller signals are labelled ASSERTED (verified-vs-asserted — the discipline whose
626
+ // absence produced the stale-L4 claim).
627
+ export function renderResumeDigest(rec, all = readAll()) {
628
+ const { added, superseded } = sessionWindow(all, rec.startedAt, rec.lastAt);
629
+ const when = (rec.lastAt ?? '').slice(0, 16).replace('T', ' ');
630
+ const lines = [
631
+ `## Resume — last recorded session (${when}Z)`,
632
+ `- observed (re-derived from the brain): ${added.length} entries added, ` +
633
+ `${superseded.length} superseded, ${rec.injectedIds?.length ?? 0} injected, ` +
634
+ `${rec.capturedIds?.length ?? 0} captured, ${rec.turnCount ?? 0} turns`,
635
+ ];
636
+ // M3: the real learned lessons, not just counts — auto-distilled candidates, trust-
637
+ // labelled (ADR-0005) so a resuming session treats them as unverified, not as fact.
638
+ const lessons = distilledLessons(added);
639
+ if (lessons.length > 0) {
640
+ lines.push(`- learned (auto-distilled this session — candidates, verify before trusting):`);
641
+ for (const e of lessons.slice(0, DIGEST_LESSON_CAP)) {
642
+ const net = tally(e.id).net;
643
+ const corr = net > 0 ? ` (+${net} corroborating)` : '';
644
+ lines.push(` - [${e.type} ${trustGlyph(e)}] ${e.text}${corr}`);
645
+ }
646
+ if (lessons.length > DIGEST_LESSON_CAP) {
647
+ lines.push(` - …and ${lessons.length - DIGEST_LESSON_CAP} more (search tag:distilled)`);
648
+ }
649
+ }
650
+ if (rec.note)
651
+ lines.push(`- next (ASSERTED by operator, unverified): ${rec.note}`);
652
+ for (const s of rec.signals ?? [])
653
+ lines.push(`- ${s.label} (ASSERTED by caller): ${s.value}`);
654
+ return lines.join('\n');
655
+ }
656
+ // The "Resume" render (ADR-0020 pt 5): the most recent PRIOR session's digest +
657
+ // the live Tier-A knowledge bundle, budgeted to the 10k cap. Both halves are
658
+ // derived from ground truth at call time.
659
+ export function renderResume(project = defaultProject(), session = SessionState.load()) {
660
+ const all = readAll();
661
+ const prior = SessionState.records().find((r) => r.sessionId !== session.sessionId);
662
+ const digest = prior
663
+ ? renderResumeDigest(prior, all)
664
+ : '## Resume\n- (first recorded session — no prior continuity)';
665
+ const head = `<vfkb-resume project="${project}">\n${digest}\n</vfkb-resume>`;
666
+ const bundle = renderContextBundle(project, Math.max(0, SESSION_BUDGET_CHARS - head.length - 1));
667
+ return `${head}\n${bundle}`;
668
+ }