@saasontools/strauss-kb 0.1.17 → 0.1.19

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/mcp-main.cjs CHANGED
@@ -79,6 +79,14 @@ var kbAnchorSchema = import_zod.z.object({
79
79
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
80
80
  message: "hash must be sha256:<64 hex chars>"
81
81
  }).optional(),
82
+ /**
83
+ * What `hash` was taken over: the span's raw text, or the normalised token
84
+ * stream a parser sees (`ast`). Absent means `raw`, which is what every
85
+ * anchor stamped before this field carries, so old hashes keep comparing
86
+ * the way they were written. An `ast` hash is blind to whitespace and
87
+ * comments, so reformatting the anchored code is not drift.
88
+ */
89
+ hash_kind: import_zod.z.enum(["raw", "ast"]).optional(),
82
90
  /** ISO 8601 timestamp of the last successful resolution. */
83
91
  resolved_at: import_zod.z.string().min(1).optional(),
84
92
  /** Line count of the text the hash was taken over. */
@@ -469,9 +477,9 @@ async function mapLimit(items, limit, fn) {
469
477
  { length: Math.min(limit, items.length) },
470
478
  async () => {
471
479
  while (!failed && next < items.length) {
472
- const at = next++;
480
+ const at2 = next++;
473
481
  try {
474
- out[at] = await fn(items[at], at);
482
+ out[at2] = await fn(items[at2], at2);
475
483
  } catch (error) {
476
484
  failed = true;
477
485
  throw error;
@@ -587,8 +595,8 @@ function safeSegment(value) {
587
595
  function revRef(rev) {
588
596
  const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
589
597
  let hash = 5381;
590
- for (let at = 0; at < rev.length; at++) {
591
- hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
598
+ for (let at2 = 0; at2 < rev.length; at2++) {
599
+ hash = (hash * 33 ^ rev.charCodeAt(at2)) >>> 0;
592
600
  }
593
601
  return `refs/strauss/${safe}-${hash.toString(16)}`;
594
602
  }
@@ -697,8 +705,8 @@ function repoUrlIsSafe(repo) {
697
705
  if (!scheme?.[1]) return false;
698
706
  if (!allowed.includes(scheme[1].toLowerCase())) return false;
699
707
  const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
700
- const at = authority.lastIndexOf("@");
701
- return at < 0 || !authority.slice(0, at).includes(":");
708
+ const at2 = authority.lastIndexOf("@");
709
+ return at2 < 0 || !authority.slice(0, at2).includes(":");
702
710
  }
703
711
  function protocolArgs() {
704
712
  const allowed = allowedProtocols();
@@ -789,7 +797,7 @@ async function readOneRepo(repo, url, declared, context) {
789
797
  return new Map([
790
798
  ...rejected2,
791
799
  ...wants.map(
792
- (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
800
+ (want, at2) => [wantKey(repo, want.ref, want.file), reads[at2]]
793
801
  )
794
802
  ]);
795
803
  }
@@ -1000,7 +1008,7 @@ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY
1000
1008
  return { ok: false, reason: "file-unreadable" };
1001
1009
  }
1002
1010
  });
1003
- return new Map(wanted.map((file, at) => [file, results[at]]));
1011
+ return new Map(wanted.map((file, at2) => [file, results[at2]]));
1004
1012
  }
1005
1013
 
1006
1014
  // src/anchor-resolver/resolver.ts
@@ -1190,8 +1198,8 @@ async function ensureGrammar(language, options = {}) {
1190
1198
  return miss(language, `grammar tree-sitter-${language}`, grammar);
1191
1199
  const parts = [];
1192
1200
  const total = pack2.tags.length;
1193
- for (const [at, part] of pack2.tags.entries()) {
1194
- const name = `${language} tags${total > 1 ? ` part ${at + 1}/${total}` : ""}`;
1201
+ for (const [at2, part] of pack2.tags.entries()) {
1202
+ const name = `${language} tags${total > 1 ? ` part ${at2 + 1}/${total}` : ""}`;
1195
1203
  const path = grammarCachePath(root, language, part.sha256, "scm");
1196
1204
  const held = await ensurePart(path, name, part, options);
1197
1205
  if (held !== true) return miss(language, name, held);
@@ -1336,8 +1344,8 @@ function typeNameIn(receiver) {
1336
1344
  while (stack.length) {
1337
1345
  const node = stack.pop();
1338
1346
  if (node.type === "type_identifier") return node.text;
1339
- for (let at = 0; at < node.childCount; at++) {
1340
- const child = node.child(at);
1347
+ for (let at2 = 0; at2 < node.childCount; at2++) {
1348
+ const child = node.child(at2);
1341
1349
  if (child) stack.push(child);
1342
1350
  }
1343
1351
  }
@@ -1346,7 +1354,7 @@ function typeNameIn(receiver) {
1346
1354
  function endsWith(chain, wanted) {
1347
1355
  if (wanted.length > chain.length) return false;
1348
1356
  const offset = chain.length - wanted.length;
1349
- return wanted.every((segment, at) => chain[offset + at] === segment);
1357
+ return wanted.every((segment, at2) => chain[offset + at2] === segment);
1350
1358
  }
1351
1359
  function width(node) {
1352
1360
  return node.endIndex - node.startIndex;
@@ -1372,6 +1380,26 @@ function spanOf(definition, source) {
1372
1380
  };
1373
1381
  }
1374
1382
 
1383
+ // src/tree-sitter-resolver/tokens.ts
1384
+ function tokens(root) {
1385
+ const out = [];
1386
+ const stack = [root];
1387
+ while (stack.length) {
1388
+ const node = stack.pop();
1389
+ if (node.type.includes("comment")) continue;
1390
+ if (node.childCount === 0) {
1391
+ const text = node.text.trim();
1392
+ if (text) out.push(text);
1393
+ continue;
1394
+ }
1395
+ for (let at2 = node.childCount - 1; at2 >= 0; at2--) {
1396
+ const child = node.child(at2);
1397
+ if (child) stack.push(child);
1398
+ }
1399
+ }
1400
+ return out;
1401
+ }
1402
+
1375
1403
  // src/tree-sitter-resolver/resolver.ts
1376
1404
  var TREE_CACHE_LIMIT = 32;
1377
1405
  var TreeSitterResolver = class {
@@ -1417,7 +1445,7 @@ var TreeSitterResolver = class {
1417
1445
  (language) => this.load(language)
1418
1446
  );
1419
1447
  languages.forEach(
1420
- (language, at) => this.loaded.set(language, loaded[at] ?? null)
1448
+ (language, at2) => this.loaded.set(language, loaded[at2] ?? null)
1421
1449
  );
1422
1450
  }
1423
1451
  /**
@@ -1505,6 +1533,60 @@ var TreeSitterResolver = class {
1505
1533
  this.trees.set(key, parsed);
1506
1534
  return parsed;
1507
1535
  }
1536
+ /**
1537
+ * Every definition this file declares, as dotted symbol and span.
1538
+ *
1539
+ * The inverse of `attempt`: that asks "where is this name", this asks "what
1540
+ * names are here". `moved` needs the second — the stored hash has to be
1541
+ * looked for at every definition in the repository, and there is no name to
1542
+ * ask about, since the whole question is which name now carries that code.
1543
+ */
1544
+ spans(source, file) {
1545
+ const language = languageForFile(file);
1546
+ if (!language) return [];
1547
+ const loaded = this.loaded.get(language);
1548
+ if (!loaded) return [];
1549
+ const parsed = this.parse(language, loaded, source);
1550
+ if (!parsed) return [];
1551
+ return parsed.definitions.filter((definition) => definition.target).map((definition) => ({
1552
+ symbol: chainOf(definition, parsed.byNodeId).join("."),
1553
+ span: spanOf(definition, source)
1554
+ }));
1555
+ }
1556
+ /**
1557
+ * The token stream of a span: every leaf the parser sees, comments dropped,
1558
+ * joined by single spaces.
1559
+ *
1560
+ * This is what makes a reformat not be drift. Hashing it rather than the raw
1561
+ * text means indentation, line breaks, trailing commas the formatter moved,
1562
+ * and every comment above or inside the definition are outside the hash —
1563
+ * and a renamed identifier or a changed literal is still inside it, because
1564
+ * those are leaves.
1565
+ *
1566
+ * `null` when the file has no grammar, the grammar would not load, or the
1567
+ * text will not parse: no normalisation is better than a guessed one.
1568
+ */
1569
+ normalize(text, file) {
1570
+ const language = file ? languageForFile(file) : void 0;
1571
+ if (!language) return null;
1572
+ const loaded = this.loaded.get(language);
1573
+ if (!loaded) return null;
1574
+ const parser = this.parser;
1575
+ if (!parser) return null;
1576
+ let tree;
1577
+ try {
1578
+ parser.setLanguage(loaded.language);
1579
+ tree = parser.parse(text);
1580
+ } catch {
1581
+ return null;
1582
+ }
1583
+ if (!tree) return null;
1584
+ try {
1585
+ return tokens(tree.rootNode).join(" ");
1586
+ } finally {
1587
+ tree.delete();
1588
+ }
1589
+ }
1508
1590
  /** Drops cached trees. Grammars stay loaded — they are immutable. */
1509
1591
  reset() {
1510
1592
  for (const parsed of this.trees.values()) parsed.tree.delete();
@@ -1658,7 +1740,7 @@ var regexResolver = {
1658
1740
  );
1659
1741
  const nearest = Math.min(...distances);
1660
1742
  if (Number.isFinite(nearest)) {
1661
- candidates = candidates.filter((_, at) => distances[at] === nearest);
1743
+ candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1662
1744
  }
1663
1745
  }
1664
1746
  if (candidates.length !== 1) return null;
@@ -1673,8 +1755,8 @@ function escapeRegExp(value) {
1673
1755
  }
1674
1756
  function distanceToParent(lines, index2, parent) {
1675
1757
  const floor = Math.max(0, index2 - PARENT_SCOPE_LINES);
1676
- for (let at = index2; at >= floor; at--) {
1677
- if (parent.test(lines[at] ?? "")) return index2 - at;
1758
+ for (let at2 = index2; at2 >= floor; at2--) {
1759
+ if (parent.test(lines[at2] ?? "")) return index2 - at2;
1678
1760
  }
1679
1761
  return Number.POSITIVE_INFINITY;
1680
1762
  }
@@ -1702,10 +1784,12 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1702
1784
  if (attempt.reason === "symbol-not-found") continue;
1703
1785
  return { ok: false, reason: attempt.reason };
1704
1786
  }
1787
+ const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
1705
1788
  return {
1706
1789
  ok: true,
1707
1790
  span: attempt.span,
1708
- ...isResolverName(resolver.name) ? { resolver: resolver.name } : {}
1791
+ ...isResolverName(resolver.name) ? { resolver: resolver.name } : {},
1792
+ ...tokens2 ? { normalized: tokens2 } : {}
1709
1793
  };
1710
1794
  }
1711
1795
  return { ok: false, reason: "symbol-not-found" };
@@ -1733,6 +1817,11 @@ function resolverChanged(source, anchor, produced) {
1733
1817
  );
1734
1818
  return before !== null && hashAnchorText(before.text) === anchor.hash;
1735
1819
  }
1820
+ function anchorHashOf(anchor, outcome) {
1821
+ const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
1822
+ const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
1823
+ return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
1824
+ }
1736
1825
 
1737
1826
  // src/anchor-resolver/drift.ts
1738
1827
  async function detectAnchorDrift(records, options = {}) {
@@ -1812,16 +1901,29 @@ function unresolved(anchor, reason, repo) {
1812
1901
  state: "unresolved",
1813
1902
  diffSize: null,
1814
1903
  ...reason ? { reason } : {},
1815
- ...repo ? { repo } : {}
1904
+ ...repo ? { repo } : {},
1905
+ ...classOf(reason)
1816
1906
  };
1817
1907
  }
1908
+ function provisionalDriftClass(entry) {
1909
+ if (entry.state === "unresolved") {
1910
+ return entry.reason === "file-missing" || entry.reason === "symbol-not-found" ? "gone" : void 0;
1911
+ }
1912
+ return entry.state === "drifted" ? "changed" : void 0;
1913
+ }
1914
+ function classOf(reason) {
1915
+ const settled = provisionalDriftClass({ state: "unresolved", reason });
1916
+ return settled ? { class: settled } : {};
1917
+ }
1818
1918
  function hashIn(source, anchor, resolvers) {
1819
1919
  const outcome = resolveAnchorSpan(source, anchor, resolvers);
1820
1920
  if (!outcome.ok) return { ok: false, reason: outcome.reason };
1921
+ const { hash, kind } = anchorHashOf(anchor, outcome);
1821
1922
  return {
1822
1923
  ok: true,
1823
1924
  current: {
1824
- hash: hashAnchorText(outcome.span.text),
1925
+ hash,
1926
+ kind,
1825
1927
  lines: outcome.span.endLine - outcome.span.startLine + 1,
1826
1928
  ...outcome.resolver ? { resolver: outcome.resolver } : {}
1827
1929
  }
@@ -1834,11 +1936,14 @@ function resolverExtras(source, anchor, current) {
1834
1936
  };
1835
1937
  }
1836
1938
  function compared(anchor, current, extra = {}) {
1939
+ const matched = current.hash === anchor.hash;
1837
1940
  return {
1838
1941
  ...base(anchor),
1839
- state: current.hash === anchor.hash ? "match" : "drifted",
1942
+ state: matched ? "match" : "drifted",
1840
1943
  currentHash: current.hash,
1944
+ hashKind: current.kind,
1841
1945
  diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1946
+ ...matched ? {} : { class: "changed" },
1842
1947
  ...extra
1843
1948
  };
1844
1949
  }
@@ -2073,14 +2178,17 @@ function asBudgets(value) {
2073
2178
  if (value === null || typeof value !== "object") return {};
2074
2179
  const table2 = value;
2075
2180
  const pick = (key, min) => {
2076
- const raw = table2[key];
2077
- return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
2181
+ const raw2 = table2[key];
2182
+ return typeof raw2 === "number" && Number.isInteger(raw2) && raw2 >= min ? raw2 : void 0;
2078
2183
  };
2079
2184
  const budgetTokens = pick("budgetTokens", 1);
2080
2185
  const fullUnderTokens = pick("fullUnderTokens", 0);
2186
+ const raw = table2["excludeTags"];
2187
+ const excludeTags = Array.isArray(raw) ? raw.filter((tag) => typeof tag === "string" && tag !== "") : void 0;
2081
2188
  return {
2082
2189
  ...budgetTokens ? { budgetTokens } : {},
2083
- ...fullUnderTokens !== void 0 ? { fullUnderTokens } : {}
2190
+ ...fullUnderTokens !== void 0 ? { fullUnderTokens } : {},
2191
+ ...excludeTags ? { excludeTags } : {}
2084
2192
  };
2085
2193
  }
2086
2194
  function contextProfileBudgets(manifest, profile) {
@@ -2280,7 +2388,7 @@ async function listPins(store, workspaceDir) {
2280
2388
  }
2281
2389
 
2282
2390
  // src/kb-pins/pin.ts
2283
- async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2391
+ async function pinBase(store, workspaceDir, bundlePath2, at2, options = {}) {
2284
2392
  const layer = options.layer ?? "project";
2285
2393
  const root = layerRoot(workspaceDir, layer);
2286
2394
  const manifest = await readPinsLayer(workspaceDir, layer);
@@ -2308,7 +2416,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2308
2416
  return {
2309
2417
  path: existing.path,
2310
2418
  layer,
2311
- pinnedAt: existing.pinnedAt ?? at,
2419
+ pinnedAt: existing.pinnedAt ?? at2,
2312
2420
  alreadyPinned: true,
2313
2421
  ...updated.mode ? { mode: updated.mode } : {},
2314
2422
  ...updated.profiles ? { profiles: updated.profiles } : {},
@@ -2318,7 +2426,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2318
2426
  }
2319
2427
  const entry = {
2320
2428
  path: storablePath(root, bundlePath2),
2321
- pinnedAt: at,
2429
+ pinnedAt: at2,
2322
2430
  ...fields
2323
2431
  };
2324
2432
  await writePinsLayer(workspaceDir, layer, {
@@ -2328,7 +2436,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2328
2436
  return {
2329
2437
  path: entry.path,
2330
2438
  layer,
2331
- pinnedAt: at,
2439
+ pinnedAt: at2,
2332
2440
  alreadyPinned: false,
2333
2441
  ...fields,
2334
2442
  ...warning ? { warning } : {}
@@ -2367,6 +2475,9 @@ async function unpinBase(workspaceDir, bundlePath2) {
2367
2475
  var import_zod6 = require("zod");
2368
2476
  var bundlePath = import_zod6.z.string().min(1).describe("Absolute path to the knowledge base directory.");
2369
2477
  var conceptId = import_zod6.z.string().min(1).describe("e.g. decision.cursor-v2");
2478
+ var TAGS = import_zod6.z.array(import_zod6.z.string().min(1)).optional().describe(
2479
+ "Keep only records carrying every one of these frontmatter tags. Matched exactly."
2480
+ );
2370
2481
  var REPO_ROOT = import_zod6.z.string().min(1).optional().describe(
2371
2482
  "Where the anchored source lives, for the drift check. Defaults to the working directory."
2372
2483
  );
@@ -2380,14 +2491,49 @@ function argvFlag(argv, name) {
2380
2491
  if (!value2) throw new KbMissingFlagValueError(name);
2381
2492
  return value2;
2382
2493
  }
2383
- const at = argv.indexOf(name);
2384
- if (at === -1) return void 0;
2385
- const value = argv[at + 1];
2494
+ const at2 = argv.indexOf(name);
2495
+ if (at2 === -1) return void 0;
2496
+ const value = argv[at2 + 1];
2386
2497
  if (value === void 0 || value.startsWith("--")) {
2387
2498
  throw new KbMissingFlagValueError(name);
2388
2499
  }
2389
2500
  return value;
2390
2501
  }
2502
+ function argvFlags(argv, name) {
2503
+ const values = [];
2504
+ for (const [at2, arg] of argv.entries()) {
2505
+ if (arg.startsWith(`${name}=`)) {
2506
+ const value = arg.slice(name.length + 1);
2507
+ if (!value) throw new KbMissingFlagValueError(name);
2508
+ values.push(value);
2509
+ } else if (arg === name) {
2510
+ const value = argv[at2 + 1];
2511
+ if (value === void 0 || value.startsWith("--")) {
2512
+ throw new KbMissingFlagValueError(name);
2513
+ }
2514
+ values.push(value);
2515
+ }
2516
+ }
2517
+ return values;
2518
+ }
2519
+ function argvWithout(argv, ...names) {
2520
+ const kept = [];
2521
+ for (let at2 = 0; at2 < argv.length; at2 += 1) {
2522
+ const arg = argv[at2];
2523
+ if (names.some((name) => arg.startsWith(`${name}=`))) continue;
2524
+ if (names.includes(arg)) {
2525
+ at2 += 1;
2526
+ continue;
2527
+ }
2528
+ kept.push(arg);
2529
+ }
2530
+ return kept;
2531
+ }
2532
+ function argvPositional(argv, ...names) {
2533
+ return argvWithout(argv.slice(1), ...names).find(
2534
+ (arg) => !arg.startsWith("--")
2535
+ );
2536
+ }
2391
2537
 
2392
2538
  // src/commands/anchor-resolve.ts
2393
2539
  function resolverSummary(results) {
@@ -2475,11 +2621,14 @@ var anchorResolveCommand = define({
2475
2621
  }
2476
2622
  const resolved = outcome.span;
2477
2623
  const producedBy = outcome.resolver;
2478
- const currentHash = hashAnchorText(resolved.text);
2624
+ const { hash: currentHash, kind } = anchorHashOf(anchor, outcome);
2479
2625
  const currentLines = resolved.endLine - resolved.startLine + 1;
2626
+ const stampedKind = outcome.normalized ? "ast" : "raw";
2627
+ const stampedHash = outcome.normalized ? anchorHashOf({ ...anchor, hash: void 0 }, outcome).hash : currentHash;
2480
2628
  const stamped = {
2481
2629
  ...anchor,
2482
- hash: currentHash,
2630
+ hash: stampedHash,
2631
+ hash_kind: stampedKind,
2483
2632
  lines: currentLines,
2484
2633
  resolved_at: now(),
2485
2634
  ...producedBy ? { resolver: producedBy } : {}
@@ -2489,7 +2638,8 @@ var anchorResolveCommand = define({
2489
2638
  results.push({
2490
2639
  ...base2,
2491
2640
  state: "stamped",
2492
- currentHash,
2641
+ currentHash: stampedHash,
2642
+ hashKind: stampedKind,
2493
2643
  ...producedBy ? { resolver: producedBy } : {}
2494
2644
  });
2495
2645
  updated.push(stamped);
@@ -2501,6 +2651,7 @@ var anchorResolveCommand = define({
2501
2651
  ...base2,
2502
2652
  state: "drifted",
2503
2653
  currentHash,
2654
+ hashKind: kind,
2504
2655
  diffSize: lineDelta(anchor, currentLines),
2505
2656
  ...producedBy ? { resolver: producedBy } : {},
2506
2657
  // A regex-stamped anchor re-read by tree-sitter drifts because the
@@ -2529,6 +2680,7 @@ var anchorResolveCommand = define({
2529
2680
  ...base2,
2530
2681
  state: "match",
2531
2682
  currentHash,
2683
+ hashKind: kind,
2532
2684
  ...producedBy ? { resolver: producedBy } : {},
2533
2685
  ...pinned ? { remoteState: "matches-ref" } : {}
2534
2686
  });
@@ -2761,7 +2913,8 @@ function warningAnchor(entry) {
2761
2913
  diffSize,
2762
2914
  ...reason !== void 0 ? { reason } : {},
2763
2915
  ...repo !== void 0 ? { repo } : {},
2764
- ...remoteState !== void 0 ? { remoteState } : {}
2916
+ ...remoteState !== void 0 ? { remoteState } : {},
2917
+ ...entry.class !== void 0 ? { class: entry.class } : {}
2765
2918
  };
2766
2919
  }
2767
2920
  function resolveHeads(from, byId) {
@@ -2813,6 +2966,13 @@ function successors(record, byId) {
2813
2966
  return { records, missing: missing2 };
2814
2967
  }
2815
2968
 
2969
+ // src/kb-tags.ts
2970
+ function matchesTags(record, filter) {
2971
+ if (!filter.tags?.length && !filter.excludeTags?.length) return true;
2972
+ const carried = new Set(record.frontmatter.tags ?? []);
2973
+ return (filter.tags ?? []).every((tag) => carried.has(tag)) && !(filter.excludeTags ?? []).some((tag) => carried.has(tag));
2974
+ }
2975
+
2816
2976
  // src/catalog.ts
2817
2977
  var EMPTY_STANDINGS = {
2818
2978
  current: 0,
@@ -2823,7 +2983,7 @@ var EMPTY_STANDINGS = {
2823
2983
  };
2824
2984
  function catalog(bundle, options = {}) {
2825
2985
  const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
2826
- const entries = adjudicate(wanted, bundle, options.now ?? /* @__PURE__ */ new Date()).map((hit) => ({
2986
+ const entries = adjudicate(wanted, bundle, options.now ?? /* @__PURE__ */ new Date()).filter((hit) => matchesTags(hit.record, options)).map((hit) => ({
2827
2987
  conceptId: hit.record.conceptId,
2828
2988
  type: hit.record.frontmatter.type,
2829
2989
  title: hit.record.frontmatter.title ?? null,
@@ -2863,25 +3023,39 @@ function renderCatalogLine(entry) {
2863
3023
  var catalogCommand = define({
2864
3024
  name: "catalog",
2865
3025
  tool: "kb_catalog",
2866
- usage: "catalog [type]",
3026
+ usage: "catalog [type] [--tag T]...",
2867
3027
  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.",
2868
3028
  input: import_zod10.z.object({
2869
3029
  bundlePath,
2870
- type: import_zod10.z.enum(KB_RECORD_TYPES).optional()
2871
- }),
2872
- fromArgv: (argv, path) => ({
2873
- bundlePath: path,
2874
- ...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {}
3030
+ type: import_zod10.z.enum(KB_RECORD_TYPES).optional(),
3031
+ tags: TAGS
2875
3032
  }),
2876
- run: async ({ store }, { bundlePath: path, type }) => render(
2877
- await store.catalog(path, { ...type ? { type } : {} }),
3033
+ fromArgv: (argv, path) => {
3034
+ const tags = argvFlags(argv, "--tag");
3035
+ const type = argvPositional(argv, "--tag");
3036
+ return {
3037
+ bundlePath: path,
3038
+ ...type ? { type } : {},
3039
+ ...tags.length ? { tags } : {}
3040
+ };
3041
+ },
3042
+ run: async ({ store }, { bundlePath: path, type, tags }) => render(
3043
+ await store.catalog(path, {
3044
+ ...type ? { type } : {},
3045
+ ...tags ? { tags } : {}
3046
+ }),
2878
3047
  path,
2879
- type
3048
+ type,
3049
+ tags
2880
3050
  )
2881
3051
  });
2882
- function render(result, bundle, type) {
3052
+ function render(result, bundle, type, tags) {
3053
+ const narrowed = [
3054
+ ...type ? [type] : [],
3055
+ ...tags?.length ? [`tags: ${tags.join(", ")}`] : []
3056
+ ].join(" \xB7 ");
2883
3057
  const lines = [
2884
- `# KB Catalog${type ? ` \u2014 ${type}` : ""}`,
3058
+ `# KB Catalog${narrowed ? ` \u2014 ${narrowed}` : ""}`,
2885
3059
  `bundle: ${bundle}`,
2886
3060
  `${count(result.recordCount, "record")}: ${standingCounts(result)}`
2887
3061
  ];
@@ -2893,7 +3067,7 @@ function render(result, bundle, type) {
2893
3067
  lines.push("");
2894
3068
  if (!result.entries.length) {
2895
3069
  lines.push(
2896
- type ? `(no records of type ${type})` : "(no records \u2014 this base is empty)"
3070
+ narrowed ? `(no records matching ${narrowed})` : "(no records \u2014 this base is empty)"
2897
3071
  );
2898
3072
  } else {
2899
3073
  for (const entry of result.entries) lines.push(renderCatalogLine(entry));
@@ -2980,7 +3154,7 @@ function preamble() {
2980
3154
  "tokens."
2981
3155
  ].join("\n");
2982
3156
  }
2983
- async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens) {
3157
+ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens, excludeTags) {
2984
3158
  const bundle = await store.list(absolutePath);
2985
3159
  if (bundle.length === 0) {
2986
3160
  return {
@@ -2994,7 +3168,8 @@ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, b
2994
3168
  let degradedFrom;
2995
3169
  if (fullCap > 0) {
2996
3170
  const full = await store.load(absolutePath, {
2997
- budgetTokens: fullCap
3171
+ budgetTokens: fullCap,
3172
+ excludeTags
2998
3173
  });
2999
3174
  if (!full.loaded && pinMode === "full") {
3000
3175
  degradedFrom = { approxTokens: full.approxTokens };
@@ -3024,7 +3199,9 @@ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, b
3024
3199
  };
3025
3200
  }
3026
3201
  }
3027
- const adjudicated = adjudicate(bundle, bundle);
3202
+ const adjudicated = adjudicate(bundle, bundle).filter(
3203
+ (hit) => matchesTags(hit.record, { excludeTags })
3204
+ );
3028
3205
  const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
3029
3206
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
3030
3207
  (hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
@@ -3045,6 +3222,7 @@ async function buildContext(store, workspaceDir, options = {}) {
3045
3222
  const fromManifest = mergedContextBudgets(merged, options.profile);
3046
3223
  budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
3047
3224
  fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
3225
+ const excludeTags = options.excludeTags ?? fromManifest.excludeTags ?? builtin.excludeTags ?? [];
3048
3226
  const pins = merged.pins.filter(
3049
3227
  (pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
3050
3228
  );
@@ -3065,7 +3243,8 @@ async function buildContext(store, workspaceDir, options = {}) {
3065
3243
  pin.absolutePath,
3066
3244
  fullUnderTokens,
3067
3245
  pin.mode,
3068
- budgetTokens
3246
+ budgetTokens,
3247
+ excludeTags
3069
3248
  ),
3070
3249
  frozen: pin.frozen === true
3071
3250
  }))
@@ -3186,7 +3365,7 @@ ${CONTEXT_END}` : null;
3186
3365
  var contextCommand = define({
3187
3366
  name: "context",
3188
3367
  tool: "kb_context",
3189
- usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
3368
+ usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
3190
3369
  description: "Index block of pinned bases (ids, titles, standing) for injection at context birth. Takes no bundlePath \u2014 reads the workspace pin manifests. Empty when nothing is pinned; refuses over budget rather than truncating. Budget precedence: flags, then the manifest `context[profile]` over `context.default`, then the built-in profile, then package defaults.",
3191
3370
  input: import_zod11.z.object({
3192
3371
  budgetTokens: import_zod11.z.number().int().positive().optional().describe(
@@ -3198,6 +3377,9 @@ var contextCommand = define({
3198
3377
  profile: import_zod11.z.string().optional().describe(
3199
3378
  "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."
3200
3379
  ),
3380
+ excludeTags: import_zod11.z.array(import_zod11.z.string().min(1)).optional().describe(
3381
+ "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
3382
+ ),
3201
3383
  format: import_zod11.z.enum(["markdown", "json"]).optional().describe(
3202
3384
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
3203
3385
  ),
@@ -3211,19 +3393,22 @@ var contextCommand = define({
3211
3393
  const profile = argvFlag(argv, "--profile");
3212
3394
  const format = argvFlag(argv, "--format");
3213
3395
  const event = argvFlag(argv, "--event");
3396
+ const excludeTags = argvFlags(argv, "--exclude-tag");
3214
3397
  return {
3215
3398
  ...budget ? { budgetTokens: Number(budget) } : {},
3216
3399
  ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
3217
3400
  ...profile ? { profile } : {},
3401
+ ...excludeTags.length ? { excludeTags } : {},
3218
3402
  ...format ? { format } : {},
3219
3403
  ...event ? { event } : {}
3220
3404
  };
3221
3405
  },
3222
- run: async ({ store }, { budgetTokens, fullUnderTokens, profile, format, event }) => {
3406
+ run: async ({ store }, { budgetTokens, fullUnderTokens, profile, excludeTags, format, event }) => {
3223
3407
  const result = await buildContext(store, process.cwd(), {
3224
3408
  ...budgetTokens ? { budgetTokens } : {},
3225
3409
  ...fullUnderTokens ? { fullUnderTokens } : {},
3226
3410
  ...profile ? { profile } : {},
3411
+ ...excludeTags ? { excludeTags } : {},
3227
3412
  // Degradations — a full pin that could not fit, a refused block — go
3228
3413
  // to stderr as well as into the block itself: stderr is diagnostics on
3229
3414
  // both surfaces (hooks discard it, MCP logs it), so an operator can
@@ -3237,7 +3422,406 @@ var contextCommand = define({
3237
3422
  });
3238
3423
 
3239
3424
  // src/commands/doctor.ts
3240
- var import_zod12 = require("zod");
3425
+ var import_zod13 = require("zod");
3426
+
3427
+ // src/drift/git.ts
3428
+ var import_node_child_process3 = require("child_process");
3429
+ var import_node_util3 = require("util");
3430
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
3431
+ var MAX_GIT_OUTPUT_BYTES = 1048576;
3432
+ var GIT_TIMEOUT_MS = 5e3;
3433
+ async function git2(cwd, args) {
3434
+ const env = { ...process.env };
3435
+ delete env["GIT_DIR"];
3436
+ delete env["GIT_WORK_TREE"];
3437
+ delete env["GIT_INDEX_FILE"];
3438
+ try {
3439
+ const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
3440
+ timeout: GIT_TIMEOUT_MS,
3441
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
3442
+ env
3443
+ });
3444
+ return { ok: true, stdout };
3445
+ } catch {
3446
+ return { ok: false };
3447
+ }
3448
+ }
3449
+ async function listRepoFiles(repoRoot) {
3450
+ const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
3451
+ if (!result.ok) return [];
3452
+ return result.stdout.split("\0").filter(Boolean);
3453
+ }
3454
+ async function readOldSource(repoRoot, anchor) {
3455
+ if (!filePathIsSafe(anchor.file))
3456
+ return { ok: false, reason: "unrecoverable" };
3457
+ if (anchor.ref && refShapeIsSafe(anchor.ref)) {
3458
+ const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
3459
+ if (shown2 !== null) {
3460
+ return {
3461
+ ok: true,
3462
+ source: shown2,
3463
+ origin: { kind: "ref", ref: anchor.ref }
3464
+ };
3465
+ }
3466
+ }
3467
+ const at2 = anchor.resolved_at;
3468
+ if (!at2 || Number.isNaN(Date.parse(at2))) {
3469
+ return { ok: false, reason: "unrecoverable" };
3470
+ }
3471
+ const found = await git2(repoRoot, [
3472
+ "log",
3473
+ "-1",
3474
+ "--format=%H",
3475
+ `--before=${at2}`,
3476
+ "--end-of-options",
3477
+ "HEAD",
3478
+ "--",
3479
+ anchor.file
3480
+ ]);
3481
+ const sha = found.ok ? found.stdout.trim() : "";
3482
+ if (!sha || !refShapeIsSafe(sha))
3483
+ return { ok: false, reason: "unrecoverable" };
3484
+ const shown = await showFile(repoRoot, sha, anchor.file);
3485
+ if (shown === null) return { ok: false, reason: "unrecoverable" };
3486
+ return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
3487
+ }
3488
+ async function showFile(repoRoot, ref, file) {
3489
+ const path = file.replace(/^\.\//, "");
3490
+ const result = await git2(repoRoot, [
3491
+ "show",
3492
+ "--end-of-options",
3493
+ `${ref}:${path}`
3494
+ ]);
3495
+ return result.ok ? result.stdout : null;
3496
+ }
3497
+
3498
+ // src/drift/moved.ts
3499
+ var import_promises7 = require("fs/promises");
3500
+ var MAX_MOVED_SEARCH_FILES = 2e3;
3501
+ var SEARCH_BATCH = 64;
3502
+ function movedSearch(repoRoot, options = {}) {
3503
+ const read = options.reader ?? anchorFileReader(repoRoot);
3504
+ const sizeOf = options.sizeOf ?? diskSize(repoRoot);
3505
+ const resolver = new TreeSitterResolver();
3506
+ let repoFiles;
3507
+ const prepared = /* @__PURE__ */ new Set();
3508
+ const filesForLanguage = async (language) => {
3509
+ repoFiles ??= listRepoFiles(repoRoot);
3510
+ return (await repoFiles).filter((file) => languageForFile(file) === language).slice(0, MAX_MOVED_SEARCH_FILES);
3511
+ };
3512
+ return {
3513
+ async find(anchor) {
3514
+ const stored = anchor.hash;
3515
+ if (!stored) return void 0;
3516
+ const language = languageForFile(anchor.file);
3517
+ if (!language) return sameFileWindow(anchor, read, stored);
3518
+ const candidates = await filesForLanguage(language);
3519
+ if (!prepared.has(language)) {
3520
+ await resolver.prepare(candidates.length ? candidates : [anchor.file]);
3521
+ prepared.add(language);
3522
+ }
3523
+ const floor = anchor.lines ?? 0;
3524
+ for (let at2 = 0; at2 < candidates.length; at2 += SEARCH_BATCH) {
3525
+ const batch = candidates.slice(at2, at2 + SEARCH_BATCH);
3526
+ const hits = await mapLimit(
3527
+ batch,
3528
+ DEFAULT_IO_CONCURRENCY,
3529
+ async (file) => {
3530
+ const size2 = await sizeOf(file);
3531
+ if (size2 !== null && size2 < floor) return void 0;
3532
+ return matchIn(resolver, read, anchor, stored, file);
3533
+ }
3534
+ );
3535
+ const found = hits.find((hit) => hit !== void 0);
3536
+ if (found) return found;
3537
+ }
3538
+ return void 0;
3539
+ }
3540
+ };
3541
+ }
3542
+ async function matchIn(resolver, read, anchor, stored, file) {
3543
+ const source = await read(file);
3544
+ if (!source.ok) return void 0;
3545
+ const normalized = source.source.replace(/\r\n/g, "\n");
3546
+ for (const found of resolver.spans(normalized, file)) {
3547
+ const text = anchor.hash_kind === "ast" ? resolver.normalize(found.span.text, file) : found.span.text;
3548
+ if (text === null || hashAnchorText(text) !== stored) continue;
3549
+ if (file === anchor.file && found.symbol === anchor.symbol) continue;
3550
+ return {
3551
+ file,
3552
+ symbol: found.symbol,
3553
+ startLine: found.span.startLine,
3554
+ endLine: found.span.endLine
3555
+ };
3556
+ }
3557
+ return void 0;
3558
+ }
3559
+ function diskSize(repoRoot) {
3560
+ return async (file) => {
3561
+ const path = anchorFilePath(repoRoot, file);
3562
+ if (path === null) return null;
3563
+ try {
3564
+ return (await (0, import_promises7.stat)(path)).size;
3565
+ } catch {
3566
+ return null;
3567
+ }
3568
+ };
3569
+ }
3570
+ async function sameFileWindow(anchor, read, stored) {
3571
+ const height = anchor.lines;
3572
+ if (!height || anchor.hash_kind === "ast") return void 0;
3573
+ const source = await read(anchor.file);
3574
+ if (!source.ok) return void 0;
3575
+ const lines = source.source.replace(/\r\n/g, "\n").split("\n");
3576
+ for (let at2 = 0; at2 + height <= lines.length; at2++) {
3577
+ if (hashAnchorText(lines.slice(at2, at2 + height).join("\n")) !== stored) {
3578
+ continue;
3579
+ }
3580
+ return {
3581
+ file: anchor.file,
3582
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
3583
+ startLine: at2 + 1,
3584
+ endLine: at2 + height
3585
+ };
3586
+ }
3587
+ return void 0;
3588
+ }
3589
+
3590
+ // src/drift/classify.ts
3591
+ async function classifyDrift(repoRoot, record, entries, options = {}) {
3592
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
3593
+ (anchor) => anchor.hash
3594
+ );
3595
+ const reader = options.reader ?? anchorFileReader(repoRoot);
3596
+ const treeSitter = new TreeSitterResolver();
3597
+ const resolvers = [treeSitter, regexResolver];
3598
+ const search = options.search ?? movedSearch(repoRoot, { ...options.reader ? { reader } : {} });
3599
+ const wanted = [];
3600
+ entries.forEach((entry, at2) => {
3601
+ const anchor = anchors[at2];
3602
+ if (!anchor) return;
3603
+ if (entry.state === "match" || isUncheckedReason(entry.reason)) return;
3604
+ wanted.push({ anchor, entry });
3605
+ });
3606
+ if (!wanted.length) return [];
3607
+ await prepareResolvers(
3608
+ resolvers,
3609
+ wanted.map(({ anchor }) => anchor.file)
3610
+ );
3611
+ const out = [];
3612
+ for (const { anchor, entry } of wanted) {
3613
+ const movedTo = await search.find(anchor);
3614
+ if (movedTo) {
3615
+ out.push({
3616
+ anchor,
3617
+ entry: { ...entry, class: "moved", movedTo },
3618
+ class: "moved"
3619
+ });
3620
+ continue;
3621
+ }
3622
+ const newText = await currentText(reader, anchor, resolvers);
3623
+ const old = options.withHistory === false ? { ok: false, reason: "unrecoverable" } : await readOldSource(repoRoot, anchor);
3624
+ const oldText = old.ok ? spanIn(old.source, anchor, resolvers) : void 0;
3625
+ const settled = newText !== void 0 && oldText !== void 0 && sameTokens(treeSitter, anchor.file, oldText, newText) ? "cosmetic" : entry.class ?? "changed";
3626
+ out.push({
3627
+ anchor,
3628
+ entry: { ...entry, class: settled },
3629
+ class: settled,
3630
+ ...newText !== void 0 ? { newText } : {},
3631
+ ...oldText !== void 0 ? { oldText } : {},
3632
+ ...old.ok ? { oldOrigin: old.origin } : {}
3633
+ });
3634
+ }
3635
+ return out;
3636
+ }
3637
+ function sameTokens(resolver, file, before, after) {
3638
+ if (before === after) return false;
3639
+ const left = resolver.normalize(before, file);
3640
+ const right = resolver.normalize(after, file);
3641
+ return left !== null && left === right;
3642
+ }
3643
+ async function currentText(reader, anchor, resolvers) {
3644
+ const read = await reader(anchor.file);
3645
+ if (!read.ok) return void 0;
3646
+ return spanIn(read.source, anchor, resolvers);
3647
+ }
3648
+ function spanIn(source, anchor, resolvers) {
3649
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
3650
+ return outcome.ok ? outcome.span.text : void 0;
3651
+ }
3652
+
3653
+ // src/drift/diff.ts
3654
+ var MAX_ANCHOR_DIFF_LINES = 200;
3655
+ var PACKET_DIFF_LINE_BUDGET = 200;
3656
+ var MIN_ANCHOR_DIFF_LINES = 12;
3657
+ function diffBudget(anchors) {
3658
+ if (anchors <= 0) return MAX_ANCHOR_DIFF_LINES;
3659
+ return Math.min(
3660
+ MAX_ANCHOR_DIFF_LINES,
3661
+ Math.max(
3662
+ MIN_ANCHOR_DIFF_LINES,
3663
+ Math.floor(PACKET_DIFF_LINE_BUDGET / anchors)
3664
+ )
3665
+ );
3666
+ }
3667
+ function unifiedDiff(before, after, options = {}) {
3668
+ const max = options.maxLines ?? MAX_ANCHOR_DIFF_LINES;
3669
+ const left = before.replace(/\r\n/g, "\n").split("\n");
3670
+ const right = after.replace(/\r\n/g, "\n").split("\n");
3671
+ const body = [];
3672
+ let added = 0;
3673
+ let removed = 0;
3674
+ for (const edit of edits(left, right)) {
3675
+ if (edit.kind === "same") body.push(` ${edit.line}`);
3676
+ else if (edit.kind === "remove") {
3677
+ body.push(`-${edit.line}`);
3678
+ removed += 1;
3679
+ } else {
3680
+ body.push(`+${edit.line}`);
3681
+ added += 1;
3682
+ }
3683
+ }
3684
+ const truncated = body.length > max;
3685
+ const shown = truncated ? body.slice(0, max) : body;
3686
+ const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
3687
+ const lines = [header, ...shown];
3688
+ if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
3689
+ return { text: lines.join("\n"), added, removed, truncated };
3690
+ }
3691
+ function edits(left, right) {
3692
+ const rows = left.length;
3693
+ const cols = right.length;
3694
+ const table2 = Array.from(
3695
+ { length: rows + 1 },
3696
+ () => new Array(cols + 1).fill(0)
3697
+ );
3698
+ for (let row2 = rows - 1; row2 >= 0; row2--) {
3699
+ for (let col2 = cols - 1; col2 >= 0; col2--) {
3700
+ table2[row2][col2] = left[row2] === right[col2] ? table2[row2 + 1][col2 + 1] + 1 : Math.max(
3701
+ table2[row2 + 1][col2],
3702
+ table2[row2][col2 + 1]
3703
+ );
3704
+ }
3705
+ }
3706
+ const out = [];
3707
+ let row = 0;
3708
+ let col = 0;
3709
+ while (row < rows && col < cols) {
3710
+ if (left[row] === right[col]) {
3711
+ out.push({ kind: "same", line: left[row] });
3712
+ row += 1;
3713
+ col += 1;
3714
+ } else if (table2[row + 1][col] >= table2[row][col + 1]) {
3715
+ out.push({ kind: "remove", line: left[row] });
3716
+ row += 1;
3717
+ } else {
3718
+ out.push({ kind: "add", line: right[col] });
3719
+ col += 1;
3720
+ }
3721
+ }
3722
+ for (; row < rows; row++)
3723
+ out.push({ kind: "remove", line: left[row] });
3724
+ for (; col < cols; col++)
3725
+ out.push({ kind: "add", line: right[col] });
3726
+ return out;
3727
+ }
3728
+
3729
+ // src/drift/packet.ts
3730
+ var PRESUMED_INVALID = [
3731
+ "fact",
3732
+ "constraint",
3733
+ "contract"
3734
+ ];
3735
+ var RATIONALE_SURVIVES = ["decision", "risk"];
3736
+ var DEFAULT_NOTES = {
3737
+ "presumed-invalidated": "the code this claim was taken from changed; presume it no longer holds until re-read",
3738
+ "rationale-may-survive": "the reasoning may outlive the code that implemented it; check whether it does",
3739
+ review: "re-read the record against the new code"
3740
+ };
3741
+ async function reassessPacket(repoRoot, record, entries, options = {}) {
3742
+ const classified = await classifyDrift(repoRoot, record, entries, {
3743
+ ...options.reader ? { reader: options.reader } : {},
3744
+ ...options.search ? { search: options.search } : {},
3745
+ withHistory: options.withDiff !== false
3746
+ });
3747
+ const open = classified.filter(
3748
+ (found) => found.class === "changed" || found.class === "gone"
3749
+ );
3750
+ if (!open.length) return { packet: null, classified };
3751
+ const budget = diffBudget(open.length);
3752
+ const anchors = open.map(
3753
+ (found) => anchorPacket(found, options.withDiff === true, budget)
3754
+ );
3755
+ const type = record.frontmatter.type;
3756
+ const fallback = isKbRecordType(type) ? PRESUMED_INVALID.includes(type) ? "presumed-invalidated" : RATIONALE_SURVIVES.includes(type) ? "rationale-may-survive" : "review" : "review";
3757
+ return {
3758
+ classified,
3759
+ packet: {
3760
+ conceptId: record.conceptId,
3761
+ title: record.frontmatter.title ?? null,
3762
+ type,
3763
+ standing: options.standing ?? "unsettled",
3764
+ why: record.frontmatter.description ?? null,
3765
+ claim: claimOf(record),
3766
+ anchors,
3767
+ impact: (options.impact?.impacted ?? []).map((entry) => ({
3768
+ conceptId: entry.conceptId,
3769
+ title: entry.title,
3770
+ standing: entry.standing,
3771
+ depth: entry.depth
3772
+ })),
3773
+ impactTruncated: options.impact?.truncated ?? false,
3774
+ default: fallback,
3775
+ defaultNote: DEFAULT_NOTES[fallback]
3776
+ }
3777
+ };
3778
+ }
3779
+ function anchorPacket(found, withDiff, maxLines) {
3780
+ const { entry } = found;
3781
+ const base2 = {
3782
+ file: entry.file,
3783
+ ...entry.symbol ? { symbol: entry.symbol } : {},
3784
+ class: found.class,
3785
+ ...entry.reason ? { reason: entry.reason } : {},
3786
+ storedHash: entry.storedHash,
3787
+ ...entry.currentHash ? { currentHash: entry.currentHash } : {},
3788
+ diffSize: entry.diffSize,
3789
+ ...entry.movedTo ? { movedTo: entry.movedTo } : {}
3790
+ };
3791
+ if (!withDiff) return base2;
3792
+ if (found.oldText === void 0 || !found.oldOrigin) {
3793
+ return { ...base2, diff: { status: "unrecoverable" } };
3794
+ }
3795
+ const rendered = unifiedDiff(found.oldText, found.newText ?? "", {
3796
+ maxLines
3797
+ });
3798
+ return {
3799
+ ...base2,
3800
+ diff: {
3801
+ status: "ok",
3802
+ source: found.oldOrigin.kind,
3803
+ ref: found.oldOrigin.ref,
3804
+ unified: rendered.text,
3805
+ added: rendered.added,
3806
+ removed: rendered.removed,
3807
+ truncated: rendered.truncated
3808
+ }
3809
+ };
3810
+ }
3811
+ function claimOf(record) {
3812
+ const type = record.frontmatter.type;
3813
+ const section = isKbRecordType(type) ? RECORD_TYPES[type].sections[0] : void 0;
3814
+ if (!section) return null;
3815
+ const lines = record.body.replace(/\r\n/g, "\n").split("\n");
3816
+ const start = lines.findIndex(
3817
+ (line) => line.trim().toLowerCase() === `## ${section}`.toLowerCase()
3818
+ );
3819
+ if (start < 0) return null;
3820
+ const rest = lines.slice(start + 1);
3821
+ const end = rest.findIndex((line) => line.startsWith("## "));
3822
+ const text = (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
3823
+ return text ? { section, text } : null;
3824
+ }
3241
3825
 
3242
3826
  // src/kb-edges.ts
3243
3827
  var KB_EDGE_KINDS = [
@@ -3487,18 +4071,18 @@ function expired(hits, now) {
3487
4071
  for (const hit of hits) {
3488
4072
  const raw = hit.record.frontmatter.stale_after;
3489
4073
  if (!raw) continue;
3490
- const at = Date.parse(raw);
3491
- if (Number.isNaN(at)) {
4074
+ const at2 = Date.parse(raw);
4075
+ if (Number.isNaN(at2)) {
3492
4076
  findings.push(
3493
4077
  finding(hit.record, `stale_after "${raw}" is not a readable date`)
3494
4078
  );
3495
4079
  continue;
3496
4080
  }
3497
- if (at < now.getTime()) {
4081
+ if (at2 < now.getTime()) {
3498
4082
  findings.push(
3499
4083
  finding(
3500
4084
  hit.record,
3501
- `stale since ${raw} (${daysBetween(at, now.getTime())} days ago)`
4085
+ `stale since ${raw} (${daysBetween(at2, now.getTime())} days ago)`
3502
4086
  )
3503
4087
  );
3504
4088
  }
@@ -3511,12 +4095,12 @@ function expiring(hits, now, withinDays) {
3511
4095
  for (const hit of hits) {
3512
4096
  const raw = hit.record.frontmatter.stale_after;
3513
4097
  if (!raw) continue;
3514
- const at = Date.parse(raw);
3515
- if (Number.isNaN(at) || at < now.getTime() || at > horizon) continue;
4098
+ const at2 = Date.parse(raw);
4099
+ if (Number.isNaN(at2) || at2 < now.getTime() || at2 > horizon) continue;
3516
4100
  findings.push(
3517
4101
  finding(
3518
4102
  hit.record,
3519
- `goes stale ${raw} (in ${daysBetween(now.getTime(), at)} days)`
4103
+ `goes stale ${raw} (in ${daysBetween(now.getTime(), at2)} days)`
3520
4104
  )
3521
4105
  );
3522
4106
  }
@@ -3686,13 +4270,16 @@ function anchorFindings(hits, kind, headline) {
3686
4270
  );
3687
4271
  }
3688
4272
  function describeAnchor(anchor) {
3689
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3690
- if (anchor.reason) return `${at} (${anchor.reason})`;
4273
+ const at2 = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
4274
+ if (anchor.class === "gone") {
4275
+ return `${at2} gone${anchor.reason ? ` (${anchor.reason})` : ""}`;
4276
+ }
4277
+ if (anchor.reason) return `${at2} (${anchor.reason})`;
3691
4278
  if (anchor.remoteState === "drifted-on-default") {
3692
- return `${at} (matches ref, moved on the default branch)`;
4279
+ return `${at2} (matches ref, moved on the default branch)`;
3693
4280
  }
3694
- if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
3695
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
4281
+ if (anchor.diffSize === null) return `${at2} (changed, size unrecorded)`;
4282
+ return anchor.diffSize === 0 ? `${at2} (content changed, same line count)` : `${at2} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3696
4283
  }
3697
4284
  function replaces(later, earlier) {
3698
4285
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
@@ -3709,21 +4296,173 @@ function daysBetween(from, to) {
3709
4296
  return Math.max(0, Math.floor((to - from) / DAY_MS));
3710
4297
  }
3711
4298
  function ageInDays(record, now) {
3712
- const at = record.frontmatter.generated?.at;
3713
- if (!at) return null;
3714
- const written = Date.parse(at);
4299
+ const at2 = record.frontmatter.generated?.at;
4300
+ if (!at2) return null;
4301
+ const written = Date.parse(at2);
3715
4302
  if (Number.isNaN(written)) return null;
3716
4303
  return daysBetween(written, now.getTime());
3717
4304
  }
3718
4305
 
4306
+ // src/commands/reassess.ts
4307
+ var import_zod12 = require("zod");
4308
+ var reassessCommand = define({
4309
+ name: "reassess",
4310
+ tool: "kb_reassess",
4311
+ usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
4312
+ description: "One drifted record, as something to judge: its claim, each anchor's drift class, the old-vs-new span diff, and the records that depend on it. Formatting-only drift is dropped. Empty when there is nothing to reassess. Writes: relocates moved anchors, keeping their hash; never verifies, supersedes, or changes standing.",
4313
+ input: import_zod12.z.object({
4314
+ bundlePath,
4315
+ conceptId,
4316
+ repoRoot: REPO_ROOT,
4317
+ withDiff: import_zod12.z.boolean().optional().describe(
4318
+ "Recover each anchor's committed span and render the diff. Reads git history."
4319
+ )
4320
+ }),
4321
+ fromArgv: (argv, path) => {
4322
+ const repoRoot = argvFlag(argv, "--repo-root");
4323
+ return {
4324
+ bundlePath: path,
4325
+ conceptId: argv[1],
4326
+ ...repoRoot !== void 0 ? { repoRoot } : {},
4327
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
4328
+ };
4329
+ },
4330
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
4331
+ const root = repoRoot ?? process.cwd();
4332
+ const bundle = await store.list(path);
4333
+ const record = bundle.find((entry) => entry.conceptId === id);
4334
+ if (!record) throw new KbRecordNotFoundError(id);
4335
+ const drift = await store.detectDrift([record], repoRoot);
4336
+ const entries = drift?.get(id) ?? [];
4337
+ if (!entries.some((entry) => entry.state !== "match")) {
4338
+ return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
4339
+ }
4340
+ const standing = adjudicate(bundle, bundle).find(
4341
+ (hit) => hit.record.conceptId === id
4342
+ )?.standing;
4343
+ const impact2 = await store.impact(path, id);
4344
+ const { packet, classified } = await reassessPacket(root, record, entries, {
4345
+ ...withDiff ? { withDiff: true } : {},
4346
+ impact: impact2,
4347
+ ...standing ? { standing } : {}
4348
+ });
4349
+ const moves = classified.filter((found) => found.class === "moved");
4350
+ let frozen = false;
4351
+ const rebaselined = [];
4352
+ if (moves.length) {
4353
+ const relocated = /* @__PURE__ */ new Map();
4354
+ for (const found of moves) {
4355
+ const to = found.entry.movedTo;
4356
+ if (!to) continue;
4357
+ relocated.set(found.anchor, {
4358
+ ...found.anchor,
4359
+ file: to.file,
4360
+ ...to.symbol ? { symbol: to.symbol } : {}
4361
+ });
4362
+ rebaselined.push({
4363
+ file: found.anchor.file,
4364
+ ...found.anchor.symbol ? { symbol: found.anchor.symbol } : {},
4365
+ toFile: to.file,
4366
+ ...to.symbol ? { toSymbol: to.symbol } : {}
4367
+ });
4368
+ }
4369
+ try {
4370
+ await assertBaseNotFrozen(process.cwd(), path);
4371
+ } catch (error) {
4372
+ if (!(error instanceof KbBaseFrozenError)) throw error;
4373
+ frozen = true;
4374
+ }
4375
+ if (!frozen) {
4376
+ await store.updateAnchors(
4377
+ path,
4378
+ id,
4379
+ (record.frontmatter.strauss_anchors ?? []).map(
4380
+ (anchor) => relocated.get(anchor) ?? anchor
4381
+ ),
4382
+ actor
4383
+ );
4384
+ }
4385
+ }
4386
+ return {
4387
+ conceptId: id,
4388
+ packet,
4389
+ rebaselined: frozen ? [] : rebaselined,
4390
+ cosmetic: classified.filter((found) => found.class === "cosmetic").length,
4391
+ ...frozen ? {
4392
+ frozen: true,
4393
+ note: "base is frozen: nothing was rebaselined"
4394
+ } : {}
4395
+ };
4396
+ },
4397
+ render: (result) => renderReassess(result)
4398
+ });
4399
+ function renderReassess(result) {
4400
+ const lines = [];
4401
+ for (const move of result.rebaselined) {
4402
+ lines.push(
4403
+ `rebaselined: ${at(move.file, move.symbol)} \u2192 ${at(move.toFile, move.toSymbol)} (same code, new address)`
4404
+ );
4405
+ }
4406
+ if (result.cosmetic) {
4407
+ lines.push(
4408
+ `${result.cosmetic} anchor${result.cosmetic === 1 ? "" : "s"} changed formatting only.`
4409
+ );
4410
+ }
4411
+ if (result.note) lines.push(result.note);
4412
+ const packet = result.packet;
4413
+ if (!packet) {
4414
+ lines.push(`${result.conceptId}: nothing to reassess.`);
4415
+ return lines.join("\n");
4416
+ }
4417
+ lines.push(
4418
+ "",
4419
+ `# ${packet.conceptId}${packet.title ? ` \u2014 ${packet.title}` : ""}`,
4420
+ `type: ${packet.type} standing: ${packet.standing}`,
4421
+ ...packet.why ? [`why: ${packet.why}`] : [],
4422
+ ...packet.claim ? ["", `## ${packet.claim.section}`, packet.claim.text] : [],
4423
+ "",
4424
+ `## Anchors (${packet.anchors.length})`
4425
+ );
4426
+ for (const anchor of packet.anchors) {
4427
+ lines.push(
4428
+ `- ${at(anchor.file, anchor.symbol)} \u2014 ${anchor.class}${anchor.reason ? ` (${anchor.reason})` : ""}`
4429
+ );
4430
+ if (!anchor.diff) continue;
4431
+ if (anchor.diff.status === "unrecoverable") {
4432
+ lines.push(
4433
+ " diff: unrecoverable \u2014 no committed span to compare against"
4434
+ );
4435
+ continue;
4436
+ }
4437
+ lines.push(
4438
+ ` diff vs ${anchor.diff.ref} (${anchor.diff.source}): +${anchor.diff.added} \u2212${anchor.diff.removed}`,
4439
+ ...anchor.diff.unified.split("\n").map((line) => ` ${line}`)
4440
+ );
4441
+ }
4442
+ if (packet.impact.length) {
4443
+ lines.push("", `## Impact (${packet.impact.length})`);
4444
+ for (const entry of packet.impact) {
4445
+ lines.push(
4446
+ `- ${entry.conceptId} [${entry.standing}]${entry.title ? ` \u2014 ${entry.title}` : ""}`
4447
+ );
4448
+ }
4449
+ if (packet.impactTruncated) lines.push("- \u2026 walk truncated");
4450
+ }
4451
+ lines.push("", `Default: ${packet.default} \u2014 ${packet.defaultNote}.`);
4452
+ return lines.join("\n");
4453
+ }
4454
+ function at(file, symbol) {
4455
+ return symbol ? `${file}:${symbol}` : file;
4456
+ }
4457
+
3719
4458
  // src/commands/doctor.ts
3720
- var days = (what, fallback) => import_zod12.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
4459
+ var days = (what, fallback) => import_zod13.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3721
4460
  var doctorCommand = define({
3722
4461
  name: "doctor",
3723
4462
  tool: "kb_doctor",
3724
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3725
- description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked 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.",
3726
- input: import_zod12.z.object({
4463
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
4464
+ description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. `drifted` narrows it to a reassessment packet per drifted record, `with_diff` adding each anchor's old-vs-new span.",
4465
+ input: import_zod13.z.object({
3727
4466
  bundlePath,
3728
4467
  repoRoot: REPO_ROOT,
3729
4468
  expiringDays: days(
@@ -3738,11 +4477,17 @@ var doctorCommand = define({
3738
4477
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3739
4478
  DEFAULT_AGING_DAYS
3740
4479
  ),
3741
- offline: import_zod12.z.boolean().optional().describe(
4480
+ offline: import_zod13.z.boolean().optional().describe(
3742
4481
  "Read foreign anchors from the local repo cache only, never fetching."
3743
4482
  ),
3744
- strict: import_zod12.z.boolean().optional().describe(
4483
+ strict: import_zod13.z.boolean().optional().describe(
3745
4484
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
4485
+ ),
4486
+ drifted: import_zod13.z.boolean().optional().describe(
4487
+ "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
4488
+ ),
4489
+ withDiff: import_zod13.z.boolean().optional().describe(
4490
+ "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
3746
4491
  )
3747
4492
  }),
3748
4493
  // Presence, not truthiness: `--expiring-days ""` is a caller who meant
@@ -3761,7 +4506,9 @@ var doctorCommand = define({
3761
4506
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
3762
4507
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
3763
4508
  ...argv.includes("--offline") ? { offline: true } : {},
3764
- ...argv.includes("--strict") ? { strict: true } : {}
4509
+ ...argv.includes("--strict") ? { strict: true } : {},
4510
+ ...argv.includes("--drifted") ? { drifted: true } : {},
4511
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
3765
4512
  };
3766
4513
  },
3767
4514
  run: async ({ store, now }, {
@@ -3770,7 +4517,9 @@ var doctorCommand = define({
3770
4517
  unverifiedDays,
3771
4518
  agingDays,
3772
4519
  repoRoot,
3773
- offline
4520
+ offline,
4521
+ drifted: drifted2,
4522
+ withDiff
3774
4523
  }) => {
3775
4524
  const checkedAt = now();
3776
4525
  const records = await store.list(path);
@@ -3785,10 +4534,51 @@ var doctorCommand = define({
3785
4534
  now: new Date(checkedAt)
3786
4535
  });
3787
4536
  const hints = grammarHints();
4537
+ if (!drifted2) {
4538
+ return {
4539
+ bundlePath: path,
4540
+ checkedAt,
4541
+ ...report,
4542
+ ...hints.length ? { hints } : {}
4543
+ };
4544
+ }
4545
+ const standings = new Map(
4546
+ adjudicate(records, records, new Date(checkedAt)).map((hit) => [
4547
+ hit.record.conceptId,
4548
+ hit.standing
4549
+ ])
4550
+ );
4551
+ const packets = [];
4552
+ const rebaselinable = [];
4553
+ const search = movedSearch(repoRoot ?? process.cwd());
4554
+ for (const found of report.groups.find((g) => g.check === "drifted")?.findings ?? []) {
4555
+ const record = records.find(
4556
+ (entry) => entry.conceptId === found.conceptId
4557
+ );
4558
+ if (!record) continue;
4559
+ const standing = standings.get(record.conceptId);
4560
+ const built = await reassessPacket(
4561
+ repoRoot ?? process.cwd(),
4562
+ record,
4563
+ anchorDrift?.get(record.conceptId) ?? [],
4564
+ {
4565
+ ...withDiff ? { withDiff: true } : {},
4566
+ impact: await store.impact(path, record.conceptId),
4567
+ ...standing ? { standing } : {},
4568
+ search
4569
+ }
4570
+ );
4571
+ if (built.packet) packets.push(built.packet);
4572
+ if (built.classified.some((entry) => entry.class === "moved")) {
4573
+ rebaselinable.push(record.conceptId);
4574
+ }
4575
+ }
3788
4576
  return {
3789
4577
  bundlePath: path,
3790
4578
  checkedAt,
3791
4579
  ...report,
4580
+ packets,
4581
+ rebaselinable,
3792
4582
  ...hints.length ? { hints } : {}
3793
4583
  };
3794
4584
  },
@@ -3802,6 +4592,7 @@ var doctorCommand = define({
3802
4592
  failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
3803
4593
  });
3804
4594
  function render2(result) {
4595
+ if (result.packets) return renderPackets(result);
3805
4596
  const { thresholds } = result;
3806
4597
  const lines = [
3807
4598
  `# KB Doctor \u2014 ${result.bundlePath}`,
@@ -3835,21 +4626,45 @@ function render2(result) {
3835
4626
  );
3836
4627
  return lines.join("\n");
3837
4628
  }
4629
+ function renderPackets(result) {
4630
+ const packets = result.packets ?? [];
4631
+ const lines = [
4632
+ `# KB Drift \u2014 ${result.bundlePath}`,
4633
+ `checked: ${result.checkedAt}`,
4634
+ `${packets.length} record${packets.length === 1 ? "" : "s"} need a reading; ${result.counts.drifted} drifted in all.`
4635
+ ];
4636
+ if (result.rebaselinable?.length) {
4637
+ lines.push(
4638
+ `moved, rebaseline with \`kb_reassess\`: ${result.rebaselinable.join(", ")}`
4639
+ );
4640
+ }
4641
+ for (const packet of packets) {
4642
+ lines.push(
4643
+ renderReassess({
4644
+ conceptId: packet.conceptId,
4645
+ packet,
4646
+ rebaselined: [],
4647
+ cosmetic: 0
4648
+ })
4649
+ );
4650
+ }
4651
+ return lines.join("\n");
4652
+ }
3838
4653
 
3839
4654
  // src/commands/impact.ts
3840
- var import_zod13 = require("zod");
4655
+ var import_zod14 = require("zod");
3841
4656
  var impactCommand = define({
3842
4657
  name: "impact",
3843
4658
  tool: "kb_impact",
3844
4659
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
3845
4660
  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.",
3846
- input: import_zod13.z.object({
4661
+ input: import_zod14.z.object({
3847
4662
  bundlePath,
3848
4663
  conceptId,
3849
- depth: import_zod13.z.number().int().positive().optional().describe(
4664
+ depth: import_zod14.z.number().int().positive().optional().describe(
3850
4665
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
3851
4666
  ),
3852
- rels: import_zod13.z.array(import_zod13.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
4667
+ rels: import_zod14.z.array(import_zod14.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3853
4668
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
3854
4669
  )
3855
4670
  }),
@@ -3870,35 +4685,49 @@ var impactCommand = define({
3870
4685
  });
3871
4686
 
3872
4687
  // src/commands/list.ts
3873
- var import_zod14 = require("zod");
4688
+ var import_zod15 = require("zod");
3874
4689
  var listCommand = define({
3875
4690
  name: "list",
3876
4691
  tool: "kb_list",
3877
- usage: "list [type]",
3878
- description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
3879
- input: import_zod14.z.object({ bundlePath, type: import_zod14.z.enum(KB_RECORD_TYPES).optional() }),
3880
- fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
3881
- run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
3882
- conceptId: record.conceptId,
3883
- title: record.frontmatter.title ?? null,
3884
- description: record.frontmatter.description ?? null,
3885
- status: record.frontmatter.strauss_status,
3886
- anchors: record.frontmatter.strauss_anchors ?? []
3887
- }))
4692
+ usage: "list [type] [--tag T]...",
4693
+ description: "Every record, optionally one type or tag. For enumerating; use kb_query for a question.",
4694
+ input: import_zod15.z.object({
4695
+ bundlePath,
4696
+ type: import_zod15.z.enum(KB_RECORD_TYPES).optional(),
4697
+ tags: TAGS
4698
+ }),
4699
+ fromArgv: (argv, path) => {
4700
+ const tags = argvFlags(argv, "--tag");
4701
+ const type = argvPositional(argv, "--tag");
4702
+ return {
4703
+ bundlePath: path,
4704
+ ...type ? { type } : {},
4705
+ ...tags.length ? { tags } : {}
4706
+ };
4707
+ },
4708
+ run: async ({ store }, { bundlePath: path, type, tags }) => (await store.list(path, type, { ...tags ? { tags } : {} })).map(
4709
+ (record) => ({
4710
+ conceptId: record.conceptId,
4711
+ title: record.frontmatter.title ?? null,
4712
+ description: record.frontmatter.description ?? null,
4713
+ status: record.frontmatter.strauss_status,
4714
+ anchors: record.frontmatter.strauss_anchors ?? []
4715
+ })
4716
+ )
3888
4717
  });
3889
4718
 
3890
4719
  // src/commands/load.ts
3891
- var import_zod15 = require("zod");
4720
+ var import_zod16 = require("zod");
3892
4721
  var loadCommand = define({
3893
4722
  name: "load",
3894
4723
  tool: "kb_load",
3895
4724
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
3896
4725
  description: "Load the whole base, each record with its standing \u2014 call it first, at the point of use, since compaction drops it. Superseded records arrive as stubs; kb_trace has the history. Over budget it refuses: kb_catalog, then kb_pack, or narrow with `type`; `all` bypasses. Never read record files directly \u2014 only kb_* tools resolve supersession. `digest` stamps the base's content, so hooks know when to reload.",
3897
- input: import_zod15.z.object({
4726
+ input: import_zod16.z.object({
3898
4727
  bundlePath,
3899
- type: import_zod15.z.enum(KB_RECORD_TYPES).optional(),
3900
- budgetTokens: import_zod15.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3901
- all: import_zod15.z.boolean().optional().describe(
4728
+ type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
4729
+ budgetTokens: import_zod16.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
4730
+ all: import_zod16.z.boolean().optional().describe(
3902
4731
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
3903
4732
  ),
3904
4733
  repoRoot: REPO_ROOT
@@ -3940,25 +4769,25 @@ var loadCommand = define({
3940
4769
  });
3941
4770
 
3942
4771
  // src/commands/log.ts
3943
- var import_zod16 = require("zod");
4772
+ var import_zod17 = require("zod");
3944
4773
  var logCommand = define({
3945
4774
  name: "log",
3946
4775
  tool: "kb_log",
3947
4776
  usage: "log",
3948
4777
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
3949
- input: import_zod16.z.object({ bundlePath }),
4778
+ input: import_zod17.z.object({ bundlePath }),
3950
4779
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3951
4780
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
3952
4781
  });
3953
4782
 
3954
4783
  // src/commands/no-decision.ts
3955
- var import_zod17 = require("zod");
4784
+ var import_zod18 = require("zod");
3956
4785
  var noDecisionCommand = define({
3957
4786
  name: "no-decision",
3958
4787
  tool: "kb_no_decision",
3959
4788
  usage: "no-decision <reason...>",
3960
4789
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
3961
- input: import_zod17.z.object({ bundlePath, reason: import_zod17.z.string().min(1) }),
4790
+ input: import_zod18.z.object({ bundlePath, reason: import_zod18.z.string().min(1) }),
3962
4791
  fromArgv: (argv, path) => ({
3963
4792
  bundlePath: path,
3964
4793
  reason: argv.slice(1).join(" ").trim()
@@ -3975,20 +4804,20 @@ var noDecisionCommand = define({
3975
4804
  });
3976
4805
 
3977
4806
  // src/commands/pack.ts
3978
- var import_zod18 = require("zod");
4807
+ var import_zod19 = require("zod");
3979
4808
  var packCommand = define({
3980
4809
  name: "pack",
3981
4810
  tool: "kb_pack",
3982
4811
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
3983
4812
  description: "Bounded neighbourhood around one record: within `hops`, ranked, cut to `maxNodes`, with every cut record named under Excluded. Use when the base is over kb_load's budget and the work centres on a record you can name. Refuses over budget rather than truncating. Everything below the header is byte-stable across runs. Resolves supersession like kb_load.",
3984
- input: import_zod18.z.object({
4813
+ input: import_zod19.z.object({
3985
4814
  bundlePath,
3986
4815
  conceptId,
3987
- hops: import_zod18.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3988
- maxNodes: import_zod18.z.number().int().positive().optional().describe(
4816
+ hops: import_zod19.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
4817
+ maxNodes: import_zod19.z.number().int().positive().optional().describe(
3989
4818
  "How many records the pack may hold, root included. Defaults to 20."
3990
4819
  ),
3991
- budgetTokens: import_zod18.z.number().int().positive().optional().describe(
4820
+ budgetTokens: import_zod19.z.number().int().positive().optional().describe(
3992
4821
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
3993
4822
  )
3994
4823
  }),
@@ -4013,12 +4842,12 @@ var packCommand = define({
4013
4842
  return render3(result, path, now());
4014
4843
  }
4015
4844
  });
4016
- function render3(result, bundle, at) {
4845
+ function render3(result, bundle, at2) {
4017
4846
  const lines = [
4018
4847
  `# KB Pack \u2014 ${result.root}`,
4019
4848
  `bundle: ${bundle}`,
4020
4849
  `budget: ~${result.tokensLoaded} of ${result.budgetTokens} tokens, ${result.recordCount} records`,
4021
- `packed: ${at}`,
4850
+ `packed: ${at2}`,
4022
4851
  "",
4023
4852
  `## Records (${result.records.length})`
4024
4853
  ];
@@ -4075,22 +4904,22 @@ function warningLabel(warning) {
4075
4904
  }
4076
4905
 
4077
4906
  // src/commands/pin.ts
4078
- var import_zod19 = require("zod");
4907
+ var import_zod20 = require("zod");
4079
4908
  var pinCommand = define({
4080
4909
  name: "pin",
4081
4910
  tool: "kb_pin",
4082
4911
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
4083
4912
  description: "Pin a base into a workspace manifest so kb_context surfaces it. Layers, nearest wins: project `.strauss/kb-pins.json` (default), `--local` (personal, gitignored), `--user` (`~/.strauss`). Idempotent; `--mode full|index`, `--profiles`, `--frozen`/`--unfreeze` update only those fields. A path with no records pins with a warning. Never touches the base itself.",
4084
- input: import_zod19.z.object({
4913
+ input: import_zod20.z.object({
4085
4914
  bundlePath,
4086
- mode: import_zod19.z.enum(["full", "index"]).optional().describe(
4915
+ mode: import_zod20.z.enum(["full", "index"]).optional().describe(
4087
4916
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
4088
4917
  ),
4089
- profiles: import_zod19.z.array(import_zod19.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4090
- layer: import_zod19.z.enum(["project", "local", "user"]).optional().describe(
4918
+ profiles: import_zod20.z.array(import_zod20.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4919
+ layer: import_zod20.z.enum(["project", "local", "user"]).optional().describe(
4091
4920
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
4092
4921
  ),
4093
- frozen: import_zod19.z.boolean().optional().describe(
4922
+ frozen: import_zod20.z.boolean().optional().describe(
4094
4923
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
4095
4924
  )
4096
4925
  }),
@@ -4119,47 +4948,49 @@ var pinCommand = define({
4119
4948
  });
4120
4949
 
4121
4950
  // src/commands/pins.ts
4122
- var import_zod20 = require("zod");
4951
+ var import_zod21 = require("zod");
4123
4952
  var pinsCommand = define({
4124
4953
  name: "pins",
4125
4954
  tool: "kb_pins",
4126
4955
  usage: "pins",
4127
4956
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
4128
- input: import_zod20.z.object({}),
4957
+ input: import_zod21.z.object({}),
4129
4958
  fromArgv: () => ({}),
4130
4959
  run: ({ store }) => listPins(store, process.cwd())
4131
4960
  });
4132
4961
 
4133
4962
  // src/commands/query.ts
4134
- var import_zod21 = require("zod");
4963
+ var import_zod22 = require("zod");
4135
4964
  var queryCommand = define({
4136
4965
  name: "query",
4137
4966
  tool: "kb_query",
4138
- usage: "query <text...> [--repo-root PATH]",
4967
+ usage: "query <text...> [--tag T]... [--repo-root PATH]",
4139
4968
  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.",
4140
- input: import_zod21.z.object({
4969
+ input: import_zod22.z.object({
4141
4970
  bundlePath,
4142
- text: import_zod21.z.string().optional(),
4143
- type: import_zod21.z.enum(KB_RECORD_TYPES).optional(),
4144
- includeNonCurrent: import_zod21.z.boolean().optional(),
4971
+ text: import_zod22.z.string().optional(),
4972
+ type: import_zod22.z.enum(KB_RECORD_TYPES).optional(),
4973
+ includeNonCurrent: import_zod22.z.boolean().optional(),
4974
+ tags: TAGS,
4145
4975
  repoRoot: REPO_ROOT
4146
4976
  }),
4147
- // `--repo-root` is a flag, so its value must not fall into the search text.
4977
+ // Both are flags, so neither's value may fall into the search text.
4148
4978
  fromArgv: (argv, path) => {
4149
4979
  const repoRoot = argvFlag(argv, "--repo-root");
4150
- const words = argv.slice(1);
4151
- const flag = words.indexOf("--repo-root");
4152
- if (flag !== -1) words.splice(flag, 2);
4980
+ const tags = argvFlags(argv, "--tag");
4981
+ const words = argvWithout(argv.slice(1), "--repo-root", "--tag");
4153
4982
  return {
4154
4983
  bundlePath: path,
4155
4984
  text: words.join(" ").trim(),
4156
4985
  includeNonCurrent: true,
4986
+ ...tags.length ? { tags } : {},
4157
4987
  ...repoRoot !== void 0 ? { repoRoot } : {}
4158
4988
  };
4159
4989
  },
4160
- run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent, repoRoot }) => (await store.query(path, text ?? "", {
4990
+ run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent, tags, repoRoot }) => (await store.query(path, text ?? "", {
4161
4991
  ...type ? { type } : {},
4162
4992
  includeNonCurrent: includeNonCurrent === true,
4993
+ ...tags ? { tags } : {},
4163
4994
  ...repoRoot !== void 0 ? { repoRoot } : {}
4164
4995
  })).map((hit) => ({
4165
4996
  conceptId: hit.record.conceptId,
@@ -4173,27 +5004,27 @@ var queryCommand = define({
4173
5004
  });
4174
5005
 
4175
5006
  // src/commands/read-index.ts
4176
- var import_zod22 = require("zod");
5007
+ var import_zod23 = require("zod");
4177
5008
  var readIndexCommand = define({
4178
5009
  name: "index",
4179
5010
  tool: "kb_index",
4180
5011
  usage: "index",
4181
5012
  description: "The index \u2014 title, type, status, description per record \u2014 rebuilt if stale. Cheapest re-orientation after compaction: call it (or kb_context) first, then kb_load or fetch by id.",
4182
- input: import_zod22.z.object({ bundlePath }),
5013
+ input: import_zod23.z.object({ bundlePath }),
4183
5014
  fromArgv: (_argv, path) => ({ bundlePath: path }),
4184
5015
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
4185
5016
  });
4186
5017
 
4187
5018
  // src/commands/schema.ts
4188
- var import_zod25 = require("zod");
5019
+ var import_zod26 = require("zod");
4189
5020
 
4190
5021
  // src/json-schema.ts
4191
- var import_zod24 = require("zod");
5022
+ var import_zod25 = require("zod");
4192
5023
 
4193
5024
  // src/kb-log.ts
4194
- var import_zod23 = require("zod");
5025
+ var import_zod24 = require("zod");
4195
5026
  var LOG_FILE = "log.jsonl";
4196
- var kbLogEntrySchema = import_zod23.z.object({
5027
+ var kbLogEntrySchema = import_zod24.z.object({
4197
5028
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
4198
5029
  // below), and a value that isn't actually chronological — a Unix
4199
5030
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -4202,12 +5033,12 @@ var kbLogEntrySchema = import_zod23.z.object({
4202
5033
  // and rejects everything else, including a non-`Z` offset — so a
4203
5034
  // malformed `at` is reported the same way a malformed line already is,
4204
5035
  // rather than silently sorting into the wrong place.
4205
- at: import_zod23.z.iso.datetime(),
4206
- by: import_zod23.z.string().min(1),
4207
- operation: import_zod23.z.string().min(1),
4208
- conceptId: import_zod23.z.string().min(1),
5036
+ at: import_zod24.z.iso.datetime(),
5037
+ by: import_zod24.z.string().min(1),
5038
+ operation: import_zod24.z.string().min(1),
5039
+ conceptId: import_zod24.z.string().min(1),
4209
5040
  /** Second concept id, where the operation relates two — supersession. */
4210
- target: import_zod23.z.string().min(1).optional()
5041
+ target: import_zod24.z.string().min(1).optional()
4211
5042
  }).strict();
4212
5043
  function renderLogEntry(entry) {
4213
5044
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -4245,11 +5076,11 @@ function parseLog(raw) {
4245
5076
  // src/json-schema.ts
4246
5077
  function kbJsonSchemas() {
4247
5078
  return {
4248
- recordFrontmatter: import_zod24.z.toJSONSchema(kbRecordFrontmatterSchema, {
5079
+ recordFrontmatter: import_zod25.z.toJSONSchema(kbRecordFrontmatterSchema, {
4249
5080
  io: "input"
4250
5081
  }),
4251
- composeInput: import_zod24.z.toJSONSchema(composeInputSchema, { io: "input" }),
4252
- logEntry: import_zod24.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
5082
+ composeInput: import_zod25.z.toJSONSchema(composeInputSchema, { io: "input" }),
5083
+ logEntry: import_zod25.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
4253
5084
  };
4254
5085
  }
4255
5086
 
@@ -4259,25 +5090,25 @@ var schemaCommand = define({
4259
5090
  tool: "kb_schema",
4260
5091
  usage: "schema",
4261
5092
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
4262
- input: import_zod25.z.object({}),
5093
+ input: import_zod26.z.object({}),
4263
5094
  fromArgv: () => ({}),
4264
5095
  run: () => Promise.resolve(kbJsonSchemas())
4265
5096
  });
4266
5097
 
4267
5098
  // src/commands/stamp.ts
4268
- var import_promises7 = require("fs/promises");
4269
- var import_zod26 = require("zod");
5099
+ var import_promises8 = require("fs/promises");
5100
+ var import_zod27 = require("zod");
4270
5101
  var DIGEST = /^[0-9a-f]{64}$/;
4271
5102
  var stampCommand = define({
4272
5103
  name: "stamp",
4273
5104
  tool: "kb_stamp",
4274
5105
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
4275
- description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids when the baseline is a prior stamp; silent when nothing changed. Reads, never writes.",
4276
- input: import_zod26.z.object({
4277
- bundlePath: import_zod26.z.string().min(1).optional().describe(
5106
+ description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests, how many records have drifted anchors \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids. Reads, never writes.",
5107
+ input: import_zod27.z.object({
5108
+ bundlePath: import_zod27.z.string().min(1).optional().describe(
4278
5109
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
4279
5110
  ),
4280
- since: import_zod26.z.string().min(1).optional().describe(
5111
+ since: import_zod27.z.string().min(1).optional().describe(
4281
5112
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
4282
5113
  )
4283
5114
  }),
@@ -4316,7 +5147,7 @@ var stampCommand = define({
4316
5147
  return reports;
4317
5148
  },
4318
5149
  render: (result) => result.map((report) => {
4319
- const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
5150
+ const counts = `${report.recordCount} record(s), ${report.superseded} superseded${report.drifted ? `, ${report.drifted} drifted` : ""}`;
4320
5151
  const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
4321
5152
  return report.changed?.length ? `${head}
4322
5153
  changed: ${report.changed.join(", ")}` : head;
@@ -4339,7 +5170,7 @@ async function readBaseline(since) {
4339
5170
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
4340
5171
  let parsed;
4341
5172
  try {
4342
- parsed = JSON.parse(await (0, import_promises7.readFile)(since, "utf8"));
5173
+ parsed = JSON.parse(await (0, import_promises8.readFile)(since, "utf8"));
4343
5174
  } catch {
4344
5175
  throw new KbStampBaselineError(since);
4345
5176
  }
@@ -4363,16 +5194,16 @@ async function readBaseline(since) {
4363
5194
  }
4364
5195
 
4365
5196
  // src/commands/status.ts
4366
- var import_zod27 = require("zod");
5197
+ var import_zod28 = require("zod");
4367
5198
  var statusCommand = define({
4368
5199
  name: "status",
4369
5200
  tool: "kb_status",
4370
5201
  usage: "status <concept-id> <status>",
4371
5202
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
4372
- input: import_zod27.z.object({
5203
+ input: import_zod28.z.object({
4373
5204
  bundlePath,
4374
5205
  conceptId,
4375
- status: import_zod27.z.enum(KB_RECORD_STATUSES)
5206
+ status: import_zod28.z.enum(KB_RECORD_STATUSES)
4376
5207
  }),
4377
5208
  fromArgv: (argv, path) => ({
4378
5209
  bundlePath: path,
@@ -4387,13 +5218,13 @@ var statusCommand = define({
4387
5218
  });
4388
5219
 
4389
5220
  // src/commands/supersede.ts
4390
- var import_zod28 = require("zod");
5221
+ var import_zod29 = require("zod");
4391
5222
  var supersedeCommand = define({
4392
5223
  name: "supersede",
4393
5224
  tool: "kb_supersede",
4394
5225
  usage: "supersede <concept-id> <replacement-id>",
4395
5226
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
4396
- input: import_zod28.z.object({ bundlePath, conceptId, replacementId: conceptId }),
5227
+ input: import_zod29.z.object({ bundlePath, conceptId, replacementId: conceptId }),
4397
5228
  fromArgv: (argv, path) => ({
4398
5229
  bundlePath: path,
4399
5230
  conceptId: argv[1],
@@ -4407,16 +5238,16 @@ var supersedeCommand = define({
4407
5238
  });
4408
5239
 
4409
5240
  // src/commands/sync-instructions.ts
4410
- var import_zod29 = require("zod");
5241
+ var import_zod30 = require("zod");
4411
5242
  var syncInstructionsCommand = define({
4412
5243
  name: "sync-instructions",
4413
5244
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
4414
5245
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
4415
- input: import_zod29.z.object({
4416
- file: import_zod29.z.string().min(1).describe("The instruction file to edit in place."),
4417
- budgetTokens: import_zod29.z.number().int().positive().optional(),
4418
- fullUnderTokens: import_zod29.z.number().int().positive().optional(),
4419
- profile: import_zod29.z.string().optional()
5246
+ input: import_zod30.z.object({
5247
+ file: import_zod30.z.string().min(1).describe("The instruction file to edit in place."),
5248
+ budgetTokens: import_zod30.z.number().int().positive().optional(),
5249
+ fullUnderTokens: import_zod30.z.number().int().positive().optional(),
5250
+ profile: import_zod30.z.string().optional()
4420
5251
  }),
4421
5252
  fromArgv: (argv) => {
4422
5253
  const budget = argvFlag(argv, "--budget");
@@ -4442,7 +5273,7 @@ var syncInstructionsCommand = define({
4442
5273
  });
4443
5274
 
4444
5275
  // src/commands/trace.ts
4445
- var import_zod30 = require("zod");
5276
+ var import_zod31 = require("zod");
4446
5277
 
4447
5278
  // src/trace.ts
4448
5279
  var TRACE_EDGES = [
@@ -4488,8 +5319,8 @@ function trace(seedId, bundle, options = {}) {
4488
5319
  return [...reached.values()].sort(byGeneratedAt);
4489
5320
  }
4490
5321
  function byGeneratedAt(left, right) {
4491
- const at = (step) => step.record.frontmatter.generated?.at ?? "";
4492
- return at(left).localeCompare(at(right)) || left.depth - right.depth;
5322
+ const at2 = (step) => step.record.frontmatter.generated?.at ?? "";
5323
+ return at2(left).localeCompare(at2(right)) || left.depth - right.depth;
4493
5324
  }
4494
5325
 
4495
5326
  // src/commands/trace.ts
@@ -4498,11 +5329,11 @@ var traceCommand = define({
4498
5329
  tool: "kb_trace",
4499
5330
  usage: "trace <concept-id> [edges...]",
4500
5331
  description: 'Timeline of how a position was reached, ordered by write time, following supersession, shared anchors and shared sources. Includes rejected, draft and superseded records \u2014 in a history they are the content. For "why is it like this"; kb_load answers "what holds now".',
4501
- input: import_zod30.z.object({
5332
+ input: import_zod31.z.object({
4502
5333
  bundlePath,
4503
5334
  conceptId,
4504
- edges: import_zod30.z.array(import_zod30.z.enum(TRACE_EDGES)).optional(),
4505
- depth: import_zod30.z.number().int().positive().optional()
5335
+ edges: import_zod31.z.array(import_zod31.z.enum(TRACE_EDGES)).optional(),
5336
+ depth: import_zod31.z.number().int().positive().optional()
4506
5337
  }),
4507
5338
  fromArgv: (argv, path) => ({
4508
5339
  bundlePath: path,
@@ -4524,37 +5355,37 @@ var traceCommand = define({
4524
5355
  });
4525
5356
 
4526
5357
  // src/commands/types.ts
4527
- var import_zod31 = require("zod");
5358
+ var import_zod32 = require("zod");
4528
5359
  var typesCommand = define({
4529
5360
  name: "types",
4530
5361
  tool: "kb_types",
4531
5362
  usage: "types",
4532
5363
  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.",
4533
- input: import_zod31.z.object({}),
5364
+ input: import_zod32.z.object({}),
4534
5365
  fromArgv: () => ({}),
4535
5366
  run: () => Promise.resolve(RECORD_TYPES)
4536
5367
  });
4537
5368
 
4538
5369
  // src/commands/unpin.ts
4539
- var import_zod32 = require("zod");
5370
+ var import_zod33 = require("zod");
4540
5371
  var unpinCommand = define({
4541
5372
  name: "unpin",
4542
5373
  tool: "kb_unpin",
4543
5374
  usage: "unpin [bundle-path]",
4544
5375
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
4545
- input: import_zod32.z.object({ bundlePath }),
5376
+ input: import_zod33.z.object({ bundlePath }),
4546
5377
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
4547
5378
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
4548
5379
  });
4549
5380
 
4550
5381
  // src/commands/validate.ts
4551
- var import_zod33 = require("zod");
5382
+ var import_zod34 = require("zod");
4552
5383
  var validateCommand = define({
4553
5384
  name: "validate",
4554
5385
  tool: "kb_validate",
4555
5386
  usage: "validate",
4556
5387
  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.",
4557
- input: import_zod33.z.object({ bundlePath }),
5388
+ input: import_zod34.z.object({ bundlePath }),
4558
5389
  fromArgv: (_argv, path) => ({ bundlePath: path }),
4559
5390
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
4560
5391
  // Warnings never fail the exit code; every other severity does.
@@ -4564,16 +5395,16 @@ var validateCommand = define({
4564
5395
  });
4565
5396
 
4566
5397
  // src/commands/verify.ts
4567
- var import_zod34 = require("zod");
5398
+ var import_zod35 = require("zod");
4568
5399
  var verifyCommand = define({
4569
5400
  name: "verify",
4570
5401
  tool: "kb_verify",
4571
5402
  usage: "verify <concept-id> --note <text>",
4572
5403
  description: "Append a verified[] event: who checked, when, and what was found. Append-only. A record's own generator is refused unless the actor is `human:`-prefixed.",
4573
- input: import_zod34.z.object({
5404
+ input: import_zod35.z.object({
4574
5405
  bundlePath,
4575
5406
  conceptId,
4576
- note: import_zod34.z.string().refine((s) => s.trim().length > 0, {
5407
+ note: import_zod35.z.string().refine((s) => s.trim().length > 0, {
4577
5408
  message: "note must say what the check found"
4578
5409
  })
4579
5410
  }),
@@ -4593,15 +5424,15 @@ var verifyCommand = define({
4593
5424
  });
4594
5425
 
4595
5426
  // src/commands/write.ts
4596
- var import_zod35 = require("zod");
5427
+ var import_zod36 = require("zod");
4597
5428
  var writeCommand = define({
4598
5429
  name: "write",
4599
5430
  tool: "kb_write",
4600
5431
  usage: "write <type> < record.json",
4601
5432
  description: "Write one record. Search first \u2014 a duplicate concept id is rejected, not overwritten; kb_types lists each type's sections. An unsourced claim is an `assumption` with assumption: true, never a vague `fact`. Conflicting records get a `risk`, `open-question`, or superseding `decision`. Prefer a new short record over overloading one. Never delete; supersede.",
4602
- input: import_zod35.z.object({
5433
+ input: import_zod36.z.object({
4603
5434
  bundlePath,
4604
- type: import_zod35.z.enum(KB_RECORD_TYPES),
5435
+ type: import_zod36.z.enum(KB_RECORD_TYPES),
4605
5436
  input: composeInputSchema
4606
5437
  }),
4607
5438
  fromArgv: async (argv, path, stdin) => ({
@@ -4625,13 +5456,13 @@ var writeCommand = define({
4625
5456
  });
4626
5457
 
4627
5458
  // src/commands/write-decision.ts
4628
- var import_zod36 = require("zod");
5459
+ var import_zod37 = require("zod");
4629
5460
  var writeDecisionCommand = define({
4630
5461
  name: "write-decision",
4631
5462
  tool: "kb_write_decision",
4632
5463
  usage: "write-decision < decision.json",
4633
5464
  description: "Write a decision, with `alternative` (what was rejected and why) and `impact` as fields. Record one when a later reader would otherwise simplify the constraint away; skip when the diff already answers it. `sources` for material read, `anchors` for code, `relatedConceptIds` for records.",
4634
- input: import_zod36.z.object({ bundlePath, input: decisionInputSchema }),
5465
+ input: import_zod37.z.object({ bundlePath, input: decisionInputSchema }),
4635
5466
  fromArgv: async (_argv, path, stdin) => ({
4636
5467
  bundlePath: path,
4637
5468
  input: JSON.parse(await stdin())
@@ -4661,6 +5492,7 @@ var KB_COMMANDS = [
4661
5492
  answerCommand,
4662
5493
  verifyCommand,
4663
5494
  anchorResolveCommand,
5495
+ reassessCommand,
4664
5496
  loadCommand,
4665
5497
  catalogCommand,
4666
5498
  packCommand,
@@ -4687,7 +5519,7 @@ var KB_COMMANDS_BY_NAME = new Map(
4687
5519
  );
4688
5520
 
4689
5521
  // src/kb-store.ts
4690
- var import_promises9 = require("fs/promises");
5522
+ var import_promises10 = require("fs/promises");
4691
5523
  var import_node_path11 = require("path");
4692
5524
 
4693
5525
  // src/markdown.ts
@@ -4748,7 +5580,7 @@ function bundleDigest(records, superseded) {
4748
5580
  }
4749
5581
 
4750
5582
  // src/search-index.ts
4751
- var import_promises8 = require("fs/promises");
5583
+ var import_promises9 = require("fs/promises");
4752
5584
  var import_node_path10 = require("path");
4753
5585
  var SEARCH_INDEX_FILE = ".index.sqlite";
4754
5586
  var COLLECTION = "kb";
@@ -4793,7 +5625,7 @@ async function searchBase(bundlePath2, query, options = {}) {
4793
5625
  }
4794
5626
  }
4795
5627
  async function isStale(bundlePath2) {
4796
- const indexAt = await (0, import_promises8.stat)((0, import_node_path10.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
5628
+ const indexAt = await (0, import_promises9.stat)((0, import_node_path10.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
4797
5629
  if (!indexAt) return true;
4798
5630
  const { readdir: readdir2 } = await import("fs/promises");
4799
5631
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -4802,8 +5634,8 @@ async function isStale(bundlePath2) {
4802
5634
  let stale = false;
4803
5635
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
4804
5636
  if (stale) return;
4805
- const at = await (0, import_promises8.stat)((0, import_node_path10.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
4806
- if (at > indexAt) stale = true;
5637
+ const at2 = await (0, import_promises9.stat)((0, import_node_path10.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
5638
+ if (at2 > indexAt) stale = true;
4807
5639
  });
4808
5640
  return stale;
4809
5641
  }
@@ -5111,7 +5943,7 @@ var KbStore = class {
5111
5943
  const conceptId2 = `${input.type}.${input.slug}`;
5112
5944
  const root = this.root(bundlePath2);
5113
5945
  const target = this.recordPath(bundlePath2, conceptId2);
5114
- await (0, import_promises9.mkdir)(root, { recursive: true });
5946
+ await (0, import_promises10.mkdir)(root, { recursive: true });
5115
5947
  await this.publish(
5116
5948
  target,
5117
5949
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -5150,24 +5982,27 @@ var KbStore = class {
5150
5982
  const target = this.recordPath(bundlePath2, conceptId2);
5151
5983
  let raw;
5152
5984
  try {
5153
- raw = await (0, import_promises9.readFile)(target, "utf8");
5985
+ raw = await (0, import_promises10.readFile)(target, "utf8");
5154
5986
  } catch {
5155
5987
  return null;
5156
5988
  }
5157
5989
  return this.parse(conceptId2, raw);
5158
5990
  }
5159
5991
  /**
5160
- * Every record in the bundle, optionally narrowed to one type.
5992
+ * Every record in the bundle, optionally narrowed to one type and to the
5993
+ * records carrying every tag in `filter.tags`. Selection only — `excludeTags`
5994
+ * is not taken here, because `query`, `catalog` and `load` read through this
5995
+ * and must adjudicate over the whole base.
5161
5996
  *
5162
5997
  * A file that fails to parse is skipped and logged rather than thrown: one
5163
5998
  * malformed record — hand-edited, or written by a producer we don't know —
5164
5999
  * must not make the whole bundle unreadable.
5165
6000
  */
5166
- async list(bundlePath2, type) {
6001
+ async list(bundlePath2, type, filter = {}) {
5167
6002
  const root = this.root(bundlePath2);
5168
6003
  let names;
5169
6004
  try {
5170
- names = await (0, import_promises9.readdir)(root);
6005
+ names = await (0, import_promises10.readdir)(root);
5171
6006
  } catch {
5172
6007
  return [];
5173
6008
  }
@@ -5175,9 +6010,11 @@ var KbStore = class {
5175
6010
  const records = await mapLimit(
5176
6011
  wanted,
5177
6012
  DEFAULT_IO_CONCURRENCY,
5178
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises9.readFile)((0, import_node_path11.join)(root, name), "utf8"))
6013
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises10.readFile)((0, import_node_path11.join)(root, name), "utf8"))
6014
+ );
6015
+ return records.filter(
6016
+ (record) => record !== null && matchesTags(record, filter)
5179
6017
  );
5180
- return records.filter((record) => record !== null);
5181
6018
  }
5182
6019
  /**
5183
6020
  * Moves a record's status, preserving everything else.
@@ -5223,8 +6060,8 @@ var KbStore = class {
5223
6060
  * and the refusal is logged under its own operation name — `mutate` only
5224
6061
  * logs what it publishes.
5225
6062
  */
5226
- async verify(bundlePath2, conceptId2, note, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
5227
- const event = kbVerifiedEventSchema.parse({ by: actor, at, note });
6063
+ async verify(bundlePath2, conceptId2, note, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
6064
+ const event = kbVerifiedEventSchema.parse({ by: actor, at: at2, note });
5228
6065
  const existing = await this.read(bundlePath2, conceptId2);
5229
6066
  if (!existing) throw new KbRecordNotFoundError(conceptId2);
5230
6067
  const generatedBy = existing.frontmatter.generated?.by;
@@ -5276,14 +6113,14 @@ var KbStore = class {
5276
6113
  return superseded;
5277
6114
  }
5278
6115
  /** Resolves an open question, stamping who answered and when. */
5279
- async answer(bundlePath2, conceptId2, answer, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
6116
+ async answer(bundlePath2, conceptId2, answer, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
5280
6117
  return this.mutate(
5281
6118
  bundlePath2,
5282
6119
  conceptId2,
5283
6120
  (frontmatter) => ({
5284
6121
  ...frontmatter,
5285
6122
  strauss_status: "resolved",
5286
- strauss_answered: { by: actor, at }
6123
+ strauss_answered: { by: actor, at: at2 }
5287
6124
  }),
5288
6125
  { operation: "answer", by: actor },
5289
6126
  (body) => `${body.trimEnd()}
@@ -5317,9 +6154,10 @@ ${answer}
5317
6154
  /* @__PURE__ */ new Date(),
5318
6155
  await this.detectDrift(narrowed, options.repoRoot)
5319
6156
  );
5320
- if (options.includeNonCurrent) return adjudicated;
5321
- const present = new Set(adjudicated.map((hit) => hit.record.conceptId));
5322
- return adjudicated.filter(
6157
+ const kept = adjudicated.filter((hit) => matchesTags(hit.record, options));
6158
+ if (options.includeNonCurrent) return kept;
6159
+ const present = new Set(kept.map((hit) => hit.record.conceptId));
6160
+ return kept.filter(
5323
6161
  (hit) => hit.standing !== "superseded" || !hit.heads.some((head) => present.has(head.conceptId))
5324
6162
  );
5325
6163
  }
@@ -5422,14 +6260,17 @@ ${answer}
5422
6260
  /* @__PURE__ */ new Date(),
5423
6261
  await this.detectDrift(wanted, options.repoRoot)
5424
6262
  );
5425
- const records = adjudicated.filter((hit) => hit.standing !== "superseded");
5426
- const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
6263
+ const kept = adjudicated.filter(
6264
+ (hit) => matchesTags(hit.record, { excludeTags: options.excludeTags })
6265
+ );
6266
+ const records = kept.filter((hit) => hit.standing !== "superseded");
6267
+ const superseded = kept.filter((hit) => hit.standing === "superseded").map(stub);
5427
6268
  const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
5428
6269
  const bundleDigestValue = bundleDigest(records, superseded);
5429
6270
  if (!options.all && approxTokens2 > budgetTokens) {
5430
6271
  return {
5431
6272
  loaded: false,
5432
- recordCount: wanted.length,
6273
+ recordCount: kept.length,
5433
6274
  approxTokens: approxTokens2,
5434
6275
  budgetTokens,
5435
6276
  message: refusalMessage({
@@ -5442,7 +6283,7 @@ ${answer}
5442
6283
  }
5443
6284
  return {
5444
6285
  loaded: true,
5445
- recordCount: wanted.length,
6286
+ recordCount: kept.length,
5446
6287
  tokensLoaded: approxTokens2,
5447
6288
  budgetTokens: options.all ? null : budgetTokens,
5448
6289
  records,
@@ -5452,24 +6293,34 @@ ${answer}
5452
6293
  }
5453
6294
  /**
5454
6295
  * `load`'s digest without `load`'s bodies — the same records, adjudicated
5455
- * the same way, handed back as a stamp. Skips the anchor drift pass, which
5456
- * reads source files and only ever adds warnings: no warning reaches the
5457
- * digest, so the value is identical to the one `load` returns.
6296
+ * the same way, handed back as a stamp.
6297
+ *
6298
+ * Drift is counted but kept out of the digest, which is what lets the reload
6299
+ * hook ask one question and get two answers: whether the base moved, and
6300
+ * whether the code under it did. A `load` and a `stamp` of the same base
6301
+ * still agree on the digest, because no warning has ever reached it.
5458
6302
  */
5459
- async stamp(bundlePath2) {
6303
+ async stamp(bundlePath2, options = {}) {
5460
6304
  const bundle = await this.list(bundlePath2);
5461
6305
  const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
5462
6306
  const current = adjudicated.filter((hit) => hit.standing !== "superseded");
5463
6307
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
5464
6308
  const stamped = bundleStamp(current, superseded);
5465
- const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
6309
+ const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at2) => typeof at2 === "string").sort();
6310
+ const drift = await this.detectDrift(bundle, options.repoRoot);
6311
+ const drifted2 = drift === void 0 ? null : [...drift.values()].filter(
6312
+ (entries) => entries.some(
6313
+ (entry) => entry.state !== "match" && !isUncheckedReason(entry.reason)
6314
+ )
6315
+ ).length;
5466
6316
  return {
5467
6317
  path: bundlePath2,
5468
6318
  digest: stamped.digest,
5469
6319
  recordCount: bundle.length,
5470
6320
  superseded: superseded.length,
5471
6321
  newestAt: dates.at(-1) ?? null,
5472
- records: stamped.records
6322
+ records: stamped.records,
6323
+ drifted: drifted2
5473
6324
  };
5474
6325
  }
5475
6326
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
@@ -5502,7 +6353,7 @@ ${answer}
5502
6353
  async readIndex(bundlePath2) {
5503
6354
  const root = this.root(bundlePath2);
5504
6355
  const expected = renderIndex(await this.list(bundlePath2));
5505
- const stored = await (0, import_promises9.readFile)((0, import_node_path11.join)(root, INDEX_FILE), "utf8").catch(
6356
+ const stored = await (0, import_promises10.readFile)((0, import_node_path11.join)(root, INDEX_FILE), "utf8").catch(
5506
6357
  () => null
5507
6358
  );
5508
6359
  if (indexIsStale(stored, expected)) {
@@ -5523,7 +6374,7 @@ ${answer}
5523
6374
  * knows which agent touched what. So a bad line is surfaced and left alone.
5524
6375
  */
5525
6376
  async readLog(bundlePath2) {
5526
- const raw = await (0, import_promises9.readFile)(
6377
+ const raw = await (0, import_promises10.readFile)(
5527
6378
  (0, import_node_path11.join)(this.root(bundlePath2), LOG_FILE),
5528
6379
  "utf8"
5529
6380
  ).catch(() => "");
@@ -5575,14 +6426,14 @@ ${answer}
5575
6426
  }
5576
6427
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
5577
6428
  const target = this.recordPath(bundlePath2, conceptId2);
5578
- const before = await (0, import_promises9.readFile)(target, "utf8").catch(() => null);
6429
+ const before = await (0, import_promises10.readFile)(target, "utf8").catch(() => null);
5579
6430
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
5580
6431
  const parsed = this.parse(conceptId2, before);
5581
6432
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
5582
6433
  const frontmatter = change(parsed.frontmatter);
5583
6434
  const body = changeBody(parsed.body);
5584
6435
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
5585
- const witness = await (0, import_promises9.readFile)(target, "utf8").catch(() => null);
6436
+ const witness = await (0, import_promises10.readFile)(target, "utf8").catch(() => null);
5586
6437
  if (witness === null || sha2563(witness) !== sha2563(before)) {
5587
6438
  throw new KbWriteConflictError(conceptId2);
5588
6439
  }
@@ -5608,20 +6459,20 @@ ${answer}
5608
6459
  */
5609
6460
  async publish(target, contents, overwrite, conceptId2) {
5610
6461
  const staging = `${target}.${process.pid}.tmp`;
5611
- await (0, import_promises9.writeFile)(staging, contents, "utf8");
6462
+ await (0, import_promises10.writeFile)(staging, contents, "utf8");
5612
6463
  try {
5613
6464
  if (overwrite) {
5614
- await (0, import_promises9.rename)(staging, target);
6465
+ await (0, import_promises10.rename)(staging, target);
5615
6466
  return;
5616
6467
  }
5617
- await (0, import_promises9.link)(staging, target);
6468
+ await (0, import_promises10.link)(staging, target);
5618
6469
  } catch (error) {
5619
6470
  if (error.code === "EEXIST") {
5620
6471
  throw new KbRecordAlreadyExistsError(conceptId2);
5621
6472
  }
5622
6473
  throw error;
5623
6474
  } finally {
5624
- await (0, import_promises9.unlink)(staging).catch(() => void 0);
6475
+ await (0, import_promises10.unlink)(staging).catch(() => void 0);
5625
6476
  }
5626
6477
  }
5627
6478
  /**
@@ -5669,14 +6520,14 @@ ${answer}
5669
6520
  try {
5670
6521
  let existing;
5671
6522
  try {
5672
- existing = await (0, import_promises9.readFile)(target, "utf8");
6523
+ existing = await (0, import_promises10.readFile)(target, "utf8");
5673
6524
  } catch (error) {
5674
6525
  if (error.code !== "ENOENT") throw error;
5675
6526
  existing = null;
5676
6527
  }
5677
6528
  if (existing === null) {
5678
6529
  try {
5679
- await (0, import_promises9.writeFile)(target, appendUnionMergeLine(""), {
6530
+ await (0, import_promises10.writeFile)(target, appendUnionMergeLine(""), {
5680
6531
  encoding: "utf8",
5681
6532
  flag: "wx"
5682
6533
  });
@@ -5697,7 +6548,7 @@ ${answer}
5697
6548
  return;
5698
6549
  }
5699
6550
  if (!hasMergeDeclaration(existing)) {
5700
- await (0, import_promises9.appendFile)(target, appendUnionMergeLine(existing), "utf8");
6551
+ await (0, import_promises10.appendFile)(target, appendUnionMergeLine(existing), "utf8");
5701
6552
  this.logger.info?.({
5702
6553
  operation: "kb.gitattributes.ensure",
5703
6554
  bundlePath: root,
@@ -5716,7 +6567,7 @@ ${answer}
5716
6567
  async record(root, entry) {
5717
6568
  await this.ensureGitattributes(root);
5718
6569
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
5719
- await (0, import_promises9.appendFile)((0, import_node_path11.join)(root, LOG_FILE), line, "utf8").catch((error) => {
6570
+ await (0, import_promises10.appendFile)((0, import_node_path11.join)(root, LOG_FILE), line, "utf8").catch((error) => {
5720
6571
  this.logger.warn?.({
5721
6572
  operation: "kb.log.append",
5722
6573
  outcome: "failed",
@@ -5793,7 +6644,7 @@ function normalizeActor(id) {
5793
6644
  }
5794
6645
 
5795
6646
  // src/version.ts
5796
- var VERSION = true ? "0.1.17" : "0.0.0-dev";
6647
+ var VERSION = true ? "0.1.19" : "0.0.0-dev";
5797
6648
 
5798
6649
  // src/mcp.ts
5799
6650
  function createKbMcpServer() {