@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/README.md +6 -1
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +414 -9
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +409 -9
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]
|
|
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
|
|
1037
|
-
dz brain query
|
|
1038
|
-
dz brain add
|
|
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
|
-
|
|
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
|
|
1121
|
-
addedTs
|
|
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':
|