@saasontools/strauss-kb 0.1.12 → 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.
@@ -44,6 +44,10 @@ var kbAnchorSchema = z.object({
44
44
  /** Line count of the text the hash was taken over. */
45
45
  lines: z.number().int().positive().optional()
46
46
  }).strict();
47
+ var kbLinkSchema = z.object({
48
+ target: z.string().min(1),
49
+ rel: z.string().min(1)
50
+ }).passthrough();
47
51
  var KB_RECORD_TYPES = [
48
52
  "fact",
49
53
  "requirement",
@@ -95,6 +99,10 @@ var kbRecordFrontmatterSchema = z.object({
95
99
  // strauss extensions — see the module comment.
96
100
  strauss_anchors: z.array(kbAnchorSchema).optional(),
97
101
  strauss_verify: z.array(z.string().min(1)).optional(),
102
+ // Typed causal edges, source → target, living on the source. `A depends_on
103
+ // B` means A needs B, so `kb_impact` walks these inbound: what breaks if B
104
+ // changes is whatever declared a dependence on it.
105
+ strauss_links: z.array(kbLinkSchema).optional(),
98
106
  // Total after parsing, tolerant before it. Our producers must supply a
99
107
  // status — an absent one would leave every reader inventing its own default
100
108
  // — but OKF calls a concept carrying only `type` fully conformant, so
@@ -179,9 +187,71 @@ var RECORD_TYPES = {
179
187
  function isKbRecordType(value) {
180
188
  return Object.prototype.hasOwnProperty.call(RECORD_TYPES, value);
181
189
  }
190
+ var KB_LINK_RELS = [
191
+ "depends_on",
192
+ "constrains",
193
+ "informs",
194
+ "blocks",
195
+ "invalidates",
196
+ "verified_by",
197
+ "satisfies",
198
+ "related_to"
199
+ ];
200
+ var LINK_RELS = {
201
+ depends_on: {
202
+ purpose: "The source needs the target to hold; the source breaks if the target changes",
203
+ phrase: "Depends on",
204
+ dependant: "source"
205
+ },
206
+ constrains: {
207
+ purpose: "The source bounds what the target may do; the target breaks if the constraint changes",
208
+ phrase: "Constrains",
209
+ dependant: "target"
210
+ },
211
+ informs: {
212
+ purpose: "The source shaped the target without binding it; the target is what needs revisiting",
213
+ phrase: "Informs",
214
+ dependant: "target"
215
+ },
216
+ blocks: {
217
+ purpose: "The target cannot proceed until the source is settled; the target is what waits",
218
+ phrase: "Blocks",
219
+ dependant: "target"
220
+ },
221
+ invalidates: {
222
+ purpose: "The source makes the target no longer hold; the target is what stops holding",
223
+ phrase: "Invalidates",
224
+ dependant: "target"
225
+ },
226
+ verified_by: {
227
+ purpose: "The target is the check that confirms the source; the source's confirmation moves with it",
228
+ phrase: "Verified by",
229
+ dependant: "source"
230
+ },
231
+ satisfies: {
232
+ purpose: "The source discharges the target's requirement; the source must change if the requirement does",
233
+ phrase: "Satisfies",
234
+ dependant: "source"
235
+ },
236
+ related_to: {
237
+ purpose: "A pointer worth following, with no claim of dependence",
238
+ phrase: "Relates to",
239
+ dependant: null
240
+ }
241
+ };
242
+ var KB_CAUSAL_LINK_RELS = KB_LINK_RELS.filter(
243
+ (rel) => LINK_RELS[rel].dependant !== null
244
+ );
245
+ function isKbLinkRel(value) {
246
+ return Object.prototype.hasOwnProperty.call(LINK_RELS, value);
247
+ }
182
248
 
183
249
  // src/compose.ts
184
250
  import { z as z2 } from "zod";
251
+ var composeLinkSchema = z2.object({
252
+ target: kbConceptIdSchema,
253
+ rel: z2.enum(KB_LINK_RELS)
254
+ }).strict();
185
255
  var composeInputSchema = z2.object({
186
256
  slug: z2.string().min(1),
187
257
  /** One line, in the reader's terms. Becomes OKF `title`. */
@@ -208,6 +278,18 @@ var composeInputSchema = z2.object({
208
278
  tags: z2.array(z2.string().min(1)).optional(),
209
279
  /** Concept ids this record relates to; rendered as body links. */
210
280
  relatedConceptIds: z2.array(kbConceptIdSchema).optional(),
281
+ /**
282
+ * Typed causal edges, source → target: `{ target: "fact.b", rel:
283
+ * "depends_on" }` on record A says A needs B. Stored in frontmatter and
284
+ * also rendered as one prose sentence each, so the meaning survives a
285
+ * reader that knows only OKF. The vocabulary goes into the description from
286
+ * the same table the walk uses, so `kb_schema` emits it.
287
+ */
288
+ links: z2.array(composeLinkSchema).max(64).optional().describe(
289
+ `Typed causal edges, source \u2192 target \u2014 a link on this record says this record <rel> the target. ${KB_LINK_RELS.map(
290
+ (rel) => `${rel}: ${LINK_RELS[rel].purpose}`
291
+ ).join("; ")}.`
292
+ ),
211
293
  /** Concept ids this record replaces. The store settles the backlinks. */
212
294
  supersedes: z2.array(kbConceptIdSchema).max(32).optional(),
213
295
  materiality: z2.enum(KB_MATERIALITIES).optional(),
@@ -247,6 +329,15 @@ function composeRecord(type, input, writtenBy, writtenAt) {
247
329
  if (parsed.owner) frontmatter.strauss_owner = parsed.owner;
248
330
  if (parsed.supersedes?.length)
249
331
  frontmatter.strauss_supersedes = parsed.supersedes;
332
+ const selfLink = parsed.links?.find(
333
+ (link2) => link2.target === `${type}.${parsed.slug}`
334
+ );
335
+ if (selfLink) {
336
+ throw new Error(
337
+ `kb: ${type}.${parsed.slug} cannot ${selfLink.rel} itself \u2014 a link must name another record`
338
+ );
339
+ }
340
+ if (parsed.links?.length) frontmatter.strauss_links = parsed.links;
250
341
  const blocks = [];
251
342
  for (const heading of spec.sections) {
252
343
  const text = sections[heading];
@@ -258,6 +349,11 @@ ${text}`);
258
349
  for (const related of parsed.relatedConceptIds ?? []) {
259
350
  blocks.push(`Relates to [${related}](${related}.md).`);
260
351
  }
352
+ for (const link2 of parsed.links ?? []) {
353
+ blocks.push(
354
+ `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
355
+ );
356
+ }
261
357
  if (parsed.sources?.length) {
262
358
  blocks.push(
263
359
  parsed.sources.map((source) => `[^${source.id}]: ${source.title ?? source.resource}`).join("\n")
@@ -281,7 +377,7 @@ var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
281
377
  impact: z3.string().min(1).optional()
282
378
  }).strict();
283
379
  function composeDecisionRecord(input, writtenBy, writtenAt) {
284
- const { alternative, impact, ...rest } = input;
380
+ const { alternative, impact: impact2, ...rest } = input;
285
381
  return composeRecord(
286
382
  DECISION_TYPE,
287
383
  {
@@ -290,7 +386,7 @@ function composeDecisionRecord(input, writtenBy, writtenAt) {
290
386
  Decision: input.title,
291
387
  Rationale: input.why,
292
388
  ...alternative ? { Rejected: alternative } : {},
293
- ...impact ? { Impact: impact } : {}
389
+ ...impact2 ? { Impact: impact2 } : {}
294
390
  }
295
391
  },
296
392
  writtenBy,
@@ -785,6 +881,7 @@ var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
785
881
  ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
786
882
  ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
787
883
  ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
884
+ ErrorTypes2["KbUnknownLinkRel"] = "KbUnknownLinkRel";
788
885
  ErrorTypes2["KbWriteConflict"] = "KbWriteConflict";
789
886
  return ErrorTypes2;
790
887
  })(ErrorTypes || {});
@@ -893,6 +990,23 @@ var KbPackBudgetExceededError = class extends BaseError {
893
990
  budgetTokens;
894
991
  excluded;
895
992
  };
993
+ var KbUnknownLinkRelError = class extends BaseError {
994
+ constructor(rel, expected) {
995
+ super({
996
+ message: `kb: ${rel} is not a rel a walk can follow \u2014 expected one of ${expected.join(", ")}`,
997
+ errorType: "KbUnknownLinkRel" /* KbUnknownLinkRel */,
998
+ code: 400,
999
+ fault: "User" /* User */,
1000
+ retriable: false,
1001
+ reportToUser: true,
1002
+ details: { rel, expected: expected.join(", ") }
1003
+ });
1004
+ this.rel = rel;
1005
+ this.expected = expected;
1006
+ }
1007
+ rel;
1008
+ expected;
1009
+ };
896
1010
  var KbMissingFlagValueError = class extends BaseError {
897
1011
  constructor(flag) {
898
1012
  super({
@@ -1624,6 +1738,7 @@ ${CONTEXT_END}` : null;
1624
1738
  // src/kb-edges.ts
1625
1739
  var KB_EDGE_KINDS = [
1626
1740
  "body-link",
1741
+ "typed-link",
1627
1742
  "supersession",
1628
1743
  "anchor",
1629
1744
  "source"
@@ -1632,10 +1747,11 @@ var BODY_LINK_TARGET = new RegExp(
1632
1747
  `\\]\\((${KB_CONCEPT_ID_PATTERN.source.replace(/^\^|\$$/g, "")})\\.md\\)`,
1633
1748
  "g"
1634
1749
  );
1635
- function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
1750
+ var DEFAULT_TYPED_LINK_RELS = KB_LINK_RELS;
1751
+ function neighbours(from, bundle, kinds = KB_EDGE_KINDS, linkRels = DEFAULT_TYPED_LINK_RELS) {
1636
1752
  const found = /* @__PURE__ */ new Map();
1637
1753
  for (const kind of kinds) {
1638
- for (const record of edgeNeighbours(from, bundle, kind)) {
1754
+ for (const record of edgeNeighbours(from, bundle, kind, linkRels)) {
1639
1755
  const existing = found.get(record.conceptId);
1640
1756
  if (existing) {
1641
1757
  if (!existing.via.includes(kind)) existing.via.push(kind);
@@ -1646,7 +1762,7 @@ function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
1646
1762
  }
1647
1763
  return [...found.values()];
1648
1764
  }
1649
- function edgeNeighbours(from, bundle, kind) {
1765
+ function edgeNeighbours(from, bundle, kind, linkRels = DEFAULT_TYPED_LINK_RELS) {
1650
1766
  switch (kind) {
1651
1767
  // A link whose target is not in the bundle is legal per compose.ts —
1652
1768
  // records are routinely written before the ones they point at exist — so
@@ -1660,6 +1776,21 @@ function edgeNeighbours(from, bundle, kind) {
1660
1776
  (candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
1661
1777
  );
1662
1778
  }
1779
+ // Outbound only, like `body-link`, and for the same reason: this is what
1780
+ // the record declares about itself. A missing target is legal — the walk
1781
+ // skips it, and `kb_validate` is what reports it as a warning. A rel
1782
+ // outside `linkRels` is skipped too, which is how an unknown rel stays
1783
+ // untraversable everywhere rather than one walk at a time.
1784
+ case "typed-link": {
1785
+ const allowed = new Set(linkRels);
1786
+ const targets = new Set(
1787
+ (from.frontmatter.strauss_links ?? []).filter((link2) => allowed.has(link2.rel)).map((link2) => link2.target)
1788
+ );
1789
+ if (!targets.size) return [];
1790
+ return bundle.filter(
1791
+ (candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
1792
+ );
1793
+ }
1663
1794
  // Both directions and both pointers: `supersede()` writes the pair, but a
1664
1795
  // hand-edit can leave one side behind, and a walk trusting one pointer
1665
1796
  // would miss a replacement the bundle openly declares.
@@ -1701,7 +1832,7 @@ function anchorsTouch(left, right) {
1701
1832
  function validateBundle(records) {
1702
1833
  const byId = new Map(records.map((record) => [record.conceptId, record]));
1703
1834
  const problems = [];
1704
- const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
1835
+ const report = (check, conceptId2, note, severity = "error") => problems.push({ check, conceptId: conceptId2, note, severity });
1705
1836
  for (const record of records) {
1706
1837
  const { conceptId: conceptId2, frontmatter: fm } = record;
1707
1838
  if (!isKbRecordType(fm.type)) {
@@ -1725,6 +1856,36 @@ function validateBundle(records) {
1725
1856
  report("supersedes", conceptId2, `${old} is not marked superseded`);
1726
1857
  }
1727
1858
  }
1859
+ for (const link2 of fm.strauss_links ?? []) {
1860
+ if (!isKbLinkRel(link2.rel)) {
1861
+ report(
1862
+ "link_rel",
1863
+ conceptId2,
1864
+ `unknown rel "${link2.rel}" on link to ${link2.target} \u2014 expected one of ${KB_LINK_RELS.join(", ")}`
1865
+ );
1866
+ }
1867
+ if (!KB_CONCEPT_ID_PATTERN.test(link2.target)) {
1868
+ report(
1869
+ "link_target",
1870
+ conceptId2,
1871
+ `target "${link2.target}" is not a valid concept id \u2014 expected <type>.<slug>, both kebab-case`
1872
+ );
1873
+ } else if (link2.target === conceptId2) {
1874
+ report(
1875
+ "link_target",
1876
+ conceptId2,
1877
+ `links to itself (${link2.rel})`,
1878
+ "warning"
1879
+ );
1880
+ } else if (!byId.has(link2.target)) {
1881
+ report(
1882
+ "link_target",
1883
+ conceptId2,
1884
+ `target ${link2.target} is not in the bundle`,
1885
+ "warning"
1886
+ );
1887
+ }
1888
+ }
1728
1889
  if (fm.strauss_assumption && fm.sources?.length) {
1729
1890
  report("assumption", conceptId2, "marked an assumption but cites sources");
1730
1891
  }
@@ -2075,7 +2236,12 @@ function kbJsonSchemas() {
2075
2236
  }
2076
2237
 
2077
2238
  // src/trace.ts
2078
- var TRACE_EDGES = ["supersession", "anchor", "source"];
2239
+ var TRACE_EDGES = [
2240
+ "typed-link",
2241
+ "supersession",
2242
+ "anchor",
2243
+ "source"
2244
+ ];
2079
2245
  function trace(seedId, bundle, options = {}) {
2080
2246
  const edges = options.edges?.length ? options.edges : TRACE_EDGES;
2081
2247
  const maxDepth = options.depth ?? 3;
@@ -2090,7 +2256,12 @@ function trace(seedId, bundle, options = {}) {
2090
2256
  const next = [];
2091
2257
  for (const from of frontier) {
2092
2258
  for (const edge of edges) {
2093
- for (const record of edgeNeighbours(from, bundle, edge)) {
2259
+ for (const record of edgeNeighbours(
2260
+ from,
2261
+ bundle,
2262
+ edge,
2263
+ KB_CAUSAL_LINK_RELS
2264
+ )) {
2094
2265
  const existing = reached.get(record.conceptId);
2095
2266
  if (existing) {
2096
2267
  if (existing.depth > 0 && !existing.via.includes(edge)) {
@@ -2322,16 +2493,28 @@ var answerCommand = define({
2322
2493
  }
2323
2494
  });
2324
2495
 
2325
- // src/commands/catalog.ts
2496
+ // src/commands/backlinks.ts
2326
2497
  import { z as z10 } from "zod";
2498
+ var backlinksCommand = define({
2499
+ name: "backlinks",
2500
+ tool: "kb_backlinks",
2501
+ usage: "backlinks <concept-id>",
2502
+ 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.",
2503
+ input: z10.object({ bundlePath, conceptId }),
2504
+ fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
2505
+ run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
2506
+ });
2507
+
2508
+ // src/commands/catalog.ts
2509
+ import { z as z11 } from "zod";
2327
2510
  var catalogCommand = define({
2328
2511
  name: "catalog",
2329
2512
  tool: "kb_catalog",
2330
2513
  usage: "catalog [type]",
2331
2514
  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.",
2332
- input: z10.object({
2515
+ input: z11.object({
2333
2516
  bundlePath,
2334
- type: z10.enum(KB_RECORD_TYPES).optional()
2517
+ type: z11.enum(KB_RECORD_TYPES).optional()
2335
2518
  }),
2336
2519
  fromArgv: (argv, path) => ({
2337
2520
  bundlePath: path,
@@ -2386,26 +2569,26 @@ function count(value, noun) {
2386
2569
  }
2387
2570
 
2388
2571
  // src/commands/context.ts
2389
- import { z as z11 } from "zod";
2572
+ import { z as z12 } from "zod";
2390
2573
  var contextCommand = define({
2391
2574
  name: "context",
2392
2575
  tool: "kb_context",
2393
2576
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
2394
2577
  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.",
2395
- input: z11.object({
2396
- budgetTokens: z11.number().int().positive().optional().describe(
2578
+ input: z12.object({
2579
+ budgetTokens: z12.number().int().positive().optional().describe(
2397
2580
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
2398
2581
  ),
2399
- fullUnderTokens: z11.number().int().positive().optional().describe(
2582
+ fullUnderTokens: z12.number().int().positive().optional().describe(
2400
2583
  "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."
2401
2584
  ),
2402
- profile: z11.string().optional().describe(
2585
+ profile: z12.string().optional().describe(
2403
2586
  "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."
2404
2587
  ),
2405
- format: z11.enum(["markdown", "json"]).optional().describe(
2588
+ format: z12.enum(["markdown", "json"]).optional().describe(
2406
2589
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
2407
2590
  ),
2408
- event: z11.string().optional().describe(
2591
+ event: z12.string().optional().describe(
2409
2592
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
2410
2593
  )
2411
2594
  }),
@@ -2441,14 +2624,14 @@ var contextCommand = define({
2441
2624
  });
2442
2625
 
2443
2626
  // src/commands/doctor.ts
2444
- import { z as z12 } from "zod";
2445
- var days = (what, fallback) => z12.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
2627
+ import { z as z13 } from "zod";
2628
+ var days = (what, fallback) => z13.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
2446
2629
  var doctorCommand = define({
2447
2630
  name: "doctor",
2448
2631
  tool: "kb_doctor",
2449
2632
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
2450
2633
  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.",
2451
- input: z12.object({
2634
+ input: z13.object({
2452
2635
  bundlePath,
2453
2636
  repoRoot: REPO_ROOT,
2454
2637
  expiringDays: days(
@@ -2463,7 +2646,7 @@ var doctorCommand = define({
2463
2646
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
2464
2647
  DEFAULT_AGING_DAYS
2465
2648
  ),
2466
- strict: z12.boolean().optional().describe(
2649
+ strict: z13.boolean().optional().describe(
2467
2650
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
2468
2651
  )
2469
2652
  }),
@@ -2538,14 +2721,47 @@ function render2(result) {
2538
2721
  return lines.join("\n");
2539
2722
  }
2540
2723
 
2724
+ // src/commands/impact.ts
2725
+ import { z as z14 } from "zod";
2726
+ var impactCommand = define({
2727
+ name: "impact",
2728
+ tool: "kb_impact",
2729
+ usage: "impact <concept-id> [--depth N] [--rels a,b]",
2730
+ 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.",
2731
+ input: z14.object({
2732
+ bundlePath,
2733
+ conceptId,
2734
+ depth: z14.number().int().positive().optional().describe(
2735
+ "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
2736
+ ),
2737
+ rels: z14.array(z14.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
2738
+ "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
2739
+ )
2740
+ }),
2741
+ fromArgv: (argv, path) => {
2742
+ const depth = argvFlag(argv, "--depth");
2743
+ const rels = argvFlag(argv, "--rels");
2744
+ return {
2745
+ bundlePath: path,
2746
+ conceptId: argv[1],
2747
+ ...depth ? { depth: Number(depth) } : {},
2748
+ ...rels ? { rels: rels.split(",").filter(Boolean) } : {}
2749
+ };
2750
+ },
2751
+ run: async ({ store }, { bundlePath: path, conceptId: id, depth, rels }) => store.impact(path, id, {
2752
+ ...depth !== void 0 ? { depth } : {},
2753
+ ...rels?.length ? { rels } : {}
2754
+ })
2755
+ });
2756
+
2541
2757
  // src/commands/list.ts
2542
- import { z as z13 } from "zod";
2758
+ import { z as z15 } from "zod";
2543
2759
  var listCommand = define({
2544
2760
  name: "list",
2545
2761
  tool: "kb_list",
2546
2762
  usage: "list [type]",
2547
2763
  description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
2548
- input: z13.object({ bundlePath, type: z13.enum(KB_RECORD_TYPES).optional() }),
2764
+ input: z15.object({ bundlePath, type: z15.enum(KB_RECORD_TYPES).optional() }),
2549
2765
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
2550
2766
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
2551
2767
  conceptId: record.conceptId,
@@ -2557,17 +2773,17 @@ var listCommand = define({
2557
2773
  });
2558
2774
 
2559
2775
  // src/commands/load.ts
2560
- import { z as z14 } from "zod";
2776
+ import { z as z16 } from "zod";
2561
2777
  var loadCommand = define({
2562
2778
  name: "load",
2563
2779
  tool: "kb_load",
2564
2780
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
2565
2781
  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.",
2566
- input: z14.object({
2782
+ input: z16.object({
2567
2783
  bundlePath,
2568
- type: z14.enum(KB_RECORD_TYPES).optional(),
2569
- budgetTokens: z14.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
2570
- all: z14.boolean().optional().describe(
2784
+ type: z16.enum(KB_RECORD_TYPES).optional(),
2785
+ budgetTokens: z16.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
2786
+ all: z16.boolean().optional().describe(
2571
2787
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
2572
2788
  ),
2573
2789
  repoRoot: REPO_ROOT
@@ -2609,25 +2825,25 @@ var loadCommand = define({
2609
2825
  });
2610
2826
 
2611
2827
  // src/commands/log.ts
2612
- import { z as z15 } from "zod";
2828
+ import { z as z17 } from "zod";
2613
2829
  var logCommand = define({
2614
2830
  name: "log",
2615
2831
  tool: "kb_log",
2616
2832
  usage: "log",
2617
2833
  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.",
2618
- input: z15.object({ bundlePath }),
2834
+ input: z17.object({ bundlePath }),
2619
2835
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2620
2836
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
2621
2837
  });
2622
2838
 
2623
2839
  // src/commands/no-decision.ts
2624
- import { z as z16 } from "zod";
2840
+ import { z as z18 } from "zod";
2625
2841
  var noDecisionCommand = define({
2626
2842
  name: "no-decision",
2627
2843
  tool: "kb_no_decision",
2628
2844
  usage: "no-decision <reason...>",
2629
2845
  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.',
2630
- input: z16.object({ bundlePath, reason: z16.string().min(1) }),
2846
+ input: z18.object({ bundlePath, reason: z18.string().min(1) }),
2631
2847
  fromArgv: (argv, path) => ({
2632
2848
  bundlePath: path,
2633
2849
  reason: argv.slice(1).join(" ").trim()
@@ -2644,20 +2860,20 @@ var noDecisionCommand = define({
2644
2860
  });
2645
2861
 
2646
2862
  // src/commands/pack.ts
2647
- import { z as z17 } from "zod";
2863
+ import { z as z19 } from "zod";
2648
2864
  var packCommand = define({
2649
2865
  name: "pack",
2650
2866
  tool: "kb_pack",
2651
2867
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
2652
2868
  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.",
2653
- input: z17.object({
2869
+ input: z19.object({
2654
2870
  bundlePath,
2655
2871
  conceptId,
2656
- hops: z17.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
2657
- maxNodes: z17.number().int().positive().optional().describe(
2872
+ hops: z19.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
2873
+ maxNodes: z19.number().int().positive().optional().describe(
2658
2874
  "How many records the pack may hold, root included. Defaults to 20."
2659
2875
  ),
2660
- budgetTokens: z17.number().int().positive().optional().describe(
2876
+ budgetTokens: z19.number().int().positive().optional().describe(
2661
2877
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
2662
2878
  )
2663
2879
  }),
@@ -2744,22 +2960,22 @@ function warningLabel(warning) {
2744
2960
  }
2745
2961
 
2746
2962
  // src/commands/pin.ts
2747
- import { z as z18 } from "zod";
2963
+ import { z as z20 } from "zod";
2748
2964
  var pinCommand = define({
2749
2965
  name: "pin",
2750
2966
  tool: "kb_pin",
2751
2967
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
2752
2968
  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.",
2753
- input: z18.object({
2969
+ input: z20.object({
2754
2970
  bundlePath,
2755
- mode: z18.enum(["full", "index"]).optional().describe(
2971
+ mode: z20.enum(["full", "index"]).optional().describe(
2756
2972
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
2757
2973
  ),
2758
- profiles: z18.array(z18.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
2759
- layer: z18.enum(["project", "local", "user"]).optional().describe(
2974
+ profiles: z20.array(z20.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
2975
+ layer: z20.enum(["project", "local", "user"]).optional().describe(
2760
2976
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
2761
2977
  ),
2762
- frozen: z18.boolean().optional().describe(
2978
+ frozen: z20.boolean().optional().describe(
2763
2979
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
2764
2980
  )
2765
2981
  }),
@@ -2788,29 +3004,29 @@ var pinCommand = define({
2788
3004
  });
2789
3005
 
2790
3006
  // src/commands/pins.ts
2791
- import { z as z19 } from "zod";
3007
+ import { z as z21 } from "zod";
2792
3008
  var pinsCommand = define({
2793
3009
  name: "pins",
2794
3010
  tool: "kb_pins",
2795
3011
  usage: "pins",
2796
3012
  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.",
2797
- input: z19.object({}),
3013
+ input: z21.object({}),
2798
3014
  fromArgv: () => ({}),
2799
3015
  run: ({ store }) => listPins(store, process.cwd())
2800
3016
  });
2801
3017
 
2802
3018
  // src/commands/query.ts
2803
- import { z as z20 } from "zod";
3019
+ import { z as z22 } from "zod";
2804
3020
  var queryCommand = define({
2805
3021
  name: "query",
2806
3022
  tool: "kb_query",
2807
3023
  usage: "query <text...> [--repo-root PATH]",
2808
3024
  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.",
2809
- input: z20.object({
3025
+ input: z22.object({
2810
3026
  bundlePath,
2811
- text: z20.string().optional(),
2812
- type: z20.enum(KB_RECORD_TYPES).optional(),
2813
- includeNonCurrent: z20.boolean().optional(),
3027
+ text: z22.string().optional(),
3028
+ type: z22.enum(KB_RECORD_TYPES).optional(),
3029
+ includeNonCurrent: z22.boolean().optional(),
2814
3030
  repoRoot: REPO_ROOT
2815
3031
  }),
2816
3032
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -2842,40 +3058,40 @@ var queryCommand = define({
2842
3058
  });
2843
3059
 
2844
3060
  // src/commands/read-index.ts
2845
- import { z as z21 } from "zod";
3061
+ import { z as z23 } from "zod";
2846
3062
  var readIndexCommand = define({
2847
3063
  name: "index",
2848
3064
  tool: "kb_index",
2849
3065
  usage: "index",
2850
3066
  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.",
2851
- input: z21.object({ bundlePath }),
3067
+ input: z23.object({ bundlePath }),
2852
3068
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2853
3069
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
2854
3070
  });
2855
3071
 
2856
3072
  // src/commands/schema.ts
2857
- import { z as z22 } from "zod";
3073
+ import { z as z24 } from "zod";
2858
3074
  var schemaCommand = define({
2859
3075
  name: "schema",
2860
3076
  tool: "kb_schema",
2861
3077
  usage: "schema",
2862
3078
  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.",
2863
- input: z22.object({}),
3079
+ input: z24.object({}),
2864
3080
  fromArgv: () => ({}),
2865
3081
  run: () => Promise.resolve(kbJsonSchemas())
2866
3082
  });
2867
3083
 
2868
3084
  // src/commands/status.ts
2869
- import { z as z23 } from "zod";
3085
+ import { z as z25 } from "zod";
2870
3086
  var statusCommand = define({
2871
3087
  name: "status",
2872
3088
  tool: "kb_status",
2873
3089
  usage: "status <concept-id> <status>",
2874
3090
  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.",
2875
- input: z23.object({
3091
+ input: z25.object({
2876
3092
  bundlePath,
2877
3093
  conceptId,
2878
- status: z23.enum(KB_RECORD_STATUSES)
3094
+ status: z25.enum(KB_RECORD_STATUSES)
2879
3095
  }),
2880
3096
  fromArgv: (argv, path) => ({
2881
3097
  bundlePath: path,
@@ -2890,13 +3106,13 @@ var statusCommand = define({
2890
3106
  });
2891
3107
 
2892
3108
  // src/commands/supersede.ts
2893
- import { z as z24 } from "zod";
3109
+ import { z as z26 } from "zod";
2894
3110
  var supersedeCommand = define({
2895
3111
  name: "supersede",
2896
3112
  tool: "kb_supersede",
2897
3113
  usage: "supersede <concept-id> <replacement-id>",
2898
3114
  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.",
2899
- input: z24.object({ bundlePath, conceptId, replacementId: conceptId }),
3115
+ input: z26.object({ bundlePath, conceptId, replacementId: conceptId }),
2900
3116
  fromArgv: (argv, path) => ({
2901
3117
  bundlePath: path,
2902
3118
  conceptId: argv[1],
@@ -2910,16 +3126,16 @@ var supersedeCommand = define({
2910
3126
  });
2911
3127
 
2912
3128
  // src/commands/sync-instructions.ts
2913
- import { z as z25 } from "zod";
3129
+ import { z as z27 } from "zod";
2914
3130
  var syncInstructionsCommand = define({
2915
3131
  name: "sync-instructions",
2916
3132
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
2917
3133
  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.",
2918
- input: z25.object({
2919
- file: z25.string().min(1).describe("The instruction file to edit in place."),
2920
- budgetTokens: z25.number().int().positive().optional(),
2921
- fullUnderTokens: z25.number().int().positive().optional(),
2922
- profile: z25.string().optional()
3134
+ input: z27.object({
3135
+ file: z27.string().min(1).describe("The instruction file to edit in place."),
3136
+ budgetTokens: z27.number().int().positive().optional(),
3137
+ fullUnderTokens: z27.number().int().positive().optional(),
3138
+ profile: z27.string().optional()
2923
3139
  }),
2924
3140
  fromArgv: (argv) => {
2925
3141
  const budget = argvFlag(argv, "--budget");
@@ -2945,17 +3161,17 @@ var syncInstructionsCommand = define({
2945
3161
  });
2946
3162
 
2947
3163
  // src/commands/trace.ts
2948
- import { z as z26 } from "zod";
3164
+ import { z as z28 } from "zod";
2949
3165
  var traceCommand = define({
2950
3166
  name: "trace",
2951
3167
  tool: "kb_trace",
2952
3168
  usage: "trace <concept-id> [edges...]",
2953
3169
  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.',
2954
- input: z26.object({
3170
+ input: z28.object({
2955
3171
  bundlePath,
2956
3172
  conceptId,
2957
- edges: z26.array(z26.enum(TRACE_EDGES)).optional(),
2958
- depth: z26.number().int().positive().optional()
3173
+ edges: z28.array(z28.enum(TRACE_EDGES)).optional(),
3174
+ depth: z28.number().int().positive().optional()
2959
3175
  }),
2960
3176
  fromArgv: (argv, path) => ({
2961
3177
  bundlePath: path,
@@ -2977,53 +3193,56 @@ var traceCommand = define({
2977
3193
  });
2978
3194
 
2979
3195
  // src/commands/types.ts
2980
- import { z as z27 } from "zod";
3196
+ import { z as z29 } from "zod";
2981
3197
  var typesCommand = define({
2982
3198
  name: "types",
2983
3199
  tool: "kb_types",
2984
3200
  usage: "types",
2985
3201
  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.",
2986
- input: z27.object({}),
3202
+ input: z29.object({}),
2987
3203
  fromArgv: () => ({}),
2988
3204
  run: () => Promise.resolve(RECORD_TYPES)
2989
3205
  });
2990
3206
 
2991
3207
  // src/commands/unpin.ts
2992
- import { z as z28 } from "zod";
3208
+ import { z as z30 } from "zod";
2993
3209
  var unpinCommand = define({
2994
3210
  name: "unpin",
2995
3211
  tool: "kb_unpin",
2996
3212
  usage: "unpin [bundle-path]",
2997
3213
  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.",
2998
- input: z28.object({ bundlePath }),
3214
+ input: z30.object({ bundlePath }),
2999
3215
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3000
3216
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3001
3217
  });
3002
3218
 
3003
3219
  // src/commands/validate.ts
3004
- import { z as z29 } from "zod";
3220
+ import { z as z31 } from "zod";
3005
3221
  var validateCommand = define({
3006
3222
  name: "validate",
3007
3223
  tool: "kb_validate",
3008
3224
  usage: "validate",
3009
- 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.",
3010
- input: z29.object({ bundlePath }),
3225
+ 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.",
3226
+ input: z31.object({ bundlePath }),
3011
3227
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3012
3228
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3013
- failsWhen: (result) => Array.isArray(result) && result.length > 0
3229
+ // Warnings never fail the exit code; every other severity does.
3230
+ failsWhen: (result) => Array.isArray(result) && result.some(
3231
+ (problem) => problem.severity !== "warning"
3232
+ )
3014
3233
  });
3015
3234
 
3016
3235
  // src/commands/verify.ts
3017
- import { z as z30 } from "zod";
3236
+ import { z as z32 } from "zod";
3018
3237
  var verifyCommand = define({
3019
3238
  name: "verify",
3020
3239
  tool: "kb_verify",
3021
3240
  usage: "verify <concept-id> --note <text>",
3022
3241
  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.",
3023
- input: z30.object({
3242
+ input: z32.object({
3024
3243
  bundlePath,
3025
3244
  conceptId,
3026
- note: z30.string().refine((s) => s.trim().length > 0, {
3245
+ note: z32.string().refine((s) => s.trim().length > 0, {
3027
3246
  message: "note must say what the check found"
3028
3247
  })
3029
3248
  }),
@@ -3043,7 +3262,7 @@ var verifyCommand = define({
3043
3262
  });
3044
3263
 
3045
3264
  // src/commands/write.ts
3046
- import { z as z31 } from "zod";
3265
+ import { z as z33 } from "zod";
3047
3266
  var writeCommand = define({
3048
3267
  name: "write",
3049
3268
  tool: "kb_write",
@@ -3057,9 +3276,9 @@ var writeCommand = define({
3057
3276
  "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
3058
3277
  "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
3059
3278
  ].join("\n"),
3060
- input: z31.object({
3279
+ input: z33.object({
3061
3280
  bundlePath,
3062
- type: z31.enum(KB_RECORD_TYPES),
3281
+ type: z33.enum(KB_RECORD_TYPES),
3063
3282
  input: composeInputSchema
3064
3283
  }),
3065
3284
  fromArgv: async (argv, path, stdin) => ({
@@ -3083,7 +3302,7 @@ var writeCommand = define({
3083
3302
  });
3084
3303
 
3085
3304
  // src/commands/write-decision.ts
3086
- import { z as z32 } from "zod";
3305
+ import { z as z34 } from "zod";
3087
3306
  var writeDecisionCommand = define({
3088
3307
  name: "write-decision",
3089
3308
  tool: "kb_write_decision",
@@ -3096,7 +3315,7 @@ var writeDecisionCommand = define({
3096
3315
  "- `alternative` is what you turned down and why, not a list of everything considered.",
3097
3316
  "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
3098
3317
  ].join("\n"),
3099
- input: z32.object({ bundlePath, input: decisionInputSchema }),
3318
+ input: z34.object({ bundlePath, input: decisionInputSchema }),
3100
3319
  fromArgv: async (_argv, path, stdin) => ({
3101
3320
  bundlePath: path,
3102
3321
  input: JSON.parse(await stdin())
@@ -3131,6 +3350,8 @@ var KB_COMMANDS = [
3131
3350
  packCommand,
3132
3351
  queryCommand,
3133
3352
  traceCommand,
3353
+ impactCommand,
3354
+ backlinksCommand,
3134
3355
  listCommand,
3135
3356
  readIndexCommand,
3136
3357
  logCommand,
@@ -3276,6 +3497,141 @@ import {
3276
3497
  } from "fs/promises";
3277
3498
  import { join as join4, resolve as resolve5, sep as sep3 } from "path";
3278
3499
 
3500
+ // src/kb-links/inbound.ts
3501
+ function inboundIndex(bundle) {
3502
+ const byTarget = /* @__PURE__ */ new Map();
3503
+ for (const record of bundle) {
3504
+ for (const link2 of record.frontmatter.strauss_links ?? []) {
3505
+ if (link2.target === record.conceptId) continue;
3506
+ const edges = byTarget.get(link2.target) ?? [];
3507
+ if (edges.some(
3508
+ (edge) => edge.from === record.conceptId && edge.rel === link2.rel
3509
+ )) {
3510
+ continue;
3511
+ }
3512
+ edges.push({ from: record.conceptId, rel: link2.rel });
3513
+ byTarget.set(link2.target, edges);
3514
+ }
3515
+ }
3516
+ return byTarget;
3517
+ }
3518
+
3519
+ // src/kb-links/backlinks.ts
3520
+ function backlinks(targetId, bundle) {
3521
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
3522
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
3523
+ const standingOf = new Map(
3524
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
3525
+ );
3526
+ const rows = [];
3527
+ for (const edge of inboundIndex(bundle).get(targetId) ?? []) {
3528
+ const record = byId.get(edge.from);
3529
+ if (!record) continue;
3530
+ const hit = standingOf.get(edge.from);
3531
+ rows.push({
3532
+ ...edge,
3533
+ title: record.frontmatter.title ?? null,
3534
+ standing: hit?.standing ?? "unsettled",
3535
+ warnings: hit?.warnings ?? []
3536
+ });
3537
+ }
3538
+ return {
3539
+ target: targetId,
3540
+ backlinks: rows.sort(
3541
+ (left, right) => left.from.localeCompare(right.from) || left.rel.localeCompare(right.rel)
3542
+ )
3543
+ };
3544
+ }
3545
+
3546
+ // src/kb-links/impact.ts
3547
+ function impact(targetId, bundle, options = {}) {
3548
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
3549
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
3550
+ const rels = resolveRels(options.rels);
3551
+ const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
3552
+ const inbound = inboundIndex(bundle);
3553
+ const standingOf = new Map(
3554
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
3555
+ );
3556
+ const reached = /* @__PURE__ */ new Map();
3557
+ const stopped = [];
3558
+ let frontier = [targetId];
3559
+ let depth = 0;
3560
+ while (frontier.length && depth < maxDepth) {
3561
+ depth += 1;
3562
+ const next = [];
3563
+ const consider = (dependantId, edge) => {
3564
+ if (dependantId === targetId) return;
3565
+ const existing = reached.get(dependantId);
3566
+ if (existing) {
3567
+ if (!hasEdge(existing.via, edge)) existing.via.push(edge);
3568
+ return;
3569
+ }
3570
+ const record = byId.get(dependantId);
3571
+ if (!record) return;
3572
+ const hit = standingOf.get(dependantId);
3573
+ const entry = {
3574
+ conceptId: dependantId,
3575
+ title: record.frontmatter.title ?? null,
3576
+ standing: hit?.standing ?? "unsettled",
3577
+ warnings: hit?.warnings ?? [],
3578
+ depth,
3579
+ via: [edge]
3580
+ };
3581
+ reached.set(dependantId, entry);
3582
+ if (entry.standing === "superseded" || entry.standing === "rejected") {
3583
+ stopped.push(dependantId);
3584
+ return;
3585
+ }
3586
+ next.push(dependantId);
3587
+ };
3588
+ for (const id of frontier) {
3589
+ for (const edge of inbound.get(id) ?? []) {
3590
+ if (!rels.has(edge.rel)) continue;
3591
+ if (dependantEnd(edge.rel) !== "source") continue;
3592
+ consider(edge.from, { source: edge.from, target: id, rel: edge.rel });
3593
+ }
3594
+ for (const link2 of byId.get(id)?.frontmatter.strauss_links ?? []) {
3595
+ if (!rels.has(link2.rel)) continue;
3596
+ if (dependantEnd(link2.rel) !== "target") continue;
3597
+ if (link2.target === id) continue;
3598
+ consider(link2.target, {
3599
+ source: id,
3600
+ target: link2.target,
3601
+ rel: link2.rel
3602
+ });
3603
+ }
3604
+ }
3605
+ frontier = next;
3606
+ }
3607
+ return {
3608
+ root: targetId,
3609
+ impacted: [...reached.values()].sort(
3610
+ (left, right) => left.depth - right.depth || left.conceptId.localeCompare(right.conceptId)
3611
+ ),
3612
+ stopped: stopped.sort(),
3613
+ truncated: frontier.length > 0,
3614
+ unexpanded: [...frontier].sort()
3615
+ };
3616
+ }
3617
+ function resolveRels(rels) {
3618
+ if (!rels?.length) return new Set(KB_CAUSAL_LINK_RELS);
3619
+ for (const rel of rels) {
3620
+ if (!isKbLinkRel(rel) || LINK_RELS[rel].dependant === null) {
3621
+ throw new KbUnknownLinkRelError(rel, KB_CAUSAL_LINK_RELS);
3622
+ }
3623
+ }
3624
+ return new Set(rels);
3625
+ }
3626
+ function dependantEnd(rel) {
3627
+ return isKbLinkRel(rel) ? LINK_RELS[rel].dependant : null;
3628
+ }
3629
+ function hasEdge(edges, edge) {
3630
+ return edges.some(
3631
+ (existing) => existing.source === edge.source && existing.target === edge.target && existing.rel === edge.rel
3632
+ );
3633
+ }
3634
+
3279
3635
  // src/kb-gitattributes.ts
3280
3636
  var GITATTRIBUTES_FILE = ".gitattributes";
3281
3637
  var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
@@ -3679,6 +4035,14 @@ ${answer}
3679
4035
  async pack(bundlePath2, rootId, options = {}) {
3680
4036
  return pack(await this.list(bundlePath2), rootId, options);
3681
4037
  }
4038
+ /** What breaks if this record changes. See `kb-links/impact.ts`. */
4039
+ async impact(bundlePath2, targetId, options = {}) {
4040
+ return impact(targetId, await this.list(bundlePath2), options);
4041
+ }
4042
+ /** Who points at this record, one hop. See `kb-links/backlinks.ts`. */
4043
+ async backlinks(bundlePath2, targetId) {
4044
+ return backlinks(targetId, await this.list(bundlePath2));
4045
+ }
3682
4046
  /**
3683
4047
  * The stored index, rebuilt if it disagrees with the records.
3684
4048
  *
@@ -4083,13 +4447,14 @@ function typeRank(record) {
4083
4447
  }
4084
4448
 
4085
4449
  // src/version.ts
4086
- var VERSION = true ? "0.1.12" : "0.0.0-dev";
4450
+ var VERSION = true ? "0.1.13" : "0.0.0-dev";
4087
4451
 
4088
4452
  export {
4089
4453
  kbSourceSchema,
4090
4454
  kbActorStampSchema,
4091
4455
  kbVerifiedEventSchema,
4092
4456
  kbAnchorSchema,
4457
+ kbLinkSchema,
4093
4458
  KB_RECORD_TYPES,
4094
4459
  KB_SLUG_PATTERN,
4095
4460
  KB_CONCEPT_ID_PATTERN,
@@ -4100,6 +4465,11 @@ export {
4100
4465
  kbRecordFrontmatterSchema,
4101
4466
  RECORD_TYPES,
4102
4467
  isKbRecordType,
4468
+ KB_LINK_RELS,
4469
+ LINK_RELS,
4470
+ KB_CAUSAL_LINK_RELS,
4471
+ isKbLinkRel,
4472
+ composeLinkSchema,
4103
4473
  composeInputSchema,
4104
4474
  composeRecord,
4105
4475
  DECISION_TYPE,
@@ -4122,6 +4492,7 @@ export {
4122
4492
  KbWriteConflictError,
4123
4493
  KbSelfVerificationError,
4124
4494
  KbPackBudgetExceededError,
4495
+ KbUnknownLinkRelError,
4125
4496
  KbMissingFlagValueError,
4126
4497
  KbInvalidConceptIdError,
4127
4498
  contextProfileBudgets,
@@ -4153,6 +4524,7 @@ export {
4153
4524
  CONTEXT_END,
4154
4525
  syncInstructions,
4155
4526
  KB_EDGE_KINDS,
4527
+ DEFAULT_TYPED_LINK_RELS,
4156
4528
  neighbours,
4157
4529
  edgeNeighbours,
4158
4530
  validateBundle,
@@ -4180,9 +4552,12 @@ export {
4180
4552
  DEFAULT_PACK_HOPS,
4181
4553
  DEFAULT_PACK_MAX_NODES,
4182
4554
  pack,
4555
+ inboundIndex,
4556
+ backlinks,
4557
+ impact,
4183
4558
  KB_DIR,
4184
4559
  DEFAULT_LOAD_BUDGET,
4185
4560
  KbStore,
4186
4561
  VERSION
4187
4562
  };
4188
- //# sourceMappingURL=chunk-33ZCBEUV.js.map
4563
+ //# sourceMappingURL=chunk-XALWG3EZ.js.map