@dzhechkov/harness-cli 0.3.138 → 0.3.142

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/src/cli.ts CHANGED
@@ -57,8 +57,13 @@ import {
57
57
  listBrain,
58
58
  promoteProjectToBrain,
59
59
  queryBrain,
60
+ groundPrompt,
61
+ buildPrimer,
62
+ exportBrainSlice,
63
+ importBrainSlice,
64
+ registerKusToBrain,
60
65
  } from '@dzhechkov/harness-core';
61
- import type { PatternRecord, TargetName } from '@dzhechkov/harness-core';
66
+ import type { PatternRecord, TargetName, BookKU } from '@dzhechkov/harness-core';
62
67
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
63
68
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
64
69
 
@@ -85,7 +90,11 @@ Usage:
85
90
  dz recall "<query>" [--limit <N>] [--books [--book <slug>]] [--project <dir>] | dz recall --all [--json]
86
91
  dz brain list [--json] (the durable cross-project knowledge brain)
87
92
  dz brain query "<q>" [--source <slug>] [--limit <N>] [--json] (cross-source lexical recall over the brain)
88
- dz brain add [--source <slug>] [--project <dir>] [--json] (promote this project's digitized-book KUs into the brain)
93
+ dz brain add [--source <slug>] [--project <dir>] [--from-slice <f>|--from-pack <p>|--from-kus <f> --slug <s>] [--kind <k>] [--license <spdx>] [--json] (grow the brain: promote this project, or import a slice/pack/KU-array)
94
+ dz brain primer <slug> [--json] (print a source's capability card — KU-type histogram + top decision moments)
95
+ dz brain export --source <slug> --out <file> (export ONE source as a portable, lexical-only books.sqlite slice)
96
+ dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] (UserPromptSubmit hook: inject brain citations for a prompt; reads STDIN if no prompt)
97
+ dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
89
98
  dz pretrain [--project <dir>]
90
99
  dz recommend "<task description>"
91
100
  dz compose <preset1+preset2+...> [--target <name>]
@@ -115,6 +124,12 @@ Presets: ${PRESET_NAMES.join(', ')}`;
115
124
  export interface CliIo {
116
125
  readonly cwd?: string;
117
126
  readonly write?: (line: string) => void;
127
+ /**
128
+ * Pre-read STDIN content (injectable so `dz brain ground`'s hook path is testable without
129
+ * an actual pipe). When omitted, the CLI reads fd 0 synchronously — but only for the one
130
+ * command that needs it (`brain ground`), and never when stdin is a TTY (nothing piped).
131
+ */
132
+ readonly stdin?: string;
118
133
  }
119
134
 
120
135
  interface ParsedArgs {
@@ -1033,9 +1048,58 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
1033
1048
  const BRAIN_USAGE = `dz brain — the durable, cross-project knowledge brain
1034
1049
 
1035
1050
  Usage:
1036
- dz brain list [--json]
1037
- dz brain query "<q>" [--source <slug>] [--limit <N>] [--json]
1038
- dz brain add [--source <slug>] [--project <dir>] [--json]`;
1051
+ dz brain list [--json]
1052
+ dz brain query "<q>" [--source <slug>] [--limit <N>] [--json]
1053
+ dz brain add [--source <slug>] [--project <dir>] [--json]
1054
+ dz brain add --from-slice <file.sqlite> [--json]
1055
+ dz brain add --from-pack <pkg-or-dir> [--json]
1056
+ dz brain add --from-kus <file.json> --slug <s> [--kind repo|book|paper] [--license <spdx>] [--override] [--json]
1057
+ dz brain primer <slug> [--json]
1058
+ dz brain export --source <slug> --out <file>
1059
+ dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text]
1060
+ dz brain init [--project <dir>] [--k <N>]
1061
+
1062
+ add: default promotes THIS project's digitized-book KUs into the brain. The three --from-* modes
1063
+ are mutually exclusive alternate inputs:
1064
+ --from-slice imports a standalone per-book slice (a \`dz brain export\` output).
1065
+ --from-pack resolves a pack (node_modules pkg or local dir) and imports every
1066
+ \`brain/<slug>.sqlite\` slice it ships.
1067
+ --from-kus registers a JSON array of already-shaped KUs under --slug (default --kind repo).
1068
+ primer: prints a source's capability card (the deterministic KU-type histogram + top decision moments).
1069
+ export: writes ONE source's KUs as a portable, lexical-only \`books.sqlite\` slice (vectors re-embed on import).
1070
+ ground: the UserPromptSubmit hook entrypoint. Grounds a prompt (positional or STDIN) against
1071
+ the brain and, when relevant KUs are found, prints Claude-Code-injectable JSON on
1072
+ stdout ({"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":…}}).
1073
+ Always exits 0 — grounding is advisory and must never fail a prompt. --text prints the
1074
+ raw citation block instead of the JSON wrapper (for manual inspection).
1075
+ init: wires \`brain ground\` into .claude/settings.json as an opt-in UserPromptSubmit hook.`;
1076
+
1077
+ /**
1078
+ * Extract the user prompt from a Claude Code `UserPromptSubmit` hook STDIN payload. Tries the
1079
+ * common JSON shapes (\`.prompt\`, \`.user_prompt\`, \`.userPrompt\`); if the payload is plain
1080
+ * non-JSON text, the whole (trimmed) text is the prompt. Empty / unrecognized → \`''\` (the caller
1081
+ * then emits nothing and exits 0 — grounding never blocks).
1082
+ */
1083
+ function extractPromptFromStdin(raw: string): string {
1084
+ const text = raw.trim();
1085
+ if (text === '') return '';
1086
+ let parsed: unknown;
1087
+ try {
1088
+ parsed = JSON.parse(text);
1089
+ } catch {
1090
+ // Plain non-JSON text → treat the whole thing as the prompt.
1091
+ return text;
1092
+ }
1093
+ if (parsed !== null && typeof parsed === 'object') {
1094
+ const obj = parsed as Record<string, unknown>;
1095
+ for (const key of ['prompt', 'user_prompt', 'userPrompt'] as const) {
1096
+ const v = obj[key];
1097
+ if (typeof v === 'string' && v.trim() !== '') return v;
1098
+ }
1099
+ }
1100
+ // Valid JSON but no recognizable prompt field (or a bare scalar) → nothing to ground.
1101
+ return '';
1102
+ }
1039
1103
 
1040
1104
  /**
1041
1105
  * Resolve the deps root — the directory whose `package.json` can resolve `better-sqlite3` **and**
@@ -1060,7 +1124,75 @@ function resolveDepsRoot(cwd: string): string {
1060
1124
  return canResolve(cliDir) ? cliDir : cwd;
1061
1125
  }
1062
1126
 
1063
- async function cmdBrain(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
1127
+ /**
1128
+ * Resolve a `--from-pack` spec to a pack directory: a local directory (absolute or cwd-relative)
1129
+ * wins; otherwise resolve it as an installed node package (its `package.json` from `cwd` then the
1130
+ * CLI's deps root). Returns the pack's root dir, or `undefined` if it is neither.
1131
+ */
1132
+ function resolvePackDir(cwd: string, depsRoot: string, spec: string): string | undefined {
1133
+ const asDir = resolve(cwd, spec);
1134
+ if (existsSync(asDir) && lstatSync(asDir).isDirectory()) return asDir;
1135
+ for (const root of [cwd, depsRoot]) {
1136
+ try {
1137
+ const req = createRequire(join(root, 'package.json'));
1138
+ return dirname(req.resolve(`${spec}/package.json`));
1139
+ } catch {
1140
+ /* try the next resolution base */
1141
+ }
1142
+ }
1143
+ return undefined;
1144
+ }
1145
+
1146
+ /**
1147
+ * Validate that a parsed JSON value is an array of KU-shaped objects (the `--from-kus` backend).
1148
+ * Every element must carry the required BookKU string fields; `book` is optional (the caller's
1149
+ * `--slug` is authoritative). On the first bad element returns `{ error }` naming exactly what is
1150
+ * wrong; otherwise returns clean `BookKU[]` (extra fields dropped).
1151
+ */
1152
+ function validateKuArray(parsed: unknown): { kus: BookKU[] } | { error: string } {
1153
+ if (!Array.isArray(parsed)) return { error: 'expected a JSON array of KU objects at the top level' };
1154
+ if (parsed.length === 0) return { error: 'the KU array is empty' };
1155
+ const required = ['kuId', 'corpusVersion', 'type', 'name', 'problem', 'content'] as const;
1156
+ const kus: BookKU[] = [];
1157
+ for (let i = 0; i < parsed.length; i += 1) {
1158
+ const v = parsed[i];
1159
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
1160
+ return { error: `KU #${i} is not an object` };
1161
+ }
1162
+ const o = v as Record<string, unknown>;
1163
+ const missing = required.filter((k) => typeof o[k] !== 'string');
1164
+ if (missing.length > 0) {
1165
+ return { error: `KU #${i} missing/invalid string field(s): ${missing.join(', ')}` };
1166
+ }
1167
+ const pages = Array.isArray(o['pages'])
1168
+ ? (o['pages'] as unknown[]).filter((n): n is number => typeof n === 'number')
1169
+ : undefined;
1170
+ const meta = o['metadata'] !== null && typeof o['metadata'] === 'object' && !Array.isArray(o['metadata'])
1171
+ ? (o['metadata'] as Record<string, unknown>)
1172
+ : undefined;
1173
+ kus.push({
1174
+ book: typeof o['book'] === 'string' ? (o['book'] as string) : '',
1175
+ kuId: o['kuId'] as string,
1176
+ corpusVersion: o['corpusVersion'] as string,
1177
+ type: o['type'] as string,
1178
+ name: o['name'] as string,
1179
+ problem: o['problem'] as string,
1180
+ content: o['content'] as string,
1181
+ ...(typeof o['chapter'] === 'string' ? { chapter: o['chapter'] as string } : {}),
1182
+ ...(pages !== undefined ? { pages } : {}),
1183
+ ...(meta !== undefined ? { metadata: meta } : {}),
1184
+ });
1185
+ }
1186
+ return { kus };
1187
+ }
1188
+
1189
+ async function cmdBrain(
1190
+ options: Map<string, string>,
1191
+ flags: Set<string>,
1192
+ cwd: string,
1193
+ write: Write,
1194
+ readStdin: () => string,
1195
+ ): Promise<number> {
1064
1196
  const sub = options.get('_positional_0');
1065
1197
  const asJson = flags.has('json');
1066
1198
 
@@ -1112,13 +1244,168 @@ async function cmdBrain(options: Map<string, string>, flags: Set<string>, cwd: s
1112
1244
  return 0;
1113
1245
  }
1114
1246
 
1247
+ // ── dz brain primer <slug> ───────────────────────────────────────────────────────────────────
1248
+ // Print a source's capability card (KU-type histogram + top decision moments) from the brain.
1249
+ if (sub === 'primer') {
1250
+ const slug = options.get('_positional_1');
1251
+ if (!slug) {
1252
+ write('dz brain primer: a source slug is required');
1253
+ write(' Example: dz brain primer ddia');
1254
+ return 1;
1255
+ }
1256
+ const { markdown, error } = await buildPrimer({ slug, depsRoot: resolveDepsRoot(cwd) });
1257
+ if (error !== undefined) {
1258
+ write(`dz brain primer: ${error}`);
1259
+ return 1;
1260
+ }
1261
+ if (asJson) { write(JSON.stringify({ slug, markdown })); return 0; }
1262
+ write(markdown);
1263
+ return 0;
1264
+ }
1265
+
1266
+ // ── dz brain export --source <slug> --out <file> ─────────────────────────────────────────────
1267
+ // Export ONE source's KUs as a portable, lexical-only books.sqlite slice.
1268
+ if (sub === 'export') {
1269
+ const source = options.get('source');
1270
+ const out = options.get('out') ?? (flags.has('out') ? '' : undefined);
1271
+ if (source === undefined || source === '' || out === undefined || out === '') {
1272
+ write('dz brain export: --source <slug> and --out <file> are both required');
1273
+ write(' Example: dz brain export --source ddia --out ./ddia.sqlite');
1274
+ return 1;
1275
+ }
1276
+ const outPath = resolve(cwd, out);
1277
+ const { kuCount, error } = await exportBrainSlice({ slug: source, outPath, depsRoot: resolveDepsRoot(cwd) });
1278
+ if (error !== undefined) {
1279
+ write(`dz brain export: ${error}`);
1280
+ return 1;
1281
+ }
1282
+ write(`dz brain: exported ${kuCount} KU slice → ${outPath}`);
1283
+ return 0;
1284
+ }
1285
+
1115
1286
  // ── dz brain add ─────────────────────────────────────────────────────────────────────────────
1287
+ // Default promotes THIS project's book KB. Three mutually-exclusive --from-* modes ingest a
1288
+ // slice, a pack's shipped slices, or a raw KU array instead.
1116
1289
  if (sub === 'add') {
1290
+ const depsRoot = resolveDepsRoot(cwd);
1291
+ const addedTs = new Date().toISOString();
1292
+ // A `--from-* ` given without a value parses as a FLAG (missing value); treat that as '' so the
1293
+ // mode still triggers its "requires a value" guard rather than silently falling through.
1294
+ const fromSlice = options.get('from-slice') ?? (flags.has('from-slice') ? '' : undefined);
1295
+ const fromPack = options.get('from-pack') ?? (flags.has('from-pack') ? '' : undefined);
1296
+ const fromKus = options.get('from-kus') ?? (flags.has('from-kus') ? '' : undefined);
1297
+
1298
+ const modes = [fromSlice, fromPack, fromKus].filter((m) => m !== undefined).length;
1299
+ if (modes > 1) {
1300
+ write('dz brain add: --from-slice, --from-pack, and --from-kus are mutually exclusive');
1301
+ return 1;
1302
+ }
1303
+
1304
+ // ── mode: --from-slice <file> → import a standalone per-book slice ──────────────────────────
1305
+ if (fromSlice !== undefined) {
1306
+ if (fromSlice === '') { write('dz brain add: --from-slice requires a slice file path'); return 1; }
1307
+ const slicePath = resolve(cwd, fromSlice);
1308
+ if (!existsSync(slicePath)) { write(`dz brain add: no slice at ${slicePath}`); return 1; }
1309
+ const result = await importBrainSlice({ slicePath, depsRoot, addedTs });
1310
+ if (asJson) { write(JSON.stringify(result)); return result.sources.length === 0 ? 1 : 0; }
1311
+ if (result.sources.length === 0) {
1312
+ write(`dz brain add: ${result.error ?? 'nothing to import from the slice'}`);
1313
+ return 1;
1314
+ }
1315
+ write(`dz brain: imported ${result.kus} KU from slice → ${result.sources.length} source(s) into ${brainHome()}: ${result.sources.join(', ')}`);
1316
+ if (result.error !== undefined) write(` (partial: ${result.error})`);
1317
+ return 0;
1318
+ }
1319
+
1320
+ // ── mode: --from-pack <pkg-or-dir> → import every brain/<slug>.sqlite the pack ships ────────
1321
+ if (fromPack !== undefined) {
1322
+ if (fromPack === '') { write('dz brain add: --from-pack requires a package name or directory'); return 1; }
1323
+ const packDir = resolvePackDir(cwd, depsRoot, fromPack);
1324
+ if (packDir === undefined) {
1325
+ write(`dz brain add: could not resolve pack '${fromPack}' (not a local directory, not an installed package)`);
1326
+ return 1;
1327
+ }
1328
+ const packBrainDir = join(packDir, 'brain');
1329
+ const slices = existsSync(packBrainDir)
1330
+ ? readdirSync(packBrainDir).filter((f) => f.endsWith('.sqlite')).sort()
1331
+ : [];
1332
+ if (slices.length === 0) {
1333
+ write(`dz brain add: no brain/<slug>.sqlite slice found in pack '${fromPack}' (${packDir})`);
1334
+ return 1;
1335
+ }
1336
+ const imported: { sources: string[]; kus: number; error?: string }[] = [];
1337
+ for (const f of slices) {
1338
+ imported.push(await importBrainSlice({ slicePath: join(packBrainDir, f), depsRoot, addedTs }));
1339
+ }
1340
+ const allSources = imported.flatMap((r) => r.sources);
1341
+ const totalKus = imported.reduce((s, r) => s + r.kus, 0);
1342
+ const firstError = imported.find((r) => r.error !== undefined)?.error;
1343
+ if (asJson) {
1344
+ write(JSON.stringify({ sources: allSources, kus: totalKus, ...(firstError !== undefined ? { error: firstError } : {}) }));
1345
+ return allSources.length === 0 ? 1 : 0;
1346
+ }
1347
+ if (allSources.length === 0) {
1348
+ write(`dz brain add: ${firstError ?? 'nothing imported'} from pack '${fromPack}'`);
1349
+ return 1;
1350
+ }
1351
+ write(`dz brain: imported ${totalKus} KU from ${slices.length} slice(s) in pack '${fromPack}' → ${allSources.length} source(s) into ${brainHome()}: ${allSources.join(', ')}`);
1352
+ if (firstError !== undefined) write(` (partial: ${firstError})`);
1353
+ return 0;
1354
+ }
1355
+
1356
+ // ── mode: --from-kus <file.json> --slug <s> → register a raw KU array ───────────────────────
1357
+ if (fromKus !== undefined) {
1358
+ if (fromKus === '') { write('dz brain add: --from-kus requires a JSON file path'); return 1; }
1359
+ const slug = options.get('slug');
1360
+ if (slug === undefined || slug === '') {
1361
+ write('dz brain add --from-kus: --slug <s> is required (every KU is registered under it)');
1362
+ return 1;
1363
+ }
1364
+ const kusPath = resolve(cwd, fromKus);
1365
+ if (!existsSync(kusPath)) { write(`dz brain add: no KU file at ${kusPath}`); return 1; }
1366
+ let parsed: unknown;
1367
+ try {
1368
+ parsed = JSON.parse(readFileSync(kusPath, 'utf8'));
1369
+ } catch (err) {
1370
+ write(`dz brain add --from-kus: invalid JSON in ${kusPath}: ${err instanceof Error ? err.message : String(err)}`);
1371
+ return 1;
1372
+ }
1373
+ const valid = validateKuArray(parsed);
1374
+ if ('error' in valid) {
1375
+ write(`dz brain add --from-kus: bad KU shape — ${valid.error}`);
1376
+ return 1;
1377
+ }
1378
+ const kind = options.get('kind') ?? 'repo';
1379
+ if (kind !== 'repo' && kind !== 'book' && kind !== 'paper') {
1380
+ write(`dz brain add --from-kus: --kind must be one of repo|book|paper (got '${kind}')`);
1381
+ return 1;
1382
+ }
1383
+ const license = options.get('license');
1384
+ const result = await registerKusToBrain({
1385
+ kus: valid.kus,
1386
+ slug,
1387
+ kind,
1388
+ depsRoot,
1389
+ addedTs,
1390
+ ...(license !== undefined ? { license } : {}),
1391
+ ...(flags.has('override') ? { override: true } : {}),
1392
+ });
1393
+ if (asJson) { write(JSON.stringify({ slug, kind, ...result })); return result.kus === 0 ? 1 : 0; }
1394
+ if (result.kus === 0) {
1395
+ write(`dz brain add --from-kus: ${result.error ?? 'nothing registered'}`);
1396
+ return 1;
1397
+ }
1398
+ write(`dz brain: registered ${result.kus} KU under '${slug}' (kind ${kind}) into ${brainHome()}`);
1399
+ if (result.error !== undefined) write(` (partial: ${result.error})`);
1400
+ return 0;
1401
+ }
1402
+
1403
+ // ── default: promote THIS project's digitized book KB ──────────────────────────────────────
1117
1404
  const source = options.get('source');
1118
1405
  const result = await promoteProjectToBrain({
1119
1406
  projectRoot: resolve(cwd, options.get('project') ?? '.'),
1120
- depsRoot: resolveDepsRoot(cwd),
1121
- addedTs: new Date().toISOString(),
1407
+ depsRoot,
1408
+ addedTs,
1122
1409
  ...(source !== undefined ? { source } : {}),
1123
1410
  });
1124
1411
  if (asJson) { write(JSON.stringify(result)); return result.sources.length === 0 ? 1 : 0; }
@@ -1134,6 +1421,107 @@ async function cmdBrain(options: Map<string, string>, flags: Set<string>, cwd: s
1134
1421
  return 0;
1135
1422
  }
1136
1423
 
1424
+ // ── dz brain ground [<prompt>] ───────────────────────────────────────────────────────────────
1425
+ // The UserPromptSubmit hook entrypoint. ALWAYS exits 0 — grounding is advisory and must never
1426
+ // fail a prompt. Emits nothing (silent) unless the brain has relevant citations for the prompt.
1427
+ if (sub === 'ground') {
1428
+ const positional = options.get('_positional_1');
1429
+ // Positional prompt wins; else read the hook payload from STDIN. Empty stdin → emit nothing.
1430
+ const prompt = positional !== undefined && positional !== ''
1431
+ ? positional
1432
+ : extractPromptFromStdin(readStdin());
1433
+ if (prompt.trim() === '') return 0;
1434
+
1435
+ const source = options.get('source');
1436
+ const kRaw = options.get('k');
1437
+ const k = kRaw !== undefined ? Math.max(1, parseInt(kRaw, 10) || 5) : undefined;
1438
+
1439
+ const gopts: { prompt: string; depsRoot: string; k?: number; source?: string } = {
1440
+ prompt,
1441
+ depsRoot: resolveDepsRoot(cwd),
1442
+ };
1443
+ if (k !== undefined) gopts.k = k;
1444
+ if (source !== undefined) gopts.source = source;
1445
+
1446
+ const res = await groundPrompt(gopts);
1447
+ if (!res.emitted) return 0; // no relevant citations → inject nothing
1448
+
1449
+ // --text → raw block for manual inspection; default → Claude-Code-injectable additionalContext.
1450
+ if (flags.has('text')) {
1451
+ write(res.block);
1452
+ return 0;
1453
+ }
1454
+ write(JSON.stringify({
1455
+ hookSpecificOutput: {
1456
+ hookEventName: 'UserPromptSubmit',
1457
+ additionalContext: res.block,
1458
+ },
1459
+ }));
1460
+ return 0;
1461
+ }
1462
+
1463
+ // ── dz brain init ────────────────────────────────────────────────────────────────────────────
1464
+ // Opt-in: wire `dz brain ground` into .claude/settings.json as a UserPromptSubmit hook.
1465
+ // Idempotent read-merge-write — preserve every existing hook/key (e.g. the agentic-qe route hook).
1466
+ if (sub === 'init') {
1467
+ const projectRoot = resolve(cwd, options.get('project') ?? '.');
1468
+ const kRaw = options.get('k');
1469
+ const k = kRaw !== undefined ? Math.max(1, parseInt(kRaw, 10) || 5) : 5;
1470
+
1471
+ // Invoke the SAME bin this CLI runs from: dist/bin.js sits next to dist/cli.js.
1472
+ const dzBin = join(dirname(fileURLToPath(import.meta.url)), 'bin.js');
1473
+ const groundCmd = `node ${JSON.stringify(dzBin)} brain ground --k ${k}`;
1474
+
1475
+ const settingsPath = join(projectRoot, '.claude', 'settings.json');
1476
+ let settings: Record<string, unknown> = {};
1477
+ if (existsSync(settingsPath)) {
1478
+ try {
1479
+ const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown;
1480
+ if (parsed !== null && typeof parsed === 'object') settings = parsed as Record<string, unknown>;
1481
+ } catch {
1482
+ // Corrupt/unreadable settings → start fresh rather than crash (grounding is opt-in glue).
1483
+ settings = {};
1484
+ }
1485
+ }
1486
+
1487
+ const hooks = (settings['hooks'] !== null && typeof settings['hooks'] === 'object'
1488
+ ? settings['hooks']
1489
+ : {}) as Record<string, unknown>;
1490
+ const ups: unknown[] = Array.isArray(hooks['UserPromptSubmit']) ? [...(hooks['UserPromptSubmit'] as unknown[])] : [];
1491
+
1492
+ // The grounding hook, in Claude Code's matcher-group shape (UserPromptSubmit takes no matcher).
1493
+ const entry = { hooks: [{ type: 'command', command: groundCmd, timeout: 5000 }] };
1494
+
1495
+ // Identify OUR entry by a `brain ground` command; replace in place (idempotent), else append.
1496
+ const isGroundEntry = (e: unknown): boolean => {
1497
+ const ex = e as { hooks?: { command?: unknown }[] };
1498
+ return Array.isArray(ex?.hooks)
1499
+ && ex.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('brain ground'));
1500
+ };
1501
+ const idx = ups.findIndex(isGroundEntry);
1502
+ const replaced = idx >= 0;
1503
+ if (replaced) ups[idx] = entry;
1504
+ else ups.push(entry);
1505
+
1506
+ hooks['UserPromptSubmit'] = ups;
1507
+ settings['hooks'] = hooks;
1508
+
1509
+ mkdirSync(dirname(settingsPath), { recursive: true });
1510
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
1511
+
1512
+ write(`dz brain init: ${replaced ? 're-wired' : 'wired'} the grounding hook into ${settingsPath}`);
1513
+ write(` UserPromptSubmit → ${groundCmd}`);
1514
+ write(` Grounding is now ON for this project (k=${k}). Every prompt is screened against your brain;`);
1515
+ write(` when ≥2 content terms co-occur in a stored KU, the retrieved citations are injected as`);
1516
+ write(` additionalContext. The command exits 0 on any failure (empty brain, error, empty stdin),`);
1517
+ write(` so a grounding miss can never block or fail a prompt (continueOnError-equivalent).`);
1518
+ write(` Honesty: the hook only MECHANICALLY injects the retrieved citations — actually answering`);
1519
+ write(` FROM them (not from model memory/drift) is agent discipline (§7.2), not something the hook`);
1520
+ write(` can enforce.`);
1521
+ write(` To turn it OFF: remove the "brain ground" entry under hooks.UserPromptSubmit in ${settingsPath}.`);
1522
+ return 0;
1523
+ }
1524
+
1137
1525
  // ── unknown / absent subcommand ──────────────────────────────────────────────────────────────
1138
1526
  write(BRAIN_USAGE);
1139
1527
  return sub === undefined ? 0 : 1;
@@ -2078,6 +2466,18 @@ async function cmdImportEcc(options: Map<string, string>, flags: Set<string>, cw
2078
2466
  export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
2079
2467
  const cwd = io.cwd ?? process.cwd();
2080
2468
  const write: Write = io.write ?? ((line) => { console.log(line); });
2469
+ // Lazy STDIN reader — only `dz brain ground` reads it, and only when no positional prompt is
2470
+ // given. Never blocks: injected `io.stdin` wins; else read fd 0 synchronously, but bail to '' on
2471
+ // a TTY (nothing piped) or any read error. Grounding must never hang waiting on an empty pipe.
2472
+ const readStdin = (): string => {
2473
+ if (io.stdin !== undefined) return io.stdin;
2474
+ try {
2475
+ if (process.stdin.isTTY) return '';
2476
+ return readFileSync(0, 'utf8');
2477
+ } catch {
2478
+ return '';
2479
+ }
2480
+ };
2081
2481
  const { command, options, flags } = parseArgs(argv);
2082
2482
 
2083
2483
  if (command === '' || command === 'help' || flags.has('help')) {
@@ -2119,7 +2519,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
2119
2519
  case 'recall':
2120
2520
  return await cmdRecall(options, flags, cwd, write);
2121
2521
  case 'brain':
2122
- return await cmdBrain(options, flags, cwd, write);
2522
+ return await cmdBrain(options, flags, cwd, write, readStdin);
2123
2523
  case 'setup':
2124
2524
  return await cmdSetup(options, flags, cwd, write);
2125
2525
  case 'pretrain':