@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/broadcast.js +134 -0
- package/dist/bundles/vfkb-mcp.mjs +106 -39
- package/dist/bundles/vfkb.mjs +545 -177
- package/dist/cli.js +86 -5
- package/dist/doctor.js +35 -0
- 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
|
@@ -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 (
|
|
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
|
+
}
|
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()
|
|
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