@viloforge/vfkb 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,635 @@
1
+ #!/usr/bin/env node
2
+ // vfkb thin CLI — the face the Claude Code hooks call (ADR-0015 Tier A/B).
3
+ // `hook session-start` / `hook post-tool-use` carry the harness JSON contract.
4
+ import { addEntry, captureToolCall, readAll, renderContext, renderContextBundle, renderContextMap, renderNaiveDump, renderResume, initContextSpine, supersede, deriveTrust, } from './engine.js';
5
+ import { SessionState, effectiveSessionId } from './session.js';
6
+ import { defaultProject } from './storage.js';
7
+ import { runExport } from './export.js';
8
+ import { promote, archive, mergeDuplicate, findLexicalDuplicates, promoteIfCorroborated, eligibleForPromotion, } from './curator.js';
9
+ import { distill } from './distiller.js';
10
+ import { recordSignal, tally } from './counters.js';
11
+ import { queryExplained } from './read.js';
12
+ import { isBrainWrite, GATING_REASON } from './gating.js';
13
+ import { decideStop, gatherStopContext } from './stop-reminder.js';
14
+ import { save } from './git.js';
15
+ import { runSessionEnd } from './session-end.js';
16
+ import { initProject, approvalNotice } from './init.js';
17
+ import { runDoctor, renderDoctor, checkNpmCurrency } from './doctor.js';
18
+ import { ENGINE_VERSION } from './version.js';
19
+ import { fromMykb, fromAdr, fromMarkdown, resolveMykbArea } from './import.js';
20
+ import { parseArgs, flagValue, flagList, flagInt, UsageError } from './args.js';
21
+ import { ENTRY_TYPES, DECISION_STATUSES } from './types.js';
22
+ import { readFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+ // The package's OWN version, read from ITS OWN package.json at runtime — never
26
+ // hardcoded (ADR-0057 step 1). Resolved relative to this module file so it works
27
+ // from dist/cli.js inside an `npm i -g` install: bin -> dist/cli.js, package.json
28
+ // is one level up from dist/, in both the tsc dev build and the packed tarball.
29
+ function packageVersion() {
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
32
+ return pkg.version || '0.0.0';
33
+ }
34
+ function readStdin() {
35
+ return new Promise((resolve) => {
36
+ let data = '';
37
+ if (process.stdin.isTTY)
38
+ return resolve('');
39
+ process.stdin.setEncoding('utf8');
40
+ process.stdin.on('data', (c) => (data += c));
41
+ process.stdin.on('end', () => resolve(data));
42
+ // Guard: if nothing arrives, don't hang forever.
43
+ setTimeout(() => resolve(data), 2000).unref?.();
44
+ });
45
+ }
46
+ // Positional flag lookup for the HOOK subcommands only — hooks stay fail-open
47
+ // (never error a harness session over argv), so they bypass the strict parser.
48
+ function flag(args, name) {
49
+ const i = args.indexOf(`--${name}`);
50
+ return i >= 0 ? args[i + 1] : undefined;
51
+ }
52
+ function argsOf(sub, rest) {
53
+ return [sub, ...rest].filter((a) => a !== undefined);
54
+ }
55
+ function entryType(verb, raw) {
56
+ if (!raw || !ENTRY_TYPES.includes(raw)) {
57
+ throw new UsageError(`${verb}: unknown entry type '${raw ?? ''}' — expected ${ENTRY_TYPES.join('|')}`);
58
+ }
59
+ return raw;
60
+ }
61
+ async function main() {
62
+ try {
63
+ await dispatch();
64
+ }
65
+ catch (err) {
66
+ if (err instanceof UsageError) {
67
+ process.stderr.write(`error: ${err.message}\n${USAGE}`);
68
+ process.exit(1);
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+ const USAGE = 'usage: vfkb <add|list|search|query|map|context|context init|resume|resume-note|curate|distill|save|' +
74
+ 'export|import|init|doctor|supersede|context-block|context-block-naive|--version|' +
75
+ 'hook session-start|hook pre-tool-use|hook post-tool-use|hook stop|hook session-end>\n';
76
+ async function dispatch() {
77
+ const [cmd, sub, ...rest] = process.argv.slice(2);
78
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
79
+ process.stdout.write(USAGE);
80
+ return;
81
+ }
82
+ // --version / -v / version: the package's own version, nothing else on stdout
83
+ // (ADR-0057 step 1 — needed by the install proof, RFC-030's update check, and
84
+ // every future bug report).
85
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
86
+ process.stdout.write(`${packageVersion()}\n`);
87
+ return;
88
+ }
89
+ // --- add <type> <text> [--role] [--tag a,b] [--why …] [--status] [--prov-status] [--valid-until] ---
90
+ if (cmd === 'add') {
91
+ const p = parseArgs('add', argsOf(sub, rest), {
92
+ role: 'value',
93
+ tag: 'value',
94
+ why: 'value',
95
+ contradicts: 'value',
96
+ status: 'value',
97
+ 'prov-status': 'value',
98
+ 'valid-until': 'value',
99
+ zone: 'value',
100
+ constitutional: 'boolean',
101
+ });
102
+ const type = entryType('add', p.positionals[0]);
103
+ const textArg = p.positionals.slice(1).join(' ').trim();
104
+ if (!textArg)
105
+ throw new UsageError('add: missing entry text');
106
+ const role = flagValue(p, 'role') || 'executor';
107
+ try {
108
+ const e = addEntry(type, textArg, {
109
+ role,
110
+ why: flagValue(p, 'why'),
111
+ tags: flagList(p, 'tag') ?? [],
112
+ contradicts: flagList(p, 'contradicts'),
113
+ status: flagValue(p, 'status'),
114
+ provStatus: flagValue(p, 'prov-status'),
115
+ validUntil: flagValue(p, 'valid-until'),
116
+ zone: flagValue(p, 'zone'),
117
+ constitutional: p.flags.get('constitutional') === true,
118
+ });
119
+ process.stdout.write(`${e.id}\t[${e.type} ${deriveTrust(e.author.role)}]\t${e.text}\n`);
120
+ }
121
+ catch (err) {
122
+ process.stderr.write(`error: ${err.message}\n`);
123
+ process.exit(1);
124
+ }
125
+ return;
126
+ }
127
+ // export <agents-md|okf> [--out <path>] — ADR-0047 brain export projections:
128
+ // deterministic, generated-marked, never auto-committed publish artifacts.
129
+ if (cmd === 'export') {
130
+ const p = parseArgs('export', rest, { out: 'value' });
131
+ if (p.positionals.length > 0) {
132
+ throw new UsageError(`export: unexpected argument '${p.positionals[0]}'`);
133
+ }
134
+ try {
135
+ process.stdout.write(runExport(sub, { out: flagValue(p, 'out') }) + '\n');
136
+ }
137
+ catch (err) {
138
+ process.stderr.write(`error: ${err.message}\n`);
139
+ process.exit(1);
140
+ }
141
+ return;
142
+ }
143
+ // init [project]: FR-1 (ADR-0030) — idempotently scaffold THIS repo (cwd) as a
144
+ // vfkb consumer (portable $VFKB_HOME wiring + .gitignore + empty brain + snippet).
145
+ if (cmd === 'init') {
146
+ const p = parseArgs('init', argsOf(sub, rest), {});
147
+ if (p.positionals.length > 1)
148
+ throw new UsageError('init: at most one [project] argument');
149
+ const root = process.cwd();
150
+ const project = p.positionals[0] || process.env.VFKB_PROJECT;
151
+ const changes = initProject(root, { project });
152
+ const resolved = project || root.split(/[/\\]/).filter(Boolean).pop() || 'project';
153
+ for (const c of changes)
154
+ process.stdout.write(`${c.action}\t${c.path}\n`);
155
+ process.stdout.write('\n' + approvalNotice(resolved) + '\n');
156
+ return;
157
+ }
158
+ // import: FR-3 (ADR-0030) — migrate existing knowledge into the brain (lossy,
159
+ // role=import). --from-mykb <area> | --from-adr [dir] | --from-markdown <file>.
160
+ if (cmd === 'import') {
161
+ const p = parseArgs('import', argsOf(sub, rest), {
162
+ 'from-adr': 'optional-value',
163
+ 'from-markdown': 'value',
164
+ 'from-mykb': 'value',
165
+ });
166
+ if (p.positionals.length > 0) {
167
+ throw new UsageError(`import: unexpected argument '${p.positionals[0]}'`);
168
+ }
169
+ const results = [];
170
+ try {
171
+ if (p.flags.has('from-adr'))
172
+ results.push(...fromAdr(flagValue(p, 'from-adr') || 'docs/adr'));
173
+ const md = flagValue(p, 'from-markdown');
174
+ if (md)
175
+ results.push(...fromMarkdown(md));
176
+ const mykb = flagValue(p, 'from-mykb');
177
+ if (mykb)
178
+ results.push(...fromMykb(resolveMykbArea(mykb)));
179
+ }
180
+ catch (err) {
181
+ process.stderr.write(`error: ${err.message}\n`);
182
+ process.exit(1);
183
+ }
184
+ if (results.length === 0) {
185
+ process.stderr.write('import: nothing imported — pass --from-mykb <area> | --from-adr [dir] | --from-markdown <file>\n');
186
+ process.exit(1);
187
+ }
188
+ for (const r of results)
189
+ process.stdout.write(`${r.id}\t${r.type}\t${r.text.split('\n')[0].slice(0, 80)}\n`);
190
+ process.stdout.write(`\nimported ${results.length} entr${results.length === 1 ? 'y' : 'ies'} (role=import, unverified)\n`);
191
+ return;
192
+ }
193
+ // doctor: FR-4 (ADR-0030) — diagnose brain↔engine compat + wiring health.
194
+ // --check-remote (ADR-0058/RFC-030): opt-in npm currency line; plain `doctor`
195
+ // stays fully offline (no fetch call, byte-identical output to pre-ADR-0058).
196
+ if (cmd === 'doctor') {
197
+ const pd = parseArgs('doctor', argsOf(sub, rest), { 'check-remote': 'boolean' });
198
+ if (pd.positionals.length > 0) {
199
+ throw new UsageError(`doctor: unexpected argument '${pd.positionals[0]}'`);
200
+ }
201
+ const brainDir = process.env.VFKB_DATA_DIR || process.env.VFKB_DIR || '.vfkb';
202
+ const report = runDoctor({ root: process.cwd(), brainDir, env: process.env });
203
+ if (pd.flags.get('check-remote') === true) {
204
+ const npm = await checkNpmCurrency({ brainDir, installedVersion: ENGINE_VERSION });
205
+ report.checks.push({ name: 'npm currency', status: npm.status, detail: npm.detail });
206
+ report.ok = !report.checks.some((c) => c.status === 'fail');
207
+ }
208
+ process.stdout.write(renderDoctor(report) + '\n');
209
+ if (!report.ok)
210
+ process.exit(1);
211
+ return;
212
+ }
213
+ // list [--type t] [--tag a,b] [--status s] [--limit N] — raw dump with structural
214
+ // filters (issue #95 fix (a)). Tag semantics mirror read.ts: entry carries ALL of
215
+ // them. --limit keeps the MOST RECENT N (append order tail).
216
+ if (cmd === 'list') {
217
+ const p = parseArgs('list', argsOf(sub, rest), {
218
+ type: 'value',
219
+ tag: 'value',
220
+ status: 'value',
221
+ limit: 'value',
222
+ });
223
+ if (p.positionals.length > 0) {
224
+ throw new UsageError(`list: unexpected argument '${p.positionals[0]}' (list takes only flags)`);
225
+ }
226
+ const type = flagValue(p, 'type');
227
+ if (type)
228
+ entryType('list --type', type);
229
+ const tags = flagList(p, 'tag');
230
+ const status = flagValue(p, 'status');
231
+ if (status && !DECISION_STATUSES.includes(status)) {
232
+ throw new UsageError(`list: unknown --status '${status}' — expected ${DECISION_STATUSES.join('|')}`);
233
+ }
234
+ const limit = flagInt(p, 'limit');
235
+ let entries = readAll();
236
+ if (type)
237
+ entries = entries.filter((e) => e.type === type);
238
+ if (tags)
239
+ entries = entries.filter((e) => tags.every((t) => e.tags.includes(t)));
240
+ if (status)
241
+ entries = entries.filter((e) => e.status === status);
242
+ if (limit !== undefined)
243
+ entries = entries.slice(-limit);
244
+ for (const e of entries) {
245
+ process.stdout.write(`${e.id}\t${e.type}\t${deriveTrust(e.author.role)}\t${e.provenance.status}\t${e.text}\n`);
246
+ }
247
+ return;
248
+ }
249
+ if (cmd === 'context-block') {
250
+ const p = parseArgs('context-block', argsOf(sub, rest), {});
251
+ if (p.positionals.length > 1)
252
+ throw new UsageError('context-block: at most one [project] argument');
253
+ process.stdout.write(renderContextBundle(p.positionals[0] || defaultProject()));
254
+ return;
255
+ }
256
+ if (cmd === 'map') {
257
+ const p = parseArgs('map', argsOf(sub, rest), {});
258
+ if (p.positionals.length > 0)
259
+ throw new UsageError(`map: unexpected argument '${p.positionals[0]}'`);
260
+ process.stdout.write(renderContextMap() + '\n');
261
+ return;
262
+ }
263
+ // context [project] | context init: the project context doc (D-ii / ADR-0025) — the
264
+ // assembled "agent's first read" (authored spine + derived Constitution/Map/decisions).
265
+ // `init` scaffolds the authored spine (<brain>/context.md) if absent.
266
+ if (cmd === 'context') {
267
+ const p = parseArgs('context', argsOf(sub, rest), {});
268
+ if (p.positionals.length > 1)
269
+ throw new UsageError('context: at most one [init|project] argument');
270
+ if (p.positionals[0] === 'init') {
271
+ const { created, path } = initContextSpine();
272
+ process.stdout.write(`${created ? 'created' : 'exists'}\t${path}\n`);
273
+ return;
274
+ }
275
+ process.stdout.write(renderContext(p.positionals[0] || defaultProject()));
276
+ return;
277
+ }
278
+ // resume [project]: the session-continuity render (ADR-0020) — prior-session
279
+ // digest (derived) + the live knowledge bundle. The MCP-pull-floor / CLI face.
280
+ if (cmd === 'resume') {
281
+ const p = parseArgs('resume', argsOf(sub, rest), {});
282
+ if (p.positionals.length > 1)
283
+ throw new UsageError('resume: at most one [project] argument');
284
+ process.stdout.write(renderResume(p.positionals[0] || defaultProject(), SessionState.load()) + '\n');
285
+ return;
286
+ }
287
+ // curate: ACE curator maintenance (ADR-0021) — deltas only, NEVER rewrites text.
288
+ // curate dups list exact lexical duplicate pairs (proposal only)
289
+ // curate promote <id> incoming -> established
290
+ // curate archive <id> retire out of the injection set
291
+ // curate merge <loserId> <winnerId> archive the loser, keep the winner
292
+ // curate signal <id> <helpful|harmful> record an append-only corroboration signal
293
+ // curate promote-auto <id> promote ONLY if corroborated (>=N signals)
294
+ if (cmd === 'curate') {
295
+ const p = parseArgs('curate', argsOf(sub, rest), {});
296
+ const [action, id1, id2] = p.positionals;
297
+ const need = (n, usage) => {
298
+ if (p.positionals.length - 1 !== n)
299
+ throw new UsageError(`usage: vfkb curate ${usage}`);
300
+ };
301
+ try {
302
+ if (action === 'dups') {
303
+ need(0, 'dups');
304
+ const dups = findLexicalDuplicates();
305
+ for (const d of dups)
306
+ process.stdout.write(`DUP\tloser=${d.loser}\twinner=${d.winner}\n`);
307
+ if (dups.length === 0)
308
+ process.stdout.write('no exact lexical duplicates\n');
309
+ }
310
+ else if (action === 'signal') {
311
+ need(2, 'signal <id> <helpful|harmful>');
312
+ if (id2 !== 'helpful' && id2 !== 'harmful') {
313
+ process.stderr.write('usage: vfkb curate signal <id> <helpful|harmful>\n');
314
+ process.exit(1);
315
+ }
316
+ recordSignal(id1, id2, 'operator');
317
+ const t = tally(id1);
318
+ process.stdout.write(`signal ${id2} -> ${id1} (helpful ${t.helpful} / harmful ${t.harmful} / net ${t.net}` +
319
+ `${eligibleForPromotion(id1) ? ', promotable' : ''})\n`);
320
+ }
321
+ else if (action === 'promote-auto') {
322
+ need(1, 'promote-auto <id>');
323
+ const e = promoteIfCorroborated(id1);
324
+ process.stdout.write(`promoted (corroborated) ${e.id} -> ${e.zone}\n`);
325
+ }
326
+ else if (action === 'promote') {
327
+ need(1, 'promote <id>');
328
+ const e = promote(id1);
329
+ process.stdout.write(`promoted ${e.id} -> ${e.zone}\n`);
330
+ }
331
+ else if (action === 'archive') {
332
+ need(1, 'archive <id>');
333
+ const e = archive(id1);
334
+ process.stdout.write(`archived ${e.id}\n`);
335
+ }
336
+ else if (action === 'merge') {
337
+ need(2, 'merge <loser> <winner>');
338
+ mergeDuplicate(id1, id2);
339
+ process.stdout.write(`merged ${id1} -> ${id2} (loser archived)\n`);
340
+ }
341
+ else {
342
+ process.stderr.write('usage: vfkb curate <dups|promote <id>|promote-auto <id>|archive <id>|merge <loser> <winner>|signal <id> <helpful|harmful>>\n');
343
+ process.exit(1);
344
+ }
345
+ }
346
+ catch (err) {
347
+ process.stderr.write(`error: ${err.message}\n`);
348
+ process.exit(1);
349
+ }
350
+ return;
351
+ }
352
+ // distill: auto-distill write side (ADR-0021) — turn this session's captured failures
353
+ // into CANDIDATE gotchas in incoming/unverified (containment). Recurrence corroborates
354
+ // an existing candidate instead of duplicating. Deterministic; never touches the
355
+ // trusted set. With KB_SESSION_ID, restricts to the session's captured ids; else all.
356
+ if (cmd === 'distill') {
357
+ const pd = parseArgs('distill', argsOf(sub, rest), {});
358
+ if (pd.positionals.length > 0) {
359
+ throw new UsageError(`distill: unexpected argument '${pd.positionals[0]}'`);
360
+ }
361
+ const session = SessionState.load();
362
+ const ids = session.capturedIds;
363
+ const { created, corroborated } = distill(ids.length ? ids : undefined);
364
+ // Distilling IS session activity — advance the record so the just-written lessons
365
+ // fall inside this session's [startedAt, lastAt] window (M3: the next session's
366
+ // resume digest derives "learned this session" from that window). No-op when ephemeral.
367
+ session.save();
368
+ for (const e of created)
369
+ process.stdout.write(`CANDIDATE\t${e.id}\tincoming/unverified\t${e.text}\n`);
370
+ for (const id of corroborated)
371
+ process.stdout.write(`CORROBORATED\t${id}\t${tally(id).net} net\n`);
372
+ if (created.length === 0 && corroborated.length === 0) {
373
+ process.stdout.write('no distillable failure signals\n');
374
+ }
375
+ return;
376
+ }
377
+ // resume-note <text...>: attach an ASSERTED operator intent ("next: …") to the
378
+ // current session's record. Persists only with KB_SESSION_ID (else ephemeral).
379
+ if (cmd === 'resume-note') {
380
+ const p = parseArgs('resume-note', argsOf(sub, rest), {});
381
+ const session = SessionState.load();
382
+ const note = p.positionals.join(' ').trim();
383
+ if (!note) {
384
+ process.stderr.write('usage: vfkb resume-note <text>\n');
385
+ process.exit(1);
386
+ }
387
+ session.setNote(note);
388
+ session.save();
389
+ process.stdout.write(session.sessionId
390
+ ? `noted for session ${session.sessionId}: ${note}\n`
391
+ : `note set (ephemeral — set KB_SESSION_ID to persist): ${note}\n`);
392
+ return;
393
+ }
394
+ // context-block-naive: mykb-v1-style flat unfiltered dump (L4 contrast baseline).
395
+ // --limit N truncates load-order (oldest-first) to N entries (reproduces the
396
+ // budget-drops-newest incident).
397
+ if (cmd === 'context-block-naive') {
398
+ const p = parseArgs('context-block-naive', argsOf(sub, rest), { limit: 'value' });
399
+ if (p.positionals.length > 1)
400
+ throw new UsageError('context-block-naive: at most one [project] argument');
401
+ const lim = flagInt(p, 'limit');
402
+ process.stdout.write(renderNaiveDump(p.positionals[0] || defaultProject(), undefined, lim));
403
+ return;
404
+ }
405
+ // supersede <oldId> <text...> [--role r] [--why w]
406
+ if (cmd === 'supersede') {
407
+ if (!sub || sub.startsWith('--')) {
408
+ throw new UsageError('usage: vfkb supersede <oldId> <text…> [--role r] [--why w]');
409
+ }
410
+ const p = parseArgs('supersede', rest, { role: 'value', why: 'value' });
411
+ const newText = p.positionals.join(' ').trim();
412
+ if (!newText)
413
+ throw new UsageError('supersede: missing new text');
414
+ try {
415
+ const e = supersede(sub, newText, {
416
+ role: flagValue(p, 'role') || 'human',
417
+ why: flagValue(p, 'why'),
418
+ });
419
+ process.stdout.write(`${e.id}\tsupersedes ${sub}\t${e.text}\n`);
420
+ }
421
+ catch (err) {
422
+ process.stderr.write(`error: ${err.message}\n`);
423
+ process.exit(1);
424
+ }
425
+ return;
426
+ }
427
+ // search/query: vfkb search <text> [--type t] [--tag a,b] [--zone z] [--status s]
428
+ // [--role r] [--verified] [--limit N] [--stale] [--superseded]
429
+ if (cmd === 'search' || cmd === 'query') {
430
+ const p = parseArgs(cmd, argsOf(sub, rest), {
431
+ type: 'value',
432
+ tag: 'value',
433
+ zone: 'value',
434
+ status: 'value',
435
+ role: 'value',
436
+ limit: 'value',
437
+ verified: 'boolean',
438
+ stale: 'boolean',
439
+ superseded: 'boolean',
440
+ });
441
+ const text = p.positionals.join(' ');
442
+ const { results, diagnosis } = queryExplained({
443
+ text: text || undefined,
444
+ type: flagValue(p, 'type'),
445
+ zone: flagValue(p, 'zone'),
446
+ status: flagValue(p, 'status'),
447
+ tags: flagList(p, 'tag'),
448
+ authorRole: flagValue(p, 'role'),
449
+ verifiedOnly: p.flags.get('verified') === true,
450
+ limit: flagInt(p, 'limit'),
451
+ includeStale: p.flags.get('stale') === true,
452
+ includeSuperseded: p.flags.get('superseded') === true,
453
+ });
454
+ for (const e of results) {
455
+ const contra = e.refs?.contradicts?.length ? `\t⚔ contradicts ${e.refs.contradicts.join(',')}` : '';
456
+ process.stdout.write(`${e.id}\t${e.type}\t${deriveTrust(e.author.role)}\t${e.text}${contra}\n`);
457
+ }
458
+ // RFC-002: an empty result is reported with its cause, not silence — so the
459
+ // human/agent reading the CLI knows "no recorded entry" vs "all matches stale".
460
+ if (results.length === 0 && diagnosis) {
461
+ const extra = diagnosis.reason === 'all_filtered'
462
+ ? ` (${diagnosis.candidates} filtered: ${Object.entries(diagnosis.filteredOut ?? {})
463
+ .map(([k, v]) => `${v} ${k}`)
464
+ .join(', ')})`
465
+ : diagnosis.reason === 'no_match' && diagnosis.belowFloor
466
+ ? ` (closest below floor, low confidence: ${diagnosis.belowFloor.entry.text})`
467
+ : '';
468
+ process.stdout.write(`NO-MATCH\t${diagnosis.reason}\tno recorded entry found${extra}\n`);
469
+ }
470
+ return;
471
+ }
472
+ // Hook subcommands are exempt from strict flag parsing BY DESIGN (issue #95):
473
+ // the harness contract is fail-open — a hook must never error a session over
474
+ // an unrecognized argv, so unknown flags here are ignored, not rejected.
475
+ if (cmd === 'hook') {
476
+ if (sub === 'session-start') {
477
+ const raw = await readStdin();
478
+ let payloadId;
479
+ try {
480
+ const p = JSON.parse(raw || '{}');
481
+ if (typeof p.session_id === 'string')
482
+ payloadId = p.session_id;
483
+ }
484
+ catch {
485
+ /* malformed → no harness id; env override may still apply */
486
+ }
487
+ const project = defaultProject();
488
+ // --naive = the mykb-v1-style flat dump (L4 contrast baseline only); --limit N truncates it.
489
+ const lim = flag(rest, 'limit');
490
+ // The Tier-A payload is the RESUME render (ADR-0020): prior-session digest +
491
+ // the live knowledge bundle, both derived. Persist this session's record so the
492
+ // NEXT session can resume from it (append-only). ADR-0039: the id comes from the
493
+ // hook's own stdin (KB_SESSION_ID is an optional override) — no harness wiring needed.
494
+ const session = SessionState.load(effectiveSessionId(payloadId));
495
+ const additionalContext = rest.includes('--naive')
496
+ ? renderNaiveDump(project, undefined, lim ? Number(lim) : undefined)
497
+ : renderResume(project, session);
498
+ session.save();
499
+ process.stdout.write(JSON.stringify({
500
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext },
501
+ }));
502
+ return;
503
+ }
504
+ if (sub === 'post-tool-use') {
505
+ const raw = await readStdin();
506
+ try {
507
+ const payload = JSON.parse(raw || '{}');
508
+ const sessionId = effectiveSessionId(typeof payload.session_id === 'string' ? payload.session_id : undefined);
509
+ const captured = captureToolCall({
510
+ tool_name: payload.tool_name,
511
+ tool_input: payload.tool_input,
512
+ // Claude Code's PostToolUse payload carries the result under `tool_response`
513
+ // ({stdout,stderr,…}), NOT `tool_result` (verified 2026-06-27). Without this
514
+ // fallback the result was dropped → every live capture classified `ok` → no
515
+ // capture:error → the distiller never fired on a real claude failure (D-iv,
516
+ // the claude analog of the pi tool_call-has-no-result gap). The host-side
517
+ // synthetic seam feeds `tool_result`, so it keeps precedence.
518
+ tool_result: payload.tool_result ?? payload.tool_response,
519
+ call_id: payload.call_id || payload.tool_use_id,
520
+ session_id: sessionId,
521
+ });
522
+ // record the captured id into the session log (Tier-B → continuity signal).
523
+ if (captured) {
524
+ const session = SessionState.load(sessionId);
525
+ session.recordCaptured(captured.id);
526
+ session.save();
527
+ }
528
+ }
529
+ catch {
530
+ /* malformed payload: capture nothing, never block the tool (exit 0) */
531
+ }
532
+ process.stdout.write('{}');
533
+ return;
534
+ }
535
+ // Stop — conditional end-of-turn decision-capture reminder (RFC-008 / ADR-0027).
536
+ // Verified contract (CLI v2.1.195): emit decision:block + additionalContext to
537
+ // continue the turn; `stop_hook_active` is the native loop guard (never block twice).
538
+ if (sub === 'stop') {
539
+ const raw = await readStdin();
540
+ let input = {};
541
+ try {
542
+ input = JSON.parse(raw || '{}');
543
+ }
544
+ catch {
545
+ /* malformed → fail-open: allow the stop, never wedge the turn */
546
+ }
547
+ // Native loop guard first (cheap, git-free): our own re-entry → allow the stop.
548
+ if (input.stop_hook_active) {
549
+ process.stdout.write('{}');
550
+ return;
551
+ }
552
+ // ADR-0039: a real (non-re-entry) Stop = one turn ended — accumulate it on the
553
+ // session record so continuity signals survive across `--resume` turns.
554
+ try {
555
+ const session = SessionState.load(effectiveSessionId(typeof input.session_id === 'string' ? input.session_id : undefined));
556
+ session.bumpTurn();
557
+ session.save();
558
+ }
559
+ catch {
560
+ /* session bookkeeping must never wedge the turn */
561
+ }
562
+ const d = decideStop({ stop_hook_active: false }, gatherStopContext());
563
+ process.stdout.write(d.block
564
+ ? JSON.stringify({
565
+ hookSpecificOutput: {
566
+ hookEventName: 'Stop',
567
+ decision: 'block',
568
+ additionalContext: d.reminder,
569
+ },
570
+ })
571
+ : '{}');
572
+ return;
573
+ }
574
+ // SessionEnd — GAP 2 (RFC-011 / ADR-0033): auto-commit the brain so `/exit` is
575
+ // safe by default. Cannot block exit / inject context (verified contract, gotcha
576
+ // f0e913b97824) → fire-and-forget; only a `systemMessage` (the main-branch warning)
577
+ // is surfaced. Fail-open: never throw, always exit 0.
578
+ if (sub === 'session-end') {
579
+ const raw = await readStdin();
580
+ let cwd;
581
+ let sessionId;
582
+ try {
583
+ const payload = JSON.parse(raw || '{}');
584
+ if (typeof payload.cwd === 'string')
585
+ cwd = payload.cwd;
586
+ if (typeof payload.session_id === 'string')
587
+ sessionId = payload.session_id;
588
+ }
589
+ catch {
590
+ /* malformed → fall back to process.cwd()/env */
591
+ }
592
+ let systemMessage;
593
+ try {
594
+ const r = runSessionEnd({ cwd, sessionId: effectiveSessionId(sessionId) });
595
+ systemMessage = r.systemMessage;
596
+ }
597
+ catch {
598
+ /* fail-open: never block exit */
599
+ }
600
+ process.stdout.write(systemMessage ? JSON.stringify({ systemMessage }) : '{}');
601
+ return;
602
+ }
603
+ // PreToolUse gating — deny direct writes to the brain (force engine writes).
604
+ if (sub === 'pre-tool-use') {
605
+ const raw = await readStdin();
606
+ try {
607
+ const payload = JSON.parse(raw || '{}');
608
+ if (isBrainWrite(payload.tool_name, payload.tool_input)) {
609
+ process.stdout.write(JSON.stringify({
610
+ hookSpecificOutput: {
611
+ hookEventName: 'PreToolUse',
612
+ permissionDecision: 'deny',
613
+ permissionDecisionReason: GATING_REASON,
614
+ },
615
+ }));
616
+ return;
617
+ }
618
+ }
619
+ catch {
620
+ /* malformed → allow (fail-open: never wedge the harness) */
621
+ }
622
+ process.stdout.write('{}');
623
+ return;
624
+ }
625
+ }
626
+ if (cmd === 'save') {
627
+ const p = parseArgs('save', argsOf(sub, rest), {});
628
+ const r = save(p.positionals.join(' ').trim() || undefined);
629
+ process.stdout.write((r.committed ? 'committed: ' : 'no-op: ') + r.message + '\n');
630
+ return;
631
+ }
632
+ process.stderr.write(USAGE);
633
+ process.exit(1);
634
+ }
635
+ main();