@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/cli-main.cjs CHANGED
@@ -78,6 +78,10 @@ var kbAnchorSchema = import_zod.z.object({
78
78
  /** Line count of the text the hash was taken over. */
79
79
  lines: import_zod.z.number().int().positive().optional()
80
80
  }).strict();
81
+ var kbLinkSchema = import_zod.z.object({
82
+ target: import_zod.z.string().min(1),
83
+ rel: import_zod.z.string().min(1)
84
+ }).passthrough();
81
85
  var KB_RECORD_TYPES = [
82
86
  "fact",
83
87
  "requirement",
@@ -129,6 +133,10 @@ var kbRecordFrontmatterSchema = import_zod.z.object({
129
133
  // strauss extensions — see the module comment.
130
134
  strauss_anchors: import_zod.z.array(kbAnchorSchema).optional(),
131
135
  strauss_verify: import_zod.z.array(import_zod.z.string().min(1)).optional(),
136
+ // Typed causal edges, source → target, living on the source. `A depends_on
137
+ // B` means A needs B, so `kb_impact` walks these inbound: what breaks if B
138
+ // changes is whatever declared a dependence on it.
139
+ strauss_links: import_zod.z.array(kbLinkSchema).optional(),
132
140
  // Total after parsing, tolerant before it. Our producers must supply a
133
141
  // status — an absent one would leave every reader inventing its own default
134
142
  // — but OKF calls a concept carrying only `type` fully conformant, so
@@ -213,8 +221,70 @@ var RECORD_TYPES = {
213
221
  function isKbRecordType(value) {
214
222
  return Object.prototype.hasOwnProperty.call(RECORD_TYPES, value);
215
223
  }
224
+ var KB_LINK_RELS = [
225
+ "depends_on",
226
+ "constrains",
227
+ "informs",
228
+ "blocks",
229
+ "invalidates",
230
+ "verified_by",
231
+ "satisfies",
232
+ "related_to"
233
+ ];
234
+ var LINK_RELS = {
235
+ depends_on: {
236
+ purpose: "The source needs the target to hold; the source breaks if the target changes",
237
+ phrase: "Depends on",
238
+ dependant: "source"
239
+ },
240
+ constrains: {
241
+ purpose: "The source bounds what the target may do; the target breaks if the constraint changes",
242
+ phrase: "Constrains",
243
+ dependant: "target"
244
+ },
245
+ informs: {
246
+ purpose: "The source shaped the target without binding it; the target is what needs revisiting",
247
+ phrase: "Informs",
248
+ dependant: "target"
249
+ },
250
+ blocks: {
251
+ purpose: "The target cannot proceed until the source is settled; the target is what waits",
252
+ phrase: "Blocks",
253
+ dependant: "target"
254
+ },
255
+ invalidates: {
256
+ purpose: "The source makes the target no longer hold; the target is what stops holding",
257
+ phrase: "Invalidates",
258
+ dependant: "target"
259
+ },
260
+ verified_by: {
261
+ purpose: "The target is the check that confirms the source; the source's confirmation moves with it",
262
+ phrase: "Verified by",
263
+ dependant: "source"
264
+ },
265
+ satisfies: {
266
+ purpose: "The source discharges the target's requirement; the source must change if the requirement does",
267
+ phrase: "Satisfies",
268
+ dependant: "source"
269
+ },
270
+ related_to: {
271
+ purpose: "A pointer worth following, with no claim of dependence",
272
+ phrase: "Relates to",
273
+ dependant: null
274
+ }
275
+ };
276
+ var KB_CAUSAL_LINK_RELS = KB_LINK_RELS.filter(
277
+ (rel) => LINK_RELS[rel].dependant !== null
278
+ );
279
+ function isKbLinkRel(value) {
280
+ return Object.prototype.hasOwnProperty.call(LINK_RELS, value);
281
+ }
216
282
 
217
283
  // src/compose.ts
284
+ var composeLinkSchema = import_zod2.z.object({
285
+ target: kbConceptIdSchema,
286
+ rel: import_zod2.z.enum(KB_LINK_RELS)
287
+ }).strict();
218
288
  var composeInputSchema = import_zod2.z.object({
219
289
  slug: import_zod2.z.string().min(1),
220
290
  /** One line, in the reader's terms. Becomes OKF `title`. */
@@ -241,6 +311,18 @@ var composeInputSchema = import_zod2.z.object({
241
311
  tags: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
242
312
  /** Concept ids this record relates to; rendered as body links. */
243
313
  relatedConceptIds: import_zod2.z.array(kbConceptIdSchema).optional(),
314
+ /**
315
+ * Typed causal edges, source → target: `{ target: "fact.b", rel:
316
+ * "depends_on" }` on record A says A needs B. Stored in frontmatter and
317
+ * also rendered as one prose sentence each, so the meaning survives a
318
+ * reader that knows only OKF. The vocabulary goes into the description from
319
+ * the same table the walk uses, so `kb_schema` emits it.
320
+ */
321
+ links: import_zod2.z.array(composeLinkSchema).max(64).optional().describe(
322
+ `Typed causal edges, source \u2192 target \u2014 a link on this record says this record <rel> the target. ${KB_LINK_RELS.map(
323
+ (rel) => `${rel}: ${LINK_RELS[rel].purpose}`
324
+ ).join("; ")}.`
325
+ ),
244
326
  /** Concept ids this record replaces. The store settles the backlinks. */
245
327
  supersedes: import_zod2.z.array(kbConceptIdSchema).max(32).optional(),
246
328
  materiality: import_zod2.z.enum(KB_MATERIALITIES).optional(),
@@ -280,6 +362,15 @@ function composeRecord(type, input, writtenBy, writtenAt) {
280
362
  if (parsed.owner) frontmatter.strauss_owner = parsed.owner;
281
363
  if (parsed.supersedes?.length)
282
364
  frontmatter.strauss_supersedes = parsed.supersedes;
365
+ const selfLink = parsed.links?.find(
366
+ (link2) => link2.target === `${type}.${parsed.slug}`
367
+ );
368
+ if (selfLink) {
369
+ throw new Error(
370
+ `kb: ${type}.${parsed.slug} cannot ${selfLink.rel} itself \u2014 a link must name another record`
371
+ );
372
+ }
373
+ if (parsed.links?.length) frontmatter.strauss_links = parsed.links;
283
374
  const blocks = [];
284
375
  for (const heading of spec.sections) {
285
376
  const text = sections[heading];
@@ -291,6 +382,11 @@ ${text}`);
291
382
  for (const related of parsed.relatedConceptIds ?? []) {
292
383
  blocks.push(`Relates to [${related}](${related}.md).`);
293
384
  }
385
+ for (const link2 of parsed.links ?? []) {
386
+ blocks.push(
387
+ `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
388
+ );
389
+ }
294
390
  if (parsed.sources?.length) {
295
391
  blocks.push(
296
392
  parsed.sources.map((source) => `[^${source.id}]: ${source.title ?? source.resource}`).join("\n")
@@ -313,7 +409,7 @@ var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
313
409
  impact: import_zod3.z.string().min(1).optional()
314
410
  }).strict();
315
411
  function composeDecisionRecord(input, writtenBy, writtenAt) {
316
- const { alternative, impact, ...rest } = input;
412
+ const { alternative, impact: impact2, ...rest } = input;
317
413
  return composeRecord(
318
414
  DECISION_TYPE,
319
415
  {
@@ -322,7 +418,7 @@ function composeDecisionRecord(input, writtenBy, writtenAt) {
322
418
  Decision: input.title,
323
419
  Rationale: input.why,
324
420
  ...alternative ? { Rejected: alternative } : {},
325
- ...impact ? { Impact: impact } : {}
421
+ ...impact2 ? { Impact: impact2 } : {}
326
422
  }
327
423
  },
328
424
  writtenBy,
@@ -904,6 +1000,23 @@ var KbPackBudgetExceededError = class extends BaseError {
904
1000
  budgetTokens;
905
1001
  excluded;
906
1002
  };
1003
+ var KbUnknownLinkRelError = class extends BaseError {
1004
+ constructor(rel, expected) {
1005
+ super({
1006
+ message: `kb: ${rel} is not a rel a walk can follow \u2014 expected one of ${expected.join(", ")}`,
1007
+ errorType: "KbUnknownLinkRel" /* KbUnknownLinkRel */,
1008
+ code: 400,
1009
+ fault: "User" /* User */,
1010
+ retriable: false,
1011
+ reportToUser: true,
1012
+ details: { rel, expected: expected.join(", ") }
1013
+ });
1014
+ this.rel = rel;
1015
+ this.expected = expected;
1016
+ }
1017
+ rel;
1018
+ expected;
1019
+ };
907
1020
  var KbMissingFlagValueError = class extends BaseError {
908
1021
  constructor(flag) {
909
1022
  super({
@@ -1435,8 +1548,20 @@ var answerCommand = define({
1435
1548
  }
1436
1549
  });
1437
1550
 
1438
- // src/commands/catalog.ts
1551
+ // src/commands/backlinks.ts
1439
1552
  var import_zod8 = require("zod");
1553
+ var backlinksCommand = define({
1554
+ name: "backlinks",
1555
+ tool: "kb_backlinks",
1556
+ usage: "backlinks <concept-id>",
1557
+ 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.",
1558
+ input: import_zod8.z.object({ bundlePath, conceptId }),
1559
+ fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
1560
+ run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
1561
+ });
1562
+
1563
+ // src/commands/catalog.ts
1564
+ var import_zod9 = require("zod");
1440
1565
 
1441
1566
  // src/adjudicate.ts
1442
1567
  var STANDING = {
@@ -1596,9 +1721,9 @@ var catalogCommand = define({
1596
1721
  tool: "kb_catalog",
1597
1722
  usage: "catalog [type]",
1598
1723
  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.",
1599
- input: import_zod8.z.object({
1724
+ input: import_zod9.z.object({
1600
1725
  bundlePath,
1601
- type: import_zod8.z.enum(KB_RECORD_TYPES).optional()
1726
+ type: import_zod9.z.enum(KB_RECORD_TYPES).optional()
1602
1727
  }),
1603
1728
  fromArgv: (argv, path) => ({
1604
1729
  bundlePath: path,
@@ -1653,7 +1778,7 @@ function count(value, noun) {
1653
1778
  }
1654
1779
 
1655
1780
  // src/commands/context.ts
1656
- var import_zod9 = require("zod");
1781
+ var import_zod10 = require("zod");
1657
1782
 
1658
1783
  // src/kb-context.ts
1659
1784
  var import_promises3 = require("fs/promises");
@@ -1919,20 +2044,20 @@ var contextCommand = define({
1919
2044
  tool: "kb_context",
1920
2045
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
1921
2046
  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.",
1922
- input: import_zod9.z.object({
1923
- budgetTokens: import_zod9.z.number().int().positive().optional().describe(
2047
+ input: import_zod10.z.object({
2048
+ budgetTokens: import_zod10.z.number().int().positive().optional().describe(
1924
2049
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
1925
2050
  ),
1926
- fullUnderTokens: import_zod9.z.number().int().positive().optional().describe(
2051
+ fullUnderTokens: import_zod10.z.number().int().positive().optional().describe(
1927
2052
  "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."
1928
2053
  ),
1929
- profile: import_zod9.z.string().optional().describe(
2054
+ profile: import_zod10.z.string().optional().describe(
1930
2055
  "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."
1931
2056
  ),
1932
- format: import_zod9.z.enum(["markdown", "json"]).optional().describe(
2057
+ format: import_zod10.z.enum(["markdown", "json"]).optional().describe(
1933
2058
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
1934
2059
  ),
1935
- event: import_zod9.z.string().optional().describe(
2060
+ event: import_zod10.z.string().optional().describe(
1936
2061
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
1937
2062
  )
1938
2063
  }),
@@ -1968,11 +2093,12 @@ var contextCommand = define({
1968
2093
  });
1969
2094
 
1970
2095
  // src/commands/doctor.ts
1971
- var import_zod10 = require("zod");
2096
+ var import_zod11 = require("zod");
1972
2097
 
1973
2098
  // src/kb-edges.ts
1974
2099
  var KB_EDGE_KINDS = [
1975
2100
  "body-link",
2101
+ "typed-link",
1976
2102
  "supersession",
1977
2103
  "anchor",
1978
2104
  "source"
@@ -1981,10 +2107,11 @@ var BODY_LINK_TARGET = new RegExp(
1981
2107
  `\\]\\((${KB_CONCEPT_ID_PATTERN.source.replace(/^\^|\$$/g, "")})\\.md\\)`,
1982
2108
  "g"
1983
2109
  );
1984
- function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
2110
+ var DEFAULT_TYPED_LINK_RELS = KB_LINK_RELS;
2111
+ function neighbours(from, bundle, kinds = KB_EDGE_KINDS, linkRels = DEFAULT_TYPED_LINK_RELS) {
1985
2112
  const found = /* @__PURE__ */ new Map();
1986
2113
  for (const kind of kinds) {
1987
- for (const record of edgeNeighbours(from, bundle, kind)) {
2114
+ for (const record of edgeNeighbours(from, bundle, kind, linkRels)) {
1988
2115
  const existing = found.get(record.conceptId);
1989
2116
  if (existing) {
1990
2117
  if (!existing.via.includes(kind)) existing.via.push(kind);
@@ -1995,7 +2122,7 @@ function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
1995
2122
  }
1996
2123
  return [...found.values()];
1997
2124
  }
1998
- function edgeNeighbours(from, bundle, kind) {
2125
+ function edgeNeighbours(from, bundle, kind, linkRels = DEFAULT_TYPED_LINK_RELS) {
1999
2126
  switch (kind) {
2000
2127
  // A link whose target is not in the bundle is legal per compose.ts —
2001
2128
  // records are routinely written before the ones they point at exist — so
@@ -2009,6 +2136,21 @@ function edgeNeighbours(from, bundle, kind) {
2009
2136
  (candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
2010
2137
  );
2011
2138
  }
2139
+ // Outbound only, like `body-link`, and for the same reason: this is what
2140
+ // the record declares about itself. A missing target is legal — the walk
2141
+ // skips it, and `kb_validate` is what reports it as a warning. A rel
2142
+ // outside `linkRels` is skipped too, which is how an unknown rel stays
2143
+ // untraversable everywhere rather than one walk at a time.
2144
+ case "typed-link": {
2145
+ const allowed = new Set(linkRels);
2146
+ const targets = new Set(
2147
+ (from.frontmatter.strauss_links ?? []).filter((link2) => allowed.has(link2.rel)).map((link2) => link2.target)
2148
+ );
2149
+ if (!targets.size) return [];
2150
+ return bundle.filter(
2151
+ (candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
2152
+ );
2153
+ }
2012
2154
  // Both directions and both pointers: `supersede()` writes the pair, but a
2013
2155
  // hand-edit can leave one side behind, and a walk trusting one pointer
2014
2156
  // would miss a replacement the bundle openly declares.
@@ -2050,7 +2192,7 @@ function anchorsTouch(left, right) {
2050
2192
  function validateBundle(records) {
2051
2193
  const byId = new Map(records.map((record) => [record.conceptId, record]));
2052
2194
  const problems = [];
2053
- const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
2195
+ const report = (check, conceptId2, note, severity = "error") => problems.push({ check, conceptId: conceptId2, note, severity });
2054
2196
  for (const record of records) {
2055
2197
  const { conceptId: conceptId2, frontmatter: fm } = record;
2056
2198
  if (!isKbRecordType(fm.type)) {
@@ -2074,6 +2216,36 @@ function validateBundle(records) {
2074
2216
  report("supersedes", conceptId2, `${old} is not marked superseded`);
2075
2217
  }
2076
2218
  }
2219
+ for (const link2 of fm.strauss_links ?? []) {
2220
+ if (!isKbLinkRel(link2.rel)) {
2221
+ report(
2222
+ "link_rel",
2223
+ conceptId2,
2224
+ `unknown rel "${link2.rel}" on link to ${link2.target} \u2014 expected one of ${KB_LINK_RELS.join(", ")}`
2225
+ );
2226
+ }
2227
+ if (!KB_CONCEPT_ID_PATTERN.test(link2.target)) {
2228
+ report(
2229
+ "link_target",
2230
+ conceptId2,
2231
+ `target "${link2.target}" is not a valid concept id \u2014 expected <type>.<slug>, both kebab-case`
2232
+ );
2233
+ } else if (link2.target === conceptId2) {
2234
+ report(
2235
+ "link_target",
2236
+ conceptId2,
2237
+ `links to itself (${link2.rel})`,
2238
+ "warning"
2239
+ );
2240
+ } else if (!byId.has(link2.target)) {
2241
+ report(
2242
+ "link_target",
2243
+ conceptId2,
2244
+ `target ${link2.target} is not in the bundle`,
2245
+ "warning"
2246
+ );
2247
+ }
2248
+ }
2077
2249
  if (fm.strauss_assumption && fm.sources?.length) {
2078
2250
  report("assumption", conceptId2, "marked an assumption but cites sources");
2079
2251
  }
@@ -2350,13 +2522,13 @@ function ageInDays(record, now) {
2350
2522
  }
2351
2523
 
2352
2524
  // src/commands/doctor.ts
2353
- var days = (what, fallback) => import_zod10.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
2525
+ var days = (what, fallback) => import_zod11.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
2354
2526
  var doctorCommand = define({
2355
2527
  name: "doctor",
2356
2528
  tool: "kb_doctor",
2357
2529
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
2358
2530
  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.",
2359
- input: import_zod10.z.object({
2531
+ input: import_zod11.z.object({
2360
2532
  bundlePath,
2361
2533
  repoRoot: REPO_ROOT,
2362
2534
  expiringDays: days(
@@ -2371,7 +2543,7 @@ var doctorCommand = define({
2371
2543
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
2372
2544
  DEFAULT_AGING_DAYS
2373
2545
  ),
2374
- strict: import_zod10.z.boolean().optional().describe(
2546
+ strict: import_zod11.z.boolean().optional().describe(
2375
2547
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
2376
2548
  )
2377
2549
  }),
@@ -2446,14 +2618,47 @@ function render2(result) {
2446
2618
  return lines.join("\n");
2447
2619
  }
2448
2620
 
2621
+ // src/commands/impact.ts
2622
+ var import_zod12 = require("zod");
2623
+ var impactCommand = define({
2624
+ name: "impact",
2625
+ tool: "kb_impact",
2626
+ usage: "impact <concept-id> [--depth N] [--rels a,b]",
2627
+ 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.",
2628
+ input: import_zod12.z.object({
2629
+ bundlePath,
2630
+ conceptId,
2631
+ depth: import_zod12.z.number().int().positive().optional().describe(
2632
+ "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
2633
+ ),
2634
+ rels: import_zod12.z.array(import_zod12.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
2635
+ "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
2636
+ )
2637
+ }),
2638
+ fromArgv: (argv, path) => {
2639
+ const depth = argvFlag(argv, "--depth");
2640
+ const rels = argvFlag(argv, "--rels");
2641
+ return {
2642
+ bundlePath: path,
2643
+ conceptId: argv[1],
2644
+ ...depth ? { depth: Number(depth) } : {},
2645
+ ...rels ? { rels: rels.split(",").filter(Boolean) } : {}
2646
+ };
2647
+ },
2648
+ run: async ({ store }, { bundlePath: path, conceptId: id, depth, rels }) => store.impact(path, id, {
2649
+ ...depth !== void 0 ? { depth } : {},
2650
+ ...rels?.length ? { rels } : {}
2651
+ })
2652
+ });
2653
+
2449
2654
  // src/commands/list.ts
2450
- var import_zod11 = require("zod");
2655
+ var import_zod13 = require("zod");
2451
2656
  var listCommand = define({
2452
2657
  name: "list",
2453
2658
  tool: "kb_list",
2454
2659
  usage: "list [type]",
2455
2660
  description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
2456
- input: import_zod11.z.object({ bundlePath, type: import_zod11.z.enum(KB_RECORD_TYPES).optional() }),
2661
+ input: import_zod13.z.object({ bundlePath, type: import_zod13.z.enum(KB_RECORD_TYPES).optional() }),
2457
2662
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
2458
2663
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
2459
2664
  conceptId: record.conceptId,
@@ -2465,17 +2670,17 @@ var listCommand = define({
2465
2670
  });
2466
2671
 
2467
2672
  // src/commands/load.ts
2468
- var import_zod12 = require("zod");
2673
+ var import_zod14 = require("zod");
2469
2674
  var loadCommand = define({
2470
2675
  name: "load",
2471
2676
  tool: "kb_load",
2472
2677
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
2473
- 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.",
2474
- input: import_zod12.z.object({
2678
+ 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.",
2679
+ input: import_zod14.z.object({
2475
2680
  bundlePath,
2476
- type: import_zod12.z.enum(KB_RECORD_TYPES).optional(),
2477
- budgetTokens: import_zod12.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
2478
- all: import_zod12.z.boolean().optional().describe(
2681
+ type: import_zod14.z.enum(KB_RECORD_TYPES).optional(),
2682
+ budgetTokens: import_zod14.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
2683
+ all: import_zod14.z.boolean().optional().describe(
2479
2684
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
2480
2685
  ),
2481
2686
  repoRoot: REPO_ROOT
@@ -2517,25 +2722,25 @@ var loadCommand = define({
2517
2722
  });
2518
2723
 
2519
2724
  // src/commands/log.ts
2520
- var import_zod13 = require("zod");
2725
+ var import_zod15 = require("zod");
2521
2726
  var logCommand = define({
2522
2727
  name: "log",
2523
2728
  tool: "kb_log",
2524
2729
  usage: "log",
2525
2730
  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.",
2526
- input: import_zod13.z.object({ bundlePath }),
2731
+ input: import_zod15.z.object({ bundlePath }),
2527
2732
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2528
2733
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
2529
2734
  });
2530
2735
 
2531
2736
  // src/commands/no-decision.ts
2532
- var import_zod14 = require("zod");
2737
+ var import_zod16 = require("zod");
2533
2738
  var noDecisionCommand = define({
2534
2739
  name: "no-decision",
2535
2740
  tool: "kb_no_decision",
2536
2741
  usage: "no-decision <reason...>",
2537
2742
  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.',
2538
- input: import_zod14.z.object({ bundlePath, reason: import_zod14.z.string().min(1) }),
2743
+ input: import_zod16.z.object({ bundlePath, reason: import_zod16.z.string().min(1) }),
2539
2744
  fromArgv: (argv, path) => ({
2540
2745
  bundlePath: path,
2541
2746
  reason: argv.slice(1).join(" ").trim()
@@ -2552,20 +2757,20 @@ var noDecisionCommand = define({
2552
2757
  });
2553
2758
 
2554
2759
  // src/commands/pack.ts
2555
- var import_zod15 = require("zod");
2760
+ var import_zod17 = require("zod");
2556
2761
  var packCommand = define({
2557
2762
  name: "pack",
2558
2763
  tool: "kb_pack",
2559
2764
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
2560
2765
  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.",
2561
- input: import_zod15.z.object({
2766
+ input: import_zod17.z.object({
2562
2767
  bundlePath,
2563
2768
  conceptId,
2564
- hops: import_zod15.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
2565
- maxNodes: import_zod15.z.number().int().positive().optional().describe(
2769
+ hops: import_zod17.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
2770
+ maxNodes: import_zod17.z.number().int().positive().optional().describe(
2566
2771
  "How many records the pack may hold, root included. Defaults to 20."
2567
2772
  ),
2568
- budgetTokens: import_zod15.z.number().int().positive().optional().describe(
2773
+ budgetTokens: import_zod17.z.number().int().positive().optional().describe(
2569
2774
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
2570
2775
  )
2571
2776
  }),
@@ -2652,22 +2857,22 @@ function warningLabel(warning) {
2652
2857
  }
2653
2858
 
2654
2859
  // src/commands/pin.ts
2655
- var import_zod16 = require("zod");
2860
+ var import_zod18 = require("zod");
2656
2861
  var pinCommand = define({
2657
2862
  name: "pin",
2658
2863
  tool: "kb_pin",
2659
2864
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
2660
2865
  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.",
2661
- input: import_zod16.z.object({
2866
+ input: import_zod18.z.object({
2662
2867
  bundlePath,
2663
- mode: import_zod16.z.enum(["full", "index"]).optional().describe(
2868
+ mode: import_zod18.z.enum(["full", "index"]).optional().describe(
2664
2869
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
2665
2870
  ),
2666
- profiles: import_zod16.z.array(import_zod16.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
2667
- layer: import_zod16.z.enum(["project", "local", "user"]).optional().describe(
2871
+ profiles: import_zod18.z.array(import_zod18.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
2872
+ layer: import_zod18.z.enum(["project", "local", "user"]).optional().describe(
2668
2873
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
2669
2874
  ),
2670
- frozen: import_zod16.z.boolean().optional().describe(
2875
+ frozen: import_zod18.z.boolean().optional().describe(
2671
2876
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
2672
2877
  )
2673
2878
  }),
@@ -2696,29 +2901,29 @@ var pinCommand = define({
2696
2901
  });
2697
2902
 
2698
2903
  // src/commands/pins.ts
2699
- var import_zod17 = require("zod");
2904
+ var import_zod19 = require("zod");
2700
2905
  var pinsCommand = define({
2701
2906
  name: "pins",
2702
2907
  tool: "kb_pins",
2703
2908
  usage: "pins",
2704
2909
  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.",
2705
- input: import_zod17.z.object({}),
2910
+ input: import_zod19.z.object({}),
2706
2911
  fromArgv: () => ({}),
2707
2912
  run: ({ store }) => listPins(store, process.cwd())
2708
2913
  });
2709
2914
 
2710
2915
  // src/commands/query.ts
2711
- var import_zod18 = require("zod");
2916
+ var import_zod20 = require("zod");
2712
2917
  var queryCommand = define({
2713
2918
  name: "query",
2714
2919
  tool: "kb_query",
2715
2920
  usage: "query <text...> [--repo-root PATH]",
2716
- 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.",
2717
- input: import_zod18.z.object({
2921
+ 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.",
2922
+ input: import_zod20.z.object({
2718
2923
  bundlePath,
2719
- text: import_zod18.z.string().optional(),
2720
- type: import_zod18.z.enum(KB_RECORD_TYPES).optional(),
2721
- includeNonCurrent: import_zod18.z.boolean().optional(),
2924
+ text: import_zod20.z.string().optional(),
2925
+ type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
2926
+ includeNonCurrent: import_zod20.z.boolean().optional(),
2722
2927
  repoRoot: REPO_ROOT
2723
2928
  }),
2724
2929
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -2750,27 +2955,27 @@ var queryCommand = define({
2750
2955
  });
2751
2956
 
2752
2957
  // src/commands/read-index.ts
2753
- var import_zod19 = require("zod");
2958
+ var import_zod21 = require("zod");
2754
2959
  var readIndexCommand = define({
2755
2960
  name: "index",
2756
2961
  tool: "kb_index",
2757
2962
  usage: "index",
2758
2963
  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.",
2759
- input: import_zod19.z.object({ bundlePath }),
2964
+ input: import_zod21.z.object({ bundlePath }),
2760
2965
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2761
2966
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
2762
2967
  });
2763
2968
 
2764
2969
  // src/commands/schema.ts
2765
- var import_zod22 = require("zod");
2970
+ var import_zod24 = require("zod");
2766
2971
 
2767
2972
  // src/json-schema.ts
2768
- var import_zod21 = require("zod");
2973
+ var import_zod23 = require("zod");
2769
2974
 
2770
2975
  // src/kb-log.ts
2771
- var import_zod20 = require("zod");
2976
+ var import_zod22 = require("zod");
2772
2977
  var LOG_FILE = "log.jsonl";
2773
- var kbLogEntrySchema = import_zod20.z.object({
2978
+ var kbLogEntrySchema = import_zod22.z.object({
2774
2979
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
2775
2980
  // below), and a value that isn't actually chronological — a Unix
2776
2981
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -2779,12 +2984,12 @@ var kbLogEntrySchema = import_zod20.z.object({
2779
2984
  // and rejects everything else, including a non-`Z` offset — so a
2780
2985
  // malformed `at` is reported the same way a malformed line already is,
2781
2986
  // rather than silently sorting into the wrong place.
2782
- at: import_zod20.z.iso.datetime(),
2783
- by: import_zod20.z.string().min(1),
2784
- operation: import_zod20.z.string().min(1),
2785
- conceptId: import_zod20.z.string().min(1),
2987
+ at: import_zod22.z.iso.datetime(),
2988
+ by: import_zod22.z.string().min(1),
2989
+ operation: import_zod22.z.string().min(1),
2990
+ conceptId: import_zod22.z.string().min(1),
2786
2991
  /** Second concept id, where the operation relates two — supersession. */
2787
- target: import_zod20.z.string().min(1).optional()
2992
+ target: import_zod22.z.string().min(1).optional()
2788
2993
  }).strict();
2789
2994
  function renderLogEntry(entry) {
2790
2995
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -2822,11 +3027,11 @@ function parseLog(raw) {
2822
3027
  // src/json-schema.ts
2823
3028
  function kbJsonSchemas() {
2824
3029
  return {
2825
- recordFrontmatter: import_zod21.z.toJSONSchema(kbRecordFrontmatterSchema, {
3030
+ recordFrontmatter: import_zod23.z.toJSONSchema(kbRecordFrontmatterSchema, {
2826
3031
  io: "input"
2827
3032
  }),
2828
- composeInput: import_zod21.z.toJSONSchema(composeInputSchema, { io: "input" }),
2829
- logEntry: import_zod21.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
3033
+ composeInput: import_zod23.z.toJSONSchema(composeInputSchema, { io: "input" }),
3034
+ logEntry: import_zod23.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
2830
3035
  };
2831
3036
  }
2832
3037
 
@@ -2836,22 +3041,22 @@ var schemaCommand = define({
2836
3041
  tool: "kb_schema",
2837
3042
  usage: "schema",
2838
3043
  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.",
2839
- input: import_zod22.z.object({}),
3044
+ input: import_zod24.z.object({}),
2840
3045
  fromArgv: () => ({}),
2841
3046
  run: () => Promise.resolve(kbJsonSchemas())
2842
3047
  });
2843
3048
 
2844
3049
  // src/commands/status.ts
2845
- var import_zod23 = require("zod");
3050
+ var import_zod25 = require("zod");
2846
3051
  var statusCommand = define({
2847
3052
  name: "status",
2848
3053
  tool: "kb_status",
2849
3054
  usage: "status <concept-id> <status>",
2850
3055
  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.",
2851
- input: import_zod23.z.object({
3056
+ input: import_zod25.z.object({
2852
3057
  bundlePath,
2853
3058
  conceptId,
2854
- status: import_zod23.z.enum(KB_RECORD_STATUSES)
3059
+ status: import_zod25.z.enum(KB_RECORD_STATUSES)
2855
3060
  }),
2856
3061
  fromArgv: (argv, path) => ({
2857
3062
  bundlePath: path,
@@ -2866,13 +3071,13 @@ var statusCommand = define({
2866
3071
  });
2867
3072
 
2868
3073
  // src/commands/supersede.ts
2869
- var import_zod24 = require("zod");
3074
+ var import_zod26 = require("zod");
2870
3075
  var supersedeCommand = define({
2871
3076
  name: "supersede",
2872
3077
  tool: "kb_supersede",
2873
3078
  usage: "supersede <concept-id> <replacement-id>",
2874
3079
  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.",
2875
- input: import_zod24.z.object({ bundlePath, conceptId, replacementId: conceptId }),
3080
+ input: import_zod26.z.object({ bundlePath, conceptId, replacementId: conceptId }),
2876
3081
  fromArgv: (argv, path) => ({
2877
3082
  bundlePath: path,
2878
3083
  conceptId: argv[1],
@@ -2886,16 +3091,16 @@ var supersedeCommand = define({
2886
3091
  });
2887
3092
 
2888
3093
  // src/commands/sync-instructions.ts
2889
- var import_zod25 = require("zod");
3094
+ var import_zod27 = require("zod");
2890
3095
  var syncInstructionsCommand = define({
2891
3096
  name: "sync-instructions",
2892
3097
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
2893
3098
  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.",
2894
- input: import_zod25.z.object({
2895
- file: import_zod25.z.string().min(1).describe("The instruction file to edit in place."),
2896
- budgetTokens: import_zod25.z.number().int().positive().optional(),
2897
- fullUnderTokens: import_zod25.z.number().int().positive().optional(),
2898
- profile: import_zod25.z.string().optional()
3099
+ input: import_zod27.z.object({
3100
+ file: import_zod27.z.string().min(1).describe("The instruction file to edit in place."),
3101
+ budgetTokens: import_zod27.z.number().int().positive().optional(),
3102
+ fullUnderTokens: import_zod27.z.number().int().positive().optional(),
3103
+ profile: import_zod27.z.string().optional()
2899
3104
  }),
2900
3105
  fromArgv: (argv) => {
2901
3106
  const budget = argvFlag(argv, "--budget");
@@ -2921,10 +3126,15 @@ var syncInstructionsCommand = define({
2921
3126
  });
2922
3127
 
2923
3128
  // src/commands/trace.ts
2924
- var import_zod26 = require("zod");
3129
+ var import_zod28 = require("zod");
2925
3130
 
2926
3131
  // src/trace.ts
2927
- var TRACE_EDGES = ["supersession", "anchor", "source"];
3132
+ var TRACE_EDGES = [
3133
+ "typed-link",
3134
+ "supersession",
3135
+ "anchor",
3136
+ "source"
3137
+ ];
2928
3138
  function trace(seedId, bundle, options = {}) {
2929
3139
  const edges = options.edges?.length ? options.edges : TRACE_EDGES;
2930
3140
  const maxDepth = options.depth ?? 3;
@@ -2939,7 +3149,12 @@ function trace(seedId, bundle, options = {}) {
2939
3149
  const next = [];
2940
3150
  for (const from of frontier) {
2941
3151
  for (const edge of edges) {
2942
- for (const record of edgeNeighbours(from, bundle, edge)) {
3152
+ for (const record of edgeNeighbours(
3153
+ from,
3154
+ bundle,
3155
+ edge,
3156
+ KB_CAUSAL_LINK_RELS
3157
+ )) {
2943
3158
  const existing = reached.get(record.conceptId);
2944
3159
  if (existing) {
2945
3160
  if (existing.depth > 0 && !existing.via.includes(edge)) {
@@ -2967,11 +3182,11 @@ var traceCommand = define({
2967
3182
  tool: "kb_trace",
2968
3183
  usage: "trace <concept-id> [edges...]",
2969
3184
  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.',
2970
- input: import_zod26.z.object({
3185
+ input: import_zod28.z.object({
2971
3186
  bundlePath,
2972
3187
  conceptId,
2973
- edges: import_zod26.z.array(import_zod26.z.enum(TRACE_EDGES)).optional(),
2974
- depth: import_zod26.z.number().int().positive().optional()
3188
+ edges: import_zod28.z.array(import_zod28.z.enum(TRACE_EDGES)).optional(),
3189
+ depth: import_zod28.z.number().int().positive().optional()
2975
3190
  }),
2976
3191
  fromArgv: (argv, path) => ({
2977
3192
  bundlePath: path,
@@ -2993,53 +3208,56 @@ var traceCommand = define({
2993
3208
  });
2994
3209
 
2995
3210
  // src/commands/types.ts
2996
- var import_zod27 = require("zod");
3211
+ var import_zod29 = require("zod");
2997
3212
  var typesCommand = define({
2998
3213
  name: "types",
2999
3214
  tool: "kb_types",
3000
3215
  usage: "types",
3001
3216
  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.",
3002
- input: import_zod27.z.object({}),
3217
+ input: import_zod29.z.object({}),
3003
3218
  fromArgv: () => ({}),
3004
3219
  run: () => Promise.resolve(RECORD_TYPES)
3005
3220
  });
3006
3221
 
3007
3222
  // src/commands/unpin.ts
3008
- var import_zod28 = require("zod");
3223
+ var import_zod30 = require("zod");
3009
3224
  var unpinCommand = define({
3010
3225
  name: "unpin",
3011
3226
  tool: "kb_unpin",
3012
3227
  usage: "unpin [bundle-path]",
3013
3228
  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.",
3014
- input: import_zod28.z.object({ bundlePath }),
3229
+ input: import_zod30.z.object({ bundlePath }),
3015
3230
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3016
3231
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3017
3232
  });
3018
3233
 
3019
3234
  // src/commands/validate.ts
3020
- var import_zod29 = require("zod");
3235
+ var import_zod31 = require("zod");
3021
3236
  var validateCommand = define({
3022
3237
  name: "validate",
3023
3238
  tool: "kb_validate",
3024
3239
  usage: "validate",
3025
- 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.",
3026
- input: import_zod29.z.object({ bundlePath }),
3240
+ 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.",
3241
+ input: import_zod31.z.object({ bundlePath }),
3027
3242
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3028
3243
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3029
- failsWhen: (result) => Array.isArray(result) && result.length > 0
3244
+ // Warnings never fail the exit code; every other severity does.
3245
+ failsWhen: (result) => Array.isArray(result) && result.some(
3246
+ (problem) => problem.severity !== "warning"
3247
+ )
3030
3248
  });
3031
3249
 
3032
3250
  // src/commands/verify.ts
3033
- var import_zod30 = require("zod");
3251
+ var import_zod32 = require("zod");
3034
3252
  var verifyCommand = define({
3035
3253
  name: "verify",
3036
3254
  tool: "kb_verify",
3037
3255
  usage: "verify <concept-id> --note <text>",
3038
3256
  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.",
3039
- input: import_zod30.z.object({
3257
+ input: import_zod32.z.object({
3040
3258
  bundlePath,
3041
3259
  conceptId,
3042
- note: import_zod30.z.string().refine((s) => s.trim().length > 0, {
3260
+ note: import_zod32.z.string().refine((s) => s.trim().length > 0, {
3043
3261
  message: "note must say what the check found"
3044
3262
  })
3045
3263
  }),
@@ -3059,7 +3277,7 @@ var verifyCommand = define({
3059
3277
  });
3060
3278
 
3061
3279
  // src/commands/write.ts
3062
- var import_zod31 = require("zod");
3280
+ var import_zod33 = require("zod");
3063
3281
  var writeCommand = define({
3064
3282
  name: "write",
3065
3283
  tool: "kb_write",
@@ -3073,9 +3291,9 @@ var writeCommand = define({
3073
3291
  "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
3074
3292
  "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
3075
3293
  ].join("\n"),
3076
- input: import_zod31.z.object({
3294
+ input: import_zod33.z.object({
3077
3295
  bundlePath,
3078
- type: import_zod31.z.enum(KB_RECORD_TYPES),
3296
+ type: import_zod33.z.enum(KB_RECORD_TYPES),
3079
3297
  input: composeInputSchema
3080
3298
  }),
3081
3299
  fromArgv: async (argv, path, stdin) => ({
@@ -3099,7 +3317,7 @@ var writeCommand = define({
3099
3317
  });
3100
3318
 
3101
3319
  // src/commands/write-decision.ts
3102
- var import_zod32 = require("zod");
3320
+ var import_zod34 = require("zod");
3103
3321
  var writeDecisionCommand = define({
3104
3322
  name: "write-decision",
3105
3323
  tool: "kb_write_decision",
@@ -3112,7 +3330,7 @@ var writeDecisionCommand = define({
3112
3330
  "- `alternative` is what you turned down and why, not a list of everything considered.",
3113
3331
  "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
3114
3332
  ].join("\n"),
3115
- input: import_zod32.z.object({ bundlePath, input: decisionInputSchema }),
3333
+ input: import_zod34.z.object({ bundlePath, input: decisionInputSchema }),
3116
3334
  fromArgv: async (_argv, path, stdin) => ({
3117
3335
  bundlePath: path,
3118
3336
  input: JSON.parse(await stdin())
@@ -3147,6 +3365,8 @@ var KB_COMMANDS = [
3147
3365
  packCommand,
3148
3366
  queryCommand,
3149
3367
  traceCommand,
3368
+ impactCommand,
3369
+ backlinksCommand,
3150
3370
  listCommand,
3151
3371
  readIndexCommand,
3152
3372
  logCommand,
@@ -3367,6 +3587,141 @@ function typeRank(record) {
3367
3587
  return index === -1 ? TYPE_PRIORITY.length : index;
3368
3588
  }
3369
3589
 
3590
+ // src/kb-links/inbound.ts
3591
+ function inboundIndex(bundle) {
3592
+ const byTarget = /* @__PURE__ */ new Map();
3593
+ for (const record of bundle) {
3594
+ for (const link2 of record.frontmatter.strauss_links ?? []) {
3595
+ if (link2.target === record.conceptId) continue;
3596
+ const edges = byTarget.get(link2.target) ?? [];
3597
+ if (edges.some(
3598
+ (edge) => edge.from === record.conceptId && edge.rel === link2.rel
3599
+ )) {
3600
+ continue;
3601
+ }
3602
+ edges.push({ from: record.conceptId, rel: link2.rel });
3603
+ byTarget.set(link2.target, edges);
3604
+ }
3605
+ }
3606
+ return byTarget;
3607
+ }
3608
+
3609
+ // src/kb-links/backlinks.ts
3610
+ function backlinks(targetId, bundle) {
3611
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
3612
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
3613
+ const standingOf = new Map(
3614
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
3615
+ );
3616
+ const rows = [];
3617
+ for (const edge of inboundIndex(bundle).get(targetId) ?? []) {
3618
+ const record = byId.get(edge.from);
3619
+ if (!record) continue;
3620
+ const hit = standingOf.get(edge.from);
3621
+ rows.push({
3622
+ ...edge,
3623
+ title: record.frontmatter.title ?? null,
3624
+ standing: hit?.standing ?? "unsettled",
3625
+ warnings: hit?.warnings ?? []
3626
+ });
3627
+ }
3628
+ return {
3629
+ target: targetId,
3630
+ backlinks: rows.sort(
3631
+ (left, right) => left.from.localeCompare(right.from) || left.rel.localeCompare(right.rel)
3632
+ )
3633
+ };
3634
+ }
3635
+
3636
+ // src/kb-links/impact.ts
3637
+ function impact(targetId, bundle, options = {}) {
3638
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
3639
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
3640
+ const rels = resolveRels(options.rels);
3641
+ const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
3642
+ const inbound = inboundIndex(bundle);
3643
+ const standingOf = new Map(
3644
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
3645
+ );
3646
+ const reached = /* @__PURE__ */ new Map();
3647
+ const stopped = [];
3648
+ let frontier = [targetId];
3649
+ let depth = 0;
3650
+ while (frontier.length && depth < maxDepth) {
3651
+ depth += 1;
3652
+ const next = [];
3653
+ const consider = (dependantId, edge) => {
3654
+ if (dependantId === targetId) return;
3655
+ const existing = reached.get(dependantId);
3656
+ if (existing) {
3657
+ if (!hasEdge(existing.via, edge)) existing.via.push(edge);
3658
+ return;
3659
+ }
3660
+ const record = byId.get(dependantId);
3661
+ if (!record) return;
3662
+ const hit = standingOf.get(dependantId);
3663
+ const entry = {
3664
+ conceptId: dependantId,
3665
+ title: record.frontmatter.title ?? null,
3666
+ standing: hit?.standing ?? "unsettled",
3667
+ warnings: hit?.warnings ?? [],
3668
+ depth,
3669
+ via: [edge]
3670
+ };
3671
+ reached.set(dependantId, entry);
3672
+ if (entry.standing === "superseded" || entry.standing === "rejected") {
3673
+ stopped.push(dependantId);
3674
+ return;
3675
+ }
3676
+ next.push(dependantId);
3677
+ };
3678
+ for (const id of frontier) {
3679
+ for (const edge of inbound.get(id) ?? []) {
3680
+ if (!rels.has(edge.rel)) continue;
3681
+ if (dependantEnd(edge.rel) !== "source") continue;
3682
+ consider(edge.from, { source: edge.from, target: id, rel: edge.rel });
3683
+ }
3684
+ for (const link2 of byId.get(id)?.frontmatter.strauss_links ?? []) {
3685
+ if (!rels.has(link2.rel)) continue;
3686
+ if (dependantEnd(link2.rel) !== "target") continue;
3687
+ if (link2.target === id) continue;
3688
+ consider(link2.target, {
3689
+ source: id,
3690
+ target: link2.target,
3691
+ rel: link2.rel
3692
+ });
3693
+ }
3694
+ }
3695
+ frontier = next;
3696
+ }
3697
+ return {
3698
+ root: targetId,
3699
+ impacted: [...reached.values()].sort(
3700
+ (left, right) => left.depth - right.depth || left.conceptId.localeCompare(right.conceptId)
3701
+ ),
3702
+ stopped: stopped.sort(),
3703
+ truncated: frontier.length > 0,
3704
+ unexpanded: [...frontier].sort()
3705
+ };
3706
+ }
3707
+ function resolveRels(rels) {
3708
+ if (!rels?.length) return new Set(KB_CAUSAL_LINK_RELS);
3709
+ for (const rel of rels) {
3710
+ if (!isKbLinkRel(rel) || LINK_RELS[rel].dependant === null) {
3711
+ throw new KbUnknownLinkRelError(rel, KB_CAUSAL_LINK_RELS);
3712
+ }
3713
+ }
3714
+ return new Set(rels);
3715
+ }
3716
+ function dependantEnd(rel) {
3717
+ return isKbLinkRel(rel) ? LINK_RELS[rel].dependant : null;
3718
+ }
3719
+ function hasEdge(edges, edge) {
3720
+ return edges.some(
3721
+ (existing) => existing.source === edge.source && existing.target === edge.target && existing.rel === edge.rel
3722
+ );
3723
+ }
3724
+
3370
3725
  // src/kb-gitattributes.ts
3371
3726
  var GITATTRIBUTES_FILE = ".gitattributes";
3372
3727
  var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
@@ -3733,6 +4088,7 @@ ${answer}
3733
4088
  const records = adjudicated.filter((hit) => hit.standing !== "superseded");
3734
4089
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
3735
4090
  const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
4091
+ const bundleDigestValue = bundleDigest(records, superseded);
3736
4092
  if (!options.all && approxTokens2 > budgetTokens) {
3737
4093
  return {
3738
4094
  loaded: false,
@@ -3743,7 +4099,8 @@ ${answer}
3743
4099
  approxTokens: approxTokens2,
3744
4100
  budgetTokens,
3745
4101
  type: options.type
3746
- })
4102
+ }),
4103
+ digest: bundleDigestValue
3747
4104
  };
3748
4105
  }
3749
4106
  return {
@@ -3752,7 +4109,8 @@ ${answer}
3752
4109
  tokensLoaded: approxTokens2,
3753
4110
  budgetTokens: options.all ? null : budgetTokens,
3754
4111
  records,
3755
- superseded
4112
+ superseded,
4113
+ digest: bundleDigestValue
3756
4114
  };
3757
4115
  }
3758
4116
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
@@ -3767,6 +4125,14 @@ ${answer}
3767
4125
  async pack(bundlePath2, rootId, options = {}) {
3768
4126
  return pack(await this.list(bundlePath2), rootId, options);
3769
4127
  }
4128
+ /** What breaks if this record changes. See `kb-links/impact.ts`. */
4129
+ async impact(bundlePath2, targetId, options = {}) {
4130
+ return impact(targetId, await this.list(bundlePath2), options);
4131
+ }
4132
+ /** Who points at this record, one hop. See `kb-links/backlinks.ts`. */
4133
+ async backlinks(bundlePath2, targetId) {
4134
+ return backlinks(targetId, await this.list(bundlePath2));
4135
+ }
3770
4136
  /**
3771
4137
  * The stored index, rebuilt if it disagrees with the records.
3772
4138
  *
@@ -4069,9 +4435,25 @@ function normalizeActor(id) {
4069
4435
  function digest(contents) {
4070
4436
  return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
4071
4437
  }
4438
+ function bundleDigest(records, superseded) {
4439
+ const entries = [
4440
+ ...records.map(
4441
+ (hit) => `${hit.record.conceptId}:current:${digest(
4442
+ stringifyMarkdownWithFrontmatter(
4443
+ hit.record.body,
4444
+ hit.record.frontmatter
4445
+ )
4446
+ )}`
4447
+ ),
4448
+ ...superseded.map(
4449
+ (entry) => `${entry.conceptId}:superseded:${digest(JSON.stringify(entry))}`
4450
+ )
4451
+ ].sort();
4452
+ return digest(entries.join("\n"));
4453
+ }
4072
4454
 
4073
4455
  // src/version.ts
4074
- var VERSION = true ? "0.1.11" : "0.0.0-dev";
4456
+ var VERSION = true ? "0.1.13" : "0.0.0-dev";
4075
4457
 
4076
4458
  // src/cli.ts
4077
4459
  async function runKbCli(argv) {