@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/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
- const additionalContext = rest.includes('--naive')
506
- ? renderNaiveDump(project, undefined, lim ? Number(lim) : undefined)
507
- : renderResume(project, session);
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 ${ENGINE_COMMIT} · schema v${SCHEMA_VERSION}`);
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', plugin
333
- ? `no manifest.json in ${brainDir} it will be stamped on the next write`
334
- : `no manifest.json in ${brainDir} run \`vfkb init\` (or it will be stamped on next write)`);
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
- add('brain↔engine compat', 'ok', `schema v${mf.schema_version} matches`);
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 && ENGINE_COMMIT !== 'dev' && mf.engine_commit !== ENGINE_COMMIT) {
349
- add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${ENGINE_COMMIT} — possible dual-clone drift`);
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] ?? '').includes('vfkb'));
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 (header.length + body.length + line.length + footer.length > budget) {
463
+ if (fixedLen() + keptLen + line.length > budget) {
418
464
  dropped++;
419
465
  continue;
420
466
  }
421
- body += line;
467
+ kept.push(line);
468
+ keptLen += line.length;
422
469
  }
423
470
  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;
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));
@@ -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
+ }