@saasontools/strauss-kb 0.1.7 → 0.1.8

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-main.cjs CHANGED
@@ -1055,14 +1055,460 @@ var contextCommand = define({
1055
1055
  }
1056
1056
  });
1057
1057
 
1058
- // src/commands/list.ts
1058
+ // src/commands/doctor.ts
1059
1059
  var import_zod8 = require("zod");
1060
+
1061
+ // src/kb-edges.ts
1062
+ var KB_EDGE_KINDS = [
1063
+ "body-link",
1064
+ "supersession",
1065
+ "anchor",
1066
+ "source"
1067
+ ];
1068
+ var BODY_LINK_TARGET = new RegExp(
1069
+ `\\]\\((${KB_CONCEPT_ID_PATTERN.source.replace(/^\^|\$$/g, "")})\\.md\\)`,
1070
+ "g"
1071
+ );
1072
+ function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
1073
+ const found = /* @__PURE__ */ new Map();
1074
+ for (const kind of kinds) {
1075
+ for (const record of edgeNeighbours(from, bundle, kind)) {
1076
+ const existing = found.get(record.conceptId);
1077
+ if (existing) {
1078
+ if (!existing.via.includes(kind)) existing.via.push(kind);
1079
+ continue;
1080
+ }
1081
+ found.set(record.conceptId, { record, via: [kind] });
1082
+ }
1083
+ }
1084
+ return [...found.values()];
1085
+ }
1086
+ function edgeNeighbours(from, bundle, kind) {
1087
+ switch (kind) {
1088
+ // A link whose target is not in the bundle is legal per compose.ts —
1089
+ // records are routinely written before the ones they point at exist — so
1090
+ // missing targets are skipped, never an error.
1091
+ case "body-link": {
1092
+ const targets = new Set(
1093
+ [...from.body.matchAll(BODY_LINK_TARGET)].map((match) => match[1])
1094
+ );
1095
+ if (!targets.size) return [];
1096
+ return bundle.filter(
1097
+ (candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
1098
+ );
1099
+ }
1100
+ // Both directions and both pointers: `supersede()` writes the pair, but a
1101
+ // hand-edit can leave one side behind, and a walk trusting one pointer
1102
+ // would miss a replacement the bundle openly declares.
1103
+ case "supersession":
1104
+ return bundle.filter(
1105
+ (candidate) => candidate.conceptId !== from.conceptId && (candidate.conceptId === from.frontmatter.strauss_superseded_by || from.frontmatter.strauss_supersedes?.includes(
1106
+ candidate.conceptId
1107
+ ) || candidate.frontmatter.strauss_superseded_by === from.conceptId || candidate.frontmatter.strauss_supersedes?.includes(from.conceptId))
1108
+ );
1109
+ // The edge that answers "why is this code shaped this way": every record
1110
+ // attached to the same file or symbol, whatever its standing.
1111
+ case "anchor": {
1112
+ const mine = from.frontmatter.strauss_anchors ?? [];
1113
+ if (!mine.length) return [];
1114
+ return bundle.filter(
1115
+ (candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.strauss_anchors ?? []).some(
1116
+ (theirs) => mine.some((ours) => anchorsTouch(ours, theirs))
1117
+ )
1118
+ );
1119
+ }
1120
+ case "source": {
1121
+ const mine = new Set((from.frontmatter.sources ?? []).map((s) => s.id));
1122
+ if (!mine.size) return [];
1123
+ return bundle.filter(
1124
+ (candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.sources ?? []).some(
1125
+ (source) => mine.has(source.id)
1126
+ )
1127
+ );
1128
+ }
1129
+ }
1130
+ }
1131
+ function anchorsTouch(left, right) {
1132
+ if (left.file !== right.file) return false;
1133
+ if (!left.symbol || !right.symbol) return true;
1134
+ return left.symbol === right.symbol;
1135
+ }
1136
+
1137
+ // src/validate.ts
1138
+ function validateBundle(records) {
1139
+ const byId = new Map(records.map((record) => [record.conceptId, record]));
1140
+ const problems = [];
1141
+ const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
1142
+ for (const record of records) {
1143
+ const { conceptId: conceptId2, frontmatter: fm } = record;
1144
+ if (!isKbRecordType(fm.type)) {
1145
+ report("type", conceptId2, `unrecognised type "${fm.type}"`);
1146
+ }
1147
+ if (fm.strauss_status === "superseded") {
1148
+ const by = fm.strauss_superseded_by;
1149
+ if (!by) {
1150
+ report("superseded_by", conceptId2, "superseded with no replacement");
1151
+ } else if (!byId.has(by)) {
1152
+ report("superseded_by", conceptId2, `replacement ${by} is missing`);
1153
+ } else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
1154
+ report("backlink", by, `does not list ${conceptId2} in supersedes`);
1155
+ }
1156
+ }
1157
+ for (const old of fm.strauss_supersedes ?? []) {
1158
+ const previous = byId.get(old);
1159
+ if (!previous) {
1160
+ report("supersedes", conceptId2, `target ${old} is missing`);
1161
+ } else if (previous.frontmatter.strauss_status !== "superseded") {
1162
+ report("supersedes", conceptId2, `${old} is not marked superseded`);
1163
+ }
1164
+ }
1165
+ if (fm.strauss_assumption && fm.sources?.length) {
1166
+ report("assumption", conceptId2, "marked an assumption but cites sources");
1167
+ }
1168
+ }
1169
+ return problems;
1170
+ }
1171
+
1172
+ // src/doctor.ts
1173
+ var DEFAULT_EXPIRING_DAYS = 30;
1174
+ var DEFAULT_UNVERIFIED_DAYS = 90;
1175
+ var DEFAULT_AGING_DAYS = 90;
1176
+ var CHECK_HEADLINES = {
1177
+ expired: "past its stale_after date",
1178
+ expiring: "stale_after falls within the window",
1179
+ unverified: "nobody has ever confirmed it, and it is old enough to matter",
1180
+ aging: "still open or still proposed long after it was written",
1181
+ orphaned: "no other record links to it",
1182
+ "broken-supersession": "the supersession pointers do not resolve",
1183
+ "superseded-but-cited": "a live record's body links to one that no longer holds"
1184
+ };
1185
+ var DAY_MS = 864e5;
1186
+ function doctor(bundle, options = {}) {
1187
+ const thresholds = {
1188
+ expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
1189
+ unverifiedDays: options.unverifiedDays ?? DEFAULT_UNVERIFIED_DAYS,
1190
+ agingDays: options.agingDays ?? DEFAULT_AGING_DAYS
1191
+ };
1192
+ const now = options.now ?? /* @__PURE__ */ new Date();
1193
+ const adjudicated = adjudicate(bundle, bundle, now);
1194
+ const standings = new Map(
1195
+ adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
1196
+ );
1197
+ const inForce = adjudicated.filter(
1198
+ (hit) => hit.standing !== "superseded" && hit.standing !== "rejected"
1199
+ );
1200
+ const groups = [
1201
+ group("expired", expired(inForce, now)),
1202
+ group("expiring", expiring(inForce, now, thresholds.expiringDays)),
1203
+ group("unverified", unverified(inForce, now, thresholds.unverifiedDays)),
1204
+ group("aging", aging(inForce, now, thresholds.agingDays)),
1205
+ group("orphaned", orphaned(bundle)),
1206
+ group("broken-supersession", brokenSupersession(bundle, adjudicated)),
1207
+ group("superseded-but-cited", supersededButCited(bundle, standings))
1208
+ ];
1209
+ const counts = Object.fromEntries(
1210
+ groups.map((entry) => [entry.check, entry.count])
1211
+ );
1212
+ const findingCount = groups.reduce((total, entry) => total + entry.count, 0);
1213
+ return {
1214
+ recordCount: bundle.length,
1215
+ thresholds,
1216
+ counts,
1217
+ groups,
1218
+ findingCount,
1219
+ healthy: findingCount === 0
1220
+ };
1221
+ }
1222
+ function group(check, findings) {
1223
+ return {
1224
+ check,
1225
+ headline: CHECK_HEADLINES[check],
1226
+ count: findings.length,
1227
+ findings
1228
+ };
1229
+ }
1230
+ function expired(hits, now) {
1231
+ const findings = [];
1232
+ for (const hit of hits) {
1233
+ const raw = hit.record.frontmatter.stale_after;
1234
+ if (!raw) continue;
1235
+ const at = Date.parse(raw);
1236
+ if (Number.isNaN(at)) {
1237
+ findings.push(
1238
+ finding(hit.record, `stale_after "${raw}" is not a readable date`)
1239
+ );
1240
+ continue;
1241
+ }
1242
+ if (at < now.getTime()) {
1243
+ findings.push(
1244
+ finding(
1245
+ hit.record,
1246
+ `stale since ${raw} (${daysBetween(at, now.getTime())} days ago)`
1247
+ )
1248
+ );
1249
+ }
1250
+ }
1251
+ return findings;
1252
+ }
1253
+ function expiring(hits, now, withinDays) {
1254
+ const horizon = now.getTime() + withinDays * DAY_MS;
1255
+ const findings = [];
1256
+ for (const hit of hits) {
1257
+ const raw = hit.record.frontmatter.stale_after;
1258
+ if (!raw) continue;
1259
+ const at = Date.parse(raw);
1260
+ if (Number.isNaN(at) || at < now.getTime() || at > horizon) continue;
1261
+ findings.push(
1262
+ finding(
1263
+ hit.record,
1264
+ `goes stale ${raw} (in ${daysBetween(now.getTime(), at)} days)`
1265
+ )
1266
+ );
1267
+ }
1268
+ return findings;
1269
+ }
1270
+ function unverified(hits, now, olderThanDays) {
1271
+ const findings = [];
1272
+ for (const hit of hits) {
1273
+ if (hit.record.frontmatter.verified?.length) continue;
1274
+ const age = ageInDays(hit.record, now);
1275
+ if (age === null || age <= olderThanDays) continue;
1276
+ findings.push(
1277
+ finding(hit.record, `never verified, written ${age} days ago`)
1278
+ );
1279
+ }
1280
+ return findings;
1281
+ }
1282
+ function aging(hits, now, olderThanDays) {
1283
+ const findings = [];
1284
+ for (const hit of hits) {
1285
+ const status = hit.record.frontmatter.strauss_status;
1286
+ if (status !== "open" && status !== "proposed") continue;
1287
+ const age = ageInDays(hit.record, now);
1288
+ if (age === null || age <= olderThanDays) continue;
1289
+ findings.push(
1290
+ finding(
1291
+ hit.record,
1292
+ status === "open" ? `open for ${age} days` : `proposed ${age} days ago and still unsettled`
1293
+ )
1294
+ );
1295
+ }
1296
+ return findings.sort(
1297
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
1298
+ );
1299
+ }
1300
+ function orphaned(bundle) {
1301
+ const present = new Set(bundle.map((record) => record.conceptId));
1302
+ const referenced = /* @__PURE__ */ new Set();
1303
+ for (const record of bundle) {
1304
+ for (const neighbour of edgeNeighbours(record, bundle, "body-link")) {
1305
+ referenced.add(neighbour.conceptId);
1306
+ }
1307
+ for (const replaced of record.frontmatter.strauss_supersedes ?? []) {
1308
+ referenced.add(replaced);
1309
+ }
1310
+ const replacement = record.frontmatter.strauss_superseded_by;
1311
+ if (replacement && present.has(replacement)) {
1312
+ referenced.add(record.conceptId);
1313
+ }
1314
+ }
1315
+ return bundle.filter((record) => !referenced.has(record.conceptId)).map((record) => finding(record, "no other record links to it"));
1316
+ }
1317
+ var SUPERSESSION_CHECKS = /* @__PURE__ */ new Set([
1318
+ "superseded_by",
1319
+ "supersedes",
1320
+ "backlink"
1321
+ ]);
1322
+ function brokenSupersession(bundle, adjudicated) {
1323
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
1324
+ const findings = [];
1325
+ const seen = /* @__PURE__ */ new Set();
1326
+ const add = (record, note) => {
1327
+ const key = `${record.conceptId}\0${note}`;
1328
+ if (seen.has(key)) return;
1329
+ seen.add(key);
1330
+ findings.push(finding(record, note));
1331
+ };
1332
+ for (const problem of validateBundle(bundle)) {
1333
+ if (!SUPERSESSION_CHECKS.has(problem.check)) continue;
1334
+ const record = byId.get(problem.conceptId);
1335
+ if (record) add(record, problem.note);
1336
+ }
1337
+ for (const record of bundle) {
1338
+ const replacement = record.frontmatter.strauss_superseded_by;
1339
+ if (!replacement) continue;
1340
+ if (!byId.has(replacement)) {
1341
+ add(record, `replacement ${replacement} is missing`);
1342
+ } else if (record.frontmatter.strauss_status !== "superseded") {
1343
+ add(
1344
+ record,
1345
+ `names ${replacement} as its replacement but is not marked superseded`
1346
+ );
1347
+ }
1348
+ }
1349
+ for (const hit of adjudicated) {
1350
+ for (const warning of hit.warnings) {
1351
+ if (warning.kind === "broken-chain") {
1352
+ add(hit.record, `replacement ${warning.missing} is missing`);
1353
+ } else if (warning.kind === "chain-cycle") {
1354
+ add(
1355
+ hit.record,
1356
+ `supersession chain cycles through ${warning.through.join(" \u2192 ")}`
1357
+ );
1358
+ } else if (warning.kind === "forked-chain") {
1359
+ add(
1360
+ hit.record,
1361
+ `two records claim to replace it: ${warning.heads.join(", ")}`
1362
+ );
1363
+ }
1364
+ }
1365
+ }
1366
+ return findings.sort(
1367
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
1368
+ );
1369
+ }
1370
+ function supersededButCited(bundle, standings) {
1371
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
1372
+ const findings = [];
1373
+ for (const record of bundle) {
1374
+ const standing = standings.get(record.conceptId);
1375
+ if (standing === "superseded" || standing === "rejected") continue;
1376
+ for (const target of edgeNeighbours(record, bundle, "body-link")) {
1377
+ const targetStanding = standings.get(target.conceptId);
1378
+ if (targetStanding !== "superseded" && targetStanding !== "rejected") {
1379
+ continue;
1380
+ }
1381
+ if (replaces(record, target)) continue;
1382
+ const replacement = target.frontmatter.strauss_superseded_by;
1383
+ findings.push(
1384
+ finding(
1385
+ record,
1386
+ `cites ${targetStanding} ${target.conceptId}${targetStanding === "superseded" && replacement && byId.has(replacement) ? ` \u2014 replaced by ${replacement}` : ""}`
1387
+ )
1388
+ );
1389
+ }
1390
+ }
1391
+ return findings;
1392
+ }
1393
+ function replaces(later, earlier) {
1394
+ return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
1395
+ }
1396
+ function finding(record, note) {
1397
+ return {
1398
+ conceptId: record.conceptId,
1399
+ title: record.frontmatter.title ?? null,
1400
+ status: record.frontmatter.strauss_status,
1401
+ note
1402
+ };
1403
+ }
1404
+ function daysBetween(from, to) {
1405
+ return Math.max(0, Math.floor((to - from) / DAY_MS));
1406
+ }
1407
+ function ageInDays(record, now) {
1408
+ const at = record.frontmatter.generated?.at;
1409
+ if (!at) return null;
1410
+ const written = Date.parse(at);
1411
+ if (Number.isNaN(written)) return null;
1412
+ return daysBetween(written, now.getTime());
1413
+ }
1414
+
1415
+ // src/commands/doctor.ts
1416
+ var days = (what, fallback) => import_zod8.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
1417
+ var doctorCommand = define({
1418
+ name: "doctor",
1419
+ tool: "kb_doctor",
1420
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
1421
+ description: "A health sweep over a whole base: what the calendar has already retired, what nobody ever confirmed, what has been open or proposed long enough that the status is now the answer, and what the graph has dropped on the floor. Read-only \u2014 it never writes, never supersedes, and never re-dates anything; every finding names a record for a person to repair. Seven checks, grouped and counted: expired (past `stale_after`), expiring (inside the window), unverified (an empty `verified[]` on a record old enough to matter), aging (still `open` or `proposed`), orphaned (no other record links to it), broken supersession (a chain that does not resolve), and superseded-but-cited (a live record whose body links to a record that no longer holds). Every group is reported even when empty, because a check that found nothing and a check that never ran look identical in a report that only lists findings.\n\nThis is the question no reader thinks to ask, which is why it needs a command: decay is invisible from inside a single record \u2014 a stale one reads exactly like a live one, and a question nobody answered reads exactly like one nobody asked. Reach for it when picking up a base someone else kept, before trusting a base you have not touched in months, or on a schedule; kb_validate is the narrower neighbour, checking only whether pointers between records agree.",
1422
+ input: import_zod8.z.object({
1423
+ bundlePath,
1424
+ expiringDays: days(
1425
+ "How far ahead `expiring` looks, in days.",
1426
+ DEFAULT_EXPIRING_DAYS
1427
+ ),
1428
+ unverifiedDays: days(
1429
+ "How old an unconfirmed record must be before `unverified` reports it, in days.",
1430
+ DEFAULT_UNVERIFIED_DAYS
1431
+ ),
1432
+ agingDays: days(
1433
+ "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
1434
+ DEFAULT_AGING_DAYS
1435
+ ),
1436
+ strict: import_zod8.z.boolean().optional().describe(
1437
+ "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
1438
+ )
1439
+ }),
1440
+ // Presence, not truthiness: `--expiring-days ""` is a caller who meant
1441
+ // something and mistyped it, and a falsy test would answer by quietly
1442
+ // sweeping at the default. Passed through as given, the schema rejects it
1443
+ // and says which field.
1444
+ fromArgv: (argv, path) => {
1445
+ const expiring2 = argvFlag(argv, "--expiring-days");
1446
+ const unverified2 = argvFlag(argv, "--unverified-days");
1447
+ const agingDays = argvFlag(argv, "--aging-days");
1448
+ return {
1449
+ bundlePath: path,
1450
+ ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
1451
+ ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
1452
+ ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
1453
+ ...argv.includes("--strict") ? { strict: true } : {}
1454
+ };
1455
+ },
1456
+ run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays }) => {
1457
+ const checkedAt = now();
1458
+ const report = doctor(await store.list(path), {
1459
+ ...expiringDays !== void 0 ? { expiringDays } : {},
1460
+ ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
1461
+ ...agingDays !== void 0 ? { agingDays } : {},
1462
+ now: new Date(checkedAt)
1463
+ });
1464
+ return { bundlePath: path, checkedAt, ...report };
1465
+ },
1466
+ render: (result) => render(result),
1467
+ // Only expiry, and only under --strict. The other six checks report debt a
1468
+ // reader decides about; an expired record is the base asserting something it
1469
+ // already said it would stop standing behind, which is the one finding a
1470
+ // pipeline can act on without a judgment call.
1471
+ failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
1472
+ });
1473
+ function render(result) {
1474
+ const { thresholds } = result;
1475
+ const lines = [
1476
+ `# KB Doctor \u2014 ${result.bundlePath}`,
1477
+ `records: ${result.recordCount}`,
1478
+ `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
1479
+ `checked: ${result.checkedAt}`,
1480
+ ""
1481
+ ];
1482
+ const width = Math.max(...result.groups.map((group2) => group2.check.length));
1483
+ for (const group2 of result.groups) {
1484
+ lines.push(
1485
+ ` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
1486
+ );
1487
+ }
1488
+ for (const group2 of result.groups) {
1489
+ if (!group2.count) continue;
1490
+ lines.push("", `## ${group2.check} (${group2.count})`);
1491
+ for (const found of group2.findings) {
1492
+ lines.push(
1493
+ `- ${found.conceptId}${found.title ? ` \u2014 ${found.title}` : ""}: ${found.note}`
1494
+ );
1495
+ }
1496
+ }
1497
+ lines.push(
1498
+ "",
1499
+ result.healthy ? "Nothing to repair." : `${result.findingCount} finding${result.findingCount === 1 ? "" : "s"} across ${result.groups.filter((group2) => group2.count).length} of ${result.groups.length} checks.`
1500
+ );
1501
+ return lines.join("\n");
1502
+ }
1503
+
1504
+ // src/commands/list.ts
1505
+ var import_zod9 = require("zod");
1060
1506
  var listCommand = define({
1061
1507
  name: "list",
1062
1508
  tool: "kb_list",
1063
1509
  usage: "list [type]",
1064
1510
  description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
1065
- input: import_zod8.z.object({ bundlePath, type: import_zod8.z.enum(KB_RECORD_TYPES).optional() }),
1511
+ input: import_zod9.z.object({ bundlePath, type: import_zod9.z.enum(KB_RECORD_TYPES).optional() }),
1066
1512
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
1067
1513
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
1068
1514
  conceptId: record.conceptId,
@@ -1074,17 +1520,17 @@ var listCommand = define({
1074
1520
  });
1075
1521
 
1076
1522
  // src/commands/load.ts
1077
- var import_zod9 = require("zod");
1523
+ var import_zod10 = require("zod");
1078
1524
  var loadCommand = define({
1079
1525
  name: "load",
1080
1526
  tool: "kb_load",
1081
1527
  usage: "load [type] [--budget N | --all]",
1082
1528
  description: "Load the whole knowledge base at once, each record with its standing. Prefer this over searching: these bases run to a few thousand tokens, and a reader holding all of it has perfect recall and knows why it is asking, which no ranker does. Superseded records arrive under `superseded` as name, replacement and date only \u2014 their bodies no longer hold, and reading one later in a long session is the mistake this prevents; pass the id to kb_trace when you need the history. Rejected and unresolved records arrive whole: what was turned down, and what is still open, is the part a diff cannot show you. Refuses with a count rather than truncating when the base is too large \u2014 a truncated base is indistinguishable from a complete one, and would have you conclude something was never decided from a slice you did not know was a slice. Call at the point of use, not once per session: a base loaded early is summarised away by compaction, so if the visible context holds no records from this base and the question at hand is one it might govern, load before answering \u2014 never conclude nothing was decided from a context with no KB content in it. This tool (with kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.\n\nThat refusal is the default guardrail, meant for an agent that would otherwise burn its whole context on one call. `all` bypasses it and loads everything regardless of size: a deliberate operator with the budget to spend, not something to reach for automatically. It is mutually exclusive with `budgetTokens`. When the reader does not need everything, kb_query or a narrower `type` filter is the better fit than either.",
1083
- input: import_zod9.z.object({
1529
+ input: import_zod10.z.object({
1084
1530
  bundlePath,
1085
- type: import_zod9.z.enum(KB_RECORD_TYPES).optional(),
1086
- budgetTokens: import_zod9.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
1087
- all: import_zod9.z.boolean().optional().describe(
1531
+ type: import_zod10.z.enum(KB_RECORD_TYPES).optional(),
1532
+ budgetTokens: import_zod10.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
1533
+ all: import_zod10.z.boolean().optional().describe(
1088
1534
  "Load the entire base regardless of size. The deliberate-operator escape hatch; mutually exclusive with budgetTokens."
1089
1535
  )
1090
1536
  }).refine((value) => !(value.all && value.budgetTokens !== void 0), {
@@ -1122,25 +1568,25 @@ var loadCommand = define({
1122
1568
  });
1123
1569
 
1124
1570
  // src/commands/log.ts
1125
- var import_zod10 = require("zod");
1571
+ var import_zod11 = require("zod");
1126
1572
  var logCommand = define({
1127
1573
  name: "log",
1128
1574
  tool: "kb_log",
1129
1575
  usage: "log",
1130
1576
  description: "What touched what, and when. The only artifact here that cannot be reconstructed from the records, so malformed lines are reported rather than repaired.",
1131
- input: import_zod10.z.object({ bundlePath }),
1577
+ input: import_zod11.z.object({ bundlePath }),
1132
1578
  fromArgv: (_argv, path) => ({ bundlePath: path }),
1133
1579
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
1134
1580
  });
1135
1581
 
1136
1582
  // src/commands/no-decision.ts
1137
- var import_zod11 = require("zod");
1583
+ var import_zod12 = require("zod");
1138
1584
  var noDecisionCommand = define({
1139
1585
  name: "no-decision",
1140
1586
  tool: "kb_no_decision",
1141
1587
  usage: "no-decision <reason...>",
1142
1588
  description: 'Claim in one sentence that there was nothing to decide. Gating on "did you write a decision?" rewards writing a junk one; gating on "did you answer?" does not, so silence has to be expressible. Idempotent \u2014 restating it is not a collision.',
1143
- input: import_zod11.z.object({ bundlePath, reason: import_zod11.z.string().min(1) }),
1589
+ input: import_zod12.z.object({ bundlePath, reason: import_zod12.z.string().min(1) }),
1144
1590
  fromArgv: (argv, path) => ({
1145
1591
  bundlePath: path,
1146
1592
  reason: argv.slice(1).join(" ").trim()
@@ -1157,20 +1603,20 @@ var noDecisionCommand = define({
1157
1603
  });
1158
1604
 
1159
1605
  // src/commands/pack.ts
1160
- var import_zod12 = require("zod");
1606
+ var import_zod13 = require("zod");
1161
1607
  var packCommand = define({
1162
1608
  name: "pack",
1163
1609
  tool: "kb_pack",
1164
1610
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
1165
1611
  description: "The bounded neighbourhood around one record: everything within `hops` of the root, ranked and cut to `maxNodes`, with every cut record named under Excluded \u2014 a named gap is knowable, a silent one is not. Prefer this over kb_load when the base is too large to hold whole and the work centres on one record; prefer it over kb_query when the question needs the governed neighbourhood \u2014 what was settled and what binds near this record \u2014 rather than a lookup by wording. Superseded records arrive as name, replacement and date stubs exactly as kb_load emits them: their bodies no longer hold, and kb_trace has the history. Refuses outright rather than truncating when the pack would exceed its token budget \u2014 a partial pack is indistinguishable from a complete one \u2014 reporting the record count and every already-cut id so the caller can lower hops or maxNodes, or raise the budget. The header carries the bundle, root, budget and a timestamp; everything below the header is byte-identical across runs over an unchanged base, so two packs can be diffed and a changed byte means changed knowledge. This tool (with kb_load, kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.",
1166
- input: import_zod12.z.object({
1612
+ input: import_zod13.z.object({
1167
1613
  bundlePath,
1168
1614
  conceptId,
1169
- hops: import_zod12.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
1170
- maxNodes: import_zod12.z.number().int().positive().optional().describe(
1615
+ hops: import_zod13.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
1616
+ maxNodes: import_zod13.z.number().int().positive().optional().describe(
1171
1617
  "How many records the pack may hold, root included. Defaults to 20."
1172
1618
  ),
1173
- budgetTokens: import_zod12.z.number().int().positive().optional().describe(
1619
+ budgetTokens: import_zod13.z.number().int().positive().optional().describe(
1174
1620
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
1175
1621
  )
1176
1622
  }),
@@ -1192,10 +1638,10 @@ var packCommand = define({
1192
1638
  ...maxNodes !== void 0 ? { maxNodes } : {},
1193
1639
  ...budgetTokens !== void 0 ? { budgetTokens } : {}
1194
1640
  });
1195
- return render(result, path, now());
1641
+ return render2(result, path, now());
1196
1642
  }
1197
1643
  });
1198
- function render(result, bundle, at) {
1644
+ function render2(result, bundle, at) {
1199
1645
  const lines = [
1200
1646
  `# KB Pack \u2014 ${result.root}`,
1201
1647
  `bundle: ${bundle}`,
@@ -1257,22 +1703,22 @@ function warningLabel(warning) {
1257
1703
  }
1258
1704
 
1259
1705
  // src/commands/pin.ts
1260
- var import_zod13 = require("zod");
1706
+ var import_zod14 = require("zod");
1261
1707
  var pinCommand = define({
1262
1708
  name: "pin",
1263
1709
  tool: "kb_pin",
1264
1710
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
1265
1711
  description: "Pin a base into a workspace pin manifest, so `context` surfaces it at every context birth. Three layers, nearest wins: the committed project manifest (.strauss/kb-pins.json, the default), `--local` (.strauss/kb-pins.local.json, personal and gitignored), and `--user` (~/.strauss/kb-pins.json, every workspace). Idempotent \u2014 re-pinning changes nothing unless --mode, --profiles, or --frozen/--unfreeze are given, which update just those fields. `--mode full` preloads the whole base into the block regardless of the full-under threshold; `--mode index` never upgrades. `--profiles` scopes the pin to named context profiles. `--frozen` marks the base concluded: write commands against it refuse and `context` labels it read-only. A path with no records yet succeeds with a warning; bases are routinely pinned before they are populated. Pins are workspace state: the pinned base itself is never touched.",
1266
- input: import_zod13.z.object({
1712
+ input: import_zod14.z.object({
1267
1713
  bundlePath,
1268
- mode: import_zod13.z.enum(["full", "index"]).optional().describe(
1714
+ mode: import_zod14.z.enum(["full", "index"]).optional().describe(
1269
1715
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
1270
1716
  ),
1271
- profiles: import_zod13.z.array(import_zod13.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
1272
- layer: import_zod13.z.enum(["project", "local", "user"]).optional().describe(
1717
+ profiles: import_zod14.z.array(import_zod14.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
1718
+ layer: import_zod14.z.enum(["project", "local", "user"]).optional().describe(
1273
1719
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
1274
1720
  ),
1275
- frozen: import_zod13.z.boolean().optional().describe(
1721
+ frozen: import_zod14.z.boolean().optional().describe(
1276
1722
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
1277
1723
  )
1278
1724
  }),
@@ -1301,29 +1747,29 @@ var pinCommand = define({
1301
1747
  });
1302
1748
 
1303
1749
  // src/commands/pins.ts
1304
- var import_zod14 = require("zod");
1750
+ var import_zod15 = require("zod");
1305
1751
  var pinsCommand = define({
1306
1752
  name: "pins",
1307
1753
  tool: "kb_pins",
1308
1754
  usage: "pins",
1309
1755
  description: "Every pinned base across the manifest layers, each with its layer and whether it currently resolves to readable records. Reads the workspace manifests rather than any one base, like kb_context.",
1310
- input: import_zod14.z.object({}),
1756
+ input: import_zod15.z.object({}),
1311
1757
  fromArgv: () => ({}),
1312
1758
  run: ({ store }) => listPins(store, process.cwd())
1313
1759
  });
1314
1760
 
1315
1761
  // src/commands/query.ts
1316
- var import_zod15 = require("zod");
1762
+ var import_zod16 = require("zod");
1317
1763
  var queryCommand = define({
1318
1764
  name: "query",
1319
1765
  tool: "kb_query",
1320
1766
  usage: "query <text...>",
1321
1767
  description: "Search and return each match with its standing. Results are flagged, never filtered: a superseded record comes back alongside whatever replaced it, and a rejected one is marked as something explicitly not adopted. Prefer kb_load when the base fits its budget: on this package's measurements, a reader holding the whole base answered eight of nine questions whose wording appears in no record, where embedding search answered four. Never read record files directly \u2014 this tool (with kb_load and kb_trace) is the only supported way to read a base; a file read bypasses supersession resolution and returns replaced records as if current.",
1322
- input: import_zod15.z.object({
1768
+ input: import_zod16.z.object({
1323
1769
  bundlePath,
1324
- text: import_zod15.z.string().optional(),
1325
- type: import_zod15.z.enum(KB_RECORD_TYPES).optional(),
1326
- includeNonCurrent: import_zod15.z.boolean().optional()
1770
+ text: import_zod16.z.string().optional(),
1771
+ type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
1772
+ includeNonCurrent: import_zod16.z.boolean().optional()
1327
1773
  }),
1328
1774
  fromArgv: (argv, path) => ({
1329
1775
  bundlePath: path,
@@ -1345,33 +1791,33 @@ var queryCommand = define({
1345
1791
  });
1346
1792
 
1347
1793
  // src/commands/read-index.ts
1348
- var import_zod16 = require("zod");
1794
+ var import_zod17 = require("zod");
1349
1795
  var readIndexCommand = define({
1350
1796
  name: "index",
1351
1797
  tool: "kb_index",
1352
1798
  usage: "index",
1353
1799
  description: "The index, rebuilt if it disagrees with the records. One call gives the whole shape of the base: title, type, status, and description per record. The cheap re-orientation call after compaction or deep in a long session \u2014 a few hundred tokens; call it (or kb_context, when bases are pinned) first, then kb_load or fetch by concept id.",
1354
- input: import_zod16.z.object({ bundlePath }),
1800
+ input: import_zod17.z.object({ bundlePath }),
1355
1801
  fromArgv: (_argv, path) => ({ bundlePath: path }),
1356
1802
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
1357
1803
  });
1358
1804
 
1359
1805
  // src/commands/schema.ts
1360
- var import_zod19 = require("zod");
1806
+ var import_zod20 = require("zod");
1361
1807
 
1362
1808
  // src/json-schema.ts
1363
- var import_zod18 = require("zod");
1809
+ var import_zod19 = require("zod");
1364
1810
 
1365
1811
  // src/kb-log.ts
1366
- var import_zod17 = require("zod");
1812
+ var import_zod18 = require("zod");
1367
1813
  var LOG_FILE = "log.jsonl";
1368
- var kbLogEntrySchema = import_zod17.z.object({
1369
- at: import_zod17.z.string().min(1),
1370
- by: import_zod17.z.string().min(1),
1371
- operation: import_zod17.z.string().min(1),
1372
- conceptId: import_zod17.z.string().min(1),
1814
+ var kbLogEntrySchema = import_zod18.z.object({
1815
+ at: import_zod18.z.string().min(1),
1816
+ by: import_zod18.z.string().min(1),
1817
+ operation: import_zod18.z.string().min(1),
1818
+ conceptId: import_zod18.z.string().min(1),
1373
1819
  /** Second concept id, where the operation relates two — supersession. */
1374
- target: import_zod17.z.string().min(1).optional()
1820
+ target: import_zod18.z.string().min(1).optional()
1375
1821
  }).strict();
1376
1822
  function renderLogEntry(entry) {
1377
1823
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -1402,11 +1848,11 @@ function parseLog(raw) {
1402
1848
  // src/json-schema.ts
1403
1849
  function kbJsonSchemas() {
1404
1850
  return {
1405
- recordFrontmatter: import_zod18.z.toJSONSchema(kbRecordFrontmatterSchema, {
1851
+ recordFrontmatter: import_zod19.z.toJSONSchema(kbRecordFrontmatterSchema, {
1406
1852
  io: "input"
1407
1853
  }),
1408
- composeInput: import_zod18.z.toJSONSchema(composeInputSchema, { io: "input" }),
1409
- logEntry: import_zod18.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
1854
+ composeInput: import_zod19.z.toJSONSchema(composeInputSchema, { io: "input" }),
1855
+ logEntry: import_zod19.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
1410
1856
  };
1411
1857
  }
1412
1858
 
@@ -1416,22 +1862,22 @@ var schemaCommand = define({
1416
1862
  tool: "kb_schema",
1417
1863
  usage: "schema",
1418
1864
  description: "JSON Schema for the frontmatter, the write input, and log entries \u2014 generated from the code that enforces them, so it cannot drift from what a write will accept.",
1419
- input: import_zod19.z.object({}),
1865
+ input: import_zod20.z.object({}),
1420
1866
  fromArgv: () => ({}),
1421
1867
  run: () => Promise.resolve(kbJsonSchemas())
1422
1868
  });
1423
1869
 
1424
1870
  // src/commands/status.ts
1425
- var import_zod20 = require("zod");
1871
+ var import_zod21 = require("zod");
1426
1872
  var statusCommand = define({
1427
1873
  name: "status",
1428
1874
  tool: "kb_status",
1429
1875
  usage: "status <concept-id> <status>",
1430
1876
  description: "Move a record's status, leaving everything else alone. Uses a compare-and-swap, so a concurrent change fails loudly rather than being overwritten.",
1431
- input: import_zod20.z.object({
1877
+ input: import_zod21.z.object({
1432
1878
  bundlePath,
1433
1879
  conceptId,
1434
- status: import_zod20.z.enum(KB_RECORD_STATUSES)
1880
+ status: import_zod21.z.enum(KB_RECORD_STATUSES)
1435
1881
  }),
1436
1882
  fromArgv: (argv, path) => ({
1437
1883
  bundlePath: path,
@@ -1446,13 +1892,13 @@ var statusCommand = define({
1446
1892
  });
1447
1893
 
1448
1894
  // src/commands/supersede.ts
1449
- var import_zod21 = require("zod");
1895
+ var import_zod22 = require("zod");
1450
1896
  var supersedeCommand = define({
1451
1897
  name: "supersede",
1452
1898
  tool: "kb_supersede",
1453
1899
  usage: "supersede <concept-id> <replacement-id>",
1454
1900
  description: "Mark a record superseded by another, linking both directions. Use this rather than editing a record whose meaning changed \u2014 a record that quietly becomes something else invalidates every reference to it, and the earlier understanding is what a later trace needs.",
1455
- input: import_zod21.z.object({ bundlePath, conceptId, replacementId: conceptId }),
1901
+ input: import_zod22.z.object({ bundlePath, conceptId, replacementId: conceptId }),
1456
1902
  fromArgv: (argv, path) => ({
1457
1903
  bundlePath: path,
1458
1904
  conceptId: argv[1],
@@ -1466,16 +1912,16 @@ var supersedeCommand = define({
1466
1912
  });
1467
1913
 
1468
1914
  // src/commands/sync-instructions.ts
1469
- var import_zod22 = require("zod");
1915
+ var import_zod23 = require("zod");
1470
1916
  var syncInstructionsCommand = define({
1471
1917
  name: "sync-instructions",
1472
1918
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
1473
1919
  description: "Idempotently plant the `context` block between sentinel comments in an instruction file (AGENTS.md, CLAUDE.md), creating the block when absent and leaving everything outside the sentinels alone. CLI-only: this is file plumbing for runtimes whose instruction files are re-read where their conversations are not, not an agent capability \u2014 the capability is kb_context.",
1474
- input: import_zod22.z.object({
1475
- file: import_zod22.z.string().min(1).describe("The instruction file to edit in place."),
1476
- budgetTokens: import_zod22.z.number().int().positive().optional(),
1477
- fullUnderTokens: import_zod22.z.number().int().positive().optional(),
1478
- profile: import_zod22.z.string().optional()
1920
+ input: import_zod23.z.object({
1921
+ file: import_zod23.z.string().min(1).describe("The instruction file to edit in place."),
1922
+ budgetTokens: import_zod23.z.number().int().positive().optional(),
1923
+ fullUnderTokens: import_zod23.z.number().int().positive().optional(),
1924
+ profile: import_zod23.z.string().optional()
1479
1925
  }),
1480
1926
  fromArgv: (argv) => {
1481
1927
  const budget = argvFlag(argv, "--budget");
@@ -1501,83 +1947,7 @@ var syncInstructionsCommand = define({
1501
1947
  });
1502
1948
 
1503
1949
  // src/commands/trace.ts
1504
- var import_zod23 = require("zod");
1505
-
1506
- // src/kb-edges.ts
1507
- var KB_EDGE_KINDS = [
1508
- "body-link",
1509
- "supersession",
1510
- "anchor",
1511
- "source"
1512
- ];
1513
- var BODY_LINK_TARGET = new RegExp(
1514
- `\\]\\((${KB_CONCEPT_ID_PATTERN.source.replace(/^\^|\$$/g, "")})\\.md\\)`,
1515
- "g"
1516
- );
1517
- function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
1518
- const found = /* @__PURE__ */ new Map();
1519
- for (const kind of kinds) {
1520
- for (const record of edgeNeighbours(from, bundle, kind)) {
1521
- const existing = found.get(record.conceptId);
1522
- if (existing) {
1523
- if (!existing.via.includes(kind)) existing.via.push(kind);
1524
- continue;
1525
- }
1526
- found.set(record.conceptId, { record, via: [kind] });
1527
- }
1528
- }
1529
- return [...found.values()];
1530
- }
1531
- function edgeNeighbours(from, bundle, kind) {
1532
- switch (kind) {
1533
- // A link whose target is not in the bundle is legal per compose.ts —
1534
- // records are routinely written before the ones they point at exist — so
1535
- // missing targets are skipped, never an error.
1536
- case "body-link": {
1537
- const targets = new Set(
1538
- [...from.body.matchAll(BODY_LINK_TARGET)].map((match) => match[1])
1539
- );
1540
- if (!targets.size) return [];
1541
- return bundle.filter(
1542
- (candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
1543
- );
1544
- }
1545
- // Both directions and both pointers: `supersede()` writes the pair, but a
1546
- // hand-edit can leave one side behind, and a walk trusting one pointer
1547
- // would miss a replacement the bundle openly declares.
1548
- case "supersession":
1549
- return bundle.filter(
1550
- (candidate) => candidate.conceptId !== from.conceptId && (candidate.conceptId === from.frontmatter.strauss_superseded_by || from.frontmatter.strauss_supersedes?.includes(
1551
- candidate.conceptId
1552
- ) || candidate.frontmatter.strauss_superseded_by === from.conceptId || candidate.frontmatter.strauss_supersedes?.includes(from.conceptId))
1553
- );
1554
- // The edge that answers "why is this code shaped this way": every record
1555
- // attached to the same file or symbol, whatever its standing.
1556
- case "anchor": {
1557
- const mine = from.frontmatter.strauss_anchors ?? [];
1558
- if (!mine.length) return [];
1559
- return bundle.filter(
1560
- (candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.strauss_anchors ?? []).some(
1561
- (theirs) => mine.some((ours) => anchorsTouch(ours, theirs))
1562
- )
1563
- );
1564
- }
1565
- case "source": {
1566
- const mine = new Set((from.frontmatter.sources ?? []).map((s) => s.id));
1567
- if (!mine.size) return [];
1568
- return bundle.filter(
1569
- (candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.sources ?? []).some(
1570
- (source) => mine.has(source.id)
1571
- )
1572
- );
1573
- }
1574
- }
1575
- }
1576
- function anchorsTouch(left, right) {
1577
- if (left.file !== right.file) return false;
1578
- if (!left.symbol || !right.symbol) return true;
1579
- return left.symbol === right.symbol;
1580
- }
1950
+ var import_zod24 = require("zod");
1581
1951
 
1582
1952
  // src/trace.ts
1583
1953
  var TRACE_EDGES = ["supersession", "anchor", "source"];
@@ -1623,11 +1993,11 @@ var traceCommand = define({
1623
1993
  tool: "kb_trace",
1624
1994
  usage: "trace <concept-id> [edges...]",
1625
1995
  description: 'How a position was arrived at, as a timeline ordered by when each record was written. Deliberately includes rejected, draft, and superseded records \u2014 in a history those are the content, not noise. Follows supersession, shared code anchors, and shared sources. Use when the question is "why is this the way it is" rather than "what do we hold now". This tool (with kb_load and kb_query) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.',
1626
- input: import_zod23.z.object({
1996
+ input: import_zod24.z.object({
1627
1997
  bundlePath,
1628
1998
  conceptId,
1629
- edges: import_zod23.z.array(import_zod23.z.enum(TRACE_EDGES)).optional(),
1630
- depth: import_zod23.z.number().int().positive().optional()
1999
+ edges: import_zod24.z.array(import_zod24.z.enum(TRACE_EDGES)).optional(),
2000
+ depth: import_zod24.z.number().int().positive().optional()
1631
2001
  }),
1632
2002
  fromArgv: (argv, path) => ({
1633
2003
  bundlePath: path,
@@ -1649,90 +2019,53 @@ var traceCommand = define({
1649
2019
  });
1650
2020
 
1651
2021
  // src/commands/types.ts
1652
- var import_zod24 = require("zod");
2022
+ var import_zod25 = require("zod");
1653
2023
  var typesCommand = define({
1654
2024
  name: "types",
1655
2025
  tool: "kb_types",
1656
2026
  usage: "types",
1657
2027
  description: "The twelve record types with their purpose, body sections, and starting status. Read this before writing rather than guessing headings \u2014 a section the type does not define is rejected.",
1658
- input: import_zod24.z.object({}),
2028
+ input: import_zod25.z.object({}),
1659
2029
  fromArgv: () => ({}),
1660
2030
  run: () => Promise.resolve(RECORD_TYPES)
1661
2031
  });
1662
2032
 
1663
2033
  // src/commands/unpin.ts
1664
- var import_zod25 = require("zod");
2034
+ var import_zod26 = require("zod");
1665
2035
  var unpinCommand = define({
1666
2036
  name: "unpin",
1667
2037
  tool: "kb_unpin",
1668
2038
  usage: "unpin [bundle-path]",
1669
2039
  description: "Remove a base from every pin manifest layer that holds it \u2014 project, local, and user \u2014 because unpinned means gone, not still injected from another file. Reports which layers were touched.",
1670
- input: import_zod25.z.object({ bundlePath }),
2040
+ input: import_zod26.z.object({ bundlePath }),
1671
2041
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
1672
2042
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
1673
2043
  });
1674
2044
 
1675
2045
  // src/commands/validate.ts
1676
- var import_zod26 = require("zod");
1677
-
1678
- // src/validate.ts
1679
- function validateBundle(records) {
1680
- const byId = new Map(records.map((record) => [record.conceptId, record]));
1681
- const problems = [];
1682
- const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
1683
- for (const record of records) {
1684
- const { conceptId: conceptId2, frontmatter: fm } = record;
1685
- if (!isKbRecordType(fm.type)) {
1686
- report("type", conceptId2, `unrecognised type "${fm.type}"`);
1687
- }
1688
- if (fm.strauss_status === "superseded") {
1689
- const by = fm.strauss_superseded_by;
1690
- if (!by) {
1691
- report("superseded_by", conceptId2, "superseded with no replacement");
1692
- } else if (!byId.has(by)) {
1693
- report("superseded_by", conceptId2, `replacement ${by} is missing`);
1694
- } else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
1695
- report("backlink", by, `does not list ${conceptId2} in supersedes`);
1696
- }
1697
- }
1698
- for (const old of fm.strauss_supersedes ?? []) {
1699
- const previous = byId.get(old);
1700
- if (!previous) {
1701
- report("supersedes", conceptId2, `target ${old} is missing`);
1702
- } else if (previous.frontmatter.strauss_status !== "superseded") {
1703
- report("supersedes", conceptId2, `${old} is not marked superseded`);
1704
- }
1705
- }
1706
- if (fm.strauss_assumption && fm.sources?.length) {
1707
- report("assumption", conceptId2, "marked an assumption but cites sources");
1708
- }
1709
- }
1710
- return problems;
1711
- }
1712
-
1713
- // src/commands/validate.ts
2046
+ var import_zod27 = require("zod");
1714
2047
  var validateCommand = define({
1715
2048
  name: "validate",
1716
2049
  tool: "kb_validate",
1717
2050
  usage: "validate",
1718
2051
  description: "Check pointers no single record can see: supersession links that disagree between the two records, and assumptions that cite sources. Per-record shape is enforced on every read, so a problem here means someone edited a file by hand.",
1719
- input: import_zod26.z.object({ bundlePath }),
2052
+ input: import_zod27.z.object({ bundlePath }),
1720
2053
  fromArgv: (_argv, path) => ({ bundlePath: path }),
1721
2054
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
1722
2055
  failsWhen: (result) => Array.isArray(result) && result.length > 0
1723
2056
  });
1724
2057
 
1725
2058
  // src/commands/verify.ts
1726
- var import_zod27 = require("zod");
2059
+ var import_zod28 = require("zod");
1727
2060
  var verifyCommand = define({
1728
2061
  name: "verify",
1729
2062
  tool: "kb_verify",
1730
2063
  usage: "verify <concept-id> --note <text>",
1731
2064
  description: "Append one verified[] event \u2014 who checked the record, when, and what the check found. Appends only; prior events are never rewritten. A record's own generator is refused unless the actor is human: re-reading your own output is not an independent check.",
1732
- input: import_zod27.z.object({
2065
+ input: import_zod28.z.object({
1733
2066
  bundlePath,
1734
2067
  conceptId,
1735
- note: import_zod27.z.string().refine((s) => s.trim().length > 0, {
2068
+ note: import_zod28.z.string().refine((s) => s.trim().length > 0, {
1736
2069
  message: "note must say what the check found"
1737
2070
  })
1738
2071
  }),
@@ -1752,7 +2085,7 @@ var verifyCommand = define({
1752
2085
  });
1753
2086
 
1754
2087
  // src/commands/write.ts
1755
- var import_zod28 = require("zod");
2088
+ var import_zod29 = require("zod");
1756
2089
  var writeCommand = define({
1757
2090
  name: "write",
1758
2091
  tool: "kb_write",
@@ -1766,9 +2099,9 @@ var writeCommand = define({
1766
2099
  "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
1767
2100
  "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
1768
2101
  ].join("\n"),
1769
- input: import_zod28.z.object({
2102
+ input: import_zod29.z.object({
1770
2103
  bundlePath,
1771
- type: import_zod28.z.enum(KB_RECORD_TYPES),
2104
+ type: import_zod29.z.enum(KB_RECORD_TYPES),
1772
2105
  input: composeInputSchema
1773
2106
  }),
1774
2107
  fromArgv: async (argv, path, stdin) => ({
@@ -1792,7 +2125,7 @@ var writeCommand = define({
1792
2125
  });
1793
2126
 
1794
2127
  // src/commands/write-decision.ts
1795
- var import_zod29 = require("zod");
2128
+ var import_zod30 = require("zod");
1796
2129
  var writeDecisionCommand = define({
1797
2130
  name: "write-decision",
1798
2131
  tool: "kb_write_decision",
@@ -1805,7 +2138,7 @@ var writeDecisionCommand = define({
1805
2138
  "- `alternative` is what you turned down and why, not a list of everything considered.",
1806
2139
  "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
1807
2140
  ].join("\n"),
1808
- input: import_zod29.z.object({ bundlePath, input: decisionInputSchema }),
2141
+ input: import_zod30.z.object({ bundlePath, input: decisionInputSchema }),
1809
2142
  fromArgv: async (_argv, path, stdin) => ({
1810
2143
  bundlePath: path,
1811
2144
  input: JSON.parse(await stdin())
@@ -1842,6 +2175,7 @@ var KB_COMMANDS = [
1842
2175
  readIndexCommand,
1843
2176
  logCommand,
1844
2177
  validateCommand,
2178
+ doctorCommand,
1845
2179
  schemaCommand,
1846
2180
  pinCommand,
1847
2181
  unpinCommand,
@@ -2674,12 +3008,13 @@ function digest(contents) {
2674
3008
  }
2675
3009
 
2676
3010
  // src/version.ts
2677
- var VERSION = true ? "0.1.7" : "0.0.0-dev";
3011
+ var VERSION = true ? "0.1.8" : "0.0.0-dev";
2678
3012
 
2679
3013
  // src/cli.ts
2680
3014
  async function runKbCli(argv) {
2681
- const { bundle, rest } = takeBundle(argv);
2682
- const name = rest[0] ?? "";
3015
+ const { flags, literal } = takeLiteral(argv);
3016
+ const { bundle, rest: withFlags } = takeBundle(flags);
3017
+ const name = withFlags[0] ?? "";
2683
3018
  if (!name || name === "-h" || name === "--help") {
2684
3019
  process.stdout.write(usage());
2685
3020
  return;
@@ -2691,6 +3026,14 @@ async function runKbCli(argv) {
2691
3026
  }
2692
3027
  const command = KB_COMMANDS_BY_NAME.get(name);
2693
3028
  if (!command) die(`unknown command ${name}`);
3029
+ const json = withFlags.includes("--json");
3030
+ if (json && !command.render) {
3031
+ die(`${name} takes no --json: its result is already the machine shape`);
3032
+ }
3033
+ const rest = [
3034
+ ...json ? withFlags.filter((argument) => argument !== "--json") : withFlags,
3035
+ ...literal
3036
+ ];
2694
3037
  const raw = await command.fromArgv(rest, bundle, readStdin);
2695
3038
  const parsed = command.input.safeParse(raw);
2696
3039
  if (!parsed.success) {
@@ -2710,13 +3053,16 @@ async function runKbCli(argv) {
2710
3053
  },
2711
3054
  parsed.data
2712
3055
  );
2713
- if (command.failsWhen?.(result)) process.exitCode = 1;
3056
+ if (command.failsWhen?.(result, parsed.data)) process.exitCode = 1;
2714
3057
  if (result === "") return;
2715
- process.stdout.write(
2716
- typeof result === "string" ? result.endsWith("\n") ? result : `${result}
2717
- ` : `${JSON.stringify(result, null, 2)}
2718
- `
2719
- );
3058
+ const text = command.render && !json ? command.render(result) : typeof result === "string" ? result : JSON.stringify(result, null, 2);
3059
+ process.stdout.write(text.endsWith("\n") ? text : `${text}
3060
+ `);
3061
+ }
3062
+ function takeLiteral(argv) {
3063
+ const at = argv.indexOf("--");
3064
+ if (at === -1) return { flags: argv, literal: [] };
3065
+ return { flags: argv.slice(0, at), literal: argv.slice(at + 1) };
2720
3066
  }
2721
3067
  function takeBundle(argv) {
2722
3068
  const at = argv.indexOf("--bundle");
@@ -2758,6 +3104,8 @@ function usage() {
2758
3104
  ),
2759
3105
  "",
2760
3106
  ` --bundle PATH defaults to ./${KB_DIR}`,
3107
+ " --json the machine shape, where a command prints a table",
3108
+ " -- everything after it is text, not flags",
2761
3109
  " --version the installed package version",
2762
3110
  " STRAUSS_KB_ACTOR names the writer in the log",
2763
3111
  ""