@saasontools/strauss-kb 0.1.16 → 0.1.18

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
@@ -23,6 +23,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  mod
24
24
  ));
25
25
 
26
+ // ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.26_supports-color@8.1.1_typescript@6.0.3_yaml@2.9.0/node_modules/tsup/assets/cjs_shims.js
27
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
28
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
+
26
30
  // src/mcp.ts
27
31
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
28
32
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
@@ -75,10 +79,24 @@ var kbAnchorSchema = import_zod.z.object({
75
79
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
76
80
  message: "hash must be sha256:<64 hex chars>"
77
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(),
78
90
  /** ISO 8601 timestamp of the last successful resolution. */
79
91
  resolved_at: import_zod.z.string().min(1).optional(),
80
92
  /** Line count of the text the hash was taken over. */
81
- lines: import_zod.z.number().int().positive().optional()
93
+ lines: import_zod.z.number().int().positive().optional(),
94
+ /**
95
+ * Which resolver produced the hashed span. Absent means an anchor stamped
96
+ * before resolvers were named, which is read as `regex` — the only one
97
+ * there was. A hash from a different resolver is drift, not a match.
98
+ */
99
+ resolver: import_zod.z.enum(["tree-sitter", "regex"]).optional()
82
100
  }).strict();
83
101
  var kbLinkSchema = import_zod.z.object({
84
102
  target: import_zod.z.string().min(1),
@@ -442,7 +460,7 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
442
460
  }
443
461
 
444
462
  // src/commands/anchor-resolve.ts
445
- var import_zod6 = require("zod");
463
+ var import_zod7 = require("zod");
446
464
 
447
465
  // src/concurrency.ts
448
466
  var DEFAULT_IO_CONCURRENCY = 16;
@@ -459,9 +477,9 @@ async function mapLimit(items, limit, fn) {
459
477
  { length: Math.min(limit, items.length) },
460
478
  async () => {
461
479
  while (!failed && next < items.length) {
462
- const at = next++;
480
+ const at2 = next++;
463
481
  try {
464
- out[at] = await fn(items[at], at);
482
+ out[at2] = await fn(items[at2], at2);
465
483
  } catch (error) {
466
484
  failed = true;
467
485
  throw error;
@@ -577,8 +595,8 @@ function safeSegment(value) {
577
595
  function revRef(rev) {
578
596
  const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
579
597
  let hash = 5381;
580
- for (let at = 0; at < rev.length; at++) {
581
- hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
598
+ for (let at2 = 0; at2 < rev.length; at2++) {
599
+ hash = (hash * 33 ^ rev.charCodeAt(at2)) >>> 0;
582
600
  }
583
601
  return `refs/strauss/${safe}-${hash.toString(16)}`;
584
602
  }
@@ -687,8 +705,8 @@ function repoUrlIsSafe(repo) {
687
705
  if (!scheme?.[1]) return false;
688
706
  if (!allowed.includes(scheme[1].toLowerCase())) return false;
689
707
  const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
690
- const at = authority.lastIndexOf("@");
691
- return at < 0 || !authority.slice(0, at).includes(":");
708
+ const at2 = authority.lastIndexOf("@");
709
+ return at2 < 0 || !authority.slice(0, at2).includes(":");
692
710
  }
693
711
  function protocolArgs() {
694
712
  const allowed = allowedProtocols();
@@ -739,18 +757,18 @@ async function readOneRepo(repo, url, declared, context) {
739
757
  if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
740
758
  const cache = cachePathFor(repo, context.cacheDir);
741
759
  if (!cache) return all({ ok: false, reason: "remote-unreachable" });
742
- const rejected = /* @__PURE__ */ new Map();
760
+ const rejected2 = /* @__PURE__ */ new Map();
743
761
  const usable = [];
744
762
  for (const want of wants) {
745
763
  const reason = wantReason(want);
746
764
  if (reason)
747
- rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
765
+ rejected2.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
748
766
  else usable.push(want);
749
767
  }
750
- if (!usable.length) return rejected;
768
+ if (!usable.length) return rejected2;
751
769
  wants = usable;
752
770
  const opened = await openCache(cache, url, context);
753
- if (opened) return new Map([...rejected, ...all(opened)]);
771
+ if (opened) return new Map([...rejected2, ...all(opened)]);
754
772
  const wantsDefault = wants.some((want) => want.ref === void 0);
755
773
  const branch = wantsDefault ? await defaultBranch(cache, context) : {};
756
774
  const revs = /* @__PURE__ */ new Map();
@@ -777,9 +795,9 @@ async function readOneRepo(repo, url, declared, context) {
777
795
  }
778
796
  );
779
797
  return new Map([
780
- ...rejected,
798
+ ...rejected2,
781
799
  ...wants.map(
782
- (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
800
+ (want, at2) => [wantKey(repo, want.ref, want.file), reads[at2]]
783
801
  )
784
802
  ]);
785
803
  }
@@ -829,13 +847,13 @@ async function defaultBranch(cache, context) {
829
847
  if (!listed.ok) {
830
848
  const reason = transportReason(listed.stderr);
831
849
  if (reason !== "ref-not-found") {
832
- const cached2 = await cachedBranch(cache);
833
- return cached2 ? { name: cached2 } : { reason };
850
+ const cached3 = await cachedBranch(cache);
851
+ return cached3 ? { name: cached3 } : { reason };
834
852
  }
835
853
  }
836
854
  }
837
- const cached = await cachedBranch(cache);
838
- if (cached) return { name: cached };
855
+ const cached2 = await cachedBranch(cache);
856
+ if (cached2) return { name: cached2 };
839
857
  return {
840
858
  reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
841
859
  };
@@ -862,8 +880,8 @@ async function ensureRev(cache, rev, context) {
862
880
  cwd: cache
863
881
  }
864
882
  );
865
- const cached = have.ok && have.stdout.trim().length > 0;
866
- if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
883
+ const cached2 = have.ok && have.stdout.trim().length > 0;
884
+ if (cached2 && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
867
885
  if (context.offline) return { ok: false, reason: "remote-unreachable" };
868
886
  const fetched = await git(
869
887
  [
@@ -879,7 +897,7 @@ async function ensureRev(cache, rev, context) {
879
897
  );
880
898
  if (!fetched.ok) {
881
899
  const reason = transportReason(fetched.stderr);
882
- if (cached && reason !== "ref-not-found") return void 0;
900
+ if (cached2 && reason !== "ref-not-found") return void 0;
883
901
  return { ok: false, reason };
884
902
  }
885
903
  const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
@@ -990,67 +1008,655 @@ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY
990
1008
  return { ok: false, reason: "file-unreadable" };
991
1009
  }
992
1010
  });
993
- return new Map(wanted.map((file, at) => [file, results[at]]));
1011
+ return new Map(wanted.map((file, at2) => [file, results[at2]]));
994
1012
  }
995
1013
 
996
1014
  // src/anchor-resolver/resolver.ts
1015
+ var import_node_crypto3 = require("crypto");
1016
+
1017
+ // src/tree-sitter-resolver/languages.ts
1018
+ var import_node_path5 = require("path");
1019
+
1020
+ // src/grammars/index.ts
1021
+ var import_promises4 = require("fs/promises");
1022
+
1023
+ // src/grammars/store.ts
997
1024
  var import_node_crypto = require("crypto");
1025
+ var import_promises3 = require("fs/promises");
1026
+ var import_node_os2 = require("os");
1027
+ var import_node_path3 = require("path");
1028
+ function grammarsCacheRoot(override) {
1029
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_DIR"] ?? (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".strauss", "grammars");
1030
+ }
1031
+ function grammarCachePath(root, language, sha2564, extension = "wasm") {
1032
+ return (0, import_node_path3.join)(root, language, `${sha2564.slice(0, 12)}.${extension}`);
1033
+ }
1034
+ function sha256(bytes) {
1035
+ return (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
1036
+ }
1037
+ function matches(bytes, entry) {
1038
+ if (entry.bytes !== void 0 && bytes.byteLength !== entry.bytes)
1039
+ return false;
1040
+ return sha256(bytes) === entry.sha256;
1041
+ }
1042
+ async function verifyCached(path, entry) {
1043
+ let bytes;
1044
+ try {
1045
+ bytes = await (0, import_promises3.readFile)(path);
1046
+ } catch {
1047
+ return false;
1048
+ }
1049
+ if (matches(bytes, entry)) return true;
1050
+ await (0, import_promises3.rm)(path, { force: true });
1051
+ return false;
1052
+ }
1053
+ async function writeCached(path, bytes) {
1054
+ await (0, import_promises3.mkdir)((0, import_node_path3.dirname)(path), { recursive: true });
1055
+ const temporary = `${path}.${process.pid}.${(0, import_node_crypto.randomBytes)(4).toString("hex")}.tmp`;
1056
+ try {
1057
+ await (0, import_promises3.writeFile)(temporary, bytes);
1058
+ await (0, import_promises3.rename)(temporary, path);
1059
+ } catch (error) {
1060
+ await (0, import_promises3.rm)(temporary, { force: true });
1061
+ throw error;
1062
+ }
1063
+ }
1064
+
1065
+ // src/grammars/fetch.ts
1066
+ var ATTEMPTS = 3;
1067
+ var BACKOFF_MS = 250;
1068
+ function grammarUrl(url, override) {
1069
+ const base2 = grammarsBaseUrl(override);
1070
+ if (!base2) return url;
1071
+ let root = base2;
1072
+ while (root.endsWith("/")) root = root.slice(0, -1);
1073
+ const pinned = new URL(url);
1074
+ return `${root}${pinned.pathname}${pinned.search}`;
1075
+ }
1076
+ function grammarsBaseUrl(override) {
1077
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_URL"];
1078
+ }
1079
+ async function downloadPart(url, name, entry, options = {}) {
1080
+ const log = options.log ?? ((line) => void process.stderr.write(line));
1081
+ const weight = entry.bytes === void 0 ? "" : ` (${size(entry.bytes)} from manifest)`;
1082
+ log(`strauss-kb: downloading ${name}${weight} from ${url}
1083
+ `);
1084
+ let cause = "";
1085
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
1086
+ const outcome = await attemptDownload(url, entry, options.fetchTimeoutMs);
1087
+ if ("bytes" in outcome) return outcome;
1088
+ cause = outcome.cause;
1089
+ log(
1090
+ `strauss-kb: ${name} attempt ${attempt}/${ATTEMPTS} failed: ${cause}
1091
+ `
1092
+ );
1093
+ if (!outcome.retry) break;
1094
+ if (attempt < ATTEMPTS) await pause(BACKOFF_MS * attempt);
1095
+ }
1096
+ log(`strauss-kb: ${name} not downloaded: ${cause}
1097
+ `);
1098
+ return { cause };
1099
+ }
1100
+ async function attemptDownload(url, entry, timeoutMs) {
1101
+ try {
1102
+ const response = await fetch(url, {
1103
+ signal: AbortSignal.timeout(fetchTimeoutMs(timeoutMs))
1104
+ });
1105
+ if (!response.ok) {
1106
+ return {
1107
+ cause: `HTTP ${response.status}`,
1108
+ retry: response.status >= 500 || response.status === 429
1109
+ };
1110
+ }
1111
+ const bytes = new Uint8Array(await response.arrayBuffer());
1112
+ if (!matches(bytes, entry))
1113
+ return { cause: "sha256 mismatch", retry: false };
1114
+ return { bytes };
1115
+ } catch (error) {
1116
+ const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
1117
+ return { cause: timedOut ? "timeout" : "network error", retry: true };
1118
+ }
1119
+ }
1120
+ function size(bytes) {
1121
+ return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1122
+ }
1123
+ function pause(ms) {
1124
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
1125
+ }
1126
+
1127
+ // src/grammars/manifest.ts
1128
+ var import_node_fs = require("fs");
1129
+ var import_node_path4 = require("path");
1130
+ var import_node_url = require("url");
1131
+
1132
+ // src/grammars/model.ts
1133
+ var import_zod4 = require("zod");
1134
+ var sha2562 = import_zod4.z.string().regex(/^[0-9a-f]{64}$/);
1135
+ var grammarWasmSchema = import_zod4.z.object({
1136
+ url: import_zod4.z.string().min(1),
1137
+ sha256: sha2562,
1138
+ bytes: import_zod4.z.number().int().positive()
1139
+ });
1140
+ var grammarTagsSchema = import_zod4.z.object({ url: import_zod4.z.string().min(1), sha256: sha2562 });
1141
+ var grammarPackSchema = import_zod4.z.object({
1142
+ package: import_zod4.z.string().min(1),
1143
+ wasm: grammarWasmSchema,
1144
+ tags: import_zod4.z.array(grammarTagsSchema),
1145
+ license: import_zod4.z.string().min(1),
1146
+ extensions: import_zod4.z.array(import_zod4.z.string().min(1))
1147
+ });
1148
+ var grammarManifestSchema = import_zod4.z.object({
1149
+ /** The runtime the packs were proved against. */
1150
+ webTreeSitter: import_zod4.z.string().min(1),
1151
+ linguist: import_zod4.z.object({ tag: import_zod4.z.string().min(1), commit: import_zod4.z.string().min(1) }),
1152
+ packs: import_zod4.z.record(import_zod4.z.string().min(1), grammarPackSchema)
1153
+ });
1154
+
1155
+ // src/grammars/manifest.ts
1156
+ var cached;
1157
+ function grammarManifest() {
1158
+ cached ??= grammarManifestSchema.parse(
1159
+ JSON.parse((0, import_node_fs.readFileSync)(grammarsDataPath("manifest.json"), "utf8"))
1160
+ );
1161
+ return cached;
1162
+ }
1163
+ function grammarsDataPath(...segments) {
1164
+ let dir = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
1165
+ for (let up = 0; up < 5; up++) {
1166
+ const candidate = (0, import_node_path4.join)(dir, "grammars");
1167
+ if ((0, import_node_fs.existsSync)((0, import_node_path4.join)(candidate, "manifest.json")))
1168
+ return (0, import_node_path4.join)(candidate, ...segments);
1169
+ dir = (0, import_node_path4.dirname)(dir);
1170
+ }
1171
+ throw new Error("grammars/manifest.json is missing from the package");
1172
+ }
1173
+
1174
+ // src/grammars/index.ts
1175
+ var inFlight = /* @__PURE__ */ new Map();
1176
+ var missing = /* @__PURE__ */ new Map();
1177
+ var uncompilable = /* @__PURE__ */ new Map();
1178
+ var rejected = /* @__PURE__ */ new Map();
1179
+ function grammarsDownloadDisabled() {
1180
+ return process.env["STRAUSS_KB_GRAMMARS"] === "off";
1181
+ }
1182
+ async function ensureGrammar(language, options = {}) {
1183
+ const pack2 = grammarManifest().packs[language];
1184
+ if (!pack2) return null;
1185
+ const root = grammarsCacheRoot(options.cacheRoot);
1186
+ const wasm = grammarCachePath(root, language, pack2.wasm.sha256);
1187
+ const key = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1188
+ const existing = inFlight.get(key);
1189
+ if (existing) return existing;
1190
+ const pending = (async () => {
1191
+ const grammar = await ensurePart(
1192
+ wasm,
1193
+ `tree-sitter-${language}`,
1194
+ pack2.wasm,
1195
+ options
1196
+ );
1197
+ if (grammar !== true)
1198
+ return miss(language, `grammar tree-sitter-${language}`, grammar);
1199
+ const parts = [];
1200
+ const total = pack2.tags.length;
1201
+ for (const [at2, part] of pack2.tags.entries()) {
1202
+ const name = `${language} tags${total > 1 ? ` part ${at2 + 1}/${total}` : ""}`;
1203
+ const path = grammarCachePath(root, language, part.sha256, "scm");
1204
+ const held = await ensurePart(path, name, part, options);
1205
+ if (held !== true) return miss(language, name, held);
1206
+ parts.push(`; ${part.url}
1207
+ ${lf(await (0, import_promises4.readFile)(path, "utf8"))}`);
1208
+ }
1209
+ missing.delete(language);
1210
+ return { wasm, query: total ? parts.join("\n") : void 0 };
1211
+ })();
1212
+ inFlight.set(key, pending);
1213
+ const result = await pending;
1214
+ if (result === null) inFlight.delete(key);
1215
+ return result;
1216
+ }
1217
+ async function ensurePart(path, name, entry, options) {
1218
+ if (await verifyCached(path, entry)) return true;
1219
+ if (options.offline === true || grammarsDownloadDisabled()) return {};
1220
+ const download = await downloadPart(
1221
+ grammarUrl(entry.url, options.baseUrl),
1222
+ name,
1223
+ entry,
1224
+ options
1225
+ );
1226
+ if ("cause" in download) return { cause: download.cause };
1227
+ await writeCached(path, download.bytes).catch(() => null);
1228
+ return true;
1229
+ }
1230
+ function miss(language, subject, failure) {
1231
+ missing.set(language, { subject, ...failure });
1232
+ return null;
1233
+ }
1234
+ function lf(body) {
1235
+ return body.replace(/\r\n/g, "\n");
1236
+ }
1237
+ function noteUncompilableQuery(language, cause) {
1238
+ uncompilable.set(language, cause);
1239
+ }
1240
+ function noteRejectedGrammar(language, cause) {
1241
+ rejected.set(language, cause);
1242
+ }
1243
+ function grammarHints() {
1244
+ const manifest = grammarManifest();
1245
+ const packs = manifest.packs;
1246
+ const lines = /* @__PURE__ */ new Map();
1247
+ for (const [language, { subject, cause }] of missing)
1248
+ lines.set(
1249
+ language,
1250
+ `${subject} not cached${cause ? ` (${cause})` : ""}; run online once, or set STRAUSS_KB_GRAMMARS_DIR`
1251
+ );
1252
+ for (const [language, cause] of rejected)
1253
+ lines.set(
1254
+ language,
1255
+ `${packs[language]?.package ?? `tree-sitter-${language}`} rejected by web-tree-sitter ${manifest.webTreeSitter}${cause ? `: ${cause}` : ""}; re-pin with pnpm grammars pin ${language}`
1256
+ );
1257
+ for (const [language, cause] of uncompilable)
1258
+ lines.set(
1259
+ language,
1260
+ `tags query for ${language} does not compile against ${packs[language]?.package ?? `tree-sitter-${language}`}: ${cause}; re-pin with pnpm grammars pin ${language}`
1261
+ );
1262
+ return [...lines].sort(([a], [b]) => a.localeCompare(b)).map(([, line]) => line);
1263
+ }
1264
+
1265
+ // src/tree-sitter-resolver/languages.ts
1266
+ var table;
1267
+ function extensionTable() {
1268
+ const manifest = grammarManifest();
1269
+ if (table?.of !== manifest)
1270
+ table = {
1271
+ of: manifest,
1272
+ extensions: Object.fromEntries(
1273
+ Object.entries(manifest.packs).flatMap(
1274
+ ([language, pack2]) => pack2.extensions.map((extension) => [extension, language])
1275
+ )
1276
+ )
1277
+ };
1278
+ return table.extensions;
1279
+ }
1280
+ function hasQuery(language) {
1281
+ return (grammarManifest().packs[language]?.tags.length ?? 0) > 0;
1282
+ }
1283
+ function languageForFile(file) {
1284
+ const language = extensionTable()[(0, import_node_path5.extname)(file).toLowerCase()];
1285
+ return language && hasQuery(language) ? language : void 0;
1286
+ }
1287
+
1288
+ // src/tree-sitter-resolver/resolver.ts
1289
+ var import_node_crypto2 = require("crypto");
1290
+ var import_web_tree_sitter = require("web-tree-sitter");
1291
+
1292
+ // src/tree-sitter-resolver/definitions.ts
1293
+ var SCOPE_ONLY = "reference.implementation";
1294
+ function index(tree, query) {
1295
+ const byName = /* @__PURE__ */ new Map();
1296
+ for (const match of query.matches(tree.rootNode)) {
1297
+ const nameNode = match.captures.find((capture) => capture.name === "name");
1298
+ const defNode = match.captures.find(
1299
+ (capture) => capture.name.startsWith("definition.") || capture.name === SCOPE_ONLY
1300
+ );
1301
+ if (!nameNode || !defNode) continue;
1302
+ const candidate = {
1303
+ node: defNode.node,
1304
+ name: nameNode.node.text,
1305
+ target: defNode.name !== SCOPE_ONLY
1306
+ };
1307
+ const existing = byName.get(nameNode.node.id);
1308
+ if (existing && width(existing.node) <= width(candidate.node)) continue;
1309
+ byName.set(nameNode.node.id, candidate);
1310
+ }
1311
+ const definitions = [...byName.values()];
1312
+ return {
1313
+ tree,
1314
+ byNodeId: new Map(
1315
+ definitions.map((definition) => [definition.node.id, definition])
1316
+ ),
1317
+ definitions
1318
+ };
1319
+ }
1320
+ function select(parsed, wanted) {
1321
+ const matches3 = parsed.definitions.filter(
1322
+ (definition) => definition.target && endsWith(chainOf(definition, parsed.byNodeId), wanted)
1323
+ );
1324
+ if (matches3.length < 2) return matches3;
1325
+ const bodied = matches3.filter(
1326
+ (definition) => definition.node.childForFieldName("body") !== null
1327
+ );
1328
+ return bodied.length === 1 ? bodied : matches3;
1329
+ }
1330
+ function chainOf(definition, byNodeId) {
1331
+ const chain = [definition.name];
1332
+ const receiver = definition.node.childForFieldName("receiver");
1333
+ const type = receiver && typeNameIn(receiver);
1334
+ if (type) chain.unshift(type);
1335
+ for (let node = definition.node.parent; node; node = node.parent) {
1336
+ const enclosing = byNodeId.get(node.id);
1337
+ if (enclosing && enclosing.node !== definition.node)
1338
+ chain.unshift(enclosing.name);
1339
+ }
1340
+ return chain;
1341
+ }
1342
+ function typeNameIn(receiver) {
1343
+ const stack = [receiver];
1344
+ while (stack.length) {
1345
+ const node = stack.pop();
1346
+ if (node.type === "type_identifier") return node.text;
1347
+ for (let at2 = 0; at2 < node.childCount; at2++) {
1348
+ const child = node.child(at2);
1349
+ if (child) stack.push(child);
1350
+ }
1351
+ }
1352
+ return void 0;
1353
+ }
1354
+ function endsWith(chain, wanted) {
1355
+ if (wanted.length > chain.length) return false;
1356
+ const offset = chain.length - wanted.length;
1357
+ return wanted.every((segment, at2) => chain[offset + at2] === segment);
1358
+ }
1359
+ function width(node) {
1360
+ return node.endIndex - node.startIndex;
1361
+ }
1362
+ function spanOf(definition, source) {
1363
+ let start = definition.node;
1364
+ let end = definition.node;
1365
+ for (let sibling = start.previousSibling; sibling?.type === "decorator"; sibling = sibling.previousSibling) {
1366
+ start = sibling;
1367
+ }
1368
+ const parent = end.parent;
1369
+ if (parent?.type === "export_statement" && parent.childForFieldName("declaration")?.id === end.id) {
1370
+ start = parent;
1371
+ end = parent;
1372
+ }
1373
+ const lines = source.split("\n");
1374
+ const startLine = start.startPosition.row;
1375
+ const endLine = end.endPosition.column === 0 && end.endPosition.row > startLine ? end.endPosition.row - 1 : end.endPosition.row;
1376
+ return {
1377
+ text: lines.slice(startLine, endLine + 1).join("\n"),
1378
+ startLine: startLine + 1,
1379
+ endLine: endLine + 1
1380
+ };
1381
+ }
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
+
1403
+ // src/tree-sitter-resolver/resolver.ts
1404
+ var TREE_CACHE_LIMIT = 32;
1405
+ var TreeSitterResolver = class {
1406
+ name = "tree-sitter";
1407
+ grammars;
1408
+ loaded = /* @__PURE__ */ new Map();
1409
+ trees = /* @__PURE__ */ new Map();
1410
+ parser;
1411
+ initialized = false;
1412
+ /** Cache effectiveness, for tests and for the latency numbers. */
1413
+ stats = { parses: 0, cacheHits: 0 };
1414
+ constructor(options = {}) {
1415
+ this.grammars = options;
1416
+ }
1417
+ /**
1418
+ * Loads the grammars these files need, once per language per process,
1419
+ * downloading each one on first use.
1420
+ *
1421
+ * A grammar that will not load is remembered as unavailable rather than
1422
+ * retried per anchor, and never throws: an unobtainable WASM is a finding.
1423
+ */
1424
+ async prepare(files) {
1425
+ const wanted = /* @__PURE__ */ new Set();
1426
+ for (const file of files) {
1427
+ const language = languageForFile(file);
1428
+ if (language && !this.loaded.has(language)) wanted.add(language);
1429
+ }
1430
+ if (!wanted.size) return;
1431
+ if (!this.initialized) {
1432
+ try {
1433
+ await import_web_tree_sitter.Parser.init();
1434
+ this.parser = new import_web_tree_sitter.Parser();
1435
+ this.initialized = true;
1436
+ } catch {
1437
+ for (const language of wanted) this.loaded.set(language, null);
1438
+ return;
1439
+ }
1440
+ }
1441
+ const languages = [...wanted];
1442
+ const loaded = await mapLimit(
1443
+ languages,
1444
+ Math.min(DEFAULT_IO_CONCURRENCY, languages.length),
1445
+ (language) => this.load(language)
1446
+ );
1447
+ languages.forEach(
1448
+ (language, at2) => this.loaded.set(language, loaded[at2] ?? null)
1449
+ );
1450
+ }
1451
+ /**
1452
+ * An unobtainable grammar, one this runtime refuses, and a query that will
1453
+ * not compile are three faults with three repairs; all are reported through
1454
+ * the grammars module so every hint has one home.
1455
+ */
1456
+ async load(language) {
1457
+ let pack2;
1458
+ try {
1459
+ pack2 = await ensureGrammar(language, this.grammars);
1460
+ } catch {
1461
+ return null;
1462
+ }
1463
+ if (!pack2?.query) return null;
1464
+ let grammar;
1465
+ try {
1466
+ grammar = await import_web_tree_sitter.Language.load(pack2.wasm);
1467
+ } catch (error) {
1468
+ noteRejectedGrammar(language, why(error));
1469
+ return null;
1470
+ }
1471
+ try {
1472
+ return { language: grammar, query: new import_web_tree_sitter.Query(grammar, pack2.query) };
1473
+ } catch (error) {
1474
+ noteUncompilableQuery(language, why(error));
1475
+ return null;
1476
+ }
1477
+ }
1478
+ /**
1479
+ * Abstains on an extension with no grammar so the regex resolver gets a
1480
+ * turn; reports `resolver-unavailable` when the grammar exists in principle
1481
+ * but could not be loaded, because falling back there would silently trade a
1482
+ * precise span for a guessed one.
1483
+ */
1484
+ attempt(source, symbol, file) {
1485
+ const language = file ? languageForFile(file) : void 0;
1486
+ if (!language) return { kind: "abstain" };
1487
+ if (!this.loaded.has(language)) return { kind: "abstain" };
1488
+ const loaded = this.loaded.get(language);
1489
+ if (!loaded) return { kind: "unresolved", reason: "resolver-unavailable" };
1490
+ const parsed = this.parse(language, loaded, source);
1491
+ if (!parsed) return { kind: "unresolved", reason: "resolver-unavailable" };
1492
+ const wanted = symbol.split(".").filter(Boolean);
1493
+ if (!wanted.length)
1494
+ return { kind: "unresolved", reason: "symbol-not-found" };
1495
+ const matches3 = select(parsed, wanted);
1496
+ if (!matches3.length)
1497
+ return { kind: "unresolved", reason: "symbol-not-found" };
1498
+ if (matches3.length > 1)
1499
+ return { kind: "unresolved", reason: "symbol-ambiguous" };
1500
+ return { kind: "resolved", span: spanOf(matches3[0], source) };
1501
+ }
1502
+ resolve(source, symbol, file) {
1503
+ const attempt = this.attempt(source, symbol, file);
1504
+ return attempt.kind === "resolved" ? attempt.span : null;
1505
+ }
1506
+ /** Parsed trees are keyed by content hash, so an unchanged file parses once. */
1507
+ parse(language, loaded, source) {
1508
+ const key = `${language}:${(0, import_node_crypto2.createHash)("sha256").update(source).digest("hex")}`;
1509
+ const cached2 = this.trees.get(key);
1510
+ if (cached2) {
1511
+ this.stats.cacheHits += 1;
1512
+ return cached2;
1513
+ }
1514
+ const parser = this.parser;
1515
+ if (!parser) return null;
1516
+ let parsed;
1517
+ try {
1518
+ parser.setLanguage(loaded.language);
1519
+ const tree = parser.parse(source);
1520
+ if (!tree) return null;
1521
+ parsed = index(tree, loaded.query);
1522
+ } catch {
1523
+ return null;
1524
+ }
1525
+ this.stats.parses += 1;
1526
+ if (this.trees.size >= TREE_CACHE_LIMIT) {
1527
+ const oldest = this.trees.keys().next();
1528
+ if (!oldest.done) {
1529
+ this.trees.get(oldest.value)?.tree.delete();
1530
+ this.trees.delete(oldest.value);
1531
+ }
1532
+ }
1533
+ this.trees.set(key, parsed);
1534
+ return parsed;
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
+ }
1590
+ /** Drops cached trees. Grammars stay loaded — they are immutable. */
1591
+ reset() {
1592
+ for (const parsed of this.trees.values()) parsed.tree.delete();
1593
+ this.trees.clear();
1594
+ this.stats.parses = 0;
1595
+ this.stats.cacheHits = 0;
1596
+ }
1597
+ };
1598
+ function why(error) {
1599
+ const text = error instanceof Error ? error.message : String(error);
1600
+ return text || "no reason given";
1601
+ }
1602
+
1603
+ // src/anchor-resolver/resolver.ts
998
1604
  var PARENT_SCOPE_LINES = 50;
999
1605
  var CLEAN_STATE = { blockComment: false, template: false };
1000
1606
  function stripLine(line, state) {
1001
1607
  let out = "";
1002
- let index = 0;
1608
+ let index2 = 0;
1003
1609
  let { blockComment, template } = state;
1004
- while (index < line.length) {
1005
- const char = line[index];
1006
- const next = line[index + 1];
1610
+ while (index2 < line.length) {
1611
+ const char = line[index2];
1612
+ const next = line[index2 + 1];
1007
1613
  if (blockComment) {
1008
1614
  if (char === "*" && next === "/") {
1009
1615
  blockComment = false;
1010
- index += 2;
1616
+ index2 += 2;
1011
1617
  continue;
1012
1618
  }
1013
- index += 1;
1619
+ index2 += 1;
1014
1620
  continue;
1015
1621
  }
1016
1622
  if (template) {
1017
1623
  if (char === "\\") {
1018
- index += 2;
1624
+ index2 += 2;
1019
1625
  continue;
1020
1626
  }
1021
1627
  if (char === "`") template = false;
1022
- index += 1;
1628
+ index2 += 1;
1023
1629
  continue;
1024
1630
  }
1025
1631
  if (char === "/" && next === "*") {
1026
1632
  blockComment = true;
1027
- index += 2;
1633
+ index2 += 2;
1028
1634
  continue;
1029
1635
  }
1030
1636
  if (char === "/" && next === "/") break;
1031
1637
  if (char === "`") {
1032
1638
  template = true;
1033
- index += 1;
1639
+ index2 += 1;
1034
1640
  continue;
1035
1641
  }
1036
1642
  if (char === "'" || char === '"') {
1037
1643
  const quote = char;
1038
- index += 1;
1039
- while (index < line.length) {
1040
- if (line[index] === "\\") {
1041
- index += 2;
1644
+ index2 += 1;
1645
+ while (index2 < line.length) {
1646
+ if (line[index2] === "\\") {
1647
+ index2 += 2;
1042
1648
  continue;
1043
1649
  }
1044
- if (line[index] === quote) {
1045
- index += 1;
1650
+ if (line[index2] === quote) {
1651
+ index2 += 1;
1046
1652
  break;
1047
1653
  }
1048
- index += 1;
1654
+ index2 += 1;
1049
1655
  }
1050
1656
  continue;
1051
1657
  }
1052
1658
  out += char;
1053
- index += 1;
1659
+ index2 += 1;
1054
1660
  }
1055
1661
  return { code: out, state: { blockComment, template } };
1056
1662
  }
@@ -1065,8 +1671,8 @@ function captureBraceBlock(lines, matchLine) {
1065
1671
  let depth = 0;
1066
1672
  let opened = false;
1067
1673
  let state = CLEAN_STATE;
1068
- for (let index = matchLine; index < lines.length; index++) {
1069
- const stripped = stripLine(lines[index] ?? "", state);
1674
+ for (let index2 = matchLine; index2 < lines.length; index2++) {
1675
+ const stripped = stripLine(lines[index2] ?? "", state);
1070
1676
  state = stripped.state;
1071
1677
  for (const char of stripped.code) {
1072
1678
  if (char === "{") {
@@ -1075,10 +1681,10 @@ function captureBraceBlock(lines, matchLine) {
1075
1681
  } else if (char === "}") {
1076
1682
  depth = Math.max(0, depth - 1);
1077
1683
  } else if (char === ";" && !opened) {
1078
- return span(lines, matchLine, index);
1684
+ return span(lines, matchLine, index2);
1079
1685
  }
1080
1686
  }
1081
- if (opened && depth === 0) return span(lines, matchLine, index);
1687
+ if (opened && depth === 0) return span(lines, matchLine, index2);
1082
1688
  }
1083
1689
  return null;
1084
1690
  }
@@ -1087,22 +1693,22 @@ function captureIndentedBlock(lines, matchLine) {
1087
1693
  const header = lines[matchLine] ?? "";
1088
1694
  const indent = header.length - header.trimStart().length;
1089
1695
  let headerEnd = -1;
1090
- for (let index = matchLine; index < lines.length && index <= matchLine + 20; index++) {
1091
- const code = stripLine(lines[index] ?? "", CLEAN_STATE).code.trimEnd();
1696
+ for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1697
+ const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
1092
1698
  if (code.endsWith(":")) {
1093
- headerEnd = index;
1699
+ headerEnd = index2;
1094
1700
  break;
1095
1701
  }
1096
- if (code.includes(":")) return span(lines, matchLine, index);
1702
+ if (code.includes(":")) return span(lines, matchLine, index2);
1097
1703
  }
1098
1704
  if (headerEnd === -1) return null;
1099
1705
  let end = headerEnd;
1100
- for (let index = headerEnd + 1; index < lines.length; index++) {
1101
- const line = lines[index] ?? "";
1706
+ for (let index2 = headerEnd + 1; index2 < lines.length; index2++) {
1707
+ const line = lines[index2] ?? "";
1102
1708
  if (line.trim() === "") continue;
1103
1709
  const lineIndent = line.length - line.trimStart().length;
1104
1710
  if (lineIndent <= indent) break;
1105
- end = index;
1711
+ end = index2;
1106
1712
  }
1107
1713
  return end === headerEnd ? null : span(lines, matchLine, end);
1108
1714
  }
@@ -1126,15 +1732,15 @@ var regexResolver = {
1126
1732
  const lines = source.split("\n");
1127
1733
  for (const tier of TIERS) {
1128
1734
  const pattern = tier(escaped);
1129
- let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1735
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1130
1736
  if (!candidates.length) continue;
1131
1737
  if (parentPattern && candidates.length > 1) {
1132
1738
  const distances = candidates.map(
1133
- (index) => distanceToParent(lines, index, parentPattern)
1739
+ (index2) => distanceToParent(lines, index2, parentPattern)
1134
1740
  );
1135
1741
  const nearest = Math.min(...distances);
1136
1742
  if (Number.isFinite(nearest)) {
1137
- candidates = candidates.filter((_, at) => distances[at] === nearest);
1743
+ candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1138
1744
  }
1139
1745
  }
1140
1746
  if (candidates.length !== 1) return null;
@@ -1147,34 +1753,82 @@ var regexResolver = {
1147
1753
  function escapeRegExp(value) {
1148
1754
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1149
1755
  }
1150
- function distanceToParent(lines, index, parent) {
1151
- const floor = Math.max(0, index - PARENT_SCOPE_LINES);
1152
- for (let at = index; at >= floor; at--) {
1153
- if (parent.test(lines[at] ?? "")) return index - at;
1756
+ function distanceToParent(lines, index2, parent) {
1757
+ const floor = Math.max(0, index2 - PARENT_SCOPE_LINES);
1758
+ for (let at2 = index2; at2 >= floor; at2--) {
1759
+ if (parent.test(lines[at2] ?? "")) return index2 - at2;
1154
1760
  }
1155
1761
  return Number.POSITIVE_INFINITY;
1156
1762
  }
1157
1763
  function hashAnchorText(text) {
1158
- return `sha256:${(0, import_node_crypto.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1764
+ return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1159
1765
  }
1160
- function resolveAnchor(source, anchor, resolver = regexResolver) {
1766
+ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1161
1767
  const normalized = source.replace(/\r\n/g, "\n");
1162
1768
  if (!anchor.symbol) {
1163
1769
  const lines = normalized.split("\n");
1164
1770
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1165
1771
  return {
1166
- text: normalized,
1167
- startLine: 1,
1168
- endLine: Math.max(1, lines.length)
1772
+ ok: true,
1773
+ span: {
1774
+ text: normalized,
1775
+ startLine: 1,
1776
+ endLine: Math.max(1, lines.length)
1777
+ }
1169
1778
  };
1170
1779
  }
1171
- return resolver.resolve(normalized, anchor.symbol);
1780
+ for (const resolver of resolvers) {
1781
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1782
+ if (attempt.kind === "abstain") continue;
1783
+ if (attempt.kind === "unresolved") {
1784
+ if (attempt.reason === "symbol-not-found") continue;
1785
+ return { ok: false, reason: attempt.reason };
1786
+ }
1787
+ const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
1788
+ return {
1789
+ ok: true,
1790
+ span: attempt.span,
1791
+ ...isResolverName(resolver.name) ? { resolver: resolver.name } : {},
1792
+ ...tokens2 ? { normalized: tokens2 } : {}
1793
+ };
1794
+ }
1795
+ return { ok: false, reason: "symbol-not-found" };
1796
+ }
1797
+ function fromResolve(resolver, source, symbol, file) {
1798
+ const span2 = resolver.resolve(source, symbol, file);
1799
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1800
+ }
1801
+ function isResolverName(name) {
1802
+ return name === "tree-sitter" || name === "regex";
1803
+ }
1804
+ async function prepareResolvers(resolvers, files) {
1805
+ for (const resolver of resolvers) await resolver.prepare?.(files);
1806
+ }
1807
+ function defaultAnchorResolvers(grammars = {}) {
1808
+ return [new TreeSitterResolver(grammars), regexResolver];
1809
+ }
1810
+ function resolverChanged(source, anchor, produced) {
1811
+ const previous = anchor.resolver ?? "regex";
1812
+ if (!produced || !anchor.symbol || previous === produced) return false;
1813
+ if (previous !== "regex") return false;
1814
+ const before = regexResolver.resolve(
1815
+ source.replace(/\r\n/g, "\n"),
1816
+ anchor.symbol
1817
+ );
1818
+ return before !== null && hashAnchorText(before.text) === anchor.hash;
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" };
1172
1824
  }
1173
1825
 
1174
1826
  // src/anchor-resolver/drift.ts
1175
1827
  async function detectAnchorDrift(records, options = {}) {
1176
1828
  const repoRoot = options.repoRoot ?? process.cwd();
1177
- const resolver = options.resolver ?? regexResolver;
1829
+ const resolvers = options.resolvers ?? (options.resolver ? [options.resolver] : defaultAnchorResolvers({
1830
+ offline: options.remote?.offline === true
1831
+ }));
1178
1832
  const origin = new LazyOrigin(repoRoot);
1179
1833
  const planned = /* @__PURE__ */ new Map();
1180
1834
  let declaresRepo = false;
@@ -1212,12 +1866,16 @@ async function detectAnchorDrift(records, options = {}) {
1212
1866
  ),
1213
1867
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1214
1868
  ]);
1869
+ await prepareResolvers(resolvers, [
1870
+ ...files,
1871
+ ...wants.map((want) => want.file)
1872
+ ]);
1215
1873
  const drift = /* @__PURE__ */ new Map();
1216
1874
  for (const record of records) {
1217
1875
  const entries = [];
1218
1876
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
1219
1877
  entries.push(
1220
- foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1878
+ foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
1221
1879
  );
1222
1880
  }
1223
1881
  if (entries.length) drift.set(record.conceptId, entries);
@@ -1243,50 +1901,94 @@ function unresolved(anchor, reason, repo) {
1243
1901
  state: "unresolved",
1244
1902
  diffSize: null,
1245
1903
  ...reason ? { reason } : {},
1246
- ...repo ? { repo } : {}
1904
+ ...repo ? { repo } : {},
1905
+ ...classOf(reason)
1247
1906
  };
1248
1907
  }
1249
- function hashIn(source, anchor, resolver) {
1250
- const resolved = resolveAnchor(source, anchor, resolver);
1251
- if (!resolved) return null;
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
+ }
1918
+ function hashIn(source, anchor, resolvers) {
1919
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
1920
+ if (!outcome.ok) return { ok: false, reason: outcome.reason };
1921
+ const { hash, kind } = anchorHashOf(anchor, outcome);
1252
1922
  return {
1253
- hash: hashAnchorText(resolved.text),
1254
- lines: resolved.endLine - resolved.startLine + 1
1923
+ ok: true,
1924
+ current: {
1925
+ hash,
1926
+ kind,
1927
+ lines: outcome.span.endLine - outcome.span.startLine + 1,
1928
+ ...outcome.resolver ? { resolver: outcome.resolver } : {}
1929
+ }
1930
+ };
1931
+ }
1932
+ function resolverExtras(source, anchor, current) {
1933
+ return {
1934
+ ...current.resolver ? { resolver: current.resolver } : {},
1935
+ ...current.hash !== anchor.hash && resolverChanged(source, anchor, current.resolver) ? { reason: "resolver-changed" } : {}
1255
1936
  };
1256
1937
  }
1257
1938
  function compared(anchor, current, extra = {}) {
1939
+ const matched = current.hash === anchor.hash;
1258
1940
  return {
1259
1941
  ...base(anchor),
1260
- state: current.hash === anchor.hash ? "match" : "drifted",
1942
+ state: matched ? "match" : "drifted",
1261
1943
  currentHash: current.hash,
1944
+ hashKind: current.kind,
1262
1945
  diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1946
+ ...matched ? {} : { class: "changed" },
1263
1947
  ...extra
1264
1948
  };
1265
1949
  }
1266
- function localEntry(anchor, read, resolver) {
1950
+ function localEntry(anchor, read, resolvers) {
1267
1951
  if (!read.ok) return unresolved(anchor, read.reason);
1268
- const current = hashIn(read.source, anchor, resolver);
1269
- return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1952
+ const found = hashIn(read.source, anchor, resolvers);
1953
+ if (!found.ok) return unresolved(anchor, found.reason);
1954
+ return compared(
1955
+ anchor,
1956
+ found.current,
1957
+ resolverExtras(read.source, anchor, found.current)
1958
+ );
1270
1959
  }
1271
- function remoteEntry(anchor, remote, resolver) {
1960
+ function remoteEntry(anchor, remote, resolvers) {
1272
1961
  const repo = anchor.repo;
1273
1962
  const key = normalizeRepoUrl(repo);
1274
1963
  const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1275
1964
  const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1276
1965
  if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1277
1966
  if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1278
- const current = hashIn(primary.source, anchor, resolver);
1279
- if (!current) return unresolved(anchor, "symbol-not-found", repo);
1280
- if (!anchor.ref) return compared(anchor, current, { repo });
1967
+ const found = hashIn(primary.source, anchor, resolvers);
1968
+ if (!found.ok) return unresolved(anchor, found.reason, repo);
1969
+ const current = found.current;
1970
+ const extras = resolverExtras(primary.source, anchor, current);
1971
+ if (!anchor.ref) return compared(anchor, current, { repo, ...extras });
1281
1972
  if (current.hash !== anchor.hash) {
1282
- return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1973
+ return compared(anchor, current, {
1974
+ repo,
1975
+ ...extras,
1976
+ remoteState: "drifted-from-ref"
1977
+ });
1283
1978
  }
1284
- const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1285
- return head && head.hash !== anchor.hash ? {
1286
- ...compared(anchor, head, { repo }),
1979
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
1980
+ return head?.ok && head.current.hash !== anchor.hash ? {
1981
+ ...compared(anchor, head.current, {
1982
+ repo,
1983
+ ...head.current.resolver ? { resolver: head.current.resolver } : {}
1984
+ }),
1287
1985
  state: "drifted",
1288
1986
  remoteState: "drifted-on-default"
1289
- } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1987
+ } : compared(anchor, current, {
1988
+ repo,
1989
+ ...extras,
1990
+ remoteState: "matches-ref"
1991
+ });
1290
1992
  }
1291
1993
 
1292
1994
  // src/errors.ts
@@ -1474,9 +2176,9 @@ var KbStampDigestBaselineError = class extends BaseError {
1474
2176
  // src/kb-pins/budgets.ts
1475
2177
  function asBudgets(value) {
1476
2178
  if (value === null || typeof value !== "object") return {};
1477
- const table = value;
2179
+ const table2 = value;
1478
2180
  const pick = (key, min) => {
1479
- const raw = table[key];
2181
+ const raw = table2[key];
1480
2182
  return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
1481
2183
  };
1482
2184
  const budgetTokens = pick("budgetTokens", 1);
@@ -1487,9 +2189,9 @@ function asBudgets(value) {
1487
2189
  };
1488
2190
  }
1489
2191
  function contextProfileBudgets(manifest, profile) {
1490
- const table = manifest.context;
1491
- if (table === null || typeof table !== "object") return {};
1492
- const entries = table;
2192
+ const table2 = manifest.context;
2193
+ if (table2 === null || typeof table2 !== "object") return {};
2194
+ const entries = table2;
1493
2195
  return {
1494
2196
  ...asBudgets(entries["default"]),
1495
2197
  ...profile ? asBudgets(entries[profile]) : {}
@@ -1520,23 +2222,23 @@ var KbBaseFrozenError = class extends Error {
1520
2222
  };
1521
2223
 
1522
2224
  // src/kb-pins/frozen.ts
1523
- var import_node_path5 = require("path");
2225
+ var import_node_path8 = require("path");
1524
2226
 
1525
2227
  // src/kb-pins/layers.ts
1526
- var import_promises3 = require("fs/promises");
1527
- var import_node_os2 = require("os");
1528
- var import_node_path4 = require("path");
2228
+ var import_promises5 = require("fs/promises");
2229
+ var import_node_os3 = require("os");
2230
+ var import_node_path7 = require("path");
1529
2231
 
1530
2232
  // src/kb-pins/model.ts
1531
- var import_node_path3 = require("path");
1532
- var import_zod4 = require("zod");
1533
- var PINS_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.json");
1534
- var PINS_LOCAL_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.local.json");
2233
+ var import_node_path6 = require("path");
2234
+ var import_zod5 = require("zod");
2235
+ var PINS_FILE = (0, import_node_path6.join)(".strauss", "kb-pins.json");
2236
+ var PINS_LOCAL_FILE = (0, import_node_path6.join)(".strauss", "kb-pins.local.json");
1535
2237
  var PIN_LAYERS = ["project", "local", "user"];
1536
- var pinSchema = import_zod4.z.object({
2238
+ var pinSchema = import_zod5.z.object({
1537
2239
  /** Relative to the manifest's root, so the file is committable. */
1538
- path: import_zod4.z.string().min(1),
1539
- pinnedAt: import_zod4.z.string().min(1).optional(),
2240
+ path: import_zod5.z.string().min(1),
2241
+ pinnedAt: import_zod5.z.string().min(1).optional(),
1540
2242
  /**
1541
2243
  * How `context` renders this base. `full` preloads the whole base into
1542
2244
  * the block regardless of the full-under threshold — for a base whose
@@ -1546,7 +2248,7 @@ var pinSchema = import_zod4.z.object({
1546
2248
  * Absent: the profile's full-under threshold decides. Invalid values
1547
2249
  * degrade to absent rather than failing the manifest.
1548
2250
  */
1549
- mode: import_zod4.z.enum(["full", "index"]).optional().catch(void 0),
2251
+ mode: import_zod5.z.enum(["full", "index"]).optional().catch(void 0),
1550
2252
  /**
1551
2253
  * Context profiles this pin surfaces in (e.g. only at session-start,
1552
2254
  * not per turn). Absent: every profile. A run without a profile sees
@@ -1554,17 +2256,17 @@ var pinSchema = import_zod4.z.object({
1554
2256
  * that skill at point of use than pinned at all — pins are what every
1555
2257
  * session should see.
1556
2258
  */
1557
- profiles: import_zod4.z.array(import_zod4.z.string()).optional().catch(void 0),
2259
+ profiles: import_zod5.z.array(import_zod5.z.string()).optional().catch(void 0),
1558
2260
  /**
1559
2261
  * The base is concluded — a finished piece of research, a frozen ADR
1560
2262
  * set. Write commands against it refuse while this workspace holds the
1561
2263
  * pin, and `context` labels it read-only. Workspace policy, not base
1562
2264
  * state: the base itself stays copyable and writable elsewhere.
1563
2265
  */
1564
- frozen: import_zod4.z.boolean().optional().catch(void 0)
2266
+ frozen: import_zod5.z.boolean().optional().catch(void 0)
1565
2267
  }).passthrough();
1566
- var pinsManifestSchema = import_zod4.z.object({
1567
- pins: import_zod4.z.array(pinSchema).default([]),
2268
+ var pinsManifestSchema = import_zod5.z.object({
2269
+ pins: import_zod5.z.array(pinSchema).default([]),
1568
2270
  /**
1569
2271
  * Per-repo budgets for the `context` command, keyed by profile —
1570
2272
  * `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
@@ -1573,18 +2275,18 @@ var pinsManifestSchema = import_zod4.z.object({
1573
2275
  * the index at every session start. `contextProfileBudgets` does the
1574
2276
  * tolerant read.
1575
2277
  */
1576
- context: import_zod4.z.unknown().optional()
2278
+ context: import_zod5.z.unknown().optional()
1577
2279
  }).passthrough();
1578
2280
 
1579
2281
  // src/kb-pins/layers.ts
1580
2282
  function userRoot() {
1581
- return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
2283
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os3.homedir)();
1582
2284
  }
1583
2285
  function layerRoot(workspaceDir, layer) {
1584
- return layer === "user" ? userRoot() : (0, import_node_path4.resolve)(workspaceDir);
2286
+ return layer === "user" ? userRoot() : (0, import_node_path7.resolve)(workspaceDir);
1585
2287
  }
1586
2288
  function layerFile(workspaceDir, layer) {
1587
- return (0, import_node_path4.join)(
2289
+ return (0, import_node_path7.join)(
1588
2290
  layerRoot(workspaceDir, layer),
1589
2291
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1590
2292
  );
@@ -1593,7 +2295,7 @@ async function readPinsLayer(workspaceDir, layer) {
1593
2295
  const file = layerFile(workspaceDir, layer);
1594
2296
  let raw;
1595
2297
  try {
1596
- raw = await (0, import_promises3.readFile)(file, "utf8");
2298
+ raw = await (0, import_promises5.readFile)(file, "utf8");
1597
2299
  } catch {
1598
2300
  return { pins: [] };
1599
2301
  }
@@ -1617,16 +2319,16 @@ async function readPinsLayer(workspaceDir, layer) {
1617
2319
  }
1618
2320
  async function writePinsLayer(workspaceDir, layer, manifest) {
1619
2321
  const file = layerFile(workspaceDir, layer);
1620
- await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(file), { recursive: true });
1621
- await (0, import_promises3.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
2322
+ await (0, import_promises5.mkdir)((0, import_node_path7.dirname)(file), { recursive: true });
2323
+ await (0, import_promises5.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1622
2324
  `, "utf8");
1623
2325
  }
1624
2326
  function resolvePinPath(rootDir, path) {
1625
- return (0, import_node_path4.isAbsolute)(path) ? (0, import_node_path4.resolve)(path) : (0, import_node_path4.resolve)(rootDir, path.split("/").join(import_node_path4.sep));
2327
+ return (0, import_node_path7.isAbsolute)(path) ? (0, import_node_path7.resolve)(path) : (0, import_node_path7.resolve)(rootDir, path.split("/").join(import_node_path7.sep));
1626
2328
  }
1627
2329
  function storablePath(rootDir, bundlePath2) {
1628
- const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(rootDir), (0, import_node_path4.resolve)(bundlePath2));
1629
- return (rel === "" ? "." : rel).split(import_node_path4.sep).join("/");
2330
+ const rel = (0, import_node_path7.relative)((0, import_node_path7.resolve)(rootDir), (0, import_node_path7.resolve)(bundlePath2));
2331
+ return (rel === "" ? "." : rel).split(import_node_path7.sep).join("/");
1630
2332
  }
1631
2333
  async function readMergedPins(workspaceDir) {
1632
2334
  const manifests = {};
@@ -1654,7 +2356,7 @@ async function readMergedPins(workspaceDir) {
1654
2356
  // src/kb-pins/frozen.ts
1655
2357
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
1656
2358
  const merged = await readMergedPins(workspaceDir);
1657
- const absolute = (0, import_node_path5.resolve)(bundlePath2);
2359
+ const absolute = (0, import_node_path8.resolve)(bundlePath2);
1658
2360
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
1659
2361
  if (pin?.frozen === true) {
1660
2362
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -1683,7 +2385,7 @@ async function listPins(store, workspaceDir) {
1683
2385
  }
1684
2386
 
1685
2387
  // src/kb-pins/pin.ts
1686
- async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2388
+ async function pinBase(store, workspaceDir, bundlePath2, at2, options = {}) {
1687
2389
  const layer = options.layer ?? "project";
1688
2390
  const root = layerRoot(workspaceDir, layer);
1689
2391
  const manifest = await readPinsLayer(workspaceDir, layer);
@@ -1711,7 +2413,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1711
2413
  return {
1712
2414
  path: existing.path,
1713
2415
  layer,
1714
- pinnedAt: existing.pinnedAt ?? at,
2416
+ pinnedAt: existing.pinnedAt ?? at2,
1715
2417
  alreadyPinned: true,
1716
2418
  ...updated.mode ? { mode: updated.mode } : {},
1717
2419
  ...updated.profiles ? { profiles: updated.profiles } : {},
@@ -1721,7 +2423,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1721
2423
  }
1722
2424
  const entry = {
1723
2425
  path: storablePath(root, bundlePath2),
1724
- pinnedAt: at,
2426
+ pinnedAt: at2,
1725
2427
  ...fields
1726
2428
  };
1727
2429
  await writePinsLayer(workspaceDir, layer, {
@@ -1731,7 +2433,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1731
2433
  return {
1732
2434
  path: entry.path,
1733
2435
  layer,
1734
- pinnedAt: at,
2436
+ pinnedAt: at2,
1735
2437
  alreadyPinned: false,
1736
2438
  ...fields,
1737
2439
  ...warning ? { warning } : {}
@@ -1739,7 +2441,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1739
2441
  }
1740
2442
 
1741
2443
  // src/kb-pins/unpin.ts
1742
- var import_node_path6 = require("path");
2444
+ var import_node_path9 = require("path");
1743
2445
  async function unpinBase(workspaceDir, bundlePath2) {
1744
2446
  const layers = [];
1745
2447
  for (const layer of PIN_LAYERS) {
@@ -1760,17 +2462,17 @@ async function unpinBase(workspaceDir, bundlePath2) {
1760
2462
  }
1761
2463
  }
1762
2464
  return {
1763
- path: storablePath((0, import_node_path6.resolve)(workspaceDir), bundlePath2),
2465
+ path: storablePath((0, import_node_path9.resolve)(workspaceDir), bundlePath2),
1764
2466
  removed: layers.length > 0,
1765
2467
  layers
1766
2468
  };
1767
2469
  }
1768
2470
 
1769
2471
  // src/commands/model.ts
1770
- var import_zod5 = require("zod");
1771
- var bundlePath = import_zod5.z.string().min(1).describe("Absolute path to the knowledge base directory.");
1772
- var conceptId = import_zod5.z.string().min(1).describe("e.g. decision.cursor-v2");
1773
- var REPO_ROOT = import_zod5.z.string().min(1).optional().describe(
2472
+ var import_zod6 = require("zod");
2473
+ var bundlePath = import_zod6.z.string().min(1).describe("Absolute path to the knowledge base directory.");
2474
+ var conceptId = import_zod6.z.string().min(1).describe("e.g. decision.cursor-v2");
2475
+ var REPO_ROOT = import_zod6.z.string().min(1).optional().describe(
1774
2476
  "Where the anchored source lives, for the drift check. Defaults to the working directory."
1775
2477
  );
1776
2478
  function define(command) {
@@ -1783,9 +2485,9 @@ function argvFlag(argv, name) {
1783
2485
  if (!value2) throw new KbMissingFlagValueError(name);
1784
2486
  return value2;
1785
2487
  }
1786
- const at = argv.indexOf(name);
1787
- if (at === -1) return void 0;
1788
- const value = argv[at + 1];
2488
+ const at2 = argv.indexOf(name);
2489
+ if (at2 === -1) return void 0;
2490
+ const value = argv[at2 + 1];
1789
2491
  if (value === void 0 || value.startsWith("--")) {
1790
2492
  throw new KbMissingFlagValueError(name);
1791
2493
  }
@@ -1793,22 +2495,30 @@ function argvFlag(argv, name) {
1793
2495
  }
1794
2496
 
1795
2497
  // src/commands/anchor-resolve.ts
2498
+ function resolverSummary(results) {
2499
+ const names = [
2500
+ ...new Set(
2501
+ results.flatMap((entry) => entry.resolver ? [entry.resolver] : [])
2502
+ )
2503
+ ].sort();
2504
+ return names.length ? `${names.join(" + ")} resolver` : "whole-file";
2505
+ }
1796
2506
  var anchorResolveCommand = define({
1797
2507
  name: "anchor-resolve",
1798
2508
  tool: "kb_anchor_resolve",
1799
2509
  usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
1800
2510
  description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
1801
- input: import_zod6.z.object({
2511
+ input: import_zod7.z.object({
1802
2512
  bundlePath,
1803
2513
  conceptId,
1804
- repoRoot: import_zod6.z.string().min(1).optional(),
1805
- offline: import_zod6.z.boolean().optional().describe(
2514
+ repoRoot: import_zod7.z.string().min(1).optional(),
2515
+ offline: import_zod7.z.boolean().optional().describe(
1806
2516
  "Resolve foreign anchors from the local repo cache only, never fetching."
1807
2517
  ),
1808
- rebaseline: import_zod6.z.boolean().optional().describe(
2518
+ rebaseline: import_zod7.z.boolean().optional().describe(
1809
2519
  "Accept the current code as the new baseline for anchors that drifted."
1810
2520
  ),
1811
- restamp: import_zod6.z.boolean().optional().describe(
2521
+ restamp: import_zod7.z.boolean().optional().describe(
1812
2522
  "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
1813
2523
  )
1814
2524
  }),
@@ -1837,6 +2547,11 @@ var anchorResolveCommand = define({
1837
2547
  const updated = [];
1838
2548
  let dirty = false;
1839
2549
  const sources = await readSources(anchors, root, offline === true);
2550
+ const resolvers = defaultAnchorResolvers({ offline: offline === true });
2551
+ await prepareResolvers(
2552
+ resolvers,
2553
+ anchors.map((anchor) => anchor.file)
2554
+ );
1840
2555
  for (const anchor of anchors) {
1841
2556
  const base2 = {
1842
2557
  file: anchor.file,
@@ -1853,27 +2568,39 @@ var anchorResolveCommand = define({
1853
2568
  updated.push(anchor);
1854
2569
  continue;
1855
2570
  }
1856
- const resolved = resolveAnchor(source.source, anchor);
1857
- if (!resolved) {
2571
+ const outcome = resolveAnchorSpan(source.source, anchor, resolvers);
2572
+ if (!outcome.ok) {
1858
2573
  results.push({
1859
2574
  ...base2,
1860
2575
  state: "unresolved",
1861
- reason: "symbol-not-found"
2576
+ reason: outcome.reason
1862
2577
  });
1863
2578
  updated.push(anchor);
1864
2579
  continue;
1865
2580
  }
1866
- const currentHash = hashAnchorText(resolved.text);
2581
+ const resolved = outcome.span;
2582
+ const producedBy = outcome.resolver;
2583
+ const { hash: currentHash, kind } = anchorHashOf(anchor, outcome);
1867
2584
  const currentLines = resolved.endLine - resolved.startLine + 1;
2585
+ const stampedKind = outcome.normalized ? "ast" : "raw";
2586
+ const stampedHash = outcome.normalized ? anchorHashOf({ ...anchor, hash: void 0 }, outcome).hash : currentHash;
1868
2587
  const stamped = {
1869
2588
  ...anchor,
1870
- hash: currentHash,
2589
+ hash: stampedHash,
2590
+ hash_kind: stampedKind,
1871
2591
  lines: currentLines,
1872
- resolved_at: now()
2592
+ resolved_at: now(),
2593
+ ...producedBy ? { resolver: producedBy } : {}
1873
2594
  };
1874
2595
  const pinned = anchor.ref !== void 0 && source.repo !== void 0;
1875
2596
  if (!anchor.hash) {
1876
- results.push({ ...base2, state: "stamped", currentHash });
2597
+ results.push({
2598
+ ...base2,
2599
+ state: "stamped",
2600
+ currentHash: stampedHash,
2601
+ hashKind: stampedKind,
2602
+ ...producedBy ? { resolver: producedBy } : {}
2603
+ });
1877
2604
  updated.push(stamped);
1878
2605
  dirty = true;
1879
2606
  continue;
@@ -1883,7 +2610,12 @@ var anchorResolveCommand = define({
1883
2610
  ...base2,
1884
2611
  state: "drifted",
1885
2612
  currentHash,
2613
+ hashKind: kind,
1886
2614
  diffSize: lineDelta(anchor, currentLines),
2615
+ ...producedBy ? { resolver: producedBy } : {},
2616
+ // A regex-stamped anchor re-read by tree-sitter drifts because the
2617
+ // resolver changed, not because the code did.
2618
+ ...resolverChanged(source.source, anchor, producedBy) ? { reason: "resolver-changed" } : {},
1887
2619
  ...pinned ? { remoteState: "drifted-from-ref" } : {},
1888
2620
  ...rebaseline ? { rebaselined: true } : {}
1889
2621
  });
@@ -1891,7 +2623,7 @@ var anchorResolveCommand = define({
1891
2623
  if (rebaseline) dirty = true;
1892
2624
  continue;
1893
2625
  }
1894
- const onDefault = pinned ? headHash(source, anchor) : void 0;
2626
+ const onDefault = pinned ? headHash(source, anchor, resolvers) : void 0;
1895
2627
  if (onDefault && onDefault.hash !== anchor.hash) {
1896
2628
  results.push({
1897
2629
  ...base2,
@@ -1907,6 +2639,8 @@ var anchorResolveCommand = define({
1907
2639
  ...base2,
1908
2640
  state: "match",
1909
2641
  currentHash,
2642
+ hashKind: kind,
2643
+ ...producedBy ? { resolver: producedBy } : {},
1910
2644
  ...pinned ? { remoteState: "matches-ref" } : {}
1911
2645
  });
1912
2646
  const refresh = restamp || anchor.resolved_at === void 0;
@@ -1924,19 +2658,21 @@ var anchorResolveCommand = define({
1924
2658
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
1925
2659
  }
1926
2660
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
2661
+ const hints = grammarHints();
2662
+ const hintNote = hints.length ? { hints } : {};
1927
2663
  const unreachable = results.filter(
1928
2664
  (entry) => isUncheckedReason(entry.reason)
1929
2665
  ).length;
1930
2666
  const checked = results.length - unreachable;
1931
- const matches2 = results.filter((entry) => entry.state === "match").length;
1932
- const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
1933
- const clean = checked > 0 && matches2 === checked && unreachable === 0;
2667
+ const matches3 = results.filter((entry) => entry.state === "match").length;
2668
+ const note = `${matches3}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
2669
+ const clean = checked > 0 && matches3 === checked && unreachable === 0;
1934
2670
  if (clean) {
1935
2671
  try {
1936
2672
  await store.verify(
1937
2673
  path,
1938
2674
  id,
1939
- `anchor-resolve: ${note} (regex resolver)`,
2675
+ `anchor-resolve: ${note} (${resolverSummary(results)})`,
1940
2676
  actor,
1941
2677
  now()
1942
2678
  );
@@ -1947,17 +2683,25 @@ var anchorResolveCommand = define({
1947
2683
  results,
1948
2684
  verified: false,
1949
2685
  verifyRefused: "self-verification",
1950
- ...frozenNote
2686
+ ...frozenNote,
2687
+ ...hintNote
1951
2688
  };
1952
2689
  }
1953
- return { conceptId: id, results, verified: true, ...frozenNote };
2690
+ return {
2691
+ conceptId: id,
2692
+ results,
2693
+ verified: true,
2694
+ ...frozenNote,
2695
+ ...hintNote
2696
+ };
1954
2697
  }
1955
2698
  return {
1956
2699
  conceptId: id,
1957
2700
  results,
1958
2701
  verified: false,
1959
2702
  ...unreachable ? { note } : {},
1960
- ...frozenNote
2703
+ ...frozenNote,
2704
+ ...hintNote
1961
2705
  };
1962
2706
  },
1963
2707
  // A stored hash that no longer resolves is a broken anchor, not an absence:
@@ -1973,13 +2717,13 @@ var anchorResolveCommand = define({
1973
2717
  function lineDelta(anchor, current) {
1974
2718
  return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
1975
2719
  }
1976
- function headHash(source, anchor) {
2720
+ function headHash(source, anchor, resolvers) {
1977
2721
  if (source.head === void 0) return void 0;
1978
- const resolved = resolveAnchor(source.head, anchor);
1979
- if (!resolved) return void 0;
2722
+ const outcome = resolveAnchorSpan(source.head, anchor, resolvers);
2723
+ if (!outcome.ok) return void 0;
1980
2724
  return {
1981
- hash: hashAnchorText(resolved.text),
1982
- lines: resolved.endLine - resolved.startLine + 1
2725
+ hash: hashAnchorText(outcome.span.text),
2726
+ lines: outcome.span.endLine - outcome.span.startLine + 1
1983
2727
  };
1984
2728
  }
1985
2729
  async function readSources(anchors, root, offline) {
@@ -2029,13 +2773,13 @@ async function readSources(anchors, root, offline) {
2029
2773
  }
2030
2774
 
2031
2775
  // src/commands/answer.ts
2032
- var import_zod7 = require("zod");
2776
+ var import_zod8 = require("zod");
2033
2777
  var answerCommand = define({
2034
2778
  name: "answer",
2035
2779
  tool: "kb_answer",
2036
2780
  usage: "answer <concept-id> <answer...>",
2037
2781
  description: "Resolve an open question: set status, stamp who and when, append an Answer section. If the answer overturns a decision or assumption, supersede that record explicitly.",
2038
- input: import_zod7.z.object({ bundlePath, conceptId, answer: import_zod7.z.string().min(1) }),
2782
+ input: import_zod8.z.object({ bundlePath, conceptId, answer: import_zod8.z.string().min(1) }),
2039
2783
  fromArgv: (argv, path) => ({
2040
2784
  bundlePath: path,
2041
2785
  conceptId: argv[1],
@@ -2049,19 +2793,19 @@ var answerCommand = define({
2049
2793
  });
2050
2794
 
2051
2795
  // src/commands/backlinks.ts
2052
- var import_zod8 = require("zod");
2796
+ var import_zod9 = require("zod");
2053
2797
  var backlinksCommand = define({
2054
2798
  name: "backlinks",
2055
2799
  tool: "kb_backlinks",
2056
2800
  usage: "backlinks <concept-id>",
2057
2801
  description: "Who points at this record: every inbound typed causal link (`strauss_links`), one hop, every rel including `related_to`, each with its rel and the standing of the record that made it. Use it when you need the exact edges \u2014 reviewing or renaming a record.",
2058
- input: import_zod8.z.object({ bundlePath, conceptId }),
2802
+ input: import_zod9.z.object({ bundlePath, conceptId }),
2059
2803
  fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
2060
2804
  run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
2061
2805
  });
2062
2806
 
2063
2807
  // src/commands/catalog.ts
2064
- var import_zod9 = require("zod");
2808
+ var import_zod10 = require("zod");
2065
2809
 
2066
2810
  // src/adjudicate.ts
2067
2811
  var STANDING = {
@@ -2128,7 +2872,8 @@ function warningAnchor(entry) {
2128
2872
  diffSize,
2129
2873
  ...reason !== void 0 ? { reason } : {},
2130
2874
  ...repo !== void 0 ? { repo } : {},
2131
- ...remoteState !== void 0 ? { remoteState } : {}
2875
+ ...remoteState !== void 0 ? { remoteState } : {},
2876
+ ...entry.class !== void 0 ? { class: entry.class } : {}
2132
2877
  };
2133
2878
  }
2134
2879
  function resolveHeads(from, byId) {
@@ -2139,8 +2884,8 @@ function resolveHeads(from, byId) {
2139
2884
  while (queue.length) {
2140
2885
  const current = queue.shift();
2141
2886
  const next = successors(current, byId);
2142
- for (const missing of next.missing) {
2143
- warnings.push({ kind: "broken-chain", missing });
2887
+ for (const missing2 of next.missing) {
2888
+ warnings.push({ kind: "broken-chain", missing: missing2 });
2144
2889
  }
2145
2890
  if (!next.records.length) {
2146
2891
  if (current.conceptId !== from.conceptId)
@@ -2171,13 +2916,13 @@ function successors(record, byId) {
2171
2916
  }
2172
2917
  }
2173
2918
  const records = [];
2174
- const missing = [];
2919
+ const missing2 = [];
2175
2920
  for (const id of ids) {
2176
2921
  const found = byId.get(id);
2177
2922
  if (found) records.push(found);
2178
- else missing.push(id);
2923
+ else missing2.push(id);
2179
2924
  }
2180
- return { records, missing };
2925
+ return { records, missing: missing2 };
2181
2926
  }
2182
2927
 
2183
2928
  // src/catalog.ts
@@ -2232,9 +2977,9 @@ var catalogCommand = define({
2232
2977
  tool: "kb_catalog",
2233
2978
  usage: "catalog [type]",
2234
2979
  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.",
2235
- input: import_zod9.z.object({
2980
+ input: import_zod10.z.object({
2236
2981
  bundlePath,
2237
- type: import_zod9.z.enum(KB_RECORD_TYPES).optional()
2982
+ type: import_zod10.z.enum(KB_RECORD_TYPES).optional()
2238
2983
  }),
2239
2984
  fromArgv: (argv, path) => ({
2240
2985
  bundlePath: path,
@@ -2289,10 +3034,10 @@ function count(value, noun) {
2289
3034
  }
2290
3035
 
2291
3036
  // src/commands/context.ts
2292
- var import_zod10 = require("zod");
3037
+ var import_zod11 = require("zod");
2293
3038
 
2294
3039
  // src/kb-context.ts
2295
- var import_promises4 = require("fs/promises");
3040
+ var import_promises6 = require("fs/promises");
2296
3041
 
2297
3042
  // src/kb-index.ts
2298
3043
  var INDEX_FILE = "INDEX.md";
@@ -2519,13 +3264,13 @@ function toHookJson(block, event) {
2519
3264
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2520
3265
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2521
3266
  async function syncInstructions(file, block) {
2522
- const existing = await (0, import_promises4.readFile)(file, "utf8").catch(() => null);
3267
+ const existing = await (0, import_promises6.readFile)(file, "utf8").catch(() => null);
2523
3268
  const region = block ? `${CONTEXT_BEGIN}
2524
3269
  ${block.trim()}
2525
3270
  ${CONTEXT_END}` : null;
2526
3271
  if (existing === null) {
2527
3272
  if (!region) return { file, action: "unchanged" };
2528
- await (0, import_promises4.writeFile)(file, `${region}
3273
+ await (0, import_promises6.writeFile)(file, `${region}
2529
3274
  `, "utf8");
2530
3275
  return { file, action: "created" };
2531
3276
  }
@@ -2536,11 +3281,11 @@ ${CONTEXT_END}` : null;
2536
3281
  const after = existing.slice(end + CONTEXT_END.length);
2537
3282
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2538
3283
  if (next === existing) return { file, action: "unchanged" };
2539
- await (0, import_promises4.writeFile)(file, next, "utf8");
3284
+ await (0, import_promises6.writeFile)(file, next, "utf8");
2540
3285
  return { file, action: region ? "replaced" : "removed" };
2541
3286
  }
2542
3287
  if (!region) return { file, action: "unchanged" };
2543
- await (0, import_promises4.writeFile)(
3288
+ await (0, import_promises6.writeFile)(
2544
3289
  file,
2545
3290
  `${existing.replace(/\n*$/, "\n\n")}${region}
2546
3291
  `,
@@ -2555,20 +3300,20 @@ var contextCommand = define({
2555
3300
  tool: "kb_context",
2556
3301
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
2557
3302
  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.",
2558
- input: import_zod10.z.object({
2559
- budgetTokens: import_zod10.z.number().int().positive().optional().describe(
3303
+ input: import_zod11.z.object({
3304
+ budgetTokens: import_zod11.z.number().int().positive().optional().describe(
2560
3305
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
2561
3306
  ),
2562
- fullUnderTokens: import_zod10.z.number().int().positive().optional().describe(
3307
+ fullUnderTokens: import_zod11.z.number().int().positive().optional().describe(
2563
3308
  "Per-base rendering threshold, applied before the budget: a base whose complete load fits under this arrives as full records instead of index lines, and the whole block still answers to budgetTokens. Off by default \u2014 index-only is the safe default at a context birth, because injected bodies outlive the qualifiers on them; the session-start profile opts tiny bases in at 1500."
2564
3309
  ),
2565
- profile: import_zod10.z.string().optional().describe(
3310
+ profile: import_zod11.z.string().optional().describe(
2566
3311
  "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."
2567
3312
  ),
2568
- format: import_zod10.z.enum(["markdown", "json"]).optional().describe(
3313
+ format: import_zod11.z.enum(["markdown", "json"]).optional().describe(
2569
3314
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
2570
3315
  ),
2571
- event: import_zod10.z.string().optional().describe(
3316
+ event: import_zod11.z.string().optional().describe(
2572
3317
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
2573
3318
  )
2574
3319
  }),
@@ -2604,7 +3349,406 @@ var contextCommand = define({
2604
3349
  });
2605
3350
 
2606
3351
  // src/commands/doctor.ts
2607
- var import_zod11 = require("zod");
3352
+ var import_zod13 = require("zod");
3353
+
3354
+ // src/drift/git.ts
3355
+ var import_node_child_process3 = require("child_process");
3356
+ var import_node_util3 = require("util");
3357
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
3358
+ var MAX_GIT_OUTPUT_BYTES = 1048576;
3359
+ var GIT_TIMEOUT_MS = 5e3;
3360
+ async function git2(cwd, args) {
3361
+ const env = { ...process.env };
3362
+ delete env["GIT_DIR"];
3363
+ delete env["GIT_WORK_TREE"];
3364
+ delete env["GIT_INDEX_FILE"];
3365
+ try {
3366
+ const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
3367
+ timeout: GIT_TIMEOUT_MS,
3368
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
3369
+ env
3370
+ });
3371
+ return { ok: true, stdout };
3372
+ } catch {
3373
+ return { ok: false };
3374
+ }
3375
+ }
3376
+ async function listRepoFiles(repoRoot) {
3377
+ const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
3378
+ if (!result.ok) return [];
3379
+ return result.stdout.split("\0").filter(Boolean);
3380
+ }
3381
+ async function readOldSource(repoRoot, anchor) {
3382
+ if (!filePathIsSafe(anchor.file))
3383
+ return { ok: false, reason: "unrecoverable" };
3384
+ if (anchor.ref && refShapeIsSafe(anchor.ref)) {
3385
+ const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
3386
+ if (shown2 !== null) {
3387
+ return {
3388
+ ok: true,
3389
+ source: shown2,
3390
+ origin: { kind: "ref", ref: anchor.ref }
3391
+ };
3392
+ }
3393
+ }
3394
+ const at2 = anchor.resolved_at;
3395
+ if (!at2 || Number.isNaN(Date.parse(at2))) {
3396
+ return { ok: false, reason: "unrecoverable" };
3397
+ }
3398
+ const found = await git2(repoRoot, [
3399
+ "log",
3400
+ "-1",
3401
+ "--format=%H",
3402
+ `--before=${at2}`,
3403
+ "--end-of-options",
3404
+ "HEAD",
3405
+ "--",
3406
+ anchor.file
3407
+ ]);
3408
+ const sha = found.ok ? found.stdout.trim() : "";
3409
+ if (!sha || !refShapeIsSafe(sha))
3410
+ return { ok: false, reason: "unrecoverable" };
3411
+ const shown = await showFile(repoRoot, sha, anchor.file);
3412
+ if (shown === null) return { ok: false, reason: "unrecoverable" };
3413
+ return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
3414
+ }
3415
+ async function showFile(repoRoot, ref, file) {
3416
+ const path = file.replace(/^\.\//, "");
3417
+ const result = await git2(repoRoot, [
3418
+ "show",
3419
+ "--end-of-options",
3420
+ `${ref}:${path}`
3421
+ ]);
3422
+ return result.ok ? result.stdout : null;
3423
+ }
3424
+
3425
+ // src/drift/moved.ts
3426
+ var import_promises7 = require("fs/promises");
3427
+ var MAX_MOVED_SEARCH_FILES = 2e3;
3428
+ var SEARCH_BATCH = 64;
3429
+ function movedSearch(repoRoot, options = {}) {
3430
+ const read = options.reader ?? anchorFileReader(repoRoot);
3431
+ const sizeOf = options.sizeOf ?? diskSize(repoRoot);
3432
+ const resolver = new TreeSitterResolver();
3433
+ let repoFiles;
3434
+ const prepared = /* @__PURE__ */ new Set();
3435
+ const filesForLanguage = async (language) => {
3436
+ repoFiles ??= listRepoFiles(repoRoot);
3437
+ return (await repoFiles).filter((file) => languageForFile(file) === language).slice(0, MAX_MOVED_SEARCH_FILES);
3438
+ };
3439
+ return {
3440
+ async find(anchor) {
3441
+ const stored = anchor.hash;
3442
+ if (!stored) return void 0;
3443
+ const language = languageForFile(anchor.file);
3444
+ if (!language) return sameFileWindow(anchor, read, stored);
3445
+ const candidates = await filesForLanguage(language);
3446
+ if (!prepared.has(language)) {
3447
+ await resolver.prepare(candidates.length ? candidates : [anchor.file]);
3448
+ prepared.add(language);
3449
+ }
3450
+ const floor = anchor.lines ?? 0;
3451
+ for (let at2 = 0; at2 < candidates.length; at2 += SEARCH_BATCH) {
3452
+ const batch = candidates.slice(at2, at2 + SEARCH_BATCH);
3453
+ const hits = await mapLimit(
3454
+ batch,
3455
+ DEFAULT_IO_CONCURRENCY,
3456
+ async (file) => {
3457
+ const size2 = await sizeOf(file);
3458
+ if (size2 !== null && size2 < floor) return void 0;
3459
+ return matchIn(resolver, read, anchor, stored, file);
3460
+ }
3461
+ );
3462
+ const found = hits.find((hit) => hit !== void 0);
3463
+ if (found) return found;
3464
+ }
3465
+ return void 0;
3466
+ }
3467
+ };
3468
+ }
3469
+ async function matchIn(resolver, read, anchor, stored, file) {
3470
+ const source = await read(file);
3471
+ if (!source.ok) return void 0;
3472
+ const normalized = source.source.replace(/\r\n/g, "\n");
3473
+ for (const found of resolver.spans(normalized, file)) {
3474
+ const text = anchor.hash_kind === "ast" ? resolver.normalize(found.span.text, file) : found.span.text;
3475
+ if (text === null || hashAnchorText(text) !== stored) continue;
3476
+ if (file === anchor.file && found.symbol === anchor.symbol) continue;
3477
+ return {
3478
+ file,
3479
+ symbol: found.symbol,
3480
+ startLine: found.span.startLine,
3481
+ endLine: found.span.endLine
3482
+ };
3483
+ }
3484
+ return void 0;
3485
+ }
3486
+ function diskSize(repoRoot) {
3487
+ return async (file) => {
3488
+ const path = anchorFilePath(repoRoot, file);
3489
+ if (path === null) return null;
3490
+ try {
3491
+ return (await (0, import_promises7.stat)(path)).size;
3492
+ } catch {
3493
+ return null;
3494
+ }
3495
+ };
3496
+ }
3497
+ async function sameFileWindow(anchor, read, stored) {
3498
+ const height = anchor.lines;
3499
+ if (!height || anchor.hash_kind === "ast") return void 0;
3500
+ const source = await read(anchor.file);
3501
+ if (!source.ok) return void 0;
3502
+ const lines = source.source.replace(/\r\n/g, "\n").split("\n");
3503
+ for (let at2 = 0; at2 + height <= lines.length; at2++) {
3504
+ if (hashAnchorText(lines.slice(at2, at2 + height).join("\n")) !== stored) {
3505
+ continue;
3506
+ }
3507
+ return {
3508
+ file: anchor.file,
3509
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
3510
+ startLine: at2 + 1,
3511
+ endLine: at2 + height
3512
+ };
3513
+ }
3514
+ return void 0;
3515
+ }
3516
+
3517
+ // src/drift/classify.ts
3518
+ async function classifyDrift(repoRoot, record, entries, options = {}) {
3519
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
3520
+ (anchor) => anchor.hash
3521
+ );
3522
+ const reader = options.reader ?? anchorFileReader(repoRoot);
3523
+ const treeSitter = new TreeSitterResolver();
3524
+ const resolvers = [treeSitter, regexResolver];
3525
+ const search = options.search ?? movedSearch(repoRoot, { ...options.reader ? { reader } : {} });
3526
+ const wanted = [];
3527
+ entries.forEach((entry, at2) => {
3528
+ const anchor = anchors[at2];
3529
+ if (!anchor) return;
3530
+ if (entry.state === "match" || isUncheckedReason(entry.reason)) return;
3531
+ wanted.push({ anchor, entry });
3532
+ });
3533
+ if (!wanted.length) return [];
3534
+ await prepareResolvers(
3535
+ resolvers,
3536
+ wanted.map(({ anchor }) => anchor.file)
3537
+ );
3538
+ const out = [];
3539
+ for (const { anchor, entry } of wanted) {
3540
+ const movedTo = await search.find(anchor);
3541
+ if (movedTo) {
3542
+ out.push({
3543
+ anchor,
3544
+ entry: { ...entry, class: "moved", movedTo },
3545
+ class: "moved"
3546
+ });
3547
+ continue;
3548
+ }
3549
+ const newText = await currentText(reader, anchor, resolvers);
3550
+ const old = options.withHistory === false ? { ok: false, reason: "unrecoverable" } : await readOldSource(repoRoot, anchor);
3551
+ const oldText = old.ok ? spanIn(old.source, anchor, resolvers) : void 0;
3552
+ const settled = newText !== void 0 && oldText !== void 0 && sameTokens(treeSitter, anchor.file, oldText, newText) ? "cosmetic" : entry.class ?? "changed";
3553
+ out.push({
3554
+ anchor,
3555
+ entry: { ...entry, class: settled },
3556
+ class: settled,
3557
+ ...newText !== void 0 ? { newText } : {},
3558
+ ...oldText !== void 0 ? { oldText } : {},
3559
+ ...old.ok ? { oldOrigin: old.origin } : {}
3560
+ });
3561
+ }
3562
+ return out;
3563
+ }
3564
+ function sameTokens(resolver, file, before, after) {
3565
+ if (before === after) return false;
3566
+ const left = resolver.normalize(before, file);
3567
+ const right = resolver.normalize(after, file);
3568
+ return left !== null && left === right;
3569
+ }
3570
+ async function currentText(reader, anchor, resolvers) {
3571
+ const read = await reader(anchor.file);
3572
+ if (!read.ok) return void 0;
3573
+ return spanIn(read.source, anchor, resolvers);
3574
+ }
3575
+ function spanIn(source, anchor, resolvers) {
3576
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
3577
+ return outcome.ok ? outcome.span.text : void 0;
3578
+ }
3579
+
3580
+ // src/drift/diff.ts
3581
+ var MAX_ANCHOR_DIFF_LINES = 200;
3582
+ var PACKET_DIFF_LINE_BUDGET = 200;
3583
+ var MIN_ANCHOR_DIFF_LINES = 12;
3584
+ function diffBudget(anchors) {
3585
+ if (anchors <= 0) return MAX_ANCHOR_DIFF_LINES;
3586
+ return Math.min(
3587
+ MAX_ANCHOR_DIFF_LINES,
3588
+ Math.max(
3589
+ MIN_ANCHOR_DIFF_LINES,
3590
+ Math.floor(PACKET_DIFF_LINE_BUDGET / anchors)
3591
+ )
3592
+ );
3593
+ }
3594
+ function unifiedDiff(before, after, options = {}) {
3595
+ const max = options.maxLines ?? MAX_ANCHOR_DIFF_LINES;
3596
+ const left = before.replace(/\r\n/g, "\n").split("\n");
3597
+ const right = after.replace(/\r\n/g, "\n").split("\n");
3598
+ const body = [];
3599
+ let added = 0;
3600
+ let removed = 0;
3601
+ for (const edit of edits(left, right)) {
3602
+ if (edit.kind === "same") body.push(` ${edit.line}`);
3603
+ else if (edit.kind === "remove") {
3604
+ body.push(`-${edit.line}`);
3605
+ removed += 1;
3606
+ } else {
3607
+ body.push(`+${edit.line}`);
3608
+ added += 1;
3609
+ }
3610
+ }
3611
+ const truncated = body.length > max;
3612
+ const shown = truncated ? body.slice(0, max) : body;
3613
+ const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
3614
+ const lines = [header, ...shown];
3615
+ if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
3616
+ return { text: lines.join("\n"), added, removed, truncated };
3617
+ }
3618
+ function edits(left, right) {
3619
+ const rows = left.length;
3620
+ const cols = right.length;
3621
+ const table2 = Array.from(
3622
+ { length: rows + 1 },
3623
+ () => new Array(cols + 1).fill(0)
3624
+ );
3625
+ for (let row2 = rows - 1; row2 >= 0; row2--) {
3626
+ for (let col2 = cols - 1; col2 >= 0; col2--) {
3627
+ table2[row2][col2] = left[row2] === right[col2] ? table2[row2 + 1][col2 + 1] + 1 : Math.max(
3628
+ table2[row2 + 1][col2],
3629
+ table2[row2][col2 + 1]
3630
+ );
3631
+ }
3632
+ }
3633
+ const out = [];
3634
+ let row = 0;
3635
+ let col = 0;
3636
+ while (row < rows && col < cols) {
3637
+ if (left[row] === right[col]) {
3638
+ out.push({ kind: "same", line: left[row] });
3639
+ row += 1;
3640
+ col += 1;
3641
+ } else if (table2[row + 1][col] >= table2[row][col + 1]) {
3642
+ out.push({ kind: "remove", line: left[row] });
3643
+ row += 1;
3644
+ } else {
3645
+ out.push({ kind: "add", line: right[col] });
3646
+ col += 1;
3647
+ }
3648
+ }
3649
+ for (; row < rows; row++)
3650
+ out.push({ kind: "remove", line: left[row] });
3651
+ for (; col < cols; col++)
3652
+ out.push({ kind: "add", line: right[col] });
3653
+ return out;
3654
+ }
3655
+
3656
+ // src/drift/packet.ts
3657
+ var PRESUMED_INVALID = [
3658
+ "fact",
3659
+ "constraint",
3660
+ "contract"
3661
+ ];
3662
+ var RATIONALE_SURVIVES = ["decision", "risk"];
3663
+ var DEFAULT_NOTES = {
3664
+ "presumed-invalidated": "the code this claim was taken from changed; presume it no longer holds until re-read",
3665
+ "rationale-may-survive": "the reasoning may outlive the code that implemented it; check whether it does",
3666
+ review: "re-read the record against the new code"
3667
+ };
3668
+ async function reassessPacket(repoRoot, record, entries, options = {}) {
3669
+ const classified = await classifyDrift(repoRoot, record, entries, {
3670
+ ...options.reader ? { reader: options.reader } : {},
3671
+ ...options.search ? { search: options.search } : {},
3672
+ withHistory: options.withDiff !== false
3673
+ });
3674
+ const open = classified.filter(
3675
+ (found) => found.class === "changed" || found.class === "gone"
3676
+ );
3677
+ if (!open.length) return { packet: null, classified };
3678
+ const budget = diffBudget(open.length);
3679
+ const anchors = open.map(
3680
+ (found) => anchorPacket(found, options.withDiff === true, budget)
3681
+ );
3682
+ const type = record.frontmatter.type;
3683
+ const fallback = isKbRecordType(type) ? PRESUMED_INVALID.includes(type) ? "presumed-invalidated" : RATIONALE_SURVIVES.includes(type) ? "rationale-may-survive" : "review" : "review";
3684
+ return {
3685
+ classified,
3686
+ packet: {
3687
+ conceptId: record.conceptId,
3688
+ title: record.frontmatter.title ?? null,
3689
+ type,
3690
+ standing: options.standing ?? "unsettled",
3691
+ why: record.frontmatter.description ?? null,
3692
+ claim: claimOf(record),
3693
+ anchors,
3694
+ impact: (options.impact?.impacted ?? []).map((entry) => ({
3695
+ conceptId: entry.conceptId,
3696
+ title: entry.title,
3697
+ standing: entry.standing,
3698
+ depth: entry.depth
3699
+ })),
3700
+ impactTruncated: options.impact?.truncated ?? false,
3701
+ default: fallback,
3702
+ defaultNote: DEFAULT_NOTES[fallback]
3703
+ }
3704
+ };
3705
+ }
3706
+ function anchorPacket(found, withDiff, maxLines) {
3707
+ const { entry } = found;
3708
+ const base2 = {
3709
+ file: entry.file,
3710
+ ...entry.symbol ? { symbol: entry.symbol } : {},
3711
+ class: found.class,
3712
+ ...entry.reason ? { reason: entry.reason } : {},
3713
+ storedHash: entry.storedHash,
3714
+ ...entry.currentHash ? { currentHash: entry.currentHash } : {},
3715
+ diffSize: entry.diffSize,
3716
+ ...entry.movedTo ? { movedTo: entry.movedTo } : {}
3717
+ };
3718
+ if (!withDiff) return base2;
3719
+ if (found.oldText === void 0 || !found.oldOrigin) {
3720
+ return { ...base2, diff: { status: "unrecoverable" } };
3721
+ }
3722
+ const rendered = unifiedDiff(found.oldText, found.newText ?? "", {
3723
+ maxLines
3724
+ });
3725
+ return {
3726
+ ...base2,
3727
+ diff: {
3728
+ status: "ok",
3729
+ source: found.oldOrigin.kind,
3730
+ ref: found.oldOrigin.ref,
3731
+ unified: rendered.text,
3732
+ added: rendered.added,
3733
+ removed: rendered.removed,
3734
+ truncated: rendered.truncated
3735
+ }
3736
+ };
3737
+ }
3738
+ function claimOf(record) {
3739
+ const type = record.frontmatter.type;
3740
+ const section = isKbRecordType(type) ? RECORD_TYPES[type].sections[0] : void 0;
3741
+ if (!section) return null;
3742
+ const lines = record.body.replace(/\r\n/g, "\n").split("\n");
3743
+ const start = lines.findIndex(
3744
+ (line) => line.trim().toLowerCase() === `## ${section}`.toLowerCase()
3745
+ );
3746
+ if (start < 0) return null;
3747
+ const rest = lines.slice(start + 1);
3748
+ const end = rest.findIndex((line) => line.startsWith("## "));
3749
+ const text = (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
3750
+ return text ? { section, text } : null;
3751
+ }
2608
3752
 
2609
3753
  // src/kb-edges.ts
2610
3754
  var KB_EDGE_KINDS = [
@@ -2790,6 +3934,18 @@ var CHECK_HEADLINES = {
2790
3934
  unchecked: "an anchor in another repository nothing could reach"
2791
3935
  };
2792
3936
  var DAY_MS = 864e5;
3937
+ function anchorResolverCounts(bundle) {
3938
+ let treeSitter = 0;
3939
+ let regex = 0;
3940
+ for (const record of bundle) {
3941
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
3942
+ if (!anchor.hash || !anchor.symbol) continue;
3943
+ if (anchor.resolver === "tree-sitter") treeSitter += 1;
3944
+ else regex += 1;
3945
+ }
3946
+ }
3947
+ return { total: treeSitter + regex, treeSitter, regex };
3948
+ }
2793
3949
  function doctor(bundle, options = {}) {
2794
3950
  const thresholds = {
2795
3951
  expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
@@ -2825,6 +3981,7 @@ function doctor(bundle, options = {}) {
2825
3981
  counts,
2826
3982
  groups,
2827
3983
  findingCount,
3984
+ anchorResolvers: anchorResolverCounts(bundle),
2828
3985
  healthy: findingCount === 0
2829
3986
  };
2830
3987
  }
@@ -2841,18 +3998,18 @@ function expired(hits, now) {
2841
3998
  for (const hit of hits) {
2842
3999
  const raw = hit.record.frontmatter.stale_after;
2843
4000
  if (!raw) continue;
2844
- const at = Date.parse(raw);
2845
- if (Number.isNaN(at)) {
4001
+ const at2 = Date.parse(raw);
4002
+ if (Number.isNaN(at2)) {
2846
4003
  findings.push(
2847
4004
  finding(hit.record, `stale_after "${raw}" is not a readable date`)
2848
4005
  );
2849
4006
  continue;
2850
4007
  }
2851
- if (at < now.getTime()) {
4008
+ if (at2 < now.getTime()) {
2852
4009
  findings.push(
2853
4010
  finding(
2854
4011
  hit.record,
2855
- `stale since ${raw} (${daysBetween(at, now.getTime())} days ago)`
4012
+ `stale since ${raw} (${daysBetween(at2, now.getTime())} days ago)`
2856
4013
  )
2857
4014
  );
2858
4015
  }
@@ -2865,12 +4022,12 @@ function expiring(hits, now, withinDays) {
2865
4022
  for (const hit of hits) {
2866
4023
  const raw = hit.record.frontmatter.stale_after;
2867
4024
  if (!raw) continue;
2868
- const at = Date.parse(raw);
2869
- if (Number.isNaN(at) || at < now.getTime() || at > horizon) continue;
4025
+ const at2 = Date.parse(raw);
4026
+ if (Number.isNaN(at2) || at2 < now.getTime() || at2 > horizon) continue;
2870
4027
  findings.push(
2871
4028
  finding(
2872
4029
  hit.record,
2873
- `goes stale ${raw} (in ${daysBetween(now.getTime(), at)} days)`
4030
+ `goes stale ${raw} (in ${daysBetween(now.getTime(), at2)} days)`
2874
4031
  )
2875
4032
  );
2876
4033
  }
@@ -3040,13 +4197,16 @@ function anchorFindings(hits, kind, headline) {
3040
4197
  );
3041
4198
  }
3042
4199
  function describeAnchor(anchor) {
3043
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3044
- if (anchor.reason) return `${at} (${anchor.reason})`;
4200
+ const at2 = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
4201
+ if (anchor.class === "gone") {
4202
+ return `${at2} gone${anchor.reason ? ` (${anchor.reason})` : ""}`;
4203
+ }
4204
+ if (anchor.reason) return `${at2} (${anchor.reason})`;
3045
4205
  if (anchor.remoteState === "drifted-on-default") {
3046
- return `${at} (matches ref, moved on the default branch)`;
4206
+ return `${at2} (matches ref, moved on the default branch)`;
3047
4207
  }
3048
- if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
3049
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
4208
+ if (anchor.diffSize === null) return `${at2} (changed, size unrecorded)`;
4209
+ return anchor.diffSize === 0 ? `${at2} (content changed, same line count)` : `${at2} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3050
4210
  }
3051
4211
  function replaces(later, earlier) {
3052
4212
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
@@ -3063,21 +4223,173 @@ function daysBetween(from, to) {
3063
4223
  return Math.max(0, Math.floor((to - from) / DAY_MS));
3064
4224
  }
3065
4225
  function ageInDays(record, now) {
3066
- const at = record.frontmatter.generated?.at;
3067
- if (!at) return null;
3068
- const written = Date.parse(at);
4226
+ const at2 = record.frontmatter.generated?.at;
4227
+ if (!at2) return null;
4228
+ const written = Date.parse(at2);
3069
4229
  if (Number.isNaN(written)) return null;
3070
4230
  return daysBetween(written, now.getTime());
3071
4231
  }
3072
4232
 
4233
+ // src/commands/reassess.ts
4234
+ var import_zod12 = require("zod");
4235
+ var reassessCommand = define({
4236
+ name: "reassess",
4237
+ tool: "kb_reassess",
4238
+ usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
4239
+ 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.",
4240
+ input: import_zod12.z.object({
4241
+ bundlePath,
4242
+ conceptId,
4243
+ repoRoot: REPO_ROOT,
4244
+ withDiff: import_zod12.z.boolean().optional().describe(
4245
+ "Recover each anchor's committed span and render the diff. Reads git history."
4246
+ )
4247
+ }),
4248
+ fromArgv: (argv, path) => {
4249
+ const repoRoot = argvFlag(argv, "--repo-root");
4250
+ return {
4251
+ bundlePath: path,
4252
+ conceptId: argv[1],
4253
+ ...repoRoot !== void 0 ? { repoRoot } : {},
4254
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
4255
+ };
4256
+ },
4257
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
4258
+ const root = repoRoot ?? process.cwd();
4259
+ const bundle = await store.list(path);
4260
+ const record = bundle.find((entry) => entry.conceptId === id);
4261
+ if (!record) throw new KbRecordNotFoundError(id);
4262
+ const drift = await store.detectDrift([record], repoRoot);
4263
+ const entries = drift?.get(id) ?? [];
4264
+ if (!entries.some((entry) => entry.state !== "match")) {
4265
+ return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
4266
+ }
4267
+ const standing = adjudicate(bundle, bundle).find(
4268
+ (hit) => hit.record.conceptId === id
4269
+ )?.standing;
4270
+ const impact2 = await store.impact(path, id);
4271
+ const { packet, classified } = await reassessPacket(root, record, entries, {
4272
+ ...withDiff ? { withDiff: true } : {},
4273
+ impact: impact2,
4274
+ ...standing ? { standing } : {}
4275
+ });
4276
+ const moves = classified.filter((found) => found.class === "moved");
4277
+ let frozen = false;
4278
+ const rebaselined = [];
4279
+ if (moves.length) {
4280
+ const relocated = /* @__PURE__ */ new Map();
4281
+ for (const found of moves) {
4282
+ const to = found.entry.movedTo;
4283
+ if (!to) continue;
4284
+ relocated.set(found.anchor, {
4285
+ ...found.anchor,
4286
+ file: to.file,
4287
+ ...to.symbol ? { symbol: to.symbol } : {}
4288
+ });
4289
+ rebaselined.push({
4290
+ file: found.anchor.file,
4291
+ ...found.anchor.symbol ? { symbol: found.anchor.symbol } : {},
4292
+ toFile: to.file,
4293
+ ...to.symbol ? { toSymbol: to.symbol } : {}
4294
+ });
4295
+ }
4296
+ try {
4297
+ await assertBaseNotFrozen(process.cwd(), path);
4298
+ } catch (error) {
4299
+ if (!(error instanceof KbBaseFrozenError)) throw error;
4300
+ frozen = true;
4301
+ }
4302
+ if (!frozen) {
4303
+ await store.updateAnchors(
4304
+ path,
4305
+ id,
4306
+ (record.frontmatter.strauss_anchors ?? []).map(
4307
+ (anchor) => relocated.get(anchor) ?? anchor
4308
+ ),
4309
+ actor
4310
+ );
4311
+ }
4312
+ }
4313
+ return {
4314
+ conceptId: id,
4315
+ packet,
4316
+ rebaselined: frozen ? [] : rebaselined,
4317
+ cosmetic: classified.filter((found) => found.class === "cosmetic").length,
4318
+ ...frozen ? {
4319
+ frozen: true,
4320
+ note: "base is frozen: nothing was rebaselined"
4321
+ } : {}
4322
+ };
4323
+ },
4324
+ render: (result) => renderReassess(result)
4325
+ });
4326
+ function renderReassess(result) {
4327
+ const lines = [];
4328
+ for (const move of result.rebaselined) {
4329
+ lines.push(
4330
+ `rebaselined: ${at(move.file, move.symbol)} \u2192 ${at(move.toFile, move.toSymbol)} (same code, new address)`
4331
+ );
4332
+ }
4333
+ if (result.cosmetic) {
4334
+ lines.push(
4335
+ `${result.cosmetic} anchor${result.cosmetic === 1 ? "" : "s"} changed formatting only.`
4336
+ );
4337
+ }
4338
+ if (result.note) lines.push(result.note);
4339
+ const packet = result.packet;
4340
+ if (!packet) {
4341
+ lines.push(`${result.conceptId}: nothing to reassess.`);
4342
+ return lines.join("\n");
4343
+ }
4344
+ lines.push(
4345
+ "",
4346
+ `# ${packet.conceptId}${packet.title ? ` \u2014 ${packet.title}` : ""}`,
4347
+ `type: ${packet.type} standing: ${packet.standing}`,
4348
+ ...packet.why ? [`why: ${packet.why}`] : [],
4349
+ ...packet.claim ? ["", `## ${packet.claim.section}`, packet.claim.text] : [],
4350
+ "",
4351
+ `## Anchors (${packet.anchors.length})`
4352
+ );
4353
+ for (const anchor of packet.anchors) {
4354
+ lines.push(
4355
+ `- ${at(anchor.file, anchor.symbol)} \u2014 ${anchor.class}${anchor.reason ? ` (${anchor.reason})` : ""}`
4356
+ );
4357
+ if (!anchor.diff) continue;
4358
+ if (anchor.diff.status === "unrecoverable") {
4359
+ lines.push(
4360
+ " diff: unrecoverable \u2014 no committed span to compare against"
4361
+ );
4362
+ continue;
4363
+ }
4364
+ lines.push(
4365
+ ` diff vs ${anchor.diff.ref} (${anchor.diff.source}): +${anchor.diff.added} \u2212${anchor.diff.removed}`,
4366
+ ...anchor.diff.unified.split("\n").map((line) => ` ${line}`)
4367
+ );
4368
+ }
4369
+ if (packet.impact.length) {
4370
+ lines.push("", `## Impact (${packet.impact.length})`);
4371
+ for (const entry of packet.impact) {
4372
+ lines.push(
4373
+ `- ${entry.conceptId} [${entry.standing}]${entry.title ? ` \u2014 ${entry.title}` : ""}`
4374
+ );
4375
+ }
4376
+ if (packet.impactTruncated) lines.push("- \u2026 walk truncated");
4377
+ }
4378
+ lines.push("", `Default: ${packet.default} \u2014 ${packet.defaultNote}.`);
4379
+ return lines.join("\n");
4380
+ }
4381
+ function at(file, symbol) {
4382
+ return symbol ? `${file}:${symbol}` : file;
4383
+ }
4384
+
3073
4385
  // src/commands/doctor.ts
3074
- var days = (what, fallback) => import_zod11.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
4386
+ var days = (what, fallback) => import_zod13.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3075
4387
  var doctorCommand = define({
3076
4388
  name: "doctor",
3077
4389
  tool: "kb_doctor",
3078
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3079
- 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.",
3080
- input: import_zod11.z.object({
4390
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
4391
+ 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.",
4392
+ input: import_zod13.z.object({
3081
4393
  bundlePath,
3082
4394
  repoRoot: REPO_ROOT,
3083
4395
  expiringDays: days(
@@ -3092,11 +4404,17 @@ var doctorCommand = define({
3092
4404
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3093
4405
  DEFAULT_AGING_DAYS
3094
4406
  ),
3095
- offline: import_zod11.z.boolean().optional().describe(
4407
+ offline: import_zod13.z.boolean().optional().describe(
3096
4408
  "Read foreign anchors from the local repo cache only, never fetching."
3097
4409
  ),
3098
- strict: import_zod11.z.boolean().optional().describe(
4410
+ strict: import_zod13.z.boolean().optional().describe(
3099
4411
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
4412
+ ),
4413
+ drifted: import_zod13.z.boolean().optional().describe(
4414
+ "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
4415
+ ),
4416
+ withDiff: import_zod13.z.boolean().optional().describe(
4417
+ "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
3100
4418
  )
3101
4419
  }),
3102
4420
  // Presence, not truthiness: `--expiring-days ""` is a caller who meant
@@ -3115,7 +4433,9 @@ var doctorCommand = define({
3115
4433
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
3116
4434
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
3117
4435
  ...argv.includes("--offline") ? { offline: true } : {},
3118
- ...argv.includes("--strict") ? { strict: true } : {}
4436
+ ...argv.includes("--strict") ? { strict: true } : {},
4437
+ ...argv.includes("--drifted") ? { drifted: true } : {},
4438
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
3119
4439
  };
3120
4440
  },
3121
4441
  run: async ({ store, now }, {
@@ -3124,7 +4444,9 @@ var doctorCommand = define({
3124
4444
  unverifiedDays,
3125
4445
  agingDays,
3126
4446
  repoRoot,
3127
- offline
4447
+ offline,
4448
+ drifted: drifted2,
4449
+ withDiff
3128
4450
  }) => {
3129
4451
  const checkedAt = now();
3130
4452
  const records = await store.list(path);
@@ -3138,7 +4460,54 @@ var doctorCommand = define({
3138
4460
  ...anchorDrift !== void 0 ? { anchorDrift } : {},
3139
4461
  now: new Date(checkedAt)
3140
4462
  });
3141
- return { bundlePath: path, checkedAt, ...report };
4463
+ const hints = grammarHints();
4464
+ if (!drifted2) {
4465
+ return {
4466
+ bundlePath: path,
4467
+ checkedAt,
4468
+ ...report,
4469
+ ...hints.length ? { hints } : {}
4470
+ };
4471
+ }
4472
+ const standings = new Map(
4473
+ adjudicate(records, records, new Date(checkedAt)).map((hit) => [
4474
+ hit.record.conceptId,
4475
+ hit.standing
4476
+ ])
4477
+ );
4478
+ const packets = [];
4479
+ const rebaselinable = [];
4480
+ const search = movedSearch(repoRoot ?? process.cwd());
4481
+ for (const found of report.groups.find((g) => g.check === "drifted")?.findings ?? []) {
4482
+ const record = records.find(
4483
+ (entry) => entry.conceptId === found.conceptId
4484
+ );
4485
+ if (!record) continue;
4486
+ const standing = standings.get(record.conceptId);
4487
+ const built = await reassessPacket(
4488
+ repoRoot ?? process.cwd(),
4489
+ record,
4490
+ anchorDrift?.get(record.conceptId) ?? [],
4491
+ {
4492
+ ...withDiff ? { withDiff: true } : {},
4493
+ impact: await store.impact(path, record.conceptId),
4494
+ ...standing ? { standing } : {},
4495
+ search
4496
+ }
4497
+ );
4498
+ if (built.packet) packets.push(built.packet);
4499
+ if (built.classified.some((entry) => entry.class === "moved")) {
4500
+ rebaselinable.push(record.conceptId);
4501
+ }
4502
+ }
4503
+ return {
4504
+ bundlePath: path,
4505
+ checkedAt,
4506
+ ...report,
4507
+ packets,
4508
+ rebaselinable,
4509
+ ...hints.length ? { hints } : {}
4510
+ };
3142
4511
  },
3143
4512
  render: (result) => render2(result),
3144
4513
  // Only expiry, and only under --strict. The other seven checks report debt a
@@ -3150,18 +4519,22 @@ var doctorCommand = define({
3150
4519
  failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
3151
4520
  });
3152
4521
  function render2(result) {
4522
+ if (result.packets) return renderPackets(result);
3153
4523
  const { thresholds } = result;
3154
4524
  const lines = [
3155
4525
  `# KB Doctor \u2014 ${result.bundlePath}`,
3156
4526
  `records: ${result.recordCount}`,
3157
4527
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
3158
4528
  `checked: ${result.checkedAt}`,
4529
+ ...result.anchorResolvers.total ? [
4530
+ `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
4531
+ ] : [],
3159
4532
  ""
3160
4533
  ];
3161
- const width = Math.max(...result.groups.map((group2) => group2.check.length));
4534
+ const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
3162
4535
  for (const group2 of result.groups) {
3163
4536
  lines.push(
3164
- ` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
4537
+ ` ${group2.check.padEnd(width2)} ${String(group2.count).padStart(3)} ${group2.headline}`
3165
4538
  );
3166
4539
  }
3167
4540
  for (const group2 of result.groups) {
@@ -3173,27 +4546,52 @@ function render2(result) {
3173
4546
  );
3174
4547
  }
3175
4548
  }
4549
+ for (const hint of result.hints ?? []) lines.push("", hint);
3176
4550
  lines.push(
3177
4551
  "",
3178
4552
  result.healthy ? "Nothing to repair." : `${result.findingCount} finding${result.findingCount === 1 ? "" : "s"} across ${result.groups.filter((group2) => group2.count).length} of ${result.groups.length} checks.`
3179
4553
  );
3180
4554
  return lines.join("\n");
3181
4555
  }
4556
+ function renderPackets(result) {
4557
+ const packets = result.packets ?? [];
4558
+ const lines = [
4559
+ `# KB Drift \u2014 ${result.bundlePath}`,
4560
+ `checked: ${result.checkedAt}`,
4561
+ `${packets.length} record${packets.length === 1 ? "" : "s"} need a reading; ${result.counts.drifted} drifted in all.`
4562
+ ];
4563
+ if (result.rebaselinable?.length) {
4564
+ lines.push(
4565
+ `moved, rebaseline with \`kb_reassess\`: ${result.rebaselinable.join(", ")}`
4566
+ );
4567
+ }
4568
+ for (const packet of packets) {
4569
+ lines.push(
4570
+ renderReassess({
4571
+ conceptId: packet.conceptId,
4572
+ packet,
4573
+ rebaselined: [],
4574
+ cosmetic: 0
4575
+ })
4576
+ );
4577
+ }
4578
+ return lines.join("\n");
4579
+ }
3182
4580
 
3183
4581
  // src/commands/impact.ts
3184
- var import_zod12 = require("zod");
4582
+ var import_zod14 = require("zod");
3185
4583
  var impactCommand = define({
3186
4584
  name: "impact",
3187
4585
  tool: "kb_impact",
3188
4586
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
3189
4587
  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.",
3190
- input: import_zod12.z.object({
4588
+ input: import_zod14.z.object({
3191
4589
  bundlePath,
3192
4590
  conceptId,
3193
- depth: import_zod12.z.number().int().positive().optional().describe(
4591
+ depth: import_zod14.z.number().int().positive().optional().describe(
3194
4592
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
3195
4593
  ),
3196
- rels: import_zod12.z.array(import_zod12.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
4594
+ rels: import_zod14.z.array(import_zod14.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3197
4595
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
3198
4596
  )
3199
4597
  }),
@@ -3214,13 +4612,13 @@ var impactCommand = define({
3214
4612
  });
3215
4613
 
3216
4614
  // src/commands/list.ts
3217
- var import_zod13 = require("zod");
4615
+ var import_zod15 = require("zod");
3218
4616
  var listCommand = define({
3219
4617
  name: "list",
3220
4618
  tool: "kb_list",
3221
4619
  usage: "list [type]",
3222
4620
  description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
3223
- input: import_zod13.z.object({ bundlePath, type: import_zod13.z.enum(KB_RECORD_TYPES).optional() }),
4621
+ input: import_zod15.z.object({ bundlePath, type: import_zod15.z.enum(KB_RECORD_TYPES).optional() }),
3224
4622
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
3225
4623
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
3226
4624
  conceptId: record.conceptId,
@@ -3232,17 +4630,17 @@ var listCommand = define({
3232
4630
  });
3233
4631
 
3234
4632
  // src/commands/load.ts
3235
- var import_zod14 = require("zod");
4633
+ var import_zod16 = require("zod");
3236
4634
  var loadCommand = define({
3237
4635
  name: "load",
3238
4636
  tool: "kb_load",
3239
4637
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
3240
4638
  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.",
3241
- input: import_zod14.z.object({
4639
+ input: import_zod16.z.object({
3242
4640
  bundlePath,
3243
- type: import_zod14.z.enum(KB_RECORD_TYPES).optional(),
3244
- budgetTokens: import_zod14.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3245
- all: import_zod14.z.boolean().optional().describe(
4641
+ type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
4642
+ budgetTokens: import_zod16.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
4643
+ all: import_zod16.z.boolean().optional().describe(
3246
4644
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
3247
4645
  ),
3248
4646
  repoRoot: REPO_ROOT
@@ -3284,25 +4682,25 @@ var loadCommand = define({
3284
4682
  });
3285
4683
 
3286
4684
  // src/commands/log.ts
3287
- var import_zod15 = require("zod");
4685
+ var import_zod17 = require("zod");
3288
4686
  var logCommand = define({
3289
4687
  name: "log",
3290
4688
  tool: "kb_log",
3291
4689
  usage: "log",
3292
4690
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
3293
- input: import_zod15.z.object({ bundlePath }),
4691
+ input: import_zod17.z.object({ bundlePath }),
3294
4692
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3295
4693
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
3296
4694
  });
3297
4695
 
3298
4696
  // src/commands/no-decision.ts
3299
- var import_zod16 = require("zod");
4697
+ var import_zod18 = require("zod");
3300
4698
  var noDecisionCommand = define({
3301
4699
  name: "no-decision",
3302
4700
  tool: "kb_no_decision",
3303
4701
  usage: "no-decision <reason...>",
3304
4702
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
3305
- input: import_zod16.z.object({ bundlePath, reason: import_zod16.z.string().min(1) }),
4703
+ input: import_zod18.z.object({ bundlePath, reason: import_zod18.z.string().min(1) }),
3306
4704
  fromArgv: (argv, path) => ({
3307
4705
  bundlePath: path,
3308
4706
  reason: argv.slice(1).join(" ").trim()
@@ -3319,20 +4717,20 @@ var noDecisionCommand = define({
3319
4717
  });
3320
4718
 
3321
4719
  // src/commands/pack.ts
3322
- var import_zod17 = require("zod");
4720
+ var import_zod19 = require("zod");
3323
4721
  var packCommand = define({
3324
4722
  name: "pack",
3325
4723
  tool: "kb_pack",
3326
4724
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
3327
4725
  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.",
3328
- input: import_zod17.z.object({
4726
+ input: import_zod19.z.object({
3329
4727
  bundlePath,
3330
4728
  conceptId,
3331
- hops: import_zod17.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3332
- maxNodes: import_zod17.z.number().int().positive().optional().describe(
4729
+ hops: import_zod19.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
4730
+ maxNodes: import_zod19.z.number().int().positive().optional().describe(
3333
4731
  "How many records the pack may hold, root included. Defaults to 20."
3334
4732
  ),
3335
- budgetTokens: import_zod17.z.number().int().positive().optional().describe(
4733
+ budgetTokens: import_zod19.z.number().int().positive().optional().describe(
3336
4734
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
3337
4735
  )
3338
4736
  }),
@@ -3357,12 +4755,12 @@ var packCommand = define({
3357
4755
  return render3(result, path, now());
3358
4756
  }
3359
4757
  });
3360
- function render3(result, bundle, at) {
4758
+ function render3(result, bundle, at2) {
3361
4759
  const lines = [
3362
4760
  `# KB Pack \u2014 ${result.root}`,
3363
4761
  `bundle: ${bundle}`,
3364
4762
  `budget: ~${result.tokensLoaded} of ${result.budgetTokens} tokens, ${result.recordCount} records`,
3365
- `packed: ${at}`,
4763
+ `packed: ${at2}`,
3366
4764
  "",
3367
4765
  `## Records (${result.records.length})`
3368
4766
  ];
@@ -3419,22 +4817,22 @@ function warningLabel(warning) {
3419
4817
  }
3420
4818
 
3421
4819
  // src/commands/pin.ts
3422
- var import_zod18 = require("zod");
4820
+ var import_zod20 = require("zod");
3423
4821
  var pinCommand = define({
3424
4822
  name: "pin",
3425
4823
  tool: "kb_pin",
3426
4824
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
3427
4825
  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.",
3428
- input: import_zod18.z.object({
4826
+ input: import_zod20.z.object({
3429
4827
  bundlePath,
3430
- mode: import_zod18.z.enum(["full", "index"]).optional().describe(
4828
+ mode: import_zod20.z.enum(["full", "index"]).optional().describe(
3431
4829
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
3432
4830
  ),
3433
- profiles: import_zod18.z.array(import_zod18.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
3434
- layer: import_zod18.z.enum(["project", "local", "user"]).optional().describe(
4831
+ profiles: import_zod20.z.array(import_zod20.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4832
+ layer: import_zod20.z.enum(["project", "local", "user"]).optional().describe(
3435
4833
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
3436
4834
  ),
3437
- frozen: import_zod18.z.boolean().optional().describe(
4835
+ frozen: import_zod20.z.boolean().optional().describe(
3438
4836
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
3439
4837
  )
3440
4838
  }),
@@ -3463,29 +4861,29 @@ var pinCommand = define({
3463
4861
  });
3464
4862
 
3465
4863
  // src/commands/pins.ts
3466
- var import_zod19 = require("zod");
4864
+ var import_zod21 = require("zod");
3467
4865
  var pinsCommand = define({
3468
4866
  name: "pins",
3469
4867
  tool: "kb_pins",
3470
4868
  usage: "pins",
3471
4869
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
3472
- input: import_zod19.z.object({}),
4870
+ input: import_zod21.z.object({}),
3473
4871
  fromArgv: () => ({}),
3474
4872
  run: ({ store }) => listPins(store, process.cwd())
3475
4873
  });
3476
4874
 
3477
4875
  // src/commands/query.ts
3478
- var import_zod20 = require("zod");
4876
+ var import_zod22 = require("zod");
3479
4877
  var queryCommand = define({
3480
4878
  name: "query",
3481
4879
  tool: "kb_query",
3482
4880
  usage: "query <text...> [--repo-root PATH]",
3483
4881
  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.",
3484
- input: import_zod20.z.object({
4882
+ input: import_zod22.z.object({
3485
4883
  bundlePath,
3486
- text: import_zod20.z.string().optional(),
3487
- type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
3488
- includeNonCurrent: import_zod20.z.boolean().optional(),
4884
+ text: import_zod22.z.string().optional(),
4885
+ type: import_zod22.z.enum(KB_RECORD_TYPES).optional(),
4886
+ includeNonCurrent: import_zod22.z.boolean().optional(),
3489
4887
  repoRoot: REPO_ROOT
3490
4888
  }),
3491
4889
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -3517,27 +4915,27 @@ var queryCommand = define({
3517
4915
  });
3518
4916
 
3519
4917
  // src/commands/read-index.ts
3520
- var import_zod21 = require("zod");
4918
+ var import_zod23 = require("zod");
3521
4919
  var readIndexCommand = define({
3522
4920
  name: "index",
3523
4921
  tool: "kb_index",
3524
4922
  usage: "index",
3525
4923
  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.",
3526
- input: import_zod21.z.object({ bundlePath }),
4924
+ input: import_zod23.z.object({ bundlePath }),
3527
4925
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3528
4926
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
3529
4927
  });
3530
4928
 
3531
4929
  // src/commands/schema.ts
3532
- var import_zod24 = require("zod");
4930
+ var import_zod26 = require("zod");
3533
4931
 
3534
4932
  // src/json-schema.ts
3535
- var import_zod23 = require("zod");
4933
+ var import_zod25 = require("zod");
3536
4934
 
3537
4935
  // src/kb-log.ts
3538
- var import_zod22 = require("zod");
4936
+ var import_zod24 = require("zod");
3539
4937
  var LOG_FILE = "log.jsonl";
3540
- var kbLogEntrySchema = import_zod22.z.object({
4938
+ var kbLogEntrySchema = import_zod24.z.object({
3541
4939
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
3542
4940
  // below), and a value that isn't actually chronological — a Unix
3543
4941
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -3546,12 +4944,12 @@ var kbLogEntrySchema = import_zod22.z.object({
3546
4944
  // and rejects everything else, including a non-`Z` offset — so a
3547
4945
  // malformed `at` is reported the same way a malformed line already is,
3548
4946
  // rather than silently sorting into the wrong place.
3549
- at: import_zod22.z.iso.datetime(),
3550
- by: import_zod22.z.string().min(1),
3551
- operation: import_zod22.z.string().min(1),
3552
- conceptId: import_zod22.z.string().min(1),
4947
+ at: import_zod24.z.iso.datetime(),
4948
+ by: import_zod24.z.string().min(1),
4949
+ operation: import_zod24.z.string().min(1),
4950
+ conceptId: import_zod24.z.string().min(1),
3553
4951
  /** Second concept id, where the operation relates two — supersession. */
3554
- target: import_zod22.z.string().min(1).optional()
4952
+ target: import_zod24.z.string().min(1).optional()
3555
4953
  }).strict();
3556
4954
  function renderLogEntry(entry) {
3557
4955
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -3561,18 +4959,18 @@ function parseLog(raw) {
3561
4959
  const entries = [];
3562
4960
  const malformed = [];
3563
4961
  const seen = /* @__PURE__ */ new Set();
3564
- raw.split("\n").forEach((text, index) => {
4962
+ raw.split("\n").forEach((text, index2) => {
3565
4963
  if (!text.trim()) return;
3566
4964
  let value;
3567
4965
  try {
3568
4966
  value = JSON.parse(text);
3569
4967
  } catch {
3570
- malformed.push({ line: index + 1, text });
4968
+ malformed.push({ line: index2 + 1, text });
3571
4969
  return;
3572
4970
  }
3573
4971
  const parsed = kbLogEntrySchema.safeParse(value);
3574
4972
  if (!parsed.success) {
3575
- malformed.push({ line: index + 1, text });
4973
+ malformed.push({ line: index2 + 1, text });
3576
4974
  return;
3577
4975
  }
3578
4976
  const key = JSON.stringify(parsed.data);
@@ -3589,11 +4987,11 @@ function parseLog(raw) {
3589
4987
  // src/json-schema.ts
3590
4988
  function kbJsonSchemas() {
3591
4989
  return {
3592
- recordFrontmatter: import_zod23.z.toJSONSchema(kbRecordFrontmatterSchema, {
4990
+ recordFrontmatter: import_zod25.z.toJSONSchema(kbRecordFrontmatterSchema, {
3593
4991
  io: "input"
3594
4992
  }),
3595
- composeInput: import_zod23.z.toJSONSchema(composeInputSchema, { io: "input" }),
3596
- logEntry: import_zod23.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
4993
+ composeInput: import_zod25.z.toJSONSchema(composeInputSchema, { io: "input" }),
4994
+ logEntry: import_zod25.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
3597
4995
  };
3598
4996
  }
3599
4997
 
@@ -3603,25 +5001,25 @@ var schemaCommand = define({
3603
5001
  tool: "kb_schema",
3604
5002
  usage: "schema",
3605
5003
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
3606
- input: import_zod24.z.object({}),
5004
+ input: import_zod26.z.object({}),
3607
5005
  fromArgv: () => ({}),
3608
5006
  run: () => Promise.resolve(kbJsonSchemas())
3609
5007
  });
3610
5008
 
3611
5009
  // src/commands/stamp.ts
3612
- var import_promises5 = require("fs/promises");
3613
- var import_zod25 = require("zod");
5010
+ var import_promises8 = require("fs/promises");
5011
+ var import_zod27 = require("zod");
3614
5012
  var DIGEST = /^[0-9a-f]{64}$/;
3615
5013
  var stampCommand = define({
3616
5014
  name: "stamp",
3617
5015
  tool: "kb_stamp",
3618
5016
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
3619
- 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.",
3620
- input: import_zod25.z.object({
3621
- bundlePath: import_zod25.z.string().min(1).optional().describe(
5017
+ 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.",
5018
+ input: import_zod27.z.object({
5019
+ bundlePath: import_zod27.z.string().min(1).optional().describe(
3622
5020
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
3623
5021
  ),
3624
- since: import_zod25.z.string().min(1).optional().describe(
5022
+ since: import_zod27.z.string().min(1).optional().describe(
3625
5023
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
3626
5024
  )
3627
5025
  }),
@@ -3660,7 +5058,7 @@ var stampCommand = define({
3660
5058
  return reports;
3661
5059
  },
3662
5060
  render: (result) => result.map((report) => {
3663
- const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
5061
+ const counts = `${report.recordCount} record(s), ${report.superseded} superseded${report.drifted ? `, ${report.drifted} drifted` : ""}`;
3664
5062
  const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
3665
5063
  return report.changed?.length ? `${head}
3666
5064
  changed: ${report.changed.join(", ")}` : head;
@@ -3683,7 +5081,7 @@ async function readBaseline(since) {
3683
5081
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
3684
5082
  let parsed;
3685
5083
  try {
3686
- parsed = JSON.parse(await (0, import_promises5.readFile)(since, "utf8"));
5084
+ parsed = JSON.parse(await (0, import_promises8.readFile)(since, "utf8"));
3687
5085
  } catch {
3688
5086
  throw new KbStampBaselineError(since);
3689
5087
  }
@@ -3707,16 +5105,16 @@ async function readBaseline(since) {
3707
5105
  }
3708
5106
 
3709
5107
  // src/commands/status.ts
3710
- var import_zod26 = require("zod");
5108
+ var import_zod28 = require("zod");
3711
5109
  var statusCommand = define({
3712
5110
  name: "status",
3713
5111
  tool: "kb_status",
3714
5112
  usage: "status <concept-id> <status>",
3715
5113
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
3716
- input: import_zod26.z.object({
5114
+ input: import_zod28.z.object({
3717
5115
  bundlePath,
3718
5116
  conceptId,
3719
- status: import_zod26.z.enum(KB_RECORD_STATUSES)
5117
+ status: import_zod28.z.enum(KB_RECORD_STATUSES)
3720
5118
  }),
3721
5119
  fromArgv: (argv, path) => ({
3722
5120
  bundlePath: path,
@@ -3731,13 +5129,13 @@ var statusCommand = define({
3731
5129
  });
3732
5130
 
3733
5131
  // src/commands/supersede.ts
3734
- var import_zod27 = require("zod");
5132
+ var import_zod29 = require("zod");
3735
5133
  var supersedeCommand = define({
3736
5134
  name: "supersede",
3737
5135
  tool: "kb_supersede",
3738
5136
  usage: "supersede <concept-id> <replacement-id>",
3739
5137
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
3740
- input: import_zod27.z.object({ bundlePath, conceptId, replacementId: conceptId }),
5138
+ input: import_zod29.z.object({ bundlePath, conceptId, replacementId: conceptId }),
3741
5139
  fromArgv: (argv, path) => ({
3742
5140
  bundlePath: path,
3743
5141
  conceptId: argv[1],
@@ -3751,16 +5149,16 @@ var supersedeCommand = define({
3751
5149
  });
3752
5150
 
3753
5151
  // src/commands/sync-instructions.ts
3754
- var import_zod28 = require("zod");
5152
+ var import_zod30 = require("zod");
3755
5153
  var syncInstructionsCommand = define({
3756
5154
  name: "sync-instructions",
3757
5155
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
3758
5156
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
3759
- input: import_zod28.z.object({
3760
- file: import_zod28.z.string().min(1).describe("The instruction file to edit in place."),
3761
- budgetTokens: import_zod28.z.number().int().positive().optional(),
3762
- fullUnderTokens: import_zod28.z.number().int().positive().optional(),
3763
- profile: import_zod28.z.string().optional()
5157
+ input: import_zod30.z.object({
5158
+ file: import_zod30.z.string().min(1).describe("The instruction file to edit in place."),
5159
+ budgetTokens: import_zod30.z.number().int().positive().optional(),
5160
+ fullUnderTokens: import_zod30.z.number().int().positive().optional(),
5161
+ profile: import_zod30.z.string().optional()
3764
5162
  }),
3765
5163
  fromArgv: (argv) => {
3766
5164
  const budget = argvFlag(argv, "--budget");
@@ -3786,7 +5184,7 @@ var syncInstructionsCommand = define({
3786
5184
  });
3787
5185
 
3788
5186
  // src/commands/trace.ts
3789
- var import_zod29 = require("zod");
5187
+ var import_zod31 = require("zod");
3790
5188
 
3791
5189
  // src/trace.ts
3792
5190
  var TRACE_EDGES = [
@@ -3832,8 +5230,8 @@ function trace(seedId, bundle, options = {}) {
3832
5230
  return [...reached.values()].sort(byGeneratedAt);
3833
5231
  }
3834
5232
  function byGeneratedAt(left, right) {
3835
- const at = (step) => step.record.frontmatter.generated?.at ?? "";
3836
- return at(left).localeCompare(at(right)) || left.depth - right.depth;
5233
+ const at2 = (step) => step.record.frontmatter.generated?.at ?? "";
5234
+ return at2(left).localeCompare(at2(right)) || left.depth - right.depth;
3837
5235
  }
3838
5236
 
3839
5237
  // src/commands/trace.ts
@@ -3842,11 +5240,11 @@ var traceCommand = define({
3842
5240
  tool: "kb_trace",
3843
5241
  usage: "trace <concept-id> [edges...]",
3844
5242
  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".',
3845
- input: import_zod29.z.object({
5243
+ input: import_zod31.z.object({
3846
5244
  bundlePath,
3847
5245
  conceptId,
3848
- edges: import_zod29.z.array(import_zod29.z.enum(TRACE_EDGES)).optional(),
3849
- depth: import_zod29.z.number().int().positive().optional()
5246
+ edges: import_zod31.z.array(import_zod31.z.enum(TRACE_EDGES)).optional(),
5247
+ depth: import_zod31.z.number().int().positive().optional()
3850
5248
  }),
3851
5249
  fromArgv: (argv, path) => ({
3852
5250
  bundlePath: path,
@@ -3868,37 +5266,37 @@ var traceCommand = define({
3868
5266
  });
3869
5267
 
3870
5268
  // src/commands/types.ts
3871
- var import_zod30 = require("zod");
5269
+ var import_zod32 = require("zod");
3872
5270
  var typesCommand = define({
3873
5271
  name: "types",
3874
5272
  tool: "kb_types",
3875
5273
  usage: "types",
3876
5274
  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.",
3877
- input: import_zod30.z.object({}),
5275
+ input: import_zod32.z.object({}),
3878
5276
  fromArgv: () => ({}),
3879
5277
  run: () => Promise.resolve(RECORD_TYPES)
3880
5278
  });
3881
5279
 
3882
5280
  // src/commands/unpin.ts
3883
- var import_zod31 = require("zod");
5281
+ var import_zod33 = require("zod");
3884
5282
  var unpinCommand = define({
3885
5283
  name: "unpin",
3886
5284
  tool: "kb_unpin",
3887
5285
  usage: "unpin [bundle-path]",
3888
5286
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
3889
- input: import_zod31.z.object({ bundlePath }),
5287
+ input: import_zod33.z.object({ bundlePath }),
3890
5288
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3891
5289
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3892
5290
  });
3893
5291
 
3894
5292
  // src/commands/validate.ts
3895
- var import_zod32 = require("zod");
5293
+ var import_zod34 = require("zod");
3896
5294
  var validateCommand = define({
3897
5295
  name: "validate",
3898
5296
  tool: "kb_validate",
3899
5297
  usage: "validate",
3900
5298
  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.",
3901
- input: import_zod32.z.object({ bundlePath }),
5299
+ input: import_zod34.z.object({ bundlePath }),
3902
5300
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3903
5301
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3904
5302
  // Warnings never fail the exit code; every other severity does.
@@ -3908,16 +5306,16 @@ var validateCommand = define({
3908
5306
  });
3909
5307
 
3910
5308
  // src/commands/verify.ts
3911
- var import_zod33 = require("zod");
5309
+ var import_zod35 = require("zod");
3912
5310
  var verifyCommand = define({
3913
5311
  name: "verify",
3914
5312
  tool: "kb_verify",
3915
5313
  usage: "verify <concept-id> --note <text>",
3916
5314
  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.",
3917
- input: import_zod33.z.object({
5315
+ input: import_zod35.z.object({
3918
5316
  bundlePath,
3919
5317
  conceptId,
3920
- note: import_zod33.z.string().refine((s) => s.trim().length > 0, {
5318
+ note: import_zod35.z.string().refine((s) => s.trim().length > 0, {
3921
5319
  message: "note must say what the check found"
3922
5320
  })
3923
5321
  }),
@@ -3937,15 +5335,15 @@ var verifyCommand = define({
3937
5335
  });
3938
5336
 
3939
5337
  // src/commands/write.ts
3940
- var import_zod34 = require("zod");
5338
+ var import_zod36 = require("zod");
3941
5339
  var writeCommand = define({
3942
5340
  name: "write",
3943
5341
  tool: "kb_write",
3944
5342
  usage: "write <type> < record.json",
3945
5343
  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.",
3946
- input: import_zod34.z.object({
5344
+ input: import_zod36.z.object({
3947
5345
  bundlePath,
3948
- type: import_zod34.z.enum(KB_RECORD_TYPES),
5346
+ type: import_zod36.z.enum(KB_RECORD_TYPES),
3949
5347
  input: composeInputSchema
3950
5348
  }),
3951
5349
  fromArgv: async (argv, path, stdin) => ({
@@ -3969,13 +5367,13 @@ var writeCommand = define({
3969
5367
  });
3970
5368
 
3971
5369
  // src/commands/write-decision.ts
3972
- var import_zod35 = require("zod");
5370
+ var import_zod37 = require("zod");
3973
5371
  var writeDecisionCommand = define({
3974
5372
  name: "write-decision",
3975
5373
  tool: "kb_write_decision",
3976
5374
  usage: "write-decision < decision.json",
3977
5375
  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.",
3978
- input: import_zod35.z.object({ bundlePath, input: decisionInputSchema }),
5376
+ input: import_zod37.z.object({ bundlePath, input: decisionInputSchema }),
3979
5377
  fromArgv: async (_argv, path, stdin) => ({
3980
5378
  bundlePath: path,
3981
5379
  input: JSON.parse(await stdin())
@@ -4005,6 +5403,7 @@ var KB_COMMANDS = [
4005
5403
  answerCommand,
4006
5404
  verifyCommand,
4007
5405
  anchorResolveCommand,
5406
+ reassessCommand,
4008
5407
  loadCommand,
4009
5408
  catalogCommand,
4010
5409
  packCommand,
@@ -4031,8 +5430,8 @@ var KB_COMMANDS_BY_NAME = new Map(
4031
5430
  );
4032
5431
 
4033
5432
  // src/kb-store.ts
4034
- var import_promises7 = require("fs/promises");
4035
- var import_node_path8 = require("path");
5433
+ var import_promises10 = require("fs/promises");
5434
+ var import_node_path11 = require("path");
4036
5435
 
4037
5436
  // src/markdown.ts
4038
5437
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -4060,15 +5459,15 @@ function parseMarkdownWithFrontmatter(text, schema) {
4060
5459
  }
4061
5460
 
4062
5461
  // src/kb-stamp.ts
4063
- var import_node_crypto2 = require("crypto");
4064
- function sha256(contents) {
4065
- return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
5462
+ var import_node_crypto4 = require("crypto");
5463
+ function sha2563(contents) {
5464
+ return (0, import_node_crypto4.createHash)("sha256").update(contents).digest("hex");
4066
5465
  }
4067
5466
  function bundleStamp(records, superseded) {
4068
5467
  const entries = [
4069
5468
  ...records.map((hit) => ({
4070
5469
  conceptId: hit.record.conceptId,
4071
- digest: `current:${sha256(
5470
+ digest: `current:${sha2563(
4072
5471
  stringifyMarkdownWithFrontmatter(
4073
5472
  hit.record.body,
4074
5473
  hit.record.frontmatter
@@ -4077,11 +5476,11 @@ function bundleStamp(records, superseded) {
4077
5476
  })),
4078
5477
  ...superseded.map((entry) => ({
4079
5478
  conceptId: entry.conceptId,
4080
- digest: `superseded:${sha256(JSON.stringify(entry))}`
5479
+ digest: `superseded:${sha2563(JSON.stringify(entry))}`
4081
5480
  }))
4082
5481
  ].sort((a, b) => a.conceptId < b.conceptId ? -1 : 1);
4083
5482
  return {
4084
- digest: sha256(
5483
+ digest: sha2563(
4085
5484
  entries.map((entry) => `${entry.conceptId}:${entry.digest}`).join("\n")
4086
5485
  ),
4087
5486
  records: entries
@@ -4092,8 +5491,8 @@ function bundleDigest(records, superseded) {
4092
5491
  }
4093
5492
 
4094
5493
  // src/search-index.ts
4095
- var import_promises6 = require("fs/promises");
4096
- var import_node_path7 = require("path");
5494
+ var import_promises9 = require("fs/promises");
5495
+ var import_node_path10 = require("path");
4097
5496
  var SEARCH_INDEX_FILE = ".index.sqlite";
4098
5497
  var COLLECTION = "kb";
4099
5498
  async function searchBase(bundlePath2, query, options = {}) {
@@ -4102,7 +5501,7 @@ async function searchBase(bundlePath2, query, options = {}) {
4102
5501
  let store = null;
4103
5502
  try {
4104
5503
  store = await qmd.createStore({
4105
- dbPath: (0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE),
5504
+ dbPath: (0, import_node_path10.join)(bundlePath2, SEARCH_INDEX_FILE),
4106
5505
  config: {
4107
5506
  collections: {
4108
5507
  [COLLECTION]: {
@@ -4137,7 +5536,7 @@ async function searchBase(bundlePath2, query, options = {}) {
4137
5536
  }
4138
5537
  }
4139
5538
  async function isStale(bundlePath2) {
4140
- const indexAt = await (0, import_promises6.stat)((0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
5539
+ const indexAt = await (0, import_promises9.stat)((0, import_node_path10.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
4141
5540
  if (!indexAt) return true;
4142
5541
  const { readdir: readdir2 } = await import("fs/promises");
4143
5542
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -4146,8 +5545,8 @@ async function isStale(bundlePath2) {
4146
5545
  let stale = false;
4147
5546
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
4148
5547
  if (stale) return;
4149
- const at = await (0, import_promises6.stat)((0, import_node_path7.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
4150
- if (at > indexAt) stale = true;
5548
+ const at2 = await (0, import_promises9.stat)((0, import_node_path10.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
5549
+ if (at2 > indexAt) stale = true;
4151
5550
  });
4152
5551
  return stale;
4153
5552
  }
@@ -4260,8 +5659,8 @@ function byRank(left, right) {
4260
5659
  ) || left.record.conceptId.localeCompare(right.record.conceptId);
4261
5660
  }
4262
5661
  function typeRank(record) {
4263
- const index = TYPE_PRIORITY.indexOf(record.frontmatter.type);
4264
- return index === -1 ? TYPE_PRIORITY.length : index;
5662
+ const index2 = TYPE_PRIORITY.indexOf(record.frontmatter.type);
5663
+ return index2 === -1 ? TYPE_PRIORITY.length : index2;
4265
5664
  }
4266
5665
 
4267
5666
  // src/kb-links/inbound.ts
@@ -4424,7 +5823,7 @@ function appendUnionMergeLine(contents) {
4424
5823
  }
4425
5824
 
4426
5825
  // src/kb-store.ts
4427
- var KB_DIR = (0, import_node_path8.join)(".strauss", "kb");
5826
+ var KB_DIR = (0, import_node_path11.join)(".strauss", "kb");
4428
5827
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
4429
5828
  var DEFAULT_LOAD_BUDGET = 25e3;
4430
5829
  var KbStore = class {
@@ -4455,7 +5854,7 @@ var KbStore = class {
4455
5854
  const conceptId2 = `${input.type}.${input.slug}`;
4456
5855
  const root = this.root(bundlePath2);
4457
5856
  const target = this.recordPath(bundlePath2, conceptId2);
4458
- await (0, import_promises7.mkdir)(root, { recursive: true });
5857
+ await (0, import_promises10.mkdir)(root, { recursive: true });
4459
5858
  await this.publish(
4460
5859
  target,
4461
5860
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -4494,7 +5893,7 @@ var KbStore = class {
4494
5893
  const target = this.recordPath(bundlePath2, conceptId2);
4495
5894
  let raw;
4496
5895
  try {
4497
- raw = await (0, import_promises7.readFile)(target, "utf8");
5896
+ raw = await (0, import_promises10.readFile)(target, "utf8");
4498
5897
  } catch {
4499
5898
  return null;
4500
5899
  }
@@ -4511,7 +5910,7 @@ var KbStore = class {
4511
5910
  const root = this.root(bundlePath2);
4512
5911
  let names;
4513
5912
  try {
4514
- names = await (0, import_promises7.readdir)(root);
5913
+ names = await (0, import_promises10.readdir)(root);
4515
5914
  } catch {
4516
5915
  return [];
4517
5916
  }
@@ -4519,7 +5918,7 @@ var KbStore = class {
4519
5918
  const records = await mapLimit(
4520
5919
  wanted,
4521
5920
  DEFAULT_IO_CONCURRENCY,
4522
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises7.readFile)((0, import_node_path8.join)(root, name), "utf8"))
5921
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises10.readFile)((0, import_node_path11.join)(root, name), "utf8"))
4523
5922
  );
4524
5923
  return records.filter((record) => record !== null);
4525
5924
  }
@@ -4567,8 +5966,8 @@ var KbStore = class {
4567
5966
  * and the refusal is logged under its own operation name — `mutate` only
4568
5967
  * logs what it publishes.
4569
5968
  */
4570
- async verify(bundlePath2, conceptId2, note, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
4571
- const event = kbVerifiedEventSchema.parse({ by: actor, at, note });
5969
+ async verify(bundlePath2, conceptId2, note, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
5970
+ const event = kbVerifiedEventSchema.parse({ by: actor, at: at2, note });
4572
5971
  const existing = await this.read(bundlePath2, conceptId2);
4573
5972
  if (!existing) throw new KbRecordNotFoundError(conceptId2);
4574
5973
  const generatedBy = existing.frontmatter.generated?.by;
@@ -4620,14 +6019,14 @@ var KbStore = class {
4620
6019
  return superseded;
4621
6020
  }
4622
6021
  /** Resolves an open question, stamping who answered and when. */
4623
- async answer(bundlePath2, conceptId2, answer, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
6022
+ async answer(bundlePath2, conceptId2, answer, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
4624
6023
  return this.mutate(
4625
6024
  bundlePath2,
4626
6025
  conceptId2,
4627
6026
  (frontmatter) => ({
4628
6027
  ...frontmatter,
4629
6028
  strauss_status: "resolved",
4630
- strauss_answered: { by: actor, at }
6029
+ strauss_answered: { by: actor, at: at2 }
4631
6030
  }),
4632
6031
  { operation: "answer", by: actor },
4633
6032
  (body) => `${body.trimEnd()}
@@ -4676,7 +6075,7 @@ ${answer}
4676
6075
  if (found.length) return found;
4677
6076
  }
4678
6077
  const lowered = needle.toLowerCase();
4679
- return bundle.filter((record) => matches(record, lowered));
6078
+ return bundle.filter((record) => matches2(record, lowered));
4680
6079
  }
4681
6080
  /**
4682
6081
  * Anchor drift over the records about to be handed back. Like the search
@@ -4796,24 +6195,34 @@ ${answer}
4796
6195
  }
4797
6196
  /**
4798
6197
  * `load`'s digest without `load`'s bodies — the same records, adjudicated
4799
- * the same way, handed back as a stamp. Skips the anchor drift pass, which
4800
- * reads source files and only ever adds warnings: no warning reaches the
4801
- * digest, so the value is identical to the one `load` returns.
6198
+ * the same way, handed back as a stamp.
6199
+ *
6200
+ * Drift is counted but kept out of the digest, which is what lets the reload
6201
+ * hook ask one question and get two answers: whether the base moved, and
6202
+ * whether the code under it did. A `load` and a `stamp` of the same base
6203
+ * still agree on the digest, because no warning has ever reached it.
4802
6204
  */
4803
- async stamp(bundlePath2) {
6205
+ async stamp(bundlePath2, options = {}) {
4804
6206
  const bundle = await this.list(bundlePath2);
4805
6207
  const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
4806
6208
  const current = adjudicated.filter((hit) => hit.standing !== "superseded");
4807
6209
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
4808
6210
  const stamped = bundleStamp(current, superseded);
4809
- const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
6211
+ const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at2) => typeof at2 === "string").sort();
6212
+ const drift = await this.detectDrift(bundle, options.repoRoot);
6213
+ const drifted2 = drift === void 0 ? null : [...drift.values()].filter(
6214
+ (entries) => entries.some(
6215
+ (entry) => entry.state !== "match" && !isUncheckedReason(entry.reason)
6216
+ )
6217
+ ).length;
4810
6218
  return {
4811
6219
  path: bundlePath2,
4812
6220
  digest: stamped.digest,
4813
6221
  recordCount: bundle.length,
4814
6222
  superseded: superseded.length,
4815
6223
  newestAt: dates.at(-1) ?? null,
4816
- records: stamped.records
6224
+ records: stamped.records,
6225
+ drifted: drifted2
4817
6226
  };
4818
6227
  }
4819
6228
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
@@ -4846,11 +6255,11 @@ ${answer}
4846
6255
  async readIndex(bundlePath2) {
4847
6256
  const root = this.root(bundlePath2);
4848
6257
  const expected = renderIndex(await this.list(bundlePath2));
4849
- const stored = await (0, import_promises7.readFile)((0, import_node_path8.join)(root, INDEX_FILE), "utf8").catch(
6258
+ const stored = await (0, import_promises10.readFile)((0, import_node_path11.join)(root, INDEX_FILE), "utf8").catch(
4850
6259
  () => null
4851
6260
  );
4852
6261
  if (indexIsStale(stored, expected)) {
4853
- await this.publish((0, import_node_path8.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
6262
+ await this.publish((0, import_node_path11.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
4854
6263
  this.logger.info?.({
4855
6264
  operation: "kb.index.repair",
4856
6265
  bundlePath: root,
@@ -4867,8 +6276,8 @@ ${answer}
4867
6276
  * knows which agent touched what. So a bad line is surfaced and left alone.
4868
6277
  */
4869
6278
  async readLog(bundlePath2) {
4870
- const raw = await (0, import_promises7.readFile)(
4871
- (0, import_node_path8.join)(this.root(bundlePath2), LOG_FILE),
6279
+ const raw = await (0, import_promises10.readFile)(
6280
+ (0, import_node_path11.join)(this.root(bundlePath2), LOG_FILE),
4872
6281
  "utf8"
4873
6282
  ).catch(() => "");
4874
6283
  const result = parseLog(raw);
@@ -4919,15 +6328,15 @@ ${answer}
4919
6328
  }
4920
6329
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
4921
6330
  const target = this.recordPath(bundlePath2, conceptId2);
4922
- const before = await (0, import_promises7.readFile)(target, "utf8").catch(() => null);
6331
+ const before = await (0, import_promises10.readFile)(target, "utf8").catch(() => null);
4923
6332
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
4924
6333
  const parsed = this.parse(conceptId2, before);
4925
6334
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
4926
6335
  const frontmatter = change(parsed.frontmatter);
4927
6336
  const body = changeBody(parsed.body);
4928
6337
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
4929
- const witness = await (0, import_promises7.readFile)(target, "utf8").catch(() => null);
4930
- if (witness === null || sha256(witness) !== sha256(before)) {
6338
+ const witness = await (0, import_promises10.readFile)(target, "utf8").catch(() => null);
6339
+ if (witness === null || sha2563(witness) !== sha2563(before)) {
4931
6340
  throw new KbWriteConflictError(conceptId2);
4932
6341
  }
4933
6342
  await this.publish(target, contents, true, conceptId2);
@@ -4952,20 +6361,20 @@ ${answer}
4952
6361
  */
4953
6362
  async publish(target, contents, overwrite, conceptId2) {
4954
6363
  const staging = `${target}.${process.pid}.tmp`;
4955
- await (0, import_promises7.writeFile)(staging, contents, "utf8");
6364
+ await (0, import_promises10.writeFile)(staging, contents, "utf8");
4956
6365
  try {
4957
6366
  if (overwrite) {
4958
- await (0, import_promises7.rename)(staging, target);
6367
+ await (0, import_promises10.rename)(staging, target);
4959
6368
  return;
4960
6369
  }
4961
- await (0, import_promises7.link)(staging, target);
6370
+ await (0, import_promises10.link)(staging, target);
4962
6371
  } catch (error) {
4963
6372
  if (error.code === "EEXIST") {
4964
6373
  throw new KbRecordAlreadyExistsError(conceptId2);
4965
6374
  }
4966
6375
  throw error;
4967
6376
  } finally {
4968
- await (0, import_promises7.unlink)(staging).catch(() => void 0);
6377
+ await (0, import_promises10.unlink)(staging).catch(() => void 0);
4969
6378
  }
4970
6379
  }
4971
6380
  /**
@@ -5009,18 +6418,18 @@ ${answer}
5009
6418
  * file must not fail the mutation it guards.
5010
6419
  */
5011
6420
  async ensureGitattributes(root) {
5012
- const target = (0, import_node_path8.join)(root, GITATTRIBUTES_FILE);
6421
+ const target = (0, import_node_path11.join)(root, GITATTRIBUTES_FILE);
5013
6422
  try {
5014
6423
  let existing;
5015
6424
  try {
5016
- existing = await (0, import_promises7.readFile)(target, "utf8");
6425
+ existing = await (0, import_promises10.readFile)(target, "utf8");
5017
6426
  } catch (error) {
5018
6427
  if (error.code !== "ENOENT") throw error;
5019
6428
  existing = null;
5020
6429
  }
5021
6430
  if (existing === null) {
5022
6431
  try {
5023
- await (0, import_promises7.writeFile)(target, appendUnionMergeLine(""), {
6432
+ await (0, import_promises10.writeFile)(target, appendUnionMergeLine(""), {
5024
6433
  encoding: "utf8",
5025
6434
  flag: "wx"
5026
6435
  });
@@ -5041,7 +6450,7 @@ ${answer}
5041
6450
  return;
5042
6451
  }
5043
6452
  if (!hasMergeDeclaration(existing)) {
5044
- await (0, import_promises7.appendFile)(target, appendUnionMergeLine(existing), "utf8");
6453
+ await (0, import_promises10.appendFile)(target, appendUnionMergeLine(existing), "utf8");
5045
6454
  this.logger.info?.({
5046
6455
  operation: "kb.gitattributes.ensure",
5047
6456
  bundlePath: root,
@@ -5060,7 +6469,7 @@ ${answer}
5060
6469
  async record(root, entry) {
5061
6470
  await this.ensureGitattributes(root);
5062
6471
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
5063
- await (0, import_promises7.appendFile)((0, import_node_path8.join)(root, LOG_FILE), line, "utf8").catch((error) => {
6472
+ await (0, import_promises10.appendFile)((0, import_node_path11.join)(root, LOG_FILE), line, "utf8").catch((error) => {
5064
6473
  this.logger.warn?.({
5065
6474
  operation: "kb.log.append",
5066
6475
  outcome: "failed",
@@ -5086,18 +6495,18 @@ ${answer}
5086
6495
  };
5087
6496
  }
5088
6497
  root(bundlePath2) {
5089
- return (0, import_node_path8.resolve)(bundlePath2);
6498
+ return (0, import_node_path11.resolve)(bundlePath2);
5090
6499
  }
5091
6500
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
5092
6501
  // bundle root; anything carrying a separator would escape it.
5093
6502
  recordPath(bundlePath2, conceptId2) {
5094
- if (conceptId2.includes(import_node_path8.sep) || conceptId2.includes("/")) {
6503
+ if (conceptId2.includes(import_node_path11.sep) || conceptId2.includes("/")) {
5095
6504
  throw new KbInvalidConceptIdError(
5096
6505
  "concept id must not contain a path separator",
5097
6506
  { conceptId: conceptId2 }
5098
6507
  );
5099
6508
  }
5100
- return (0, import_node_path8.join)(this.root(bundlePath2), `${conceptId2}.md`);
6509
+ return (0, import_node_path11.join)(this.root(bundlePath2), `${conceptId2}.md`);
5101
6510
  }
5102
6511
  };
5103
6512
  function estimateTokens(record) {
@@ -5124,7 +6533,7 @@ function stub(hit) {
5124
6533
  at: hit.record.frontmatter.generated?.at ?? null
5125
6534
  };
5126
6535
  }
5127
- function matches(record, needle) {
6536
+ function matches2(record, needle) {
5128
6537
  const { title, description } = record.frontmatter;
5129
6538
  return [record.conceptId, title, description, record.body].some(
5130
6539
  (field) => field?.toLowerCase().includes(needle)
@@ -5137,7 +6546,7 @@ function normalizeActor(id) {
5137
6546
  }
5138
6547
 
5139
6548
  // src/version.ts
5140
- var VERSION = true ? "0.1.16" : "0.0.0-dev";
6549
+ var VERSION = true ? "0.1.18" : "0.0.0-dev";
5141
6550
 
5142
6551
  // src/mcp.ts
5143
6552
  function createKbMcpServer() {