@viloforge/vfkb 0.2.3 → 0.4.0

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
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process';
6
6
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
7
  import { join, dirname } 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))
@@ -349,6 +350,40 @@ export function runDoctor(opts) {
349
350
  add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${ENGINE_COMMIT} — possible dual-clone drift`);
350
351
  }
351
352
  }
353
+ // 2b. Durable-capture journal (ADR-0064): what would recovery do, and is a
354
+ // redaction half-done? Read-only — recovery itself runs at session start.
355
+ {
356
+ const js = journalStatus(brainDir);
357
+ if (js.suppressedInEntries > 0) {
358
+ 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)`);
359
+ }
360
+ else if (js.restorable > 0) {
361
+ 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)`);
362
+ }
363
+ else {
364
+ add('journal', 'ok', `${js.walLines} line(s) in the uncommitted window, nothing to restore`);
365
+ }
366
+ // Migration gap (review M3): an existing consumer that never re-ran init
367
+ // has no .journal/ gitignore line — its next `git add .vfkb` would COMMIT
368
+ // the wal (a tracked journal dies by the same reset --hard, RFC-034
369
+ // Alternatives; and a committed wal defeats §4 redaction).
370
+ if (existsSync(join(brainDir, '.journal'))) {
371
+ try {
372
+ execFileSync('git', ['-C', root, 'check-ignore', '-q', join(brainDir, '.journal', 'wal.jsonl')], {
373
+ stdio: 'ignore',
374
+ });
375
+ }
376
+ catch {
377
+ try {
378
+ execFileSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], { stdio: 'ignore' });
379
+ 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)`);
380
+ }
381
+ catch {
382
+ /* not a git repo — nothing to ignore */
383
+ }
384
+ }
385
+ }
386
+ }
352
387
  // 3. $VFKB_BUNDLE_DIR resolves the bundles (the portability indirection, FR-2).
353
388
  // On a plugin-wired repo the plugin vendors its own engine copy (ADR-0045), so an
354
389
  // unset bundle dir is healthy, not a gap.
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
+ }
package/dist/storage.js CHANGED
@@ -8,6 +8,7 @@ import { basename, dirname, join, resolve } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { storageBackend } from './backend.js';
11
+ import { journalAppend } from './journal.js';
11
12
  import { normalizeEntry } from './validate.js';
12
13
  export function isTombstone(r) {
13
14
  return r.deleted === true;
@@ -51,7 +52,15 @@ export function defaultProject() {
51
52
  // GUARANTEED side-effect (mykb L11; ADR-0014) — policy, so it lives here,
52
53
  // above the backend's raw append. ---
53
54
  export function appendRecord(rec) {
54
- storageBackend().append(rec);
55
+ const be = storageBackend();
56
+ // ADR-0064: journal-first untracked mirror — the durability floor for the
57
+ // window between this append and the next brain commit. JSONL-fs only: the
58
+ // journal is the file tier's crash net; a non-fs backend owns its own
59
+ // durability, and its location() is not a filesystem path (mirroring it
60
+ // materialized a literal 'memory:/test/.journal' dir in the cwd).
61
+ if (be.name === 'jsonl-fs')
62
+ journalAppend(be.location(), rec);
63
+ be.append(rec);
55
64
  writeMeta();
56
65
  }
57
66
  // ADR-0040: the exclusive section for read-decide-append engine ops, provided by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viloforge/vfkb",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "ViloForge KnowledgeBase (vfkb) — per-project knowledge substrate for the ViloForge software factory. Greenfield TypeScript.",
5
5
  "type": "module",
6
6
  "main": "dist/engine.js",