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