@viloforge/vfkb 0.2.3 → 0.4.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/broadcast.js +134 -0
- package/dist/bundles/vfkb-mcp.mjs +106 -39
- package/dist/bundles/vfkb.mjs +595 -183
- package/dist/cli.js +86 -5
- package/dist/doctor.js +138 -10
- package/dist/engine.js +80 -5
- package/dist/init.js +1 -1
- package/dist/journal.js +207 -0
- package/dist/storage.js +10 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// `hook session-start` / `hook post-tool-use` carry the harness JSON contract.
|
|
4
4
|
import { addEntry, captureToolCall, readAll, renderContext, renderContextBundle, renderContextMap, renderNaiveDump, renderResume, initContextSpine, supersede, deriveTrust, } from './engine.js';
|
|
5
5
|
import { SessionState, effectiveSessionId } from './session.js';
|
|
6
|
-
import { defaultProject } from './storage.js';
|
|
6
|
+
import { brainDir, defaultProject, withExclusive, writeMeta } from './storage.js';
|
|
7
|
+
import { purgeJournal, recoverFromJournal } from './journal.js';
|
|
7
8
|
import { runExport } from './export.js';
|
|
9
|
+
import { broadcast as runBroadcast } from './broadcast.js';
|
|
8
10
|
import { promote, archive, mergeDuplicate, findLexicalDuplicates, promoteIfCorroborated, eligibleForPromotion, } from './curator.js';
|
|
9
11
|
import { distill } from './distiller.js';
|
|
10
12
|
import { recordSignal, tally } from './counters.js';
|
|
@@ -80,7 +82,7 @@ async function main() {
|
|
|
80
82
|
throw err;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
const USAGE = 'usage: vfkb <add|list|search|query|map|context|context init|resume|resume-note|curate|distill|save|' +
|
|
85
|
+
const USAGE = 'usage: vfkb <add|broadcast|journal purge|list|search|query|map|context|context init|resume|resume-note|curate|distill|save|' +
|
|
84
86
|
'export|import|init|doctor|supersede|context-block|context-block-naive|--version|' +
|
|
85
87
|
'hook session-start|hook pre-tool-use|hook post-tool-use|hook stop|hook session-end>\n';
|
|
86
88
|
async function dispatch() {
|
|
@@ -134,6 +136,43 @@ async function dispatch() {
|
|
|
134
136
|
}
|
|
135
137
|
return;
|
|
136
138
|
}
|
|
139
|
+
// --- broadcast "<text>" --to <dir>[,<dir>…] [--op <name>] [--tag a,b] ---
|
|
140
|
+
// ADR-0063 §3: one cross-repo record per target brain, engine-written,
|
|
141
|
+
// origin/date/tag stamped, per-target result + commit posture, no commits.
|
|
142
|
+
if (cmd === 'broadcast') {
|
|
143
|
+
const p = parseArgs('broadcast', argsOf(sub, rest), {
|
|
144
|
+
to: 'value',
|
|
145
|
+
op: 'value',
|
|
146
|
+
tag: 'value',
|
|
147
|
+
});
|
|
148
|
+
const text = p.positionals.join(' ').trim();
|
|
149
|
+
const targets = flagList(p, 'to') ?? [];
|
|
150
|
+
if (!text || targets.length === 0) {
|
|
151
|
+
throw new UsageError('usage: vfkb broadcast "<text>" --to <dir>[,<dir>…] [--op <name>] [--tag a,b]');
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const results = runBroadcast(text, targets, {
|
|
155
|
+
op: flagValue(p, 'op'),
|
|
156
|
+
tags: flagList(p, 'tag'),
|
|
157
|
+
});
|
|
158
|
+
let failed = 0;
|
|
159
|
+
for (const r of results) {
|
|
160
|
+
if (r.ok) {
|
|
161
|
+
process.stdout.write(`written\t${r.target}\t${r.id}\t${r.posture}${r.healed ? '\t(manifest healed — brain was wired but manifest-less, vfkb#193)' : ''}\n`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
failed++;
|
|
165
|
+
process.stdout.write(`REFUSED\t${r.target}\t${r.reason}\n`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
process.stdout.write(`\nbroadcast: ${results.length - failed}/${results.length} written${failed ? ` — ${failed} refused (partial broadcast, visible by design)` : ''}\n`);
|
|
169
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
137
176
|
// export <agents-md|okf> [--out <path>] — ADR-0047 brain export projections:
|
|
138
177
|
// deterministic, generated-marked, never auto-committed publish artifacts.
|
|
139
178
|
if (cmd === 'export') {
|
|
@@ -502,9 +541,32 @@ async function dispatch() {
|
|
|
502
541
|
// NEXT session can resume from it (append-only). ADR-0039: the id comes from the
|
|
503
542
|
// hook's own stdin (KB_SESSION_ID is an optional override) — no harness wiring needed.
|
|
504
543
|
const session = SessionState.load(effectiveSessionId(payloadId));
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
544
|
+
// ADR-0064 §2: journal recovery runs BEFORE the digest renders, so a
|
|
545
|
+
// brain destroyed by a careless git operation is whole again by the time
|
|
546
|
+
// the session reads it. Fail-open (a hook must never error a session);
|
|
547
|
+
// the restore report rides the injected digest — the loud channel —
|
|
548
|
+
// because hook stderr is not reliably surfaced.
|
|
549
|
+
let restoreNote = '';
|
|
550
|
+
try {
|
|
551
|
+
const rec = withExclusive(() => recoverFromJournal(brainDir()));
|
|
552
|
+
if (rec.restored > 0) {
|
|
553
|
+
// Restores bypass appendRecord (no re-journaling loop), so refresh
|
|
554
|
+
// the freshness meta here — a long-lived index consumer must not
|
|
555
|
+
// keep serving a pre-restore view (review m9).
|
|
556
|
+
writeMeta();
|
|
557
|
+
restoreNote =
|
|
558
|
+
`⚠ vfkb restored ${rec.restored} journaled entr${rec.restored === 1 ? 'y' : 'ies'} ` +
|
|
559
|
+
`lost from entries.jsonl — likely a destructive git operation on uncommitted brain ` +
|
|
560
|
+
`state (ADR-0064). Verify with kb_list and commit the brain on your next topic branch.\n\n`;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
/* fail-open — recovery must never cost a session its start */
|
|
565
|
+
}
|
|
566
|
+
const additionalContext = restoreNote +
|
|
567
|
+
(rest.includes('--naive')
|
|
568
|
+
? renderNaiveDump(project, undefined, lim ? Number(lim) : undefined)
|
|
569
|
+
: renderResume(project, session));
|
|
508
570
|
session.save();
|
|
509
571
|
process.stdout.write(JSON.stringify({
|
|
510
572
|
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext },
|
|
@@ -639,6 +701,25 @@ async function dispatch() {
|
|
|
639
701
|
process.stdout.write((r.committed ? 'committed: ' : 'no-op: ') + r.message + '\n');
|
|
640
702
|
return;
|
|
641
703
|
}
|
|
704
|
+
// --- journal purge (--id <id> | --all) — the ADR-0064 §4 redaction escape
|
|
705
|
+
// hatch: removes journal lines and suppresses their (id, updated) pairs so
|
|
706
|
+
// recovery never resurrects a deliberately redacted entry.
|
|
707
|
+
if (cmd === 'journal') {
|
|
708
|
+
if (sub !== 'purge') {
|
|
709
|
+
throw new UsageError('usage: vfkb journal purge (--id <id> | --all)');
|
|
710
|
+
}
|
|
711
|
+
const p = parseArgs('journal purge', rest, { id: 'value', all: 'boolean' });
|
|
712
|
+
const id = flagValue(p, 'id');
|
|
713
|
+
const all = p.flags.has('all');
|
|
714
|
+
if (!!id === all) {
|
|
715
|
+
throw new UsageError('usage: vfkb journal purge (--id <id> | --all)');
|
|
716
|
+
}
|
|
717
|
+
const r = withExclusive(() => purgeJournal(brainDir(), { id, all }));
|
|
718
|
+
process.stdout.write(r.purged > 0
|
|
719
|
+
? `purged ${r.purged} journal line(s); pair(s) suppressed — recovery will never restore them (ADR-0064 §4). Remember: a redaction of entries.jsonl is complete only with this purge.\n`
|
|
720
|
+
: 'no matching journal lines\n');
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
642
723
|
process.stderr.write(USAGE);
|
|
643
724
|
process.exit(1);
|
|
644
725
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// or inconsistent wiring, and the dual-clone drift signal (a brain last stamped by
|
|
4
4
|
// a different engine build). Deterministic; unit-tested (the inner gate per ADR-0023).
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
-
import { join, dirname } from 'node:path';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { join, dirname, relative, resolve, isAbsolute } from 'node:path';
|
|
8
8
|
import { SCHEMA_VERSION, ENGINE_VERSION, ENGINE_COMMIT } from './version.js';
|
|
9
|
+
import { journalStatus } from './journal.js';
|
|
9
10
|
import { readManifest } from './manifest.js';
|
|
10
11
|
function readJson(path) {
|
|
11
12
|
if (!existsSync(path))
|
|
@@ -312,12 +313,81 @@ export async function checkNpmCurrency(opts) {
|
|
|
312
313
|
clearTimeout(timer);
|
|
313
314
|
}
|
|
314
315
|
}
|
|
316
|
+
// #186 — what OLD (pre-plugin) vfkb wiring actually is: a hook command that
|
|
317
|
+
// invokes the ENGINE's hook dispatcher, either through the committed bootstrap
|
|
318
|
+
// (ADR-0031) or by naming a hook subcommand directly.
|
|
319
|
+
//
|
|
320
|
+
// The predicate used to be `JSON.stringify(hook).includes('vfkb')`, which
|
|
321
|
+
// classified ANY hook mentioning the string as old wiring — including the
|
|
322
|
+
// ADR-0059 INACTIVE guard (`.claude/vfkb-guard.mjs`) that the plugin's own
|
|
323
|
+
// MIGRATION_GUIDE tells every consumer to commit. So a correctly migrated repo
|
|
324
|
+
// was told to delete its own INACTIVE detector, and real double wiring was
|
|
325
|
+
// buried in a warning consumers had learned to ignore.
|
|
326
|
+
const HOOK_SUBCOMMANDS = ['session-start', 'pre-tool-use', 'post-tool-use', 'stop', 'session-end'];
|
|
327
|
+
export function isEngineWiring(hookJson) {
|
|
328
|
+
// Compare against the LITERAL command text, not its JSON encoding: a tab in the
|
|
329
|
+
// command is the two characters \\t once stringified, and a quoted subcommand is
|
|
330
|
+
// \\". Un-escaping first means the separator rule below is about real whitespace
|
|
331
|
+
// and quoting rather than about JSON spelling. (Review of PR #216, minor 5: the
|
|
332
|
+
// previous `\\?` sat before the whitespace, where JSON never puts a backslash.)
|
|
333
|
+
const text = hookJson.replace(/\\[tnr]/g, ' ').replace(/\\(.)/g, '$1');
|
|
334
|
+
if (/bootstrap\.mjs/.test(text))
|
|
335
|
+
return true;
|
|
336
|
+
return new RegExp(`\\bhook["'\\s]+(${HOOK_SUBCOMMANDS.join('|')})\\b`).test(text);
|
|
337
|
+
}
|
|
338
|
+
// #212 — a sentinel commit means "this build does not know its own identity"
|
|
339
|
+
// (version.ts's honest `dev` fallback on the tsc/dist path), NOT "a different
|
|
340
|
+
// engine stamped this brain". Comparing a sentinel against a real sha is not a
|
|
341
|
+
// drift observation, it is an absence of one.
|
|
342
|
+
//
|
|
343
|
+
// The old predicate exempted a dev RUNNING engine but not a dev MANIFEST, so a
|
|
344
|
+
// brain stamped by a dist-path build (e.g. the documented `node dist/cli.js`
|
|
345
|
+
// fallback, which is how ViloGate's manifest got `"dev"`) reported drift forever
|
|
346
|
+
// against every real-sha engine.
|
|
347
|
+
export function engineDrift(manifestCommit, runningCommit) {
|
|
348
|
+
if (!manifestCommit || manifestCommit === 'dev')
|
|
349
|
+
return false;
|
|
350
|
+
if (!runningCommit || runningCommit === 'dev')
|
|
351
|
+
return false;
|
|
352
|
+
return manifestCommit !== runningCommit;
|
|
353
|
+
}
|
|
354
|
+
// #206 — the work tree that actually governs a path, or undefined outside a repo.
|
|
355
|
+
function repoToplevel(root) {
|
|
356
|
+
try {
|
|
357
|
+
return execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], {
|
|
358
|
+
encoding: 'utf8',
|
|
359
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
360
|
+
}).trim();
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Containment on path BOUNDARIES: `/repo` must not be judged to contain
|
|
367
|
+
// `/repo-other/...`. Both sides are realpath'd so a symlinked brain dir (or a
|
|
368
|
+
// /tmp that resolves to /private/tmp) compares honestly rather than by spelling.
|
|
369
|
+
function isUnder(parent, child) {
|
|
370
|
+
if (!parent)
|
|
371
|
+
return false;
|
|
372
|
+
const real = (p) => {
|
|
373
|
+
try {
|
|
374
|
+
return realpathSync(p);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return resolve(p);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const rel = relative(real(parent), real(child));
|
|
381
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
382
|
+
}
|
|
315
383
|
export function runDoctor(opts) {
|
|
316
384
|
const { root, brainDir, env } = opts;
|
|
385
|
+
// The identity we compare the brain's stamp against (injectable — see DoctorOpts).
|
|
386
|
+
const runningCommit = opts.engineCommit ?? ENGINE_COMMIT;
|
|
317
387
|
const checks = [];
|
|
318
388
|
const add = (name, status, detail) => checks.push({ name, status, detail });
|
|
319
389
|
// 1. Engine identity (info).
|
|
320
|
-
add('engine', 'ok', `version ${ENGINE_VERSION} · commit ${
|
|
390
|
+
add('engine', 'ok', `version ${ENGINE_VERSION} · commit ${runningCommit} · schema v${SCHEMA_VERSION}`);
|
|
321
391
|
// Read the project settings once (used by plugin detection, hooks checks, and
|
|
322
392
|
// project-consistency below), and detect ADR-0045 plugin wiring up front — it changes
|
|
323
393
|
// what "healthy" means for the wiring checks AND which advice is safe to give
|
|
@@ -329,9 +399,19 @@ export function runDoctor(opts) {
|
|
|
329
399
|
// 2. Brain ↔ engine compat (the load-bearing check).
|
|
330
400
|
const mf = readManifest(brainDir);
|
|
331
401
|
if (!mf) {
|
|
332
|
-
add('brain manifest', 'warn',
|
|
333
|
-
|
|
334
|
-
|
|
402
|
+
add('brain manifest', 'warn',
|
|
403
|
+
// #188 — the ordinary write path (addEntry → appendRecord → backend.append)
|
|
404
|
+
// never touches manifest.json. `writeManifest` has exactly two callers:
|
|
405
|
+
// `vfkb init` (init.ts) and the broadcast heal, which fires only when the
|
|
406
|
+
// manifest is ABSENT (broadcast.ts). Saying "on the next write" claimed
|
|
407
|
+
// behaviour the code does not have.
|
|
408
|
+
// On a PLUGIN-wired repo the message must not prescribe `vfkb init` — that
|
|
409
|
+
// advice is what scaffolds double wiring (issue #77), and the invariant is
|
|
410
|
+
// asserted in doctor.test.ts. A plugin-born brain simply has no manifest
|
|
411
|
+
// until a cross-repo broadcast heals it (vfkb#193).
|
|
412
|
+
plugin
|
|
413
|
+
? `no manifest.json in ${brainDir} — plugin-born brains have none until a cross-repo broadcast heals it (vfkb#193); the ordinary write path never creates one`
|
|
414
|
+
: `no manifest.json in ${brainDir} — run \`vfkb init\` to stamp it (the ordinary write path never creates one)`);
|
|
335
415
|
}
|
|
336
416
|
else if (typeof mf.schema_version !== 'number') {
|
|
337
417
|
add('brain manifest', 'warn', 'manifest has no numeric schema_version');
|
|
@@ -343,10 +423,58 @@ export function runDoctor(opts) {
|
|
|
343
423
|
add('brain↔engine compat', 'warn', `brain schema v${mf.schema_version} is older than engine v${SCHEMA_VERSION} — migration may be needed`);
|
|
344
424
|
}
|
|
345
425
|
else {
|
|
346
|
-
|
|
426
|
+
// #212 — a sentinel stamp is suppressed as DRIFT (it is not drift), but it must
|
|
427
|
+
// not become invisible: the brain's provenance genuinely is unrecorded, and the
|
|
428
|
+
// stamping half of #212 (a build that does not know its commit should not write
|
|
429
|
+
// one) is still open. Reported in the detail rather than as a new warn — it is a
|
|
430
|
+
// true statement, but not one the consumer can act on, and this PR exists to stop
|
|
431
|
+
// doctor crying wolf.
|
|
432
|
+
const sentinel = mf.engine_commit === 'dev'
|
|
433
|
+
? ` · stamped by a build that did not know its own commit ("dev") — provenance unknown (#212)`
|
|
434
|
+
: '';
|
|
435
|
+
add('brain↔engine compat', 'ok', `schema v${mf.schema_version} matches${sentinel}`);
|
|
347
436
|
// Drift signal: same schema but a different engine build last stamped the brain.
|
|
348
|
-
if (mf.engine_commit
|
|
349
|
-
add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${
|
|
437
|
+
if (engineDrift(mf.engine_commit, runningCommit)) {
|
|
438
|
+
add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${runningCommit} — possible dual-clone drift`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// 2b. Durable-capture journal (ADR-0064): what would recovery do, and is a
|
|
442
|
+
// redaction half-done? Read-only — recovery itself runs at session start.
|
|
443
|
+
{
|
|
444
|
+
const js = journalStatus(brainDir);
|
|
445
|
+
if (js.suppressedInEntries > 0) {
|
|
446
|
+
add('journal', 'warn', `${js.suppressedInEntries} suppressed (purged) pair(s) still present in entries.jsonl — a redaction is half-done: remove the line(s) from entries.jsonl too (ADR-0064 §4)`);
|
|
447
|
+
}
|
|
448
|
+
else if (js.restorable > 0) {
|
|
449
|
+
add('journal', 'warn', `${js.restorable} journaled entr${js.restorable === 1 ? 'y' : 'ies'} missing from entries.jsonl — the next session-start restores them (or run \`vfkb hook session-start\` after checking why they vanished; recovery runs ONLY there)`);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
add('journal', 'ok', `${js.walLines} line(s) in the uncommitted window, nothing to restore`);
|
|
453
|
+
}
|
|
454
|
+
// Migration gap (review M3): an existing consumer that never re-ran init
|
|
455
|
+
// has no .journal/ gitignore line — its next `git add .vfkb` would COMMIT
|
|
456
|
+
// the wal (a tracked journal dies by the same reset --hard, RFC-034
|
|
457
|
+
// Alternatives; and a committed wal defeats §4 redaction).
|
|
458
|
+
// #206 — judge only a journal the repo actually governs. With the brain on
|
|
459
|
+
// the default ~/.vfkb tier (VFKB_DATA_DIR outside the work tree),
|
|
460
|
+
// `check-ignore` exits 128 ("path outside work tree"), which the catch-all
|
|
461
|
+
// below read as plain "not ignored" — so doctor advised editing a .gitignore
|
|
462
|
+
// that does not govern that path at all.
|
|
463
|
+
if (existsSync(join(brainDir, '.journal')) && isUnder(repoToplevel(root), join(brainDir, '.journal'))) {
|
|
464
|
+
try {
|
|
465
|
+
execFileSync('git', ['-C', root, 'check-ignore', '-q', join(brainDir, '.journal', 'wal.jsonl')], {
|
|
466
|
+
stdio: 'ignore',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
try {
|
|
471
|
+
execFileSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], { stdio: 'ignore' });
|
|
472
|
+
add('journal gitignore', 'warn', `.vfkb/.journal/ exists but is NOT gitignored — add '.vfkb/.journal/' to .gitignore before the next brain commit (a committed journal defeats its purpose and the ADR-0064 §4 redaction)`);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
/* not a git repo — nothing to ignore */
|
|
476
|
+
}
|
|
477
|
+
}
|
|
350
478
|
}
|
|
351
479
|
}
|
|
352
480
|
// 3. $VFKB_BUNDLE_DIR resolves the bundles (the portability indirection, FR-2).
|
|
@@ -395,7 +523,7 @@ export function runDoctor(opts) {
|
|
|
395
523
|
// 5. Hooks wiring (same plugin logic as check 4).
|
|
396
524
|
const hooks = settings?.hooks ?? {};
|
|
397
525
|
const expected = ['SessionStart', 'PreToolUse', 'Stop', 'SessionEnd'];
|
|
398
|
-
const have = expected.filter((e) => JSON.stringify(hooks[e] ?? '')
|
|
526
|
+
const have = expected.filter((e) => isEngineWiring(JSON.stringify(hooks[e] ?? '')));
|
|
399
527
|
if (plugin && have.length > 0) {
|
|
400
528
|
add('.claude/settings.json', 'warn', `vfkb hooks present ALONGSIDE the plugin (double wiring) — remove them; the plugin's hooks are primary (ADR-0045)`);
|
|
401
529
|
}
|
package/dist/engine.js
CHANGED
|
@@ -370,6 +370,32 @@ export function latestHandoff(all = readAll(), today = nowIso().slice(0, 10), su
|
|
|
370
370
|
}
|
|
371
371
|
return latest;
|
|
372
372
|
}
|
|
373
|
+
// --- Cross-repo operations pin (ADR-0063 §2): the delivery channel for visitor
|
|
374
|
+
// records. Same selection-is-a-filter discipline as the handoff pin: newest
|
|
375
|
+
// injectable entry tagged `cross-repo`. Entries also tagged handoff/next are
|
|
376
|
+
// excluded defensively — ADR-0063 §1 forbids the combination (it would claim the
|
|
377
|
+
// resident's continuity pin), and excluding them here keeps one entry from ever
|
|
378
|
+
// rendering in both pinned sections.
|
|
379
|
+
const CROSS_REPO_PIN_CAP_CHARS = 2000;
|
|
380
|
+
export function latestCrossRepo(all = readAll(), today = nowIso().slice(0, 10), superseded = supersededIds(all)) {
|
|
381
|
+
let latest = null;
|
|
382
|
+
for (const e of all) {
|
|
383
|
+
if (!e.tags.includes('cross-repo'))
|
|
384
|
+
continue;
|
|
385
|
+
if (e.tags.includes('handoff') || e.tags.includes('next'))
|
|
386
|
+
continue;
|
|
387
|
+
if (!isInjectable(e, today, superseded))
|
|
388
|
+
continue;
|
|
389
|
+
if (!latest) {
|
|
390
|
+
latest = e;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const cmp = e.updated.localeCompare(latest.updated) || e.created.localeCompare(latest.created);
|
|
394
|
+
if (cmp >= 0)
|
|
395
|
+
latest = e;
|
|
396
|
+
}
|
|
397
|
+
return latest;
|
|
398
|
+
}
|
|
373
399
|
export function renderContextBundle(project = defaultProject(), budget = SESSION_BUDGET_CHARS) {
|
|
374
400
|
const all = readAll();
|
|
375
401
|
const today = nowIso().slice(0, 10);
|
|
@@ -405,25 +431,74 @@ export function renderContextBundle(project = defaultProject(), budget = SESSION
|
|
|
405
431
|
: handoff.text;
|
|
406
432
|
body += `## Last handoff\n- [${handoff.type} ${trustGlyph(handoff)}] ${text}\n\n`;
|
|
407
433
|
}
|
|
434
|
+
// Cross-repo operations (ADR-0063 §2): pinned after the handoff, never
|
|
435
|
+
// budget-dropped — resident continuity and visitor records each get one
|
|
436
|
+
// guaranteed slot; neither can evict the other. Same bounded-pin discipline
|
|
437
|
+
// as the handoff (visitor records are free text from another session).
|
|
438
|
+
const crossRepo = latestCrossRepo(all, today, superseded);
|
|
439
|
+
if (crossRepo && !constitutionalIds.has(crossRepo.id)) {
|
|
440
|
+
const text = crossRepo.text.length > CROSS_REPO_PIN_CAP_CHARS
|
|
441
|
+
? `${crossRepo.text.slice(0, CROSS_REPO_PIN_CAP_CHARS)}… (truncated — kb_get ${crossRepo.id} for the rest)`
|
|
442
|
+
: crossRepo.text;
|
|
443
|
+
body += `## Cross-repo operations\n- [${crossRepo.type} ${trustGlyph(crossRepo)}] ${text}\n\n`;
|
|
444
|
+
}
|
|
408
445
|
// Context Map (ADR-0006): the navigational index, always injected, never dropped.
|
|
409
446
|
body += renderContextMap() + '\n\n';
|
|
447
|
+
// Ranked lines are collected (not appended) so the omission note can evict
|
|
448
|
+
// trailing lines when needed — under the old append-as-you-go shape, a body
|
|
449
|
+
// that filled the budget exactly silently dropped the note itself, hiding
|
|
450
|
+
// that anything was cut at all (#177, the quiet-omission case).
|
|
451
|
+
const kept = [];
|
|
452
|
+
let keptLen = 0;
|
|
410
453
|
let dropped = 0;
|
|
454
|
+
const fixedLen = () => header.length + body.length + footer.length;
|
|
411
455
|
for (const e of injectable) {
|
|
412
456
|
if (constitutionalIds.has(e.id))
|
|
413
457
|
continue; // already in the Constitution section
|
|
414
458
|
if (handoff && e.id === handoff.id)
|
|
415
459
|
continue; // already pinned in Last handoff
|
|
460
|
+
if (crossRepo && e.id === crossRepo.id)
|
|
461
|
+
continue; // already pinned in Cross-repo operations
|
|
416
462
|
const line = `- [${e.type} ${trustGlyph(e)}] ${e.text}\n`;
|
|
417
|
-
if (
|
|
463
|
+
if (fixedLen() + keptLen + line.length > budget) {
|
|
418
464
|
dropped++;
|
|
419
465
|
continue;
|
|
420
466
|
}
|
|
421
|
-
|
|
467
|
+
kept.push(line);
|
|
468
|
+
keptLen += line.length;
|
|
422
469
|
}
|
|
423
470
|
if (dropped > 0) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
471
|
+
// Visible and actionable, not an HTML comment: name the count and the pull
|
|
472
|
+
// path. Evicting a kept line changes `dropped` and possibly the note's
|
|
473
|
+
// digit count, so the fit is re-checked each round.
|
|
474
|
+
const note = () => `(+ ${dropped} lower-ranked entries omitted for the ${budget}-char budget — kb_search / kb_list pulls them)\n`;
|
|
475
|
+
const evicted = [];
|
|
476
|
+
while (kept.length > 0 && fixedLen() + keptLen + note().length > budget) {
|
|
477
|
+
const line = kept.pop();
|
|
478
|
+
keptLen -= line.length;
|
|
479
|
+
evicted.push(line);
|
|
480
|
+
dropped++;
|
|
481
|
+
}
|
|
482
|
+
if (fixedLen() + keptLen + note().length <= budget) {
|
|
483
|
+
body += kept.join('') + note();
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
// The note cannot fit even with everything evicted (fixed never-dropped
|
|
487
|
+
// sections leave less slack than one note line). Losing content to a
|
|
488
|
+
// note that then never renders would be a double loss — restore the
|
|
489
|
+
// evicted lines and emit without the note (review finding, round 1:
|
|
490
|
+
// the empty-kept fallback rendered FEWER entries than stock, noteless).
|
|
491
|
+
while (evicted.length > 0) {
|
|
492
|
+
const line = evicted.pop();
|
|
493
|
+
kept.push(line);
|
|
494
|
+
keptLen += line.length;
|
|
495
|
+
dropped--;
|
|
496
|
+
}
|
|
497
|
+
body += kept.join('');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
body += kept.join('');
|
|
427
502
|
}
|
|
428
503
|
return header + body + footer;
|
|
429
504
|
}
|
package/dist/init.js
CHANGED
|
@@ -201,7 +201,7 @@ export function initProject(root, opts = {}) {
|
|
|
201
201
|
// 4. .gitignore — the derived/operational stanza (append once).
|
|
202
202
|
{
|
|
203
203
|
const path = join(root, '.gitignore');
|
|
204
|
-
const lines = ['.vfkb/index-meta.json', '.vfkb/.sessions/', '.vfkb/.signals/'];
|
|
204
|
+
const lines = ['.vfkb/index-meta.json', '.vfkb/.sessions/', '.vfkb/.signals/', '.vfkb/.journal/'];
|
|
205
205
|
const existed = existsSync(path);
|
|
206
206
|
const cur = existed ? readFileSync(path, 'utf8') : '';
|
|
207
207
|
const missing = lines.filter((l) => !cur.split(/\r?\n/).includes(l));
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// ADR-0064 (RFC-034) — durable capture: an untracked write-ahead journal
|
|
2
|
+
// closes the brain-loss window between an engine append and the next brain
|
|
3
|
+
// commit. `<brain>/.journal/` is gitignored BY DESIGN: `git checkout --`,
|
|
4
|
+
// `reset --hard`, and stash operate on tracked state and leave it alive.
|
|
5
|
+
//
|
|
6
|
+
// Key discipline (review-hardened, RFC-034 §2/§3): everything is keyed on the
|
|
7
|
+
// `(id, updated)` PAIR, never the bare id — entries.jsonl is an append-only
|
|
8
|
+
// LWW log (a retag appends a new revision line for an existing id), and a
|
|
9
|
+
// bare-id key would prune exactly the uncommitted revision lines the journal
|
|
10
|
+
// exists to protect.
|
|
11
|
+
//
|
|
12
|
+
// This module deliberately imports nothing from storage.ts (storage imports
|
|
13
|
+
// journalAppend, so an import back would cycle); callers pass the brain dir
|
|
14
|
+
// and hold the ADR-0040 lock around recovery themselves.
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
18
|
+
const walPath = (brain) => join(brain, '.journal', 'wal.jsonl');
|
|
19
|
+
const suppressedPath = (brain) => join(brain, '.journal', 'suppressed');
|
|
20
|
+
// Wal rewrites (prune/purge) are temp-file + rename so a crash mid-rewrite
|
|
21
|
+
// cannot truncate the journal. Residual race, documented (review m5): plain
|
|
22
|
+
// `journalAppend`s are deliberately lock-free, so a mirror line landing
|
|
23
|
+
// between a rewrite's read and its rename is dropped from the wal — the
|
|
24
|
+
// primary append is unaffected; that one entry merely loses journal
|
|
25
|
+
// protection until its next revision or commit. Compound-rare and fails
|
|
26
|
+
// toward the pre-journal status quo, never toward corruption.
|
|
27
|
+
function rewriteWal(wal, keep) {
|
|
28
|
+
const tmp = `${wal}.tmp`;
|
|
29
|
+
writeFileSync(tmp, keep.length > 0 ? keep.map((l) => l.raw).join('\n') + '\n' : '', 'utf8');
|
|
30
|
+
renameSync(tmp, wal);
|
|
31
|
+
}
|
|
32
|
+
const pairOf = (r) => `${String(r.id)}\u0000${String(r.updated ?? '')}`;
|
|
33
|
+
function parseLines(text) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const raw of text.split('\n')) {
|
|
36
|
+
if (raw.trim().length === 0)
|
|
37
|
+
continue;
|
|
38
|
+
try {
|
|
39
|
+
const rec = JSON.parse(raw);
|
|
40
|
+
if (typeof rec.id !== 'string')
|
|
41
|
+
continue;
|
|
42
|
+
out.push({ raw, pair: pairOf(rec), id: rec.id });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* a torn/corrupt journal line protects nothing — skip it */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
function pairsOfFile(path) {
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
return new Set();
|
|
53
|
+
return new Set(parseLines(readFileSync(path, 'utf8')).map((l) => l.pair));
|
|
54
|
+
}
|
|
55
|
+
function suppressedPairs(brain) {
|
|
56
|
+
const p = suppressedPath(brain);
|
|
57
|
+
if (!existsSync(p))
|
|
58
|
+
return new Set();
|
|
59
|
+
return new Set(readFileSync(p, 'utf8')
|
|
60
|
+
.split('\n')
|
|
61
|
+
.filter((l) => l.includes('\t'))
|
|
62
|
+
.map((l) => l.replace('\t', '\u0000')));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Mirror one engine append into the journal — called JOURNAL-FIRST (before the
|
|
66
|
+
* primary append; a crash between the two leaves an extra journal line that
|
|
67
|
+
* idempotent recovery treats as lost-and-restorable, which is benign; the
|
|
68
|
+
* reverse order would leave a crash window with the primary unprotected).
|
|
69
|
+
* Fail-open with a loud edge: the safety net must never make capture itself
|
|
70
|
+
* less reliable. `VFKB_NO_JOURNAL=1` is the kill switch (contrast arms,
|
|
71
|
+
* emergencies).
|
|
72
|
+
*/
|
|
73
|
+
export function journalAppend(brain, rec) {
|
|
74
|
+
if (process.env.VFKB_NO_JOURNAL)
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(join(brain, '.journal'), { recursive: true });
|
|
78
|
+
appendFileSync(walPath(brain), JSON.stringify(rec) + '\n', 'utf8');
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
process.stderr.write(`vfkb: journal mirror failed (primary append proceeds unprotected): ${err.message}\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Conservative git classification (RFC-034 §3): a brain is a git brain iff
|
|
85
|
+
// rev-parse succeeds; if the subsequent HEAD read fails for ANY reason
|
|
86
|
+
// (unborn branch, detached/corrupt state, entries not tracked), prune nothing
|
|
87
|
+
// this pass — never prune on uncertainty.
|
|
88
|
+
function pairsAtHead(brain) {
|
|
89
|
+
const repoDir = dirname(brain);
|
|
90
|
+
const git = (...a) => execFileSync('git', ['-C', repoDir, ...a], {
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
93
|
+
}).trim();
|
|
94
|
+
try {
|
|
95
|
+
if (git('rev-parse', '--is-inside-work-tree') !== 'true')
|
|
96
|
+
return 'not-git';
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return 'not-git';
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const top = git('rev-parse', '--show-toplevel');
|
|
103
|
+
const rel = relative(top, join(brain, 'entries.jsonl')).split(sep).join('/');
|
|
104
|
+
const head = execFileSync('git', ['-C', repoDir, 'cat-file', '-p', `HEAD:${rel}`], {
|
|
105
|
+
encoding: 'utf8',
|
|
106
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
107
|
+
});
|
|
108
|
+
return new Set(parseLines(head).map((l) => l.pair));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return 'unknown';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Deterministic recovery + prune (RFC-034 §2/§3). Caller holds the ADR-0040
|
|
116
|
+
* lock (recovery is a read-decide-append op). Idempotent: a second run
|
|
117
|
+
* restores nothing.
|
|
118
|
+
*
|
|
119
|
+
* - restore: every journaled `(id, updated)` line absent from entries.jsonl
|
|
120
|
+
* (and not suppressed) is re-appended VERBATIM — byte-identical, same ids.
|
|
121
|
+
* - prune: git brains drop wal lines whose pair is committed at HEAD
|
|
122
|
+
* (committed is durable); non-git brains drop pairs present in
|
|
123
|
+
* entries.jsonl itself; suppressed pairs are dropped as deliberately dead.
|
|
124
|
+
*/
|
|
125
|
+
export function recoverFromJournal(brain) {
|
|
126
|
+
// Kill-switch symmetry (review m7): an operator who disabled the journal in
|
|
127
|
+
// an emergency must not keep getting session-start restores from a stale wal.
|
|
128
|
+
if (process.env.VFKB_NO_JOURNAL)
|
|
129
|
+
return { restored: 0, pruned: 0 };
|
|
130
|
+
const wal = walPath(brain);
|
|
131
|
+
if (!existsSync(wal))
|
|
132
|
+
return { restored: 0, pruned: 0 };
|
|
133
|
+
const walLines = parseLines(readFileSync(wal, 'utf8'));
|
|
134
|
+
if (walLines.length === 0)
|
|
135
|
+
return { restored: 0, pruned: 0 };
|
|
136
|
+
const entriesPath = join(brain, 'entries.jsonl');
|
|
137
|
+
const present = pairsOfFile(entriesPath);
|
|
138
|
+
const suppressed = suppressedPairs(brain);
|
|
139
|
+
const toRestore = walLines.filter((l) => !present.has(l.pair) && !suppressed.has(l.pair));
|
|
140
|
+
if (toRestore.length > 0) {
|
|
141
|
+
// Torn-tail guard (review M1): a crash mid-append or a hand-redaction saved
|
|
142
|
+
// without a final newline leaves entries.jsonl ending in a partial line —
|
|
143
|
+
// appending blind would GLUE the restored line onto it, corrupting the very
|
|
144
|
+
// entry recovery exists to save (and the same-pass prune would then drop
|
|
145
|
+
// the journal copy: permanent loss in the non-git tier).
|
|
146
|
+
let prefix = '';
|
|
147
|
+
if (existsSync(entriesPath)) {
|
|
148
|
+
const cur = readFileSync(entriesPath, 'utf8');
|
|
149
|
+
if (cur.length > 0 && !cur.endsWith('\n'))
|
|
150
|
+
prefix = '\n';
|
|
151
|
+
}
|
|
152
|
+
appendFileSync(entriesPath, prefix + toRestore.map((l) => l.raw).join('\n') + '\n', 'utf8');
|
|
153
|
+
}
|
|
154
|
+
// Prune. After restore, every wal pair is in entries.jsonl; durability of
|
|
155
|
+
// the FILE is git's job for git brains, the file itself otherwise.
|
|
156
|
+
const head = pairsAtHead(brain);
|
|
157
|
+
let keep;
|
|
158
|
+
if (head === 'unknown') {
|
|
159
|
+
// HEAD unreadable — prune NOTHING this pass (RFC-034 §3, literally: never
|
|
160
|
+
// prune on uncertainty; even suppressed lines wait for a readable pass).
|
|
161
|
+
keep = walLines;
|
|
162
|
+
}
|
|
163
|
+
else if (head === 'not-git') {
|
|
164
|
+
keep = walLines.filter((l) => !suppressed.has(l.pair) && !present.has(l.pair) && !toRestore.includes(l));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
keep = walLines.filter((l) => !suppressed.has(l.pair) && !head.has(l.pair));
|
|
168
|
+
}
|
|
169
|
+
const pruned = walLines.length - keep.length;
|
|
170
|
+
if (pruned > 0) {
|
|
171
|
+
rewriteWal(wal, keep);
|
|
172
|
+
}
|
|
173
|
+
return { restored: toRestore.length, pruned };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Redaction escape hatch (RFC-034 §4): remove matching lines from the journal
|
|
177
|
+
* and record their pairs in `.journal/suppressed` — a suppressed pair is never
|
|
178
|
+
* recovered again, so a deliberate redaction of entries.jsonl stays redacted.
|
|
179
|
+
* A FUTURE clean revision of the same id journals and recovers normally (its
|
|
180
|
+
* new `updated` stamp makes a new pair).
|
|
181
|
+
*/
|
|
182
|
+
export function purgeJournal(brain, opts) {
|
|
183
|
+
const wal = walPath(brain);
|
|
184
|
+
if (!existsSync(wal))
|
|
185
|
+
return { purged: 0 };
|
|
186
|
+
const walLines = parseLines(readFileSync(wal, 'utf8'));
|
|
187
|
+
const gone = walLines.filter((l) => (opts.all ? true : l.id === opts.id));
|
|
188
|
+
if (gone.length === 0)
|
|
189
|
+
return { purged: 0 };
|
|
190
|
+
const keep = walLines.filter((l) => !gone.includes(l));
|
|
191
|
+
mkdirSync(join(brain, '.journal'), { recursive: true });
|
|
192
|
+
appendFileSync(suppressedPath(brain), gone.map((l) => l.pair.replace('\u0000', '\t')).join('\n') + '\n', 'utf8');
|
|
193
|
+
rewriteWal(wal, keep);
|
|
194
|
+
return { purged: gone.length };
|
|
195
|
+
}
|
|
196
|
+
/** Doctor's read-only view: what would recovery do, and is a redaction half-done? */
|
|
197
|
+
export function journalStatus(brain) {
|
|
198
|
+
const wal = walPath(brain);
|
|
199
|
+
const walLines = existsSync(wal) ? parseLines(readFileSync(wal, 'utf8')) : [];
|
|
200
|
+
const present = pairsOfFile(join(brain, 'entries.jsonl'));
|
|
201
|
+
const suppressed = suppressedPairs(brain);
|
|
202
|
+
return {
|
|
203
|
+
walLines: walLines.length,
|
|
204
|
+
restorable: walLines.filter((l) => !present.has(l.pair) && !suppressed.has(l.pair)).length,
|
|
205
|
+
suppressedInEntries: [...suppressed].filter((p) => present.has(p)).length,
|
|
206
|
+
};
|
|
207
|
+
}
|