@saasontools/strauss-kb 0.1.11 → 0.1.13

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/index.cjs CHANGED
@@ -40,10 +40,12 @@ __export(index_exports, {
40
40
  DEFAULT_LOAD_BUDGET: () => DEFAULT_LOAD_BUDGET,
41
41
  DEFAULT_PACK_HOPS: () => DEFAULT_PACK_HOPS,
42
42
  DEFAULT_PACK_MAX_NODES: () => DEFAULT_PACK_MAX_NODES,
43
+ DEFAULT_TYPED_LINK_RELS: () => DEFAULT_TYPED_LINK_RELS,
43
44
  DEFAULT_UNVERIFIED_DAYS: () => DEFAULT_UNVERIFIED_DAYS,
44
45
  ErrorTypes: () => ErrorTypes,
45
46
  Fault: () => Fault,
46
47
  INDEX_FILE: () => INDEX_FILE,
48
+ KB_CAUSAL_LINK_RELS: () => KB_CAUSAL_LINK_RELS,
47
49
  KB_COMMANDS: () => KB_COMMANDS,
48
50
  KB_COMMANDS_BY_NAME: () => KB_COMMANDS_BY_NAME,
49
51
  KB_CONCEPT_ID_PATTERN: () => KB_CONCEPT_ID_PATTERN,
@@ -51,6 +53,7 @@ __export(index_exports, {
51
53
  KB_DIR: () => KB_DIR,
52
54
  KB_DOCTOR_CHECKS: () => KB_DOCTOR_CHECKS,
53
55
  KB_EDGE_KINDS: () => KB_EDGE_KINDS,
56
+ KB_LINK_RELS: () => KB_LINK_RELS,
54
57
  KB_MATERIALITIES: () => KB_MATERIALITIES,
55
58
  KB_RECORD_STATUSES: () => KB_RECORD_STATUSES,
56
59
  KB_RECORD_TYPES: () => KB_RECORD_TYPES,
@@ -64,7 +67,9 @@ __export(index_exports, {
64
67
  KbRecordNotFoundError: () => KbRecordNotFoundError,
65
68
  KbSelfVerificationError: () => KbSelfVerificationError,
66
69
  KbStore: () => KbStore,
70
+ KbUnknownLinkRelError: () => KbUnknownLinkRelError,
67
71
  KbWriteConflictError: () => KbWriteConflictError,
72
+ LINK_RELS: () => LINK_RELS,
68
73
  LOG_FILE: () => LOG_FILE,
69
74
  NO_DECISION_SLUG: () => NO_DECISION_SLUG,
70
75
  PINS_FILE: () => PINS_FILE,
@@ -76,10 +81,12 @@ __export(index_exports, {
76
81
  adjudicate: () => adjudicate,
77
82
  anchorFilePath: () => anchorFilePath,
78
83
  assertBaseNotFrozen: () => assertBaseNotFrozen,
84
+ backlinks: () => backlinks,
79
85
  buildContext: () => buildContext,
80
86
  catalog: () => catalog,
81
87
  composeDecisionRecord: () => composeDecisionRecord,
82
88
  composeInputSchema: () => composeInputSchema,
89
+ composeLinkSchema: () => composeLinkSchema,
83
90
  composeNoDecisionRecord: () => composeNoDecisionRecord,
84
91
  composeRecord: () => composeRecord,
85
92
  contextProfileBudgets: () => contextProfileBudgets,
@@ -89,13 +96,17 @@ __export(index_exports, {
89
96
  doctor: () => doctor,
90
97
  edgeNeighbours: () => edgeNeighbours,
91
98
  hashAnchorText: () => hashAnchorText,
99
+ impact: () => impact,
100
+ inboundIndex: () => inboundIndex,
92
101
  indexIsStale: () => indexIsStale,
102
+ isKbLinkRel: () => isKbLinkRel,
93
103
  isKbRecordType: () => isKbRecordType,
94
104
  isNoDecisionRecord: () => isNoDecisionRecord,
95
105
  kbActorStampSchema: () => kbActorStampSchema,
96
106
  kbAnchorSchema: () => kbAnchorSchema,
97
107
  kbConceptIdSchema: () => kbConceptIdSchema,
98
108
  kbJsonSchemas: () => kbJsonSchemas,
109
+ kbLinkSchema: () => kbLinkSchema,
99
110
  kbLogEntrySchema: () => kbLogEntrySchema,
100
111
  kbRecordFrontmatterSchema: () => kbRecordFrontmatterSchema,
101
112
  kbSourceSchema: () => kbSourceSchema,
@@ -239,6 +250,10 @@ var kbAnchorSchema = import_zod.z.object({
239
250
  /** Line count of the text the hash was taken over. */
240
251
  lines: import_zod.z.number().int().positive().optional()
241
252
  }).strict();
253
+ var kbLinkSchema = import_zod.z.object({
254
+ target: import_zod.z.string().min(1),
255
+ rel: import_zod.z.string().min(1)
256
+ }).passthrough();
242
257
  var KB_RECORD_TYPES = [
243
258
  "fact",
244
259
  "requirement",
@@ -290,6 +305,10 @@ var kbRecordFrontmatterSchema = import_zod.z.object({
290
305
  // strauss extensions — see the module comment.
291
306
  strauss_anchors: import_zod.z.array(kbAnchorSchema).optional(),
292
307
  strauss_verify: import_zod.z.array(import_zod.z.string().min(1)).optional(),
308
+ // Typed causal edges, source → target, living on the source. `A depends_on
309
+ // B` means A needs B, so `kb_impact` walks these inbound: what breaks if B
310
+ // changes is whatever declared a dependence on it.
311
+ strauss_links: import_zod.z.array(kbLinkSchema).optional(),
293
312
  // Total after parsing, tolerant before it. Our producers must supply a
294
313
  // status — an absent one would leave every reader inventing its own default
295
314
  // — but OKF calls a concept carrying only `type` fully conformant, so
@@ -322,6 +341,7 @@ var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
322
341
  ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
323
342
  ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
324
343
  ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
344
+ ErrorTypes2["KbUnknownLinkRel"] = "KbUnknownLinkRel";
325
345
  ErrorTypes2["KbWriteConflict"] = "KbWriteConflict";
326
346
  return ErrorTypes2;
327
347
  })(ErrorTypes || {});
@@ -430,6 +450,23 @@ var KbPackBudgetExceededError = class extends BaseError {
430
450
  budgetTokens;
431
451
  excluded;
432
452
  };
453
+ var KbUnknownLinkRelError = class extends BaseError {
454
+ constructor(rel, expected) {
455
+ super({
456
+ message: `kb: ${rel} is not a rel a walk can follow \u2014 expected one of ${expected.join(", ")}`,
457
+ errorType: "KbUnknownLinkRel" /* KbUnknownLinkRel */,
458
+ code: 400,
459
+ fault: "User" /* User */,
460
+ retriable: false,
461
+ reportToUser: true,
462
+ details: { rel, expected: expected.join(", ") }
463
+ });
464
+ this.rel = rel;
465
+ this.expected = expected;
466
+ }
467
+ rel;
468
+ expected;
469
+ };
433
470
  var KbMissingFlagValueError = class extends BaseError {
434
471
  constructor(flag) {
435
472
  super({
@@ -1150,9 +1187,135 @@ async function loadQmd(logger) {
1150
1187
  }
1151
1188
  }
1152
1189
 
1190
+ // src/record-types.ts
1191
+ var RECORD_TYPES = {
1192
+ fact: {
1193
+ purpose: "Observed or sourced fact",
1194
+ sections: ["Claim", "Evidence", "Implication"],
1195
+ initialStatus: "accepted"
1196
+ },
1197
+ requirement: {
1198
+ purpose: "Required behavior or outcome",
1199
+ sections: ["Claim", "Evidence", "Implication"],
1200
+ initialStatus: "proposed"
1201
+ },
1202
+ constraint: {
1203
+ purpose: "Limitation, compatibility boundary, policy, or restriction",
1204
+ sections: ["Claim", "Evidence", "Implication"],
1205
+ initialStatus: "accepted"
1206
+ },
1207
+ decision: {
1208
+ purpose: "Chosen or proposed direction",
1209
+ sections: ["Decision", "Rationale", "Rejected", "Impact"],
1210
+ initialStatus: "accepted"
1211
+ },
1212
+ assumption: {
1213
+ purpose: "Unsourced or not-yet-confirmed working assumption",
1214
+ sections: ["Claim", "Why we think so", "What would falsify it"],
1215
+ initialStatus: "draft"
1216
+ },
1217
+ "open-question": {
1218
+ purpose: "Question needing resolution",
1219
+ sections: ["Question", "Why it matters", "Default assumption"],
1220
+ initialStatus: "open"
1221
+ },
1222
+ risk: {
1223
+ purpose: "Something that can go wrong",
1224
+ sections: ["Risk", "Why it matters", "Mitigation", "Verification"],
1225
+ initialStatus: "open"
1226
+ },
1227
+ contract: {
1228
+ purpose: "API, data, event, schema, or permission contract",
1229
+ sections: ["Contract", "Producer", "Consumer", "Compatibility"],
1230
+ initialStatus: "proposed"
1231
+ },
1232
+ flow: {
1233
+ purpose: "Sequence, lifecycle, or state behavior",
1234
+ sections: ["Flow", "Trigger", "Steps", "Failure modes"],
1235
+ initialStatus: "accepted"
1236
+ },
1237
+ "affected-system": {
1238
+ purpose: "Component, service, package, integration, or external system",
1239
+ sections: ["System", "How it is affected", "Blast radius"],
1240
+ initialStatus: "accepted"
1241
+ },
1242
+ "test-obligation": {
1243
+ purpose: "Behavior or contract that must be verified",
1244
+ sections: ["Obligation", "Why it matters", "How to verify"],
1245
+ initialStatus: "open"
1246
+ },
1247
+ "source-note": {
1248
+ purpose: "Extracted note from source material",
1249
+ sections: ["Note", "Where it came from"],
1250
+ initialStatus: "accepted"
1251
+ }
1252
+ };
1253
+ function isKbRecordType(value) {
1254
+ return Object.prototype.hasOwnProperty.call(RECORD_TYPES, value);
1255
+ }
1256
+ var KB_LINK_RELS = [
1257
+ "depends_on",
1258
+ "constrains",
1259
+ "informs",
1260
+ "blocks",
1261
+ "invalidates",
1262
+ "verified_by",
1263
+ "satisfies",
1264
+ "related_to"
1265
+ ];
1266
+ var LINK_RELS = {
1267
+ depends_on: {
1268
+ purpose: "The source needs the target to hold; the source breaks if the target changes",
1269
+ phrase: "Depends on",
1270
+ dependant: "source"
1271
+ },
1272
+ constrains: {
1273
+ purpose: "The source bounds what the target may do; the target breaks if the constraint changes",
1274
+ phrase: "Constrains",
1275
+ dependant: "target"
1276
+ },
1277
+ informs: {
1278
+ purpose: "The source shaped the target without binding it; the target is what needs revisiting",
1279
+ phrase: "Informs",
1280
+ dependant: "target"
1281
+ },
1282
+ blocks: {
1283
+ purpose: "The target cannot proceed until the source is settled; the target is what waits",
1284
+ phrase: "Blocks",
1285
+ dependant: "target"
1286
+ },
1287
+ invalidates: {
1288
+ purpose: "The source makes the target no longer hold; the target is what stops holding",
1289
+ phrase: "Invalidates",
1290
+ dependant: "target"
1291
+ },
1292
+ verified_by: {
1293
+ purpose: "The target is the check that confirms the source; the source's confirmation moves with it",
1294
+ phrase: "Verified by",
1295
+ dependant: "source"
1296
+ },
1297
+ satisfies: {
1298
+ purpose: "The source discharges the target's requirement; the source must change if the requirement does",
1299
+ phrase: "Satisfies",
1300
+ dependant: "source"
1301
+ },
1302
+ related_to: {
1303
+ purpose: "A pointer worth following, with no claim of dependence",
1304
+ phrase: "Relates to",
1305
+ dependant: null
1306
+ }
1307
+ };
1308
+ var KB_CAUSAL_LINK_RELS = KB_LINK_RELS.filter(
1309
+ (rel) => LINK_RELS[rel].dependant !== null
1310
+ );
1311
+ function isKbLinkRel(value) {
1312
+ return Object.prototype.hasOwnProperty.call(LINK_RELS, value);
1313
+ }
1314
+
1153
1315
  // src/kb-edges.ts
1154
1316
  var KB_EDGE_KINDS = [
1155
1317
  "body-link",
1318
+ "typed-link",
1156
1319
  "supersession",
1157
1320
  "anchor",
1158
1321
  "source"
@@ -1161,10 +1324,11 @@ var BODY_LINK_TARGET = new RegExp(
1161
1324
  `\\]\\((${KB_CONCEPT_ID_PATTERN.source.replace(/^\^|\$$/g, "")})\\.md\\)`,
1162
1325
  "g"
1163
1326
  );
1164
- function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
1327
+ var DEFAULT_TYPED_LINK_RELS = KB_LINK_RELS;
1328
+ function neighbours(from, bundle, kinds = KB_EDGE_KINDS, linkRels = DEFAULT_TYPED_LINK_RELS) {
1165
1329
  const found = /* @__PURE__ */ new Map();
1166
1330
  for (const kind of kinds) {
1167
- for (const record of edgeNeighbours(from, bundle, kind)) {
1331
+ for (const record of edgeNeighbours(from, bundle, kind, linkRels)) {
1168
1332
  const existing = found.get(record.conceptId);
1169
1333
  if (existing) {
1170
1334
  if (!existing.via.includes(kind)) existing.via.push(kind);
@@ -1175,7 +1339,7 @@ function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
1175
1339
  }
1176
1340
  return [...found.values()];
1177
1341
  }
1178
- function edgeNeighbours(from, bundle, kind) {
1342
+ function edgeNeighbours(from, bundle, kind, linkRels = DEFAULT_TYPED_LINK_RELS) {
1179
1343
  switch (kind) {
1180
1344
  // A link whose target is not in the bundle is legal per compose.ts —
1181
1345
  // records are routinely written before the ones they point at exist — so
@@ -1189,6 +1353,21 @@ function edgeNeighbours(from, bundle, kind) {
1189
1353
  (candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
1190
1354
  );
1191
1355
  }
1356
+ // Outbound only, like `body-link`, and for the same reason: this is what
1357
+ // the record declares about itself. A missing target is legal — the walk
1358
+ // skips it, and `kb_validate` is what reports it as a warning. A rel
1359
+ // outside `linkRels` is skipped too, which is how an unknown rel stays
1360
+ // untraversable everywhere rather than one walk at a time.
1361
+ case "typed-link": {
1362
+ const allowed = new Set(linkRels);
1363
+ const targets = new Set(
1364
+ (from.frontmatter.strauss_links ?? []).filter((link2) => allowed.has(link2.rel)).map((link2) => link2.target)
1365
+ );
1366
+ if (!targets.size) return [];
1367
+ return bundle.filter(
1368
+ (candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
1369
+ );
1370
+ }
1192
1371
  // Both directions and both pointers: `supersede()` writes the pair, but a
1193
1372
  // hand-edit can leave one side behind, and a walk trusting one pointer
1194
1373
  // would miss a replacement the bundle openly declares.
@@ -1227,7 +1406,12 @@ function anchorsTouch(left, right) {
1227
1406
  }
1228
1407
 
1229
1408
  // src/trace.ts
1230
- var TRACE_EDGES = ["supersession", "anchor", "source"];
1409
+ var TRACE_EDGES = [
1410
+ "typed-link",
1411
+ "supersession",
1412
+ "anchor",
1413
+ "source"
1414
+ ];
1231
1415
  function trace(seedId, bundle, options = {}) {
1232
1416
  const edges = options.edges?.length ? options.edges : TRACE_EDGES;
1233
1417
  const maxDepth = options.depth ?? 3;
@@ -1242,7 +1426,12 @@ function trace(seedId, bundle, options = {}) {
1242
1426
  const next = [];
1243
1427
  for (const from of frontier) {
1244
1428
  for (const edge of edges) {
1245
- for (const record of edgeNeighbours(from, bundle, edge)) {
1429
+ for (const record of edgeNeighbours(
1430
+ from,
1431
+ bundle,
1432
+ edge,
1433
+ KB_CAUSAL_LINK_RELS
1434
+ )) {
1246
1435
  const existing = reached.get(record.conceptId);
1247
1436
  if (existing) {
1248
1437
  if (existing.depth > 0 && !existing.via.includes(edge)) {
@@ -1394,6 +1583,141 @@ function renderCatalogLine(entry) {
1394
1583
  return `- ${parts.join(" \xB7 ")}`;
1395
1584
  }
1396
1585
 
1586
+ // src/kb-links/inbound.ts
1587
+ function inboundIndex(bundle) {
1588
+ const byTarget = /* @__PURE__ */ new Map();
1589
+ for (const record of bundle) {
1590
+ for (const link2 of record.frontmatter.strauss_links ?? []) {
1591
+ if (link2.target === record.conceptId) continue;
1592
+ const edges = byTarget.get(link2.target) ?? [];
1593
+ if (edges.some(
1594
+ (edge) => edge.from === record.conceptId && edge.rel === link2.rel
1595
+ )) {
1596
+ continue;
1597
+ }
1598
+ edges.push({ from: record.conceptId, rel: link2.rel });
1599
+ byTarget.set(link2.target, edges);
1600
+ }
1601
+ }
1602
+ return byTarget;
1603
+ }
1604
+
1605
+ // src/kb-links/backlinks.ts
1606
+ function backlinks(targetId, bundle) {
1607
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
1608
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
1609
+ const standingOf = new Map(
1610
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
1611
+ );
1612
+ const rows = [];
1613
+ for (const edge of inboundIndex(bundle).get(targetId) ?? []) {
1614
+ const record = byId.get(edge.from);
1615
+ if (!record) continue;
1616
+ const hit = standingOf.get(edge.from);
1617
+ rows.push({
1618
+ ...edge,
1619
+ title: record.frontmatter.title ?? null,
1620
+ standing: hit?.standing ?? "unsettled",
1621
+ warnings: hit?.warnings ?? []
1622
+ });
1623
+ }
1624
+ return {
1625
+ target: targetId,
1626
+ backlinks: rows.sort(
1627
+ (left, right) => left.from.localeCompare(right.from) || left.rel.localeCompare(right.rel)
1628
+ )
1629
+ };
1630
+ }
1631
+
1632
+ // src/kb-links/impact.ts
1633
+ function impact(targetId, bundle, options = {}) {
1634
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
1635
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
1636
+ const rels = resolveRels(options.rels);
1637
+ const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
1638
+ const inbound = inboundIndex(bundle);
1639
+ const standingOf = new Map(
1640
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
1641
+ );
1642
+ const reached = /* @__PURE__ */ new Map();
1643
+ const stopped = [];
1644
+ let frontier = [targetId];
1645
+ let depth = 0;
1646
+ while (frontier.length && depth < maxDepth) {
1647
+ depth += 1;
1648
+ const next = [];
1649
+ const consider = (dependantId, edge) => {
1650
+ if (dependantId === targetId) return;
1651
+ const existing = reached.get(dependantId);
1652
+ if (existing) {
1653
+ if (!hasEdge(existing.via, edge)) existing.via.push(edge);
1654
+ return;
1655
+ }
1656
+ const record = byId.get(dependantId);
1657
+ if (!record) return;
1658
+ const hit = standingOf.get(dependantId);
1659
+ const entry = {
1660
+ conceptId: dependantId,
1661
+ title: record.frontmatter.title ?? null,
1662
+ standing: hit?.standing ?? "unsettled",
1663
+ warnings: hit?.warnings ?? [],
1664
+ depth,
1665
+ via: [edge]
1666
+ };
1667
+ reached.set(dependantId, entry);
1668
+ if (entry.standing === "superseded" || entry.standing === "rejected") {
1669
+ stopped.push(dependantId);
1670
+ return;
1671
+ }
1672
+ next.push(dependantId);
1673
+ };
1674
+ for (const id of frontier) {
1675
+ for (const edge of inbound.get(id) ?? []) {
1676
+ if (!rels.has(edge.rel)) continue;
1677
+ if (dependantEnd(edge.rel) !== "source") continue;
1678
+ consider(edge.from, { source: edge.from, target: id, rel: edge.rel });
1679
+ }
1680
+ for (const link2 of byId.get(id)?.frontmatter.strauss_links ?? []) {
1681
+ if (!rels.has(link2.rel)) continue;
1682
+ if (dependantEnd(link2.rel) !== "target") continue;
1683
+ if (link2.target === id) continue;
1684
+ consider(link2.target, {
1685
+ source: id,
1686
+ target: link2.target,
1687
+ rel: link2.rel
1688
+ });
1689
+ }
1690
+ }
1691
+ frontier = next;
1692
+ }
1693
+ return {
1694
+ root: targetId,
1695
+ impacted: [...reached.values()].sort(
1696
+ (left, right) => left.depth - right.depth || left.conceptId.localeCompare(right.conceptId)
1697
+ ),
1698
+ stopped: stopped.sort(),
1699
+ truncated: frontier.length > 0,
1700
+ unexpanded: [...frontier].sort()
1701
+ };
1702
+ }
1703
+ function resolveRels(rels) {
1704
+ if (!rels?.length) return new Set(KB_CAUSAL_LINK_RELS);
1705
+ for (const rel of rels) {
1706
+ if (!isKbLinkRel(rel) || LINK_RELS[rel].dependant === null) {
1707
+ throw new KbUnknownLinkRelError(rel, KB_CAUSAL_LINK_RELS);
1708
+ }
1709
+ }
1710
+ return new Set(rels);
1711
+ }
1712
+ function dependantEnd(rel) {
1713
+ return isKbLinkRel(rel) ? LINK_RELS[rel].dependant : null;
1714
+ }
1715
+ function hasEdge(edges, edge) {
1716
+ return edges.some(
1717
+ (existing) => existing.source === edge.source && existing.target === edge.target && existing.rel === edge.rel
1718
+ );
1719
+ }
1720
+
1397
1721
  // src/kb-gitattributes.ts
1398
1722
  var GITATTRIBUTES_FILE = ".gitattributes";
1399
1723
  var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
@@ -1760,6 +2084,7 @@ ${answer}
1760
2084
  const records = adjudicated.filter((hit) => hit.standing !== "superseded");
1761
2085
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
1762
2086
  const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
2087
+ const bundleDigestValue = bundleDigest(records, superseded);
1763
2088
  if (!options.all && approxTokens2 > budgetTokens) {
1764
2089
  return {
1765
2090
  loaded: false,
@@ -1770,7 +2095,8 @@ ${answer}
1770
2095
  approxTokens: approxTokens2,
1771
2096
  budgetTokens,
1772
2097
  type: options.type
1773
- })
2098
+ }),
2099
+ digest: bundleDigestValue
1774
2100
  };
1775
2101
  }
1776
2102
  return {
@@ -1779,7 +2105,8 @@ ${answer}
1779
2105
  tokensLoaded: approxTokens2,
1780
2106
  budgetTokens: options.all ? null : budgetTokens,
1781
2107
  records,
1782
- superseded
2108
+ superseded,
2109
+ digest: bundleDigestValue
1783
2110
  };
1784
2111
  }
1785
2112
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
@@ -1794,6 +2121,14 @@ ${answer}
1794
2121
  async pack(bundlePath2, rootId, options = {}) {
1795
2122
  return pack(await this.list(bundlePath2), rootId, options);
1796
2123
  }
2124
+ /** What breaks if this record changes. See `kb-links/impact.ts`. */
2125
+ async impact(bundlePath2, targetId, options = {}) {
2126
+ return impact(targetId, await this.list(bundlePath2), options);
2127
+ }
2128
+ /** Who points at this record, one hop. See `kb-links/backlinks.ts`. */
2129
+ async backlinks(bundlePath2, targetId) {
2130
+ return backlinks(targetId, await this.list(bundlePath2));
2131
+ }
1797
2132
  /**
1798
2133
  * The stored index, rebuilt if it disagrees with the records.
1799
2134
  *
@@ -2096,76 +2431,29 @@ function normalizeActor(id) {
2096
2431
  function digest(contents) {
2097
2432
  return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
2098
2433
  }
2099
-
2100
- // src/record-types.ts
2101
- var RECORD_TYPES = {
2102
- fact: {
2103
- purpose: "Observed or sourced fact",
2104
- sections: ["Claim", "Evidence", "Implication"],
2105
- initialStatus: "accepted"
2106
- },
2107
- requirement: {
2108
- purpose: "Required behavior or outcome",
2109
- sections: ["Claim", "Evidence", "Implication"],
2110
- initialStatus: "proposed"
2111
- },
2112
- constraint: {
2113
- purpose: "Limitation, compatibility boundary, policy, or restriction",
2114
- sections: ["Claim", "Evidence", "Implication"],
2115
- initialStatus: "accepted"
2116
- },
2117
- decision: {
2118
- purpose: "Chosen or proposed direction",
2119
- sections: ["Decision", "Rationale", "Rejected", "Impact"],
2120
- initialStatus: "accepted"
2121
- },
2122
- assumption: {
2123
- purpose: "Unsourced or not-yet-confirmed working assumption",
2124
- sections: ["Claim", "Why we think so", "What would falsify it"],
2125
- initialStatus: "draft"
2126
- },
2127
- "open-question": {
2128
- purpose: "Question needing resolution",
2129
- sections: ["Question", "Why it matters", "Default assumption"],
2130
- initialStatus: "open"
2131
- },
2132
- risk: {
2133
- purpose: "Something that can go wrong",
2134
- sections: ["Risk", "Why it matters", "Mitigation", "Verification"],
2135
- initialStatus: "open"
2136
- },
2137
- contract: {
2138
- purpose: "API, data, event, schema, or permission contract",
2139
- sections: ["Contract", "Producer", "Consumer", "Compatibility"],
2140
- initialStatus: "proposed"
2141
- },
2142
- flow: {
2143
- purpose: "Sequence, lifecycle, or state behavior",
2144
- sections: ["Flow", "Trigger", "Steps", "Failure modes"],
2145
- initialStatus: "accepted"
2146
- },
2147
- "affected-system": {
2148
- purpose: "Component, service, package, integration, or external system",
2149
- sections: ["System", "How it is affected", "Blast radius"],
2150
- initialStatus: "accepted"
2151
- },
2152
- "test-obligation": {
2153
- purpose: "Behavior or contract that must be verified",
2154
- sections: ["Obligation", "Why it matters", "How to verify"],
2155
- initialStatus: "open"
2156
- },
2157
- "source-note": {
2158
- purpose: "Extracted note from source material",
2159
- sections: ["Note", "Where it came from"],
2160
- initialStatus: "accepted"
2161
- }
2162
- };
2163
- function isKbRecordType(value) {
2164
- return Object.prototype.hasOwnProperty.call(RECORD_TYPES, value);
2434
+ function bundleDigest(records, superseded) {
2435
+ const entries = [
2436
+ ...records.map(
2437
+ (hit) => `${hit.record.conceptId}:current:${digest(
2438
+ stringifyMarkdownWithFrontmatter(
2439
+ hit.record.body,
2440
+ hit.record.frontmatter
2441
+ )
2442
+ )}`
2443
+ ),
2444
+ ...superseded.map(
2445
+ (entry) => `${entry.conceptId}:superseded:${digest(JSON.stringify(entry))}`
2446
+ )
2447
+ ].sort();
2448
+ return digest(entries.join("\n"));
2165
2449
  }
2166
2450
 
2167
2451
  // src/compose.ts
2168
2452
  var import_zod3 = require("zod");
2453
+ var composeLinkSchema = import_zod3.z.object({
2454
+ target: kbConceptIdSchema,
2455
+ rel: import_zod3.z.enum(KB_LINK_RELS)
2456
+ }).strict();
2169
2457
  var composeInputSchema = import_zod3.z.object({
2170
2458
  slug: import_zod3.z.string().min(1),
2171
2459
  /** One line, in the reader's terms. Becomes OKF `title`. */
@@ -2192,6 +2480,18 @@ var composeInputSchema = import_zod3.z.object({
2192
2480
  tags: import_zod3.z.array(import_zod3.z.string().min(1)).optional(),
2193
2481
  /** Concept ids this record relates to; rendered as body links. */
2194
2482
  relatedConceptIds: import_zod3.z.array(kbConceptIdSchema).optional(),
2483
+ /**
2484
+ * Typed causal edges, source → target: `{ target: "fact.b", rel:
2485
+ * "depends_on" }` on record A says A needs B. Stored in frontmatter and
2486
+ * also rendered as one prose sentence each, so the meaning survives a
2487
+ * reader that knows only OKF. The vocabulary goes into the description from
2488
+ * the same table the walk uses, so `kb_schema` emits it.
2489
+ */
2490
+ links: import_zod3.z.array(composeLinkSchema).max(64).optional().describe(
2491
+ `Typed causal edges, source \u2192 target \u2014 a link on this record says this record <rel> the target. ${KB_LINK_RELS.map(
2492
+ (rel) => `${rel}: ${LINK_RELS[rel].purpose}`
2493
+ ).join("; ")}.`
2494
+ ),
2195
2495
  /** Concept ids this record replaces. The store settles the backlinks. */
2196
2496
  supersedes: import_zod3.z.array(kbConceptIdSchema).max(32).optional(),
2197
2497
  materiality: import_zod3.z.enum(KB_MATERIALITIES).optional(),
@@ -2231,6 +2531,15 @@ function composeRecord(type, input, writtenBy, writtenAt) {
2231
2531
  if (parsed.owner) frontmatter.strauss_owner = parsed.owner;
2232
2532
  if (parsed.supersedes?.length)
2233
2533
  frontmatter.strauss_supersedes = parsed.supersedes;
2534
+ const selfLink = parsed.links?.find(
2535
+ (link2) => link2.target === `${type}.${parsed.slug}`
2536
+ );
2537
+ if (selfLink) {
2538
+ throw new Error(
2539
+ `kb: ${type}.${parsed.slug} cannot ${selfLink.rel} itself \u2014 a link must name another record`
2540
+ );
2541
+ }
2542
+ if (parsed.links?.length) frontmatter.strauss_links = parsed.links;
2234
2543
  const blocks = [];
2235
2544
  for (const heading of spec.sections) {
2236
2545
  const text = sections[heading];
@@ -2242,6 +2551,11 @@ ${text}`);
2242
2551
  for (const related of parsed.relatedConceptIds ?? []) {
2243
2552
  blocks.push(`Relates to [${related}](${related}.md).`);
2244
2553
  }
2554
+ for (const link2 of parsed.links ?? []) {
2555
+ blocks.push(
2556
+ `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
2557
+ );
2558
+ }
2245
2559
  if (parsed.sources?.length) {
2246
2560
  blocks.push(
2247
2561
  parsed.sources.map((source) => `[^${source.id}]: ${source.title ?? source.resource}`).join("\n")
@@ -2882,7 +3196,7 @@ function normalize(path) {
2882
3196
  function validateBundle(records) {
2883
3197
  const byId = new Map(records.map((record) => [record.conceptId, record]));
2884
3198
  const problems = [];
2885
- const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
3199
+ const report = (check, conceptId2, note, severity = "error") => problems.push({ check, conceptId: conceptId2, note, severity });
2886
3200
  for (const record of records) {
2887
3201
  const { conceptId: conceptId2, frontmatter: fm } = record;
2888
3202
  if (!isKbRecordType(fm.type)) {
@@ -2906,6 +3220,36 @@ function validateBundle(records) {
2906
3220
  report("supersedes", conceptId2, `${old} is not marked superseded`);
2907
3221
  }
2908
3222
  }
3223
+ for (const link2 of fm.strauss_links ?? []) {
3224
+ if (!isKbLinkRel(link2.rel)) {
3225
+ report(
3226
+ "link_rel",
3227
+ conceptId2,
3228
+ `unknown rel "${link2.rel}" on link to ${link2.target} \u2014 expected one of ${KB_LINK_RELS.join(", ")}`
3229
+ );
3230
+ }
3231
+ if (!KB_CONCEPT_ID_PATTERN.test(link2.target)) {
3232
+ report(
3233
+ "link_target",
3234
+ conceptId2,
3235
+ `target "${link2.target}" is not a valid concept id \u2014 expected <type>.<slug>, both kebab-case`
3236
+ );
3237
+ } else if (link2.target === conceptId2) {
3238
+ report(
3239
+ "link_target",
3240
+ conceptId2,
3241
+ `links to itself (${link2.rel})`,
3242
+ "warning"
3243
+ );
3244
+ } else if (!byId.has(link2.target)) {
3245
+ report(
3246
+ "link_target",
3247
+ conceptId2,
3248
+ `target ${link2.target} is not in the bundle`,
3249
+ "warning"
3250
+ );
3251
+ }
3252
+ }
2909
3253
  if (fm.strauss_assumption && fm.sources?.length) {
2910
3254
  report("assumption", conceptId2, "marked an assumption but cites sources");
2911
3255
  }
@@ -3200,7 +3544,7 @@ var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
3200
3544
  impact: import_zod6.z.string().min(1).optional()
3201
3545
  }).strict();
3202
3546
  function composeDecisionRecord(input, writtenBy, writtenAt) {
3203
- const { alternative, impact, ...rest } = input;
3547
+ const { alternative, impact: impact2, ...rest } = input;
3204
3548
  return composeRecord(
3205
3549
  DECISION_TYPE,
3206
3550
  {
@@ -3209,7 +3553,7 @@ function composeDecisionRecord(input, writtenBy, writtenAt) {
3209
3553
  Decision: input.title,
3210
3554
  Rationale: input.why,
3211
3555
  ...alternative ? { Rejected: alternative } : {},
3212
- ...impact ? { Impact: impact } : {}
3556
+ ...impact2 ? { Impact: impact2 } : {}
3213
3557
  }
3214
3558
  },
3215
3559
  writtenBy,
@@ -3448,16 +3792,28 @@ var answerCommand = define({
3448
3792
  }
3449
3793
  });
3450
3794
 
3451
- // src/commands/catalog.ts
3795
+ // src/commands/backlinks.ts
3452
3796
  var import_zod10 = require("zod");
3797
+ var backlinksCommand = define({
3798
+ name: "backlinks",
3799
+ tool: "kb_backlinks",
3800
+ usage: "backlinks <concept-id>",
3801
+ description: "Who points at this record: every inbound typed causal link (`strauss_links`), one hop, every rel including `related_to`, each with its rel and the standing of the record that made it. Use it when you need the exact edges \u2014 reviewing or renaming a record.",
3802
+ input: import_zod10.z.object({ bundlePath, conceptId }),
3803
+ fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
3804
+ run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
3805
+ });
3806
+
3807
+ // src/commands/catalog.ts
3808
+ var import_zod11 = require("zod");
3453
3809
  var catalogCommand = define({
3454
3810
  name: "catalog",
3455
3811
  tool: "kb_catalog",
3456
3812
  usage: "catalog [type]",
3457
3813
  description: "Lists every record as one line \u2014 concept id, type, title, standing, and a stale flag \u2014 at roughly thirty tokens each. Pick this over kb_load once kb_load refuses: kb_catalog never refuses. Superseded records show only their replacement; fetch bodies with kb_load, kb_pack, kb_query, or kb_trace.",
3458
- input: import_zod10.z.object({
3814
+ input: import_zod11.z.object({
3459
3815
  bundlePath,
3460
- type: import_zod10.z.enum(KB_RECORD_TYPES).optional()
3816
+ type: import_zod11.z.enum(KB_RECORD_TYPES).optional()
3461
3817
  }),
3462
3818
  fromArgv: (argv, path) => ({
3463
3819
  bundlePath: path,
@@ -3512,26 +3868,26 @@ function count(value, noun) {
3512
3868
  }
3513
3869
 
3514
3870
  // src/commands/context.ts
3515
- var import_zod11 = require("zod");
3871
+ var import_zod12 = require("zod");
3516
3872
  var contextCommand = define({
3517
3873
  name: "context",
3518
3874
  tool: "kb_context",
3519
3875
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
3520
3876
  description: "The pinned-base index block, for injection at every context birth \u2014 startup, clear, resume, and after compaction. An index, not the content: concept ids, titles and standing, with the bodies left behind kb_load at the point of use. Emits nothing when nothing is pinned. Refuses with the list of bases and their sizes rather than truncating past its budget. Budgets resolve most-specific-first: explicit flags, then the workspace manifests' `context` tables (per profile, over their `default`), then the built-in profile (session-start, compact, turn), then package defaults \u2014 so a repo tunes its own numbers in .strauss/kb-pins.json without touching hook commands. Like kb_schema and kb_types this takes no bundlePath \u2014 it reads the workspace pin manifests, because which bases a session should see is workspace state, not a property of one base.",
3521
- input: import_zod11.z.object({
3522
- budgetTokens: import_zod11.z.number().int().positive().optional().describe(
3877
+ input: import_zod12.z.object({
3878
+ budgetTokens: import_zod12.z.number().int().positive().optional().describe(
3523
3879
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
3524
3880
  ),
3525
- fullUnderTokens: import_zod11.z.number().int().positive().optional().describe(
3881
+ fullUnderTokens: import_zod12.z.number().int().positive().optional().describe(
3526
3882
  "Per-base rendering threshold, applied before the budget: a base whose complete load fits under this arrives as full records instead of index lines, and the whole block still answers to budgetTokens. Off by default \u2014 index-only is the safe default at a context birth, because injected bodies outlive the qualifiers on them; the session-start profile opts tiny bases in at 1500."
3527
3883
  ),
3528
- profile: import_zod11.z.string().optional().describe(
3884
+ profile: import_zod12.z.string().optional().describe(
3529
3885
  "Named budget set: built-ins are session-start (full-under 1500), compact and turn (budget 2500); the manifests' `context` tables override per repo. Unknown names fall through to defaults rather than failing."
3530
3886
  ),
3531
- format: import_zod11.z.enum(["markdown", "json"]).optional().describe(
3887
+ format: import_zod12.z.enum(["markdown", "json"]).optional().describe(
3532
3888
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
3533
3889
  ),
3534
- event: import_zod11.z.string().optional().describe(
3890
+ event: import_zod12.z.string().optional().describe(
3535
3891
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
3536
3892
  )
3537
3893
  }),
@@ -3567,14 +3923,14 @@ var contextCommand = define({
3567
3923
  });
3568
3924
 
3569
3925
  // src/commands/doctor.ts
3570
- var import_zod12 = require("zod");
3571
- var days = (what, fallback) => import_zod12.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3926
+ var import_zod13 = require("zod");
3927
+ var days = (what, fallback) => import_zod13.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3572
3928
  var doctorCommand = define({
3573
3929
  name: "doctor",
3574
3930
  tool: "kb_doctor",
3575
3931
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
3576
3932
  description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
3577
- input: import_zod12.z.object({
3933
+ input: import_zod13.z.object({
3578
3934
  bundlePath,
3579
3935
  repoRoot: REPO_ROOT,
3580
3936
  expiringDays: days(
@@ -3589,7 +3945,7 @@ var doctorCommand = define({
3589
3945
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3590
3946
  DEFAULT_AGING_DAYS
3591
3947
  ),
3592
- strict: import_zod12.z.boolean().optional().describe(
3948
+ strict: import_zod13.z.boolean().optional().describe(
3593
3949
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
3594
3950
  )
3595
3951
  }),
@@ -3664,14 +4020,47 @@ function render2(result) {
3664
4020
  return lines.join("\n");
3665
4021
  }
3666
4022
 
4023
+ // src/commands/impact.ts
4024
+ var import_zod14 = require("zod");
4025
+ var impactCommand = define({
4026
+ name: "impact",
4027
+ tool: "kb_impact",
4028
+ usage: "impact <concept-id> [--depth N] [--rels a,b]",
4029
+ description: "What breaks if this record changes: its transitive set of dependants, each with its standing. Each rel declares which of its ends depends on the other, and the walk follows each rel in its own direction. Naming `related_to` or an unknown rel in `rels` is an error. kb_backlinks gives one flat hop.",
4030
+ input: import_zod14.z.object({
4031
+ bundlePath,
4032
+ conceptId,
4033
+ depth: import_zod14.z.number().int().positive().optional().describe(
4034
+ "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
4035
+ ),
4036
+ rels: import_zod14.z.array(import_zod14.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
4037
+ "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
4038
+ )
4039
+ }),
4040
+ fromArgv: (argv, path) => {
4041
+ const depth = argvFlag(argv, "--depth");
4042
+ const rels = argvFlag(argv, "--rels");
4043
+ return {
4044
+ bundlePath: path,
4045
+ conceptId: argv[1],
4046
+ ...depth ? { depth: Number(depth) } : {},
4047
+ ...rels ? { rels: rels.split(",").filter(Boolean) } : {}
4048
+ };
4049
+ },
4050
+ run: async ({ store }, { bundlePath: path, conceptId: id, depth, rels }) => store.impact(path, id, {
4051
+ ...depth !== void 0 ? { depth } : {},
4052
+ ...rels?.length ? { rels } : {}
4053
+ })
4054
+ });
4055
+
3667
4056
  // src/commands/list.ts
3668
- var import_zod13 = require("zod");
4057
+ var import_zod15 = require("zod");
3669
4058
  var listCommand = define({
3670
4059
  name: "list",
3671
4060
  tool: "kb_list",
3672
4061
  usage: "list [type]",
3673
4062
  description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
3674
- input: import_zod13.z.object({ bundlePath, type: import_zod13.z.enum(KB_RECORD_TYPES).optional() }),
4063
+ input: import_zod15.z.object({ bundlePath, type: import_zod15.z.enum(KB_RECORD_TYPES).optional() }),
3675
4064
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
3676
4065
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
3677
4066
  conceptId: record.conceptId,
@@ -3683,17 +4072,17 @@ var listCommand = define({
3683
4072
  });
3684
4073
 
3685
4074
  // src/commands/load.ts
3686
- var import_zod14 = require("zod");
4075
+ var import_zod16 = require("zod");
3687
4076
  var loadCommand = define({
3688
4077
  name: "load",
3689
4078
  tool: "kb_load",
3690
4079
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
3691
- description: "Loads the whole knowledge base at once, each record with its standing. Superseded records arrive as stubs (name, replacement, date); rejected and open records arrive whole. Refuses past the token budget rather than truncating \u2014 call kb_catalog, then kb_pack on the record that matters, or narrow with `type`; kb_query for a lookup by wording. `all` bypasses the budget.",
3692
- input: import_zod14.z.object({
4080
+ description: "Loads the whole knowledge base at once, each record with its standing. Superseded records arrive as stubs; rejected and open records arrive whole. Refuses past the token budget \u2014 call kb_catalog, kb_pack on it; `all` bypasses the budget. Never read record files directly. Cache-stable; `digest` is the base's content stamp \u2014 hooks use it to tell you when to reload.",
4081
+ input: import_zod16.z.object({
3693
4082
  bundlePath,
3694
- type: import_zod14.z.enum(KB_RECORD_TYPES).optional(),
3695
- budgetTokens: import_zod14.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3696
- all: import_zod14.z.boolean().optional().describe(
4083
+ type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
4084
+ budgetTokens: import_zod16.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
4085
+ all: import_zod16.z.boolean().optional().describe(
3697
4086
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
3698
4087
  ),
3699
4088
  repoRoot: REPO_ROOT
@@ -3735,25 +4124,25 @@ var loadCommand = define({
3735
4124
  });
3736
4125
 
3737
4126
  // src/commands/log.ts
3738
- var import_zod15 = require("zod");
4127
+ var import_zod17 = require("zod");
3739
4128
  var logCommand = define({
3740
4129
  name: "log",
3741
4130
  tool: "kb_log",
3742
4131
  usage: "log",
3743
4132
  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.",
3744
- input: import_zod15.z.object({ bundlePath }),
4133
+ input: import_zod17.z.object({ bundlePath }),
3745
4134
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3746
4135
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
3747
4136
  });
3748
4137
 
3749
4138
  // src/commands/no-decision.ts
3750
- var import_zod16 = require("zod");
4139
+ var import_zod18 = require("zod");
3751
4140
  var noDecisionCommand = define({
3752
4141
  name: "no-decision",
3753
4142
  tool: "kb_no_decision",
3754
4143
  usage: "no-decision <reason...>",
3755
4144
  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.',
3756
- input: import_zod16.z.object({ bundlePath, reason: import_zod16.z.string().min(1) }),
4145
+ input: import_zod18.z.object({ bundlePath, reason: import_zod18.z.string().min(1) }),
3757
4146
  fromArgv: (argv, path) => ({
3758
4147
  bundlePath: path,
3759
4148
  reason: argv.slice(1).join(" ").trim()
@@ -3770,20 +4159,20 @@ var noDecisionCommand = define({
3770
4159
  });
3771
4160
 
3772
4161
  // src/commands/pack.ts
3773
- var import_zod17 = require("zod");
4162
+ var import_zod19 = require("zod");
3774
4163
  var packCommand = define({
3775
4164
  name: "pack",
3776
4165
  tool: "kb_pack",
3777
4166
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
3778
4167
  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.",
3779
- input: import_zod17.z.object({
4168
+ input: import_zod19.z.object({
3780
4169
  bundlePath,
3781
4170
  conceptId,
3782
- hops: import_zod17.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3783
- maxNodes: import_zod17.z.number().int().positive().optional().describe(
4171
+ hops: import_zod19.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
4172
+ maxNodes: import_zod19.z.number().int().positive().optional().describe(
3784
4173
  "How many records the pack may hold, root included. Defaults to 20."
3785
4174
  ),
3786
- budgetTokens: import_zod17.z.number().int().positive().optional().describe(
4175
+ budgetTokens: import_zod19.z.number().int().positive().optional().describe(
3787
4176
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
3788
4177
  )
3789
4178
  }),
@@ -3870,22 +4259,22 @@ function warningLabel(warning) {
3870
4259
  }
3871
4260
 
3872
4261
  // src/commands/pin.ts
3873
- var import_zod18 = require("zod");
4262
+ var import_zod20 = require("zod");
3874
4263
  var pinCommand = define({
3875
4264
  name: "pin",
3876
4265
  tool: "kb_pin",
3877
4266
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
3878
4267
  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.",
3879
- input: import_zod18.z.object({
4268
+ input: import_zod20.z.object({
3880
4269
  bundlePath,
3881
- mode: import_zod18.z.enum(["full", "index"]).optional().describe(
4270
+ mode: import_zod20.z.enum(["full", "index"]).optional().describe(
3882
4271
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
3883
4272
  ),
3884
- profiles: import_zod18.z.array(import_zod18.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
3885
- layer: import_zod18.z.enum(["project", "local", "user"]).optional().describe(
4273
+ profiles: import_zod20.z.array(import_zod20.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4274
+ layer: import_zod20.z.enum(["project", "local", "user"]).optional().describe(
3886
4275
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
3887
4276
  ),
3888
- frozen: import_zod18.z.boolean().optional().describe(
4277
+ frozen: import_zod20.z.boolean().optional().describe(
3889
4278
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
3890
4279
  )
3891
4280
  }),
@@ -3914,29 +4303,29 @@ var pinCommand = define({
3914
4303
  });
3915
4304
 
3916
4305
  // src/commands/pins.ts
3917
- var import_zod19 = require("zod");
4306
+ var import_zod21 = require("zod");
3918
4307
  var pinsCommand = define({
3919
4308
  name: "pins",
3920
4309
  tool: "kb_pins",
3921
4310
  usage: "pins",
3922
4311
  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.",
3923
- input: import_zod19.z.object({}),
4312
+ input: import_zod21.z.object({}),
3924
4313
  fromArgv: () => ({}),
3925
4314
  run: ({ store }) => listPins(store, process.cwd())
3926
4315
  });
3927
4316
 
3928
4317
  // src/commands/query.ts
3929
- var import_zod20 = require("zod");
4318
+ var import_zod22 = require("zod");
3930
4319
  var queryCommand = define({
3931
4320
  name: "query",
3932
4321
  tool: "kb_query",
3933
4322
  usage: "query <text...> [--repo-root PATH]",
3934
- 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. This is the lookup-by-wording rung, and the narrowest of the three: use it when you know roughly what the record says. The decision rule around it \u2014 while the base fits kb_load's token budget, kb_load it whole, because 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; once kb_load refuses, kb_catalog for one line per record and then kb_pack on the record the work centres on; and kb_query when the question is a point lookup rather than a neighbourhood. A query cannot tell you that nothing was decided \u2014 it returns its nearest hit whatever the distance \u2014 so reach for kb_catalog when the question is what exists. Never read record files directly: this tool (with kb_load, kb_catalog, kb_pack 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.",
3935
- input: import_zod20.z.object({
4323
+ description: "Search; every hit carries its standing. Flagged, never filtered: a superseded hit returns with its replacement, a rejected one is marked. Prefer kb_load when the base fits its budget \u2014 a full read beats search. Results are volatile: place them at the tail, not the cached prefix. Never read record files directly.",
4324
+ input: import_zod22.z.object({
3936
4325
  bundlePath,
3937
- text: import_zod20.z.string().optional(),
3938
- type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
3939
- includeNonCurrent: import_zod20.z.boolean().optional(),
4326
+ text: import_zod22.z.string().optional(),
4327
+ type: import_zod22.z.enum(KB_RECORD_TYPES).optional(),
4328
+ includeNonCurrent: import_zod22.z.boolean().optional(),
3940
4329
  repoRoot: REPO_ROOT
3941
4330
  }),
3942
4331
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -3968,40 +4357,40 @@ var queryCommand = define({
3968
4357
  });
3969
4358
 
3970
4359
  // src/commands/read-index.ts
3971
- var import_zod21 = require("zod");
4360
+ var import_zod23 = require("zod");
3972
4361
  var readIndexCommand = define({
3973
4362
  name: "index",
3974
4363
  tool: "kb_index",
3975
4364
  usage: "index",
3976
4365
  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.",
3977
- input: import_zod21.z.object({ bundlePath }),
4366
+ input: import_zod23.z.object({ bundlePath }),
3978
4367
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3979
4368
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
3980
4369
  });
3981
4370
 
3982
4371
  // src/commands/schema.ts
3983
- var import_zod22 = require("zod");
4372
+ var import_zod24 = require("zod");
3984
4373
  var schemaCommand = define({
3985
4374
  name: "schema",
3986
4375
  tool: "kb_schema",
3987
4376
  usage: "schema",
3988
4377
  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.",
3989
- input: import_zod22.z.object({}),
4378
+ input: import_zod24.z.object({}),
3990
4379
  fromArgv: () => ({}),
3991
4380
  run: () => Promise.resolve(kbJsonSchemas())
3992
4381
  });
3993
4382
 
3994
4383
  // src/commands/status.ts
3995
- var import_zod23 = require("zod");
4384
+ var import_zod25 = require("zod");
3996
4385
  var statusCommand = define({
3997
4386
  name: "status",
3998
4387
  tool: "kb_status",
3999
4388
  usage: "status <concept-id> <status>",
4000
4389
  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.",
4001
- input: import_zod23.z.object({
4390
+ input: import_zod25.z.object({
4002
4391
  bundlePath,
4003
4392
  conceptId,
4004
- status: import_zod23.z.enum(KB_RECORD_STATUSES)
4393
+ status: import_zod25.z.enum(KB_RECORD_STATUSES)
4005
4394
  }),
4006
4395
  fromArgv: (argv, path) => ({
4007
4396
  bundlePath: path,
@@ -4016,13 +4405,13 @@ var statusCommand = define({
4016
4405
  });
4017
4406
 
4018
4407
  // src/commands/supersede.ts
4019
- var import_zod24 = require("zod");
4408
+ var import_zod26 = require("zod");
4020
4409
  var supersedeCommand = define({
4021
4410
  name: "supersede",
4022
4411
  tool: "kb_supersede",
4023
4412
  usage: "supersede <concept-id> <replacement-id>",
4024
4413
  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.",
4025
- input: import_zod24.z.object({ bundlePath, conceptId, replacementId: conceptId }),
4414
+ input: import_zod26.z.object({ bundlePath, conceptId, replacementId: conceptId }),
4026
4415
  fromArgv: (argv, path) => ({
4027
4416
  bundlePath: path,
4028
4417
  conceptId: argv[1],
@@ -4036,16 +4425,16 @@ var supersedeCommand = define({
4036
4425
  });
4037
4426
 
4038
4427
  // src/commands/sync-instructions.ts
4039
- var import_zod25 = require("zod");
4428
+ var import_zod27 = require("zod");
4040
4429
  var syncInstructionsCommand = define({
4041
4430
  name: "sync-instructions",
4042
4431
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
4043
4432
  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.",
4044
- input: import_zod25.z.object({
4045
- file: import_zod25.z.string().min(1).describe("The instruction file to edit in place."),
4046
- budgetTokens: import_zod25.z.number().int().positive().optional(),
4047
- fullUnderTokens: import_zod25.z.number().int().positive().optional(),
4048
- profile: import_zod25.z.string().optional()
4433
+ input: import_zod27.z.object({
4434
+ file: import_zod27.z.string().min(1).describe("The instruction file to edit in place."),
4435
+ budgetTokens: import_zod27.z.number().int().positive().optional(),
4436
+ fullUnderTokens: import_zod27.z.number().int().positive().optional(),
4437
+ profile: import_zod27.z.string().optional()
4049
4438
  }),
4050
4439
  fromArgv: (argv) => {
4051
4440
  const budget = argvFlag(argv, "--budget");
@@ -4071,17 +4460,17 @@ var syncInstructionsCommand = define({
4071
4460
  });
4072
4461
 
4073
4462
  // src/commands/trace.ts
4074
- var import_zod26 = require("zod");
4463
+ var import_zod28 = require("zod");
4075
4464
  var traceCommand = define({
4076
4465
  name: "trace",
4077
4466
  tool: "kb_trace",
4078
4467
  usage: "trace <concept-id> [edges...]",
4079
4468
  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.',
4080
- input: import_zod26.z.object({
4469
+ input: import_zod28.z.object({
4081
4470
  bundlePath,
4082
4471
  conceptId,
4083
- edges: import_zod26.z.array(import_zod26.z.enum(TRACE_EDGES)).optional(),
4084
- depth: import_zod26.z.number().int().positive().optional()
4472
+ edges: import_zod28.z.array(import_zod28.z.enum(TRACE_EDGES)).optional(),
4473
+ depth: import_zod28.z.number().int().positive().optional()
4085
4474
  }),
4086
4475
  fromArgv: (argv, path) => ({
4087
4476
  bundlePath: path,
@@ -4103,53 +4492,56 @@ var traceCommand = define({
4103
4492
  });
4104
4493
 
4105
4494
  // src/commands/types.ts
4106
- var import_zod27 = require("zod");
4495
+ var import_zod29 = require("zod");
4107
4496
  var typesCommand = define({
4108
4497
  name: "types",
4109
4498
  tool: "kb_types",
4110
4499
  usage: "types",
4111
4500
  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.",
4112
- input: import_zod27.z.object({}),
4501
+ input: import_zod29.z.object({}),
4113
4502
  fromArgv: () => ({}),
4114
4503
  run: () => Promise.resolve(RECORD_TYPES)
4115
4504
  });
4116
4505
 
4117
4506
  // src/commands/unpin.ts
4118
- var import_zod28 = require("zod");
4507
+ var import_zod30 = require("zod");
4119
4508
  var unpinCommand = define({
4120
4509
  name: "unpin",
4121
4510
  tool: "kb_unpin",
4122
4511
  usage: "unpin [bundle-path]",
4123
4512
  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.",
4124
- input: import_zod28.z.object({ bundlePath }),
4513
+ input: import_zod30.z.object({ bundlePath }),
4125
4514
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
4126
4515
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
4127
4516
  });
4128
4517
 
4129
4518
  // src/commands/validate.ts
4130
- var import_zod29 = require("zod");
4519
+ var import_zod31 = require("zod");
4131
4520
  var validateCommand = define({
4132
4521
  name: "validate",
4133
4522
  tool: "kb_validate",
4134
4523
  usage: "validate",
4135
- 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.",
4136
- input: import_zod29.z.object({ bundlePath }),
4524
+ description: "Check pointers no single record can see: supersession links that disagree between the two records, typed causal links, and assumptions that cite sources. Each finding carries a severity: errors fail the exit code, warnings do not.",
4525
+ input: import_zod31.z.object({ bundlePath }),
4137
4526
  fromArgv: (_argv, path) => ({ bundlePath: path }),
4138
4527
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
4139
- failsWhen: (result) => Array.isArray(result) && result.length > 0
4528
+ // Warnings never fail the exit code; every other severity does.
4529
+ failsWhen: (result) => Array.isArray(result) && result.some(
4530
+ (problem) => problem.severity !== "warning"
4531
+ )
4140
4532
  });
4141
4533
 
4142
4534
  // src/commands/verify.ts
4143
- var import_zod30 = require("zod");
4535
+ var import_zod32 = require("zod");
4144
4536
  var verifyCommand = define({
4145
4537
  name: "verify",
4146
4538
  tool: "kb_verify",
4147
4539
  usage: "verify <concept-id> --note <text>",
4148
4540
  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.",
4149
- input: import_zod30.z.object({
4541
+ input: import_zod32.z.object({
4150
4542
  bundlePath,
4151
4543
  conceptId,
4152
- note: import_zod30.z.string().refine((s) => s.trim().length > 0, {
4544
+ note: import_zod32.z.string().refine((s) => s.trim().length > 0, {
4153
4545
  message: "note must say what the check found"
4154
4546
  })
4155
4547
  }),
@@ -4169,7 +4561,7 @@ var verifyCommand = define({
4169
4561
  });
4170
4562
 
4171
4563
  // src/commands/write.ts
4172
- var import_zod31 = require("zod");
4564
+ var import_zod33 = require("zod");
4173
4565
  var writeCommand = define({
4174
4566
  name: "write",
4175
4567
  tool: "kb_write",
@@ -4183,9 +4575,9 @@ var writeCommand = define({
4183
4575
  "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
4184
4576
  "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
4185
4577
  ].join("\n"),
4186
- input: import_zod31.z.object({
4578
+ input: import_zod33.z.object({
4187
4579
  bundlePath,
4188
- type: import_zod31.z.enum(KB_RECORD_TYPES),
4580
+ type: import_zod33.z.enum(KB_RECORD_TYPES),
4189
4581
  input: composeInputSchema
4190
4582
  }),
4191
4583
  fromArgv: async (argv, path, stdin) => ({
@@ -4209,7 +4601,7 @@ var writeCommand = define({
4209
4601
  });
4210
4602
 
4211
4603
  // src/commands/write-decision.ts
4212
- var import_zod32 = require("zod");
4604
+ var import_zod34 = require("zod");
4213
4605
  var writeDecisionCommand = define({
4214
4606
  name: "write-decision",
4215
4607
  tool: "kb_write_decision",
@@ -4222,7 +4614,7 @@ var writeDecisionCommand = define({
4222
4614
  "- `alternative` is what you turned down and why, not a list of everything considered.",
4223
4615
  "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
4224
4616
  ].join("\n"),
4225
- input: import_zod32.z.object({ bundlePath, input: decisionInputSchema }),
4617
+ input: import_zod34.z.object({ bundlePath, input: decisionInputSchema }),
4226
4618
  fromArgv: async (_argv, path, stdin) => ({
4227
4619
  bundlePath: path,
4228
4620
  input: JSON.parse(await stdin())
@@ -4257,6 +4649,8 @@ var KB_COMMANDS = [
4257
4649
  packCommand,
4258
4650
  queryCommand,
4259
4651
  traceCommand,
4652
+ impactCommand,
4653
+ backlinksCommand,
4260
4654
  listCommand,
4261
4655
  readIndexCommand,
4262
4656
  logCommand,
@@ -4279,7 +4673,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
4279
4673
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
4280
4674
 
4281
4675
  // src/version.ts
4282
- var VERSION = true ? "0.1.11" : "0.0.0-dev";
4676
+ var VERSION = true ? "0.1.13" : "0.0.0-dev";
4283
4677
 
4284
4678
  // src/mcp.ts
4285
4679
  function createKbMcpServer() {
@@ -4431,10 +4825,12 @@ function usage() {
4431
4825
  DEFAULT_LOAD_BUDGET,
4432
4826
  DEFAULT_PACK_HOPS,
4433
4827
  DEFAULT_PACK_MAX_NODES,
4828
+ DEFAULT_TYPED_LINK_RELS,
4434
4829
  DEFAULT_UNVERIFIED_DAYS,
4435
4830
  ErrorTypes,
4436
4831
  Fault,
4437
4832
  INDEX_FILE,
4833
+ KB_CAUSAL_LINK_RELS,
4438
4834
  KB_COMMANDS,
4439
4835
  KB_COMMANDS_BY_NAME,
4440
4836
  KB_CONCEPT_ID_PATTERN,
@@ -4442,6 +4838,7 @@ function usage() {
4442
4838
  KB_DIR,
4443
4839
  KB_DOCTOR_CHECKS,
4444
4840
  KB_EDGE_KINDS,
4841
+ KB_LINK_RELS,
4445
4842
  KB_MATERIALITIES,
4446
4843
  KB_RECORD_STATUSES,
4447
4844
  KB_RECORD_TYPES,
@@ -4455,7 +4852,9 @@ function usage() {
4455
4852
  KbRecordNotFoundError,
4456
4853
  KbSelfVerificationError,
4457
4854
  KbStore,
4855
+ KbUnknownLinkRelError,
4458
4856
  KbWriteConflictError,
4857
+ LINK_RELS,
4459
4858
  LOG_FILE,
4460
4859
  NO_DECISION_SLUG,
4461
4860
  PINS_FILE,
@@ -4467,10 +4866,12 @@ function usage() {
4467
4866
  adjudicate,
4468
4867
  anchorFilePath,
4469
4868
  assertBaseNotFrozen,
4869
+ backlinks,
4470
4870
  buildContext,
4471
4871
  catalog,
4472
4872
  composeDecisionRecord,
4473
4873
  composeInputSchema,
4874
+ composeLinkSchema,
4474
4875
  composeNoDecisionRecord,
4475
4876
  composeRecord,
4476
4877
  contextProfileBudgets,
@@ -4480,13 +4881,17 @@ function usage() {
4480
4881
  doctor,
4481
4882
  edgeNeighbours,
4482
4883
  hashAnchorText,
4884
+ impact,
4885
+ inboundIndex,
4483
4886
  indexIsStale,
4887
+ isKbLinkRel,
4484
4888
  isKbRecordType,
4485
4889
  isNoDecisionRecord,
4486
4890
  kbActorStampSchema,
4487
4891
  kbAnchorSchema,
4488
4892
  kbConceptIdSchema,
4489
4893
  kbJsonSchemas,
4894
+ kbLinkSchema,
4490
4895
  kbLogEntrySchema,
4491
4896
  kbRecordFrontmatterSchema,
4492
4897
  kbSourceSchema,