@viloforge/vfkb 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/args.js +82 -0
- package/dist/backend.js +115 -0
- package/dist/bootstrap.test.js +64 -0
- package/dist/bundles/vfkb-mcp.mjs +32139 -0
- package/dist/bundles/vfkb.mjs +17583 -0
- package/dist/bundles.test.js +177 -0
- package/dist/cli.js +635 -0
- package/dist/counters.js +61 -0
- package/dist/curator.js +87 -0
- package/dist/distiller.js +116 -0
- package/dist/doctor.js +479 -0
- package/dist/doctor.test.js +179 -0
- package/dist/engine.js +668 -0
- package/dist/env-compat.test.js +23 -0
- package/dist/export.js +371 -0
- package/dist/gating.js +34 -0
- package/dist/git.js +43 -0
- package/dist/import.js +99 -0
- package/dist/import.test.js +54 -0
- package/dist/index-store.js +101 -0
- package/dist/init.js +264 -0
- package/dist/init.test.js +148 -0
- package/dist/lock.js +158 -0
- package/dist/manifest.js +38 -0
- package/dist/mcp-server.js +213 -0
- package/dist/pi-extension.js +117 -0
- package/dist/pi-mcp-bridge.js +94 -0
- package/dist/pi-types.js +6 -0
- package/dist/read.js +112 -0
- package/dist/secrets.js +36 -0
- package/dist/session-end.js +183 -0
- package/dist/session-end.test.js +149 -0
- package/dist/session.js +157 -0
- package/dist/stop-reminder.js +130 -0
- package/dist/storage.js +145 -0
- package/dist/types.js +6 -0
- package/dist/validate.js +83 -0
- package/dist/version.js +32 -0
- package/package.json +51 -0
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
|
+
}
|