@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.
@@ -40,10 +40,24 @@ var kbAnchorSchema = z.object({
40
40
  hash: z.string().regex(/^sha256:[0-9a-f]{64}$/, {
41
41
  message: "hash must be sha256:<64 hex chars>"
42
42
  }).optional(),
43
+ /**
44
+ * What `hash` was taken over: the span's raw text, or the normalised token
45
+ * stream a parser sees (`ast`). Absent means `raw`, which is what every
46
+ * anchor stamped before this field carries, so old hashes keep comparing
47
+ * the way they were written. An `ast` hash is blind to whitespace and
48
+ * comments, so reformatting the anchored code is not drift.
49
+ */
50
+ hash_kind: z.enum(["raw", "ast"]).optional(),
43
51
  /** ISO 8601 timestamp of the last successful resolution. */
44
52
  resolved_at: z.string().min(1).optional(),
45
53
  /** Line count of the text the hash was taken over. */
46
- lines: z.number().int().positive().optional()
54
+ lines: z.number().int().positive().optional(),
55
+ /**
56
+ * Which resolver produced the hashed span. Absent means an anchor stamped
57
+ * before resolvers were named, which is read as `regex` — the only one
58
+ * there was. A hash from a different resolver is drift, not a match.
59
+ */
60
+ resolver: z.enum(["tree-sitter", "regex"]).optional()
47
61
  }).strict();
48
62
  var kbLinkSchema = z.object({
49
63
  target: z.string().min(1),
@@ -518,8 +532,8 @@ function safeSegment(value) {
518
532
  function revRef(rev) {
519
533
  const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
520
534
  let hash = 5381;
521
- for (let at = 0; at < rev.length; at++) {
522
- hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
535
+ for (let at2 = 0; at2 < rev.length; at2++) {
536
+ hash = (hash * 33 ^ rev.charCodeAt(at2)) >>> 0;
523
537
  }
524
538
  return `refs/strauss/${safe}-${hash.toString(16)}`;
525
539
  }
@@ -542,9 +556,9 @@ async function mapLimit(items, limit, fn) {
542
556
  { length: Math.min(limit, items.length) },
543
557
  async () => {
544
558
  while (!failed && next < items.length) {
545
- const at = next++;
559
+ const at2 = next++;
546
560
  try {
547
- out[at] = await fn(items[at], at);
561
+ out[at2] = await fn(items[at2], at2);
548
562
  } catch (error) {
549
563
  failed = true;
550
564
  throw error;
@@ -657,8 +671,8 @@ function repoUrlIsSafe(repo) {
657
671
  if (!scheme?.[1]) return false;
658
672
  if (!allowed.includes(scheme[1].toLowerCase())) return false;
659
673
  const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
660
- const at = authority.lastIndexOf("@");
661
- return at < 0 || !authority.slice(0, at).includes(":");
674
+ const at2 = authority.lastIndexOf("@");
675
+ return at2 < 0 || !authority.slice(0, at2).includes(":");
662
676
  }
663
677
  function protocolArgs() {
664
678
  const allowed = allowedProtocols();
@@ -709,18 +723,18 @@ async function readOneRepo(repo, url, declared, context) {
709
723
  if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
710
724
  const cache = cachePathFor(repo, context.cacheDir);
711
725
  if (!cache) return all({ ok: false, reason: "remote-unreachable" });
712
- const rejected = /* @__PURE__ */ new Map();
726
+ const rejected2 = /* @__PURE__ */ new Map();
713
727
  const usable = [];
714
728
  for (const want of wants) {
715
729
  const reason = wantReason(want);
716
730
  if (reason)
717
- rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
731
+ rejected2.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
718
732
  else usable.push(want);
719
733
  }
720
- if (!usable.length) return rejected;
734
+ if (!usable.length) return rejected2;
721
735
  wants = usable;
722
736
  const opened = await openCache(cache, url, context);
723
- if (opened) return new Map([...rejected, ...all(opened)]);
737
+ if (opened) return new Map([...rejected2, ...all(opened)]);
724
738
  const wantsDefault = wants.some((want) => want.ref === void 0);
725
739
  const branch = wantsDefault ? await defaultBranch(cache, context) : {};
726
740
  const revs = /* @__PURE__ */ new Map();
@@ -747,9 +761,9 @@ async function readOneRepo(repo, url, declared, context) {
747
761
  }
748
762
  );
749
763
  return new Map([
750
- ...rejected,
764
+ ...rejected2,
751
765
  ...wants.map(
752
- (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
766
+ (want, at2) => [wantKey(repo, want.ref, want.file), reads[at2]]
753
767
  )
754
768
  ]);
755
769
  }
@@ -799,13 +813,13 @@ async function defaultBranch(cache, context) {
799
813
  if (!listed.ok) {
800
814
  const reason = transportReason(listed.stderr);
801
815
  if (reason !== "ref-not-found") {
802
- const cached2 = await cachedBranch(cache);
803
- return cached2 ? { name: cached2 } : { reason };
816
+ const cached3 = await cachedBranch(cache);
817
+ return cached3 ? { name: cached3 } : { reason };
804
818
  }
805
819
  }
806
820
  }
807
- const cached = await cachedBranch(cache);
808
- if (cached) return { name: cached };
821
+ const cached2 = await cachedBranch(cache);
822
+ if (cached2) return { name: cached2 };
809
823
  return {
810
824
  reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
811
825
  };
@@ -832,8 +846,8 @@ async function ensureRev(cache, rev, context) {
832
846
  cwd: cache
833
847
  }
834
848
  );
835
- const cached = have.ok && have.stdout.trim().length > 0;
836
- if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
849
+ const cached2 = have.ok && have.stdout.trim().length > 0;
850
+ if (cached2 && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
837
851
  if (context.offline) return { ok: false, reason: "remote-unreachable" };
838
852
  const fetched = await git(
839
853
  [
@@ -849,7 +863,7 @@ async function ensureRev(cache, rev, context) {
849
863
  );
850
864
  if (!fetched.ok) {
851
865
  const reason = transportReason(fetched.stderr);
852
- if (cached && reason !== "ref-not-found") return void 0;
866
+ if (cached2 && reason !== "ref-not-found") return void 0;
853
867
  return { ok: false, reason };
854
868
  }
855
869
  const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
@@ -960,67 +974,654 @@ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY
960
974
  return { ok: false, reason: "file-unreadable" };
961
975
  }
962
976
  });
963
- return new Map(wanted.map((file, at) => [file, results[at]]));
977
+ return new Map(wanted.map((file, at2) => [file, results[at2]]));
978
+ }
979
+
980
+ // src/grammars/store.ts
981
+ import { createHash, randomBytes } from "crypto";
982
+ import { mkdir as mkdir2, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
983
+ import { homedir as homedir2 } from "os";
984
+ import { dirname, join as join2 } from "path";
985
+ function grammarsCacheRoot(override) {
986
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_DIR"] ?? join2(homedir2(), ".strauss", "grammars");
987
+ }
988
+ function grammarCachePath(root, language, sha2564, extension = "wasm") {
989
+ return join2(root, language, `${sha2564.slice(0, 12)}.${extension}`);
990
+ }
991
+ function sha256(bytes) {
992
+ return createHash("sha256").update(bytes).digest("hex");
993
+ }
994
+ function matches(bytes, entry) {
995
+ if (entry.bytes !== void 0 && bytes.byteLength !== entry.bytes)
996
+ return false;
997
+ return sha256(bytes) === entry.sha256;
998
+ }
999
+ async function verifyCached(path, entry) {
1000
+ let bytes;
1001
+ try {
1002
+ bytes = await readFile2(path);
1003
+ } catch {
1004
+ return false;
1005
+ }
1006
+ if (matches(bytes, entry)) return true;
1007
+ await rm(path, { force: true });
1008
+ return false;
1009
+ }
1010
+ async function writeCached(path, bytes) {
1011
+ await mkdir2(dirname(path), { recursive: true });
1012
+ const temporary = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
1013
+ try {
1014
+ await writeFile(temporary, bytes);
1015
+ await rename(temporary, path);
1016
+ } catch (error) {
1017
+ await rm(temporary, { force: true });
1018
+ throw error;
1019
+ }
1020
+ }
1021
+
1022
+ // src/grammars/manifest.ts
1023
+ import { existsSync, readFileSync } from "fs";
1024
+ import { dirname as dirname2, join as join3 } from "path";
1025
+ import { fileURLToPath } from "url";
1026
+
1027
+ // src/grammars/model.ts
1028
+ import { z as z4 } from "zod";
1029
+ var sha2562 = z4.string().regex(/^[0-9a-f]{64}$/);
1030
+ var grammarWasmSchema = z4.object({
1031
+ url: z4.string().min(1),
1032
+ sha256: sha2562,
1033
+ bytes: z4.number().int().positive()
1034
+ });
1035
+ var grammarTagsSchema = z4.object({ url: z4.string().min(1), sha256: sha2562 });
1036
+ var grammarPackSchema = z4.object({
1037
+ package: z4.string().min(1),
1038
+ wasm: grammarWasmSchema,
1039
+ tags: z4.array(grammarTagsSchema),
1040
+ license: z4.string().min(1),
1041
+ extensions: z4.array(z4.string().min(1))
1042
+ });
1043
+ var grammarManifestSchema = z4.object({
1044
+ /** The runtime the packs were proved against. */
1045
+ webTreeSitter: z4.string().min(1),
1046
+ linguist: z4.object({ tag: z4.string().min(1), commit: z4.string().min(1) }),
1047
+ packs: z4.record(z4.string().min(1), grammarPackSchema)
1048
+ });
1049
+
1050
+ // src/grammars/manifest.ts
1051
+ var cached;
1052
+ function grammarManifest() {
1053
+ cached ??= grammarManifestSchema.parse(
1054
+ JSON.parse(readFileSync(grammarsDataPath("manifest.json"), "utf8"))
1055
+ );
1056
+ return cached;
1057
+ }
1058
+ function grammarsDataPath(...segments) {
1059
+ let dir = dirname2(fileURLToPath(import.meta.url));
1060
+ for (let up = 0; up < 5; up++) {
1061
+ const candidate = join3(dir, "grammars");
1062
+ if (existsSync(join3(candidate, "manifest.json")))
1063
+ return join3(candidate, ...segments);
1064
+ dir = dirname2(dir);
1065
+ }
1066
+ throw new Error("grammars/manifest.json is missing from the package");
1067
+ }
1068
+
1069
+ // src/grammars/index.ts
1070
+ import { readFile as readFile3 } from "fs/promises";
1071
+
1072
+ // src/grammars/fetch.ts
1073
+ var ATTEMPTS = 3;
1074
+ var BACKOFF_MS = 250;
1075
+ function grammarUrl(url, override) {
1076
+ const base2 = grammarsBaseUrl(override);
1077
+ if (!base2) return url;
1078
+ let root = base2;
1079
+ while (root.endsWith("/")) root = root.slice(0, -1);
1080
+ const pinned = new URL(url);
1081
+ return `${root}${pinned.pathname}${pinned.search}`;
1082
+ }
1083
+ function grammarsBaseUrl(override) {
1084
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_URL"];
1085
+ }
1086
+ async function downloadPart(url, name, entry, options = {}) {
1087
+ const log = options.log ?? ((line) => void process.stderr.write(line));
1088
+ const weight = entry.bytes === void 0 ? "" : ` (${size(entry.bytes)} from manifest)`;
1089
+ log(`strauss-kb: downloading ${name}${weight} from ${url}
1090
+ `);
1091
+ let cause = "";
1092
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
1093
+ const outcome = await attemptDownload(url, entry, options.fetchTimeoutMs);
1094
+ if ("bytes" in outcome) return outcome;
1095
+ cause = outcome.cause;
1096
+ log(
1097
+ `strauss-kb: ${name} attempt ${attempt}/${ATTEMPTS} failed: ${cause}
1098
+ `
1099
+ );
1100
+ if (!outcome.retry) break;
1101
+ if (attempt < ATTEMPTS) await pause(BACKOFF_MS * attempt);
1102
+ }
1103
+ log(`strauss-kb: ${name} not downloaded: ${cause}
1104
+ `);
1105
+ return { cause };
1106
+ }
1107
+ async function attemptDownload(url, entry, timeoutMs) {
1108
+ try {
1109
+ const response = await fetch(url, {
1110
+ signal: AbortSignal.timeout(fetchTimeoutMs(timeoutMs))
1111
+ });
1112
+ if (!response.ok) {
1113
+ return {
1114
+ cause: `HTTP ${response.status}`,
1115
+ retry: response.status >= 500 || response.status === 429
1116
+ };
1117
+ }
1118
+ const bytes = new Uint8Array(await response.arrayBuffer());
1119
+ if (!matches(bytes, entry))
1120
+ return { cause: "sha256 mismatch", retry: false };
1121
+ return { bytes };
1122
+ } catch (error) {
1123
+ const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
1124
+ return { cause: timedOut ? "timeout" : "network error", retry: true };
1125
+ }
1126
+ }
1127
+ function size(bytes) {
1128
+ return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1129
+ }
1130
+ function pause(ms) {
1131
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
1132
+ }
1133
+
1134
+ // src/grammars/index.ts
1135
+ var inFlight = /* @__PURE__ */ new Map();
1136
+ var missing = /* @__PURE__ */ new Map();
1137
+ var uncompilable = /* @__PURE__ */ new Map();
1138
+ var rejected = /* @__PURE__ */ new Map();
1139
+ function grammarsDownloadDisabled() {
1140
+ return process.env["STRAUSS_KB_GRAMMARS"] === "off";
1141
+ }
1142
+ async function ensureGrammar(language, options = {}) {
1143
+ const pack2 = grammarManifest().packs[language];
1144
+ if (!pack2) return null;
1145
+ const root = grammarsCacheRoot(options.cacheRoot);
1146
+ const wasm = grammarCachePath(root, language, pack2.wasm.sha256);
1147
+ const key = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1148
+ const existing = inFlight.get(key);
1149
+ if (existing) return existing;
1150
+ const pending = (async () => {
1151
+ const grammar = await ensurePart(
1152
+ wasm,
1153
+ `tree-sitter-${language}`,
1154
+ pack2.wasm,
1155
+ options
1156
+ );
1157
+ if (grammar !== true)
1158
+ return miss(language, `grammar tree-sitter-${language}`, grammar);
1159
+ const parts = [];
1160
+ const total = pack2.tags.length;
1161
+ for (const [at2, part] of pack2.tags.entries()) {
1162
+ const name = `${language} tags${total > 1 ? ` part ${at2 + 1}/${total}` : ""}`;
1163
+ const path = grammarCachePath(root, language, part.sha256, "scm");
1164
+ const held = await ensurePart(path, name, part, options);
1165
+ if (held !== true) return miss(language, name, held);
1166
+ parts.push(`; ${part.url}
1167
+ ${lf(await readFile3(path, "utf8"))}`);
1168
+ }
1169
+ missing.delete(language);
1170
+ return { wasm, query: total ? parts.join("\n") : void 0 };
1171
+ })();
1172
+ inFlight.set(key, pending);
1173
+ const result = await pending;
1174
+ if (result === null) inFlight.delete(key);
1175
+ return result;
1176
+ }
1177
+ async function ensurePart(path, name, entry, options) {
1178
+ if (await verifyCached(path, entry)) return true;
1179
+ if (options.offline === true || grammarsDownloadDisabled()) return {};
1180
+ const download = await downloadPart(
1181
+ grammarUrl(entry.url, options.baseUrl),
1182
+ name,
1183
+ entry,
1184
+ options
1185
+ );
1186
+ if ("cause" in download) return { cause: download.cause };
1187
+ await writeCached(path, download.bytes).catch(() => null);
1188
+ return true;
1189
+ }
1190
+ function miss(language, subject, failure) {
1191
+ missing.set(language, { subject, ...failure });
1192
+ return null;
1193
+ }
1194
+ function lf(body) {
1195
+ return body.replace(/\r\n/g, "\n");
1196
+ }
1197
+ function noteUncompilableQuery(language, cause) {
1198
+ uncompilable.set(language, cause);
1199
+ }
1200
+ function noteRejectedGrammar(language, cause) {
1201
+ rejected.set(language, cause);
1202
+ }
1203
+ function grammarHints() {
1204
+ const manifest = grammarManifest();
1205
+ const packs = manifest.packs;
1206
+ const lines = /* @__PURE__ */ new Map();
1207
+ for (const [language, { subject, cause }] of missing)
1208
+ lines.set(
1209
+ language,
1210
+ `${subject} not cached${cause ? ` (${cause})` : ""}; run online once, or set STRAUSS_KB_GRAMMARS_DIR`
1211
+ );
1212
+ for (const [language, cause] of rejected)
1213
+ lines.set(
1214
+ language,
1215
+ `${packs[language]?.package ?? `tree-sitter-${language}`} rejected by web-tree-sitter ${manifest.webTreeSitter}${cause ? `: ${cause}` : ""}; re-pin with pnpm grammars pin ${language}`
1216
+ );
1217
+ for (const [language, cause] of uncompilable)
1218
+ lines.set(
1219
+ language,
1220
+ `tags query for ${language} does not compile against ${packs[language]?.package ?? `tree-sitter-${language}`}: ${cause}; re-pin with pnpm grammars pin ${language}`
1221
+ );
1222
+ return [...lines].sort(([a], [b]) => a.localeCompare(b)).map(([, line]) => line);
1223
+ }
1224
+
1225
+ // src/tree-sitter-resolver/languages.ts
1226
+ import { extname } from "path";
1227
+ var table;
1228
+ function extensionTable() {
1229
+ const manifest = grammarManifest();
1230
+ if (table?.of !== manifest)
1231
+ table = {
1232
+ of: manifest,
1233
+ extensions: Object.fromEntries(
1234
+ Object.entries(manifest.packs).flatMap(
1235
+ ([language, pack2]) => pack2.extensions.map((extension) => [extension, language])
1236
+ )
1237
+ )
1238
+ };
1239
+ return table.extensions;
1240
+ }
1241
+ function hasQuery(language) {
1242
+ return (grammarManifest().packs[language]?.tags.length ?? 0) > 0;
1243
+ }
1244
+ function languageForFile(file) {
1245
+ const language = extensionTable()[extname(file).toLowerCase()];
1246
+ return language && hasQuery(language) ? language : void 0;
1247
+ }
1248
+ function treeSitterLanguages() {
1249
+ return [...new Set(Object.values(extensionTable()))].filter(hasQuery).sort();
1250
+ }
1251
+
1252
+ // src/tree-sitter-resolver/resolver.ts
1253
+ import { createHash as createHash2 } from "crypto";
1254
+ import { Language, Parser, Query } from "web-tree-sitter";
1255
+
1256
+ // src/tree-sitter-resolver/definitions.ts
1257
+ var SCOPE_ONLY = "reference.implementation";
1258
+ function index(tree, query) {
1259
+ const byName = /* @__PURE__ */ new Map();
1260
+ for (const match of query.matches(tree.rootNode)) {
1261
+ const nameNode = match.captures.find((capture) => capture.name === "name");
1262
+ const defNode = match.captures.find(
1263
+ (capture) => capture.name.startsWith("definition.") || capture.name === SCOPE_ONLY
1264
+ );
1265
+ if (!nameNode || !defNode) continue;
1266
+ const candidate = {
1267
+ node: defNode.node,
1268
+ name: nameNode.node.text,
1269
+ target: defNode.name !== SCOPE_ONLY
1270
+ };
1271
+ const existing = byName.get(nameNode.node.id);
1272
+ if (existing && width(existing.node) <= width(candidate.node)) continue;
1273
+ byName.set(nameNode.node.id, candidate);
1274
+ }
1275
+ const definitions = [...byName.values()];
1276
+ return {
1277
+ tree,
1278
+ byNodeId: new Map(
1279
+ definitions.map((definition) => [definition.node.id, definition])
1280
+ ),
1281
+ definitions
1282
+ };
1283
+ }
1284
+ function select(parsed, wanted) {
1285
+ const matches3 = parsed.definitions.filter(
1286
+ (definition) => definition.target && endsWith(chainOf(definition, parsed.byNodeId), wanted)
1287
+ );
1288
+ if (matches3.length < 2) return matches3;
1289
+ const bodied = matches3.filter(
1290
+ (definition) => definition.node.childForFieldName("body") !== null
1291
+ );
1292
+ return bodied.length === 1 ? bodied : matches3;
1293
+ }
1294
+ function chainOf(definition, byNodeId) {
1295
+ const chain = [definition.name];
1296
+ const receiver = definition.node.childForFieldName("receiver");
1297
+ const type = receiver && typeNameIn(receiver);
1298
+ if (type) chain.unshift(type);
1299
+ for (let node = definition.node.parent; node; node = node.parent) {
1300
+ const enclosing = byNodeId.get(node.id);
1301
+ if (enclosing && enclosing.node !== definition.node)
1302
+ chain.unshift(enclosing.name);
1303
+ }
1304
+ return chain;
1305
+ }
1306
+ function typeNameIn(receiver) {
1307
+ const stack = [receiver];
1308
+ while (stack.length) {
1309
+ const node = stack.pop();
1310
+ if (node.type === "type_identifier") return node.text;
1311
+ for (let at2 = 0; at2 < node.childCount; at2++) {
1312
+ const child = node.child(at2);
1313
+ if (child) stack.push(child);
1314
+ }
1315
+ }
1316
+ return void 0;
1317
+ }
1318
+ function endsWith(chain, wanted) {
1319
+ if (wanted.length > chain.length) return false;
1320
+ const offset = chain.length - wanted.length;
1321
+ return wanted.every((segment, at2) => chain[offset + at2] === segment);
1322
+ }
1323
+ function width(node) {
1324
+ return node.endIndex - node.startIndex;
1325
+ }
1326
+ function spanOf(definition, source) {
1327
+ let start = definition.node;
1328
+ let end = definition.node;
1329
+ for (let sibling = start.previousSibling; sibling?.type === "decorator"; sibling = sibling.previousSibling) {
1330
+ start = sibling;
1331
+ }
1332
+ const parent = end.parent;
1333
+ if (parent?.type === "export_statement" && parent.childForFieldName("declaration")?.id === end.id) {
1334
+ start = parent;
1335
+ end = parent;
1336
+ }
1337
+ const lines = source.split("\n");
1338
+ const startLine = start.startPosition.row;
1339
+ const endLine = end.endPosition.column === 0 && end.endPosition.row > startLine ? end.endPosition.row - 1 : end.endPosition.row;
1340
+ return {
1341
+ text: lines.slice(startLine, endLine + 1).join("\n"),
1342
+ startLine: startLine + 1,
1343
+ endLine: endLine + 1
1344
+ };
1345
+ }
1346
+
1347
+ // src/tree-sitter-resolver/tokens.ts
1348
+ function tokens(root) {
1349
+ const out = [];
1350
+ const stack = [root];
1351
+ while (stack.length) {
1352
+ const node = stack.pop();
1353
+ if (node.type.includes("comment")) continue;
1354
+ if (node.childCount === 0) {
1355
+ const text = node.text.trim();
1356
+ if (text) out.push(text);
1357
+ continue;
1358
+ }
1359
+ for (let at2 = node.childCount - 1; at2 >= 0; at2--) {
1360
+ const child = node.child(at2);
1361
+ if (child) stack.push(child);
1362
+ }
1363
+ }
1364
+ return out;
1365
+ }
1366
+
1367
+ // src/tree-sitter-resolver/resolver.ts
1368
+ var TREE_CACHE_LIMIT = 32;
1369
+ var TreeSitterResolver = class {
1370
+ name = "tree-sitter";
1371
+ grammars;
1372
+ loaded = /* @__PURE__ */ new Map();
1373
+ trees = /* @__PURE__ */ new Map();
1374
+ parser;
1375
+ initialized = false;
1376
+ /** Cache effectiveness, for tests and for the latency numbers. */
1377
+ stats = { parses: 0, cacheHits: 0 };
1378
+ constructor(options = {}) {
1379
+ this.grammars = options;
1380
+ }
1381
+ /**
1382
+ * Loads the grammars these files need, once per language per process,
1383
+ * downloading each one on first use.
1384
+ *
1385
+ * A grammar that will not load is remembered as unavailable rather than
1386
+ * retried per anchor, and never throws: an unobtainable WASM is a finding.
1387
+ */
1388
+ async prepare(files) {
1389
+ const wanted = /* @__PURE__ */ new Set();
1390
+ for (const file of files) {
1391
+ const language = languageForFile(file);
1392
+ if (language && !this.loaded.has(language)) wanted.add(language);
1393
+ }
1394
+ if (!wanted.size) return;
1395
+ if (!this.initialized) {
1396
+ try {
1397
+ await Parser.init();
1398
+ this.parser = new Parser();
1399
+ this.initialized = true;
1400
+ } catch {
1401
+ for (const language of wanted) this.loaded.set(language, null);
1402
+ return;
1403
+ }
1404
+ }
1405
+ const languages = [...wanted];
1406
+ const loaded = await mapLimit(
1407
+ languages,
1408
+ Math.min(DEFAULT_IO_CONCURRENCY, languages.length),
1409
+ (language) => this.load(language)
1410
+ );
1411
+ languages.forEach(
1412
+ (language, at2) => this.loaded.set(language, loaded[at2] ?? null)
1413
+ );
1414
+ }
1415
+ /**
1416
+ * An unobtainable grammar, one this runtime refuses, and a query that will
1417
+ * not compile are three faults with three repairs; all are reported through
1418
+ * the grammars module so every hint has one home.
1419
+ */
1420
+ async load(language) {
1421
+ let pack2;
1422
+ try {
1423
+ pack2 = await ensureGrammar(language, this.grammars);
1424
+ } catch {
1425
+ return null;
1426
+ }
1427
+ if (!pack2?.query) return null;
1428
+ let grammar;
1429
+ try {
1430
+ grammar = await Language.load(pack2.wasm);
1431
+ } catch (error) {
1432
+ noteRejectedGrammar(language, why(error));
1433
+ return null;
1434
+ }
1435
+ try {
1436
+ return { language: grammar, query: new Query(grammar, pack2.query) };
1437
+ } catch (error) {
1438
+ noteUncompilableQuery(language, why(error));
1439
+ return null;
1440
+ }
1441
+ }
1442
+ /**
1443
+ * Abstains on an extension with no grammar so the regex resolver gets a
1444
+ * turn; reports `resolver-unavailable` when the grammar exists in principle
1445
+ * but could not be loaded, because falling back there would silently trade a
1446
+ * precise span for a guessed one.
1447
+ */
1448
+ attempt(source, symbol, file) {
1449
+ const language = file ? languageForFile(file) : void 0;
1450
+ if (!language) return { kind: "abstain" };
1451
+ if (!this.loaded.has(language)) return { kind: "abstain" };
1452
+ const loaded = this.loaded.get(language);
1453
+ if (!loaded) return { kind: "unresolved", reason: "resolver-unavailable" };
1454
+ const parsed = this.parse(language, loaded, source);
1455
+ if (!parsed) return { kind: "unresolved", reason: "resolver-unavailable" };
1456
+ const wanted = symbol.split(".").filter(Boolean);
1457
+ if (!wanted.length)
1458
+ return { kind: "unresolved", reason: "symbol-not-found" };
1459
+ const matches3 = select(parsed, wanted);
1460
+ if (!matches3.length)
1461
+ return { kind: "unresolved", reason: "symbol-not-found" };
1462
+ if (matches3.length > 1)
1463
+ return { kind: "unresolved", reason: "symbol-ambiguous" };
1464
+ return { kind: "resolved", span: spanOf(matches3[0], source) };
1465
+ }
1466
+ resolve(source, symbol, file) {
1467
+ const attempt = this.attempt(source, symbol, file);
1468
+ return attempt.kind === "resolved" ? attempt.span : null;
1469
+ }
1470
+ /** Parsed trees are keyed by content hash, so an unchanged file parses once. */
1471
+ parse(language, loaded, source) {
1472
+ const key = `${language}:${createHash2("sha256").update(source).digest("hex")}`;
1473
+ const cached2 = this.trees.get(key);
1474
+ if (cached2) {
1475
+ this.stats.cacheHits += 1;
1476
+ return cached2;
1477
+ }
1478
+ const parser = this.parser;
1479
+ if (!parser) return null;
1480
+ let parsed;
1481
+ try {
1482
+ parser.setLanguage(loaded.language);
1483
+ const tree = parser.parse(source);
1484
+ if (!tree) return null;
1485
+ parsed = index(tree, loaded.query);
1486
+ } catch {
1487
+ return null;
1488
+ }
1489
+ this.stats.parses += 1;
1490
+ if (this.trees.size >= TREE_CACHE_LIMIT) {
1491
+ const oldest = this.trees.keys().next();
1492
+ if (!oldest.done) {
1493
+ this.trees.get(oldest.value)?.tree.delete();
1494
+ this.trees.delete(oldest.value);
1495
+ }
1496
+ }
1497
+ this.trees.set(key, parsed);
1498
+ return parsed;
1499
+ }
1500
+ /**
1501
+ * Every definition this file declares, as dotted symbol and span.
1502
+ *
1503
+ * The inverse of `attempt`: that asks "where is this name", this asks "what
1504
+ * names are here". `moved` needs the second — the stored hash has to be
1505
+ * looked for at every definition in the repository, and there is no name to
1506
+ * ask about, since the whole question is which name now carries that code.
1507
+ */
1508
+ spans(source, file) {
1509
+ const language = languageForFile(file);
1510
+ if (!language) return [];
1511
+ const loaded = this.loaded.get(language);
1512
+ if (!loaded) return [];
1513
+ const parsed = this.parse(language, loaded, source);
1514
+ if (!parsed) return [];
1515
+ return parsed.definitions.filter((definition) => definition.target).map((definition) => ({
1516
+ symbol: chainOf(definition, parsed.byNodeId).join("."),
1517
+ span: spanOf(definition, source)
1518
+ }));
1519
+ }
1520
+ /**
1521
+ * The token stream of a span: every leaf the parser sees, comments dropped,
1522
+ * joined by single spaces.
1523
+ *
1524
+ * This is what makes a reformat not be drift. Hashing it rather than the raw
1525
+ * text means indentation, line breaks, trailing commas the formatter moved,
1526
+ * and every comment above or inside the definition are outside the hash —
1527
+ * and a renamed identifier or a changed literal is still inside it, because
1528
+ * those are leaves.
1529
+ *
1530
+ * `null` when the file has no grammar, the grammar would not load, or the
1531
+ * text will not parse: no normalisation is better than a guessed one.
1532
+ */
1533
+ normalize(text, file) {
1534
+ const language = file ? languageForFile(file) : void 0;
1535
+ if (!language) return null;
1536
+ const loaded = this.loaded.get(language);
1537
+ if (!loaded) return null;
1538
+ const parser = this.parser;
1539
+ if (!parser) return null;
1540
+ let tree;
1541
+ try {
1542
+ parser.setLanguage(loaded.language);
1543
+ tree = parser.parse(text);
1544
+ } catch {
1545
+ return null;
1546
+ }
1547
+ if (!tree) return null;
1548
+ try {
1549
+ return tokens(tree.rootNode).join(" ");
1550
+ } finally {
1551
+ tree.delete();
1552
+ }
1553
+ }
1554
+ /** Drops cached trees. Grammars stay loaded — they are immutable. */
1555
+ reset() {
1556
+ for (const parsed of this.trees.values()) parsed.tree.delete();
1557
+ this.trees.clear();
1558
+ this.stats.parses = 0;
1559
+ this.stats.cacheHits = 0;
1560
+ }
1561
+ };
1562
+ function why(error) {
1563
+ const text = error instanceof Error ? error.message : String(error);
1564
+ return text || "no reason given";
964
1565
  }
965
1566
 
966
1567
  // src/anchor-resolver/resolver.ts
967
- import { createHash } from "crypto";
1568
+ import { createHash as createHash3 } from "crypto";
968
1569
  var PARENT_SCOPE_LINES = 50;
969
1570
  var CLEAN_STATE = { blockComment: false, template: false };
970
1571
  function stripLine(line, state) {
971
1572
  let out = "";
972
- let index = 0;
1573
+ let index2 = 0;
973
1574
  let { blockComment, template } = state;
974
- while (index < line.length) {
975
- const char = line[index];
976
- const next = line[index + 1];
1575
+ while (index2 < line.length) {
1576
+ const char = line[index2];
1577
+ const next = line[index2 + 1];
977
1578
  if (blockComment) {
978
1579
  if (char === "*" && next === "/") {
979
1580
  blockComment = false;
980
- index += 2;
1581
+ index2 += 2;
981
1582
  continue;
982
1583
  }
983
- index += 1;
1584
+ index2 += 1;
984
1585
  continue;
985
1586
  }
986
1587
  if (template) {
987
1588
  if (char === "\\") {
988
- index += 2;
1589
+ index2 += 2;
989
1590
  continue;
990
1591
  }
991
1592
  if (char === "`") template = false;
992
- index += 1;
1593
+ index2 += 1;
993
1594
  continue;
994
1595
  }
995
1596
  if (char === "/" && next === "*") {
996
1597
  blockComment = true;
997
- index += 2;
1598
+ index2 += 2;
998
1599
  continue;
999
1600
  }
1000
1601
  if (char === "/" && next === "/") break;
1001
1602
  if (char === "`") {
1002
1603
  template = true;
1003
- index += 1;
1604
+ index2 += 1;
1004
1605
  continue;
1005
1606
  }
1006
1607
  if (char === "'" || char === '"') {
1007
1608
  const quote = char;
1008
- index += 1;
1009
- while (index < line.length) {
1010
- if (line[index] === "\\") {
1011
- index += 2;
1609
+ index2 += 1;
1610
+ while (index2 < line.length) {
1611
+ if (line[index2] === "\\") {
1612
+ index2 += 2;
1012
1613
  continue;
1013
1614
  }
1014
- if (line[index] === quote) {
1015
- index += 1;
1615
+ if (line[index2] === quote) {
1616
+ index2 += 1;
1016
1617
  break;
1017
1618
  }
1018
- index += 1;
1619
+ index2 += 1;
1019
1620
  }
1020
1621
  continue;
1021
1622
  }
1022
1623
  out += char;
1023
- index += 1;
1624
+ index2 += 1;
1024
1625
  }
1025
1626
  return { code: out, state: { blockComment, template } };
1026
1627
  }
@@ -1035,8 +1636,8 @@ function captureBraceBlock(lines, matchLine) {
1035
1636
  let depth = 0;
1036
1637
  let opened = false;
1037
1638
  let state = CLEAN_STATE;
1038
- for (let index = matchLine; index < lines.length; index++) {
1039
- const stripped = stripLine(lines[index] ?? "", state);
1639
+ for (let index2 = matchLine; index2 < lines.length; index2++) {
1640
+ const stripped = stripLine(lines[index2] ?? "", state);
1040
1641
  state = stripped.state;
1041
1642
  for (const char of stripped.code) {
1042
1643
  if (char === "{") {
@@ -1045,10 +1646,10 @@ function captureBraceBlock(lines, matchLine) {
1045
1646
  } else if (char === "}") {
1046
1647
  depth = Math.max(0, depth - 1);
1047
1648
  } else if (char === ";" && !opened) {
1048
- return span(lines, matchLine, index);
1649
+ return span(lines, matchLine, index2);
1049
1650
  }
1050
1651
  }
1051
- if (opened && depth === 0) return span(lines, matchLine, index);
1652
+ if (opened && depth === 0) return span(lines, matchLine, index2);
1052
1653
  }
1053
1654
  return null;
1054
1655
  }
@@ -1057,22 +1658,22 @@ function captureIndentedBlock(lines, matchLine) {
1057
1658
  const header = lines[matchLine] ?? "";
1058
1659
  const indent = header.length - header.trimStart().length;
1059
1660
  let headerEnd = -1;
1060
- for (let index = matchLine; index < lines.length && index <= matchLine + 20; index++) {
1061
- const code = stripLine(lines[index] ?? "", CLEAN_STATE).code.trimEnd();
1661
+ for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1662
+ const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
1062
1663
  if (code.endsWith(":")) {
1063
- headerEnd = index;
1664
+ headerEnd = index2;
1064
1665
  break;
1065
1666
  }
1066
- if (code.includes(":")) return span(lines, matchLine, index);
1667
+ if (code.includes(":")) return span(lines, matchLine, index2);
1067
1668
  }
1068
1669
  if (headerEnd === -1) return null;
1069
1670
  let end = headerEnd;
1070
- for (let index = headerEnd + 1; index < lines.length; index++) {
1071
- const line = lines[index] ?? "";
1671
+ for (let index2 = headerEnd + 1; index2 < lines.length; index2++) {
1672
+ const line = lines[index2] ?? "";
1072
1673
  if (line.trim() === "") continue;
1073
1674
  const lineIndent = line.length - line.trimStart().length;
1074
1675
  if (lineIndent <= indent) break;
1075
- end = index;
1676
+ end = index2;
1076
1677
  }
1077
1678
  return end === headerEnd ? null : span(lines, matchLine, end);
1078
1679
  }
@@ -1096,15 +1697,15 @@ var regexResolver = {
1096
1697
  const lines = source.split("\n");
1097
1698
  for (const tier of TIERS) {
1098
1699
  const pattern = tier(escaped);
1099
- let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1700
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1100
1701
  if (!candidates.length) continue;
1101
1702
  if (parentPattern && candidates.length > 1) {
1102
1703
  const distances = candidates.map(
1103
- (index) => distanceToParent(lines, index, parentPattern)
1704
+ (index2) => distanceToParent(lines, index2, parentPattern)
1104
1705
  );
1105
1706
  const nearest = Math.min(...distances);
1106
1707
  if (Number.isFinite(nearest)) {
1107
- candidates = candidates.filter((_, at) => distances[at] === nearest);
1708
+ candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1108
1709
  }
1109
1710
  }
1110
1711
  if (candidates.length !== 1) return null;
@@ -1117,34 +1718,86 @@ var regexResolver = {
1117
1718
  function escapeRegExp(value) {
1118
1719
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1119
1720
  }
1120
- function distanceToParent(lines, index, parent) {
1121
- const floor = Math.max(0, index - PARENT_SCOPE_LINES);
1122
- for (let at = index; at >= floor; at--) {
1123
- if (parent.test(lines[at] ?? "")) return index - at;
1721
+ function distanceToParent(lines, index2, parent) {
1722
+ const floor = Math.max(0, index2 - PARENT_SCOPE_LINES);
1723
+ for (let at2 = index2; at2 >= floor; at2--) {
1724
+ if (parent.test(lines[at2] ?? "")) return index2 - at2;
1124
1725
  }
1125
1726
  return Number.POSITIVE_INFINITY;
1126
1727
  }
1127
1728
  function hashAnchorText(text) {
1128
- return `sha256:${createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1729
+ return `sha256:${createHash3("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1129
1730
  }
1130
1731
  function resolveAnchor(source, anchor, resolver = regexResolver) {
1732
+ const outcome = resolveAnchorSpan(source, anchor, [resolver]);
1733
+ return outcome.ok ? outcome.span : null;
1734
+ }
1735
+ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1131
1736
  const normalized = source.replace(/\r\n/g, "\n");
1132
1737
  if (!anchor.symbol) {
1133
1738
  const lines = normalized.split("\n");
1134
1739
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1135
1740
  return {
1136
- text: normalized,
1137
- startLine: 1,
1138
- endLine: Math.max(1, lines.length)
1741
+ ok: true,
1742
+ span: {
1743
+ text: normalized,
1744
+ startLine: 1,
1745
+ endLine: Math.max(1, lines.length)
1746
+ }
1747
+ };
1748
+ }
1749
+ for (const resolver of resolvers) {
1750
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1751
+ if (attempt.kind === "abstain") continue;
1752
+ if (attempt.kind === "unresolved") {
1753
+ if (attempt.reason === "symbol-not-found") continue;
1754
+ return { ok: false, reason: attempt.reason };
1755
+ }
1756
+ const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
1757
+ return {
1758
+ ok: true,
1759
+ span: attempt.span,
1760
+ ...isResolverName(resolver.name) ? { resolver: resolver.name } : {},
1761
+ ...tokens2 ? { normalized: tokens2 } : {}
1139
1762
  };
1140
1763
  }
1141
- return resolver.resolve(normalized, anchor.symbol);
1764
+ return { ok: false, reason: "symbol-not-found" };
1765
+ }
1766
+ function fromResolve(resolver, source, symbol, file) {
1767
+ const span2 = resolver.resolve(source, symbol, file);
1768
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1769
+ }
1770
+ function isResolverName(name) {
1771
+ return name === "tree-sitter" || name === "regex";
1772
+ }
1773
+ async function prepareResolvers(resolvers, files) {
1774
+ for (const resolver of resolvers) await resolver.prepare?.(files);
1775
+ }
1776
+ function defaultAnchorResolvers(grammars = {}) {
1777
+ return [new TreeSitterResolver(grammars), regexResolver];
1778
+ }
1779
+ function resolverChanged(source, anchor, produced) {
1780
+ const previous = anchor.resolver ?? "regex";
1781
+ if (!produced || !anchor.symbol || previous === produced) return false;
1782
+ if (previous !== "regex") return false;
1783
+ const before = regexResolver.resolve(
1784
+ source.replace(/\r\n/g, "\n"),
1785
+ anchor.symbol
1786
+ );
1787
+ return before !== null && hashAnchorText(before.text) === anchor.hash;
1788
+ }
1789
+ function anchorHashOf(anchor, outcome) {
1790
+ const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
1791
+ const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
1792
+ return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
1142
1793
  }
1143
1794
 
1144
1795
  // src/anchor-resolver/drift.ts
1145
1796
  async function detectAnchorDrift(records, options = {}) {
1146
1797
  const repoRoot = options.repoRoot ?? process.cwd();
1147
- const resolver = options.resolver ?? regexResolver;
1798
+ const resolvers = options.resolvers ?? (options.resolver ? [options.resolver] : defaultAnchorResolvers({
1799
+ offline: options.remote?.offline === true
1800
+ }));
1148
1801
  const origin = new LazyOrigin(repoRoot);
1149
1802
  const planned = /* @__PURE__ */ new Map();
1150
1803
  let declaresRepo = false;
@@ -1182,12 +1835,16 @@ async function detectAnchorDrift(records, options = {}) {
1182
1835
  ),
1183
1836
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1184
1837
  ]);
1838
+ await prepareResolvers(resolvers, [
1839
+ ...files,
1840
+ ...wants.map((want) => want.file)
1841
+ ]);
1185
1842
  const drift = /* @__PURE__ */ new Map();
1186
1843
  for (const record of records) {
1187
1844
  const entries = [];
1188
1845
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
1189
1846
  entries.push(
1190
- foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1847
+ foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
1191
1848
  );
1192
1849
  }
1193
1850
  if (entries.length) drift.set(record.conceptId, entries);
@@ -1213,50 +1870,94 @@ function unresolved(anchor, reason, repo) {
1213
1870
  state: "unresolved",
1214
1871
  diffSize: null,
1215
1872
  ...reason ? { reason } : {},
1216
- ...repo ? { repo } : {}
1873
+ ...repo ? { repo } : {},
1874
+ ...classOf(reason)
1875
+ };
1876
+ }
1877
+ function provisionalDriftClass(entry) {
1878
+ if (entry.state === "unresolved") {
1879
+ return entry.reason === "file-missing" || entry.reason === "symbol-not-found" ? "gone" : void 0;
1880
+ }
1881
+ return entry.state === "drifted" ? "changed" : void 0;
1882
+ }
1883
+ function classOf(reason) {
1884
+ const settled = provisionalDriftClass({ state: "unresolved", reason });
1885
+ return settled ? { class: settled } : {};
1886
+ }
1887
+ function hashIn(source, anchor, resolvers) {
1888
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
1889
+ if (!outcome.ok) return { ok: false, reason: outcome.reason };
1890
+ const { hash, kind } = anchorHashOf(anchor, outcome);
1891
+ return {
1892
+ ok: true,
1893
+ current: {
1894
+ hash,
1895
+ kind,
1896
+ lines: outcome.span.endLine - outcome.span.startLine + 1,
1897
+ ...outcome.resolver ? { resolver: outcome.resolver } : {}
1898
+ }
1217
1899
  };
1218
1900
  }
1219
- function hashIn(source, anchor, resolver) {
1220
- const resolved = resolveAnchor(source, anchor, resolver);
1221
- if (!resolved) return null;
1901
+ function resolverExtras(source, anchor, current) {
1222
1902
  return {
1223
- hash: hashAnchorText(resolved.text),
1224
- lines: resolved.endLine - resolved.startLine + 1
1903
+ ...current.resolver ? { resolver: current.resolver } : {},
1904
+ ...current.hash !== anchor.hash && resolverChanged(source, anchor, current.resolver) ? { reason: "resolver-changed" } : {}
1225
1905
  };
1226
1906
  }
1227
1907
  function compared(anchor, current, extra = {}) {
1908
+ const matched = current.hash === anchor.hash;
1228
1909
  return {
1229
1910
  ...base(anchor),
1230
- state: current.hash === anchor.hash ? "match" : "drifted",
1911
+ state: matched ? "match" : "drifted",
1231
1912
  currentHash: current.hash,
1913
+ hashKind: current.kind,
1232
1914
  diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1915
+ ...matched ? {} : { class: "changed" },
1233
1916
  ...extra
1234
1917
  };
1235
1918
  }
1236
- function localEntry(anchor, read, resolver) {
1919
+ function localEntry(anchor, read, resolvers) {
1237
1920
  if (!read.ok) return unresolved(anchor, read.reason);
1238
- const current = hashIn(read.source, anchor, resolver);
1239
- return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1921
+ const found = hashIn(read.source, anchor, resolvers);
1922
+ if (!found.ok) return unresolved(anchor, found.reason);
1923
+ return compared(
1924
+ anchor,
1925
+ found.current,
1926
+ resolverExtras(read.source, anchor, found.current)
1927
+ );
1240
1928
  }
1241
- function remoteEntry(anchor, remote, resolver) {
1929
+ function remoteEntry(anchor, remote, resolvers) {
1242
1930
  const repo = anchor.repo;
1243
1931
  const key = normalizeRepoUrl(repo);
1244
1932
  const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1245
1933
  const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1246
1934
  if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1247
1935
  if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1248
- const current = hashIn(primary.source, anchor, resolver);
1249
- if (!current) return unresolved(anchor, "symbol-not-found", repo);
1250
- if (!anchor.ref) return compared(anchor, current, { repo });
1936
+ const found = hashIn(primary.source, anchor, resolvers);
1937
+ if (!found.ok) return unresolved(anchor, found.reason, repo);
1938
+ const current = found.current;
1939
+ const extras = resolverExtras(primary.source, anchor, current);
1940
+ if (!anchor.ref) return compared(anchor, current, { repo, ...extras });
1251
1941
  if (current.hash !== anchor.hash) {
1252
- return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1942
+ return compared(anchor, current, {
1943
+ repo,
1944
+ ...extras,
1945
+ remoteState: "drifted-from-ref"
1946
+ });
1253
1947
  }
1254
- const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1255
- return head && head.hash !== anchor.hash ? {
1256
- ...compared(anchor, head, { repo }),
1948
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
1949
+ return head?.ok && head.current.hash !== anchor.hash ? {
1950
+ ...compared(anchor, head.current, {
1951
+ repo,
1952
+ ...head.current.resolver ? { resolver: head.current.resolver } : {}
1953
+ }),
1257
1954
  state: "drifted",
1258
1955
  remoteState: "drifted-on-default"
1259
- } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1956
+ } : compared(anchor, current, {
1957
+ repo,
1958
+ ...extras,
1959
+ remoteState: "matches-ref"
1960
+ });
1260
1961
  }
1261
1962
 
1262
1963
  // src/errors.ts
@@ -1463,9 +2164,9 @@ var KbStampDigestBaselineError = class extends BaseError {
1463
2164
  // src/kb-pins/budgets.ts
1464
2165
  function asBudgets(value) {
1465
2166
  if (value === null || typeof value !== "object") return {};
1466
- const table = value;
2167
+ const table2 = value;
1467
2168
  const pick = (key, min) => {
1468
- const raw = table[key];
2169
+ const raw = table2[key];
1469
2170
  return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
1470
2171
  };
1471
2172
  const budgetTokens = pick("budgetTokens", 1);
@@ -1476,9 +2177,9 @@ function asBudgets(value) {
1476
2177
  };
1477
2178
  }
1478
2179
  function contextProfileBudgets(manifest, profile) {
1479
- const table = manifest.context;
1480
- if (table === null || typeof table !== "object") return {};
1481
- const entries = table;
2180
+ const table2 = manifest.context;
2181
+ if (table2 === null || typeof table2 !== "object") return {};
2182
+ const entries = table2;
1482
2183
  return {
1483
2184
  ...asBudgets(entries["default"]),
1484
2185
  ...profile ? asBudgets(entries[profile]) : {}
@@ -1509,15 +2210,15 @@ var KbBaseFrozenError = class extends Error {
1509
2210
  };
1510
2211
 
1511
2212
  // src/kb-pins/model.ts
1512
- import { join as join2 } from "path";
1513
- import { z as z4 } from "zod";
1514
- var PINS_FILE = join2(".strauss", "kb-pins.json");
1515
- var PINS_LOCAL_FILE = join2(".strauss", "kb-pins.local.json");
2213
+ import { join as join4 } from "path";
2214
+ import { z as z5 } from "zod";
2215
+ var PINS_FILE = join4(".strauss", "kb-pins.json");
2216
+ var PINS_LOCAL_FILE = join4(".strauss", "kb-pins.local.json");
1516
2217
  var PIN_LAYERS = ["project", "local", "user"];
1517
- var pinSchema = z4.object({
2218
+ var pinSchema = z5.object({
1518
2219
  /** Relative to the manifest's root, so the file is committable. */
1519
- path: z4.string().min(1),
1520
- pinnedAt: z4.string().min(1).optional(),
2220
+ path: z5.string().min(1),
2221
+ pinnedAt: z5.string().min(1).optional(),
1521
2222
  /**
1522
2223
  * How `context` renders this base. `full` preloads the whole base into
1523
2224
  * the block regardless of the full-under threshold — for a base whose
@@ -1527,7 +2228,7 @@ var pinSchema = z4.object({
1527
2228
  * Absent: the profile's full-under threshold decides. Invalid values
1528
2229
  * degrade to absent rather than failing the manifest.
1529
2230
  */
1530
- mode: z4.enum(["full", "index"]).optional().catch(void 0),
2231
+ mode: z5.enum(["full", "index"]).optional().catch(void 0),
1531
2232
  /**
1532
2233
  * Context profiles this pin surfaces in (e.g. only at session-start,
1533
2234
  * not per turn). Absent: every profile. A run without a profile sees
@@ -1535,17 +2236,17 @@ var pinSchema = z4.object({
1535
2236
  * that skill at point of use than pinned at all — pins are what every
1536
2237
  * session should see.
1537
2238
  */
1538
- profiles: z4.array(z4.string()).optional().catch(void 0),
2239
+ profiles: z5.array(z5.string()).optional().catch(void 0),
1539
2240
  /**
1540
2241
  * The base is concluded — a finished piece of research, a frozen ADR
1541
2242
  * set. Write commands against it refuse while this workspace holds the
1542
2243
  * pin, and `context` labels it read-only. Workspace policy, not base
1543
2244
  * state: the base itself stays copyable and writable elsewhere.
1544
2245
  */
1545
- frozen: z4.boolean().optional().catch(void 0)
2246
+ frozen: z5.boolean().optional().catch(void 0)
1546
2247
  }).passthrough();
1547
- var pinsManifestSchema = z4.object({
1548
- pins: z4.array(pinSchema).default([]),
2248
+ var pinsManifestSchema = z5.object({
2249
+ pins: z5.array(pinSchema).default([]),
1549
2250
  /**
1550
2251
  * Per-repo budgets for the `context` command, keyed by profile —
1551
2252
  * `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
@@ -1554,21 +2255,21 @@ var pinsManifestSchema = z4.object({
1554
2255
  * the index at every session start. `contextProfileBudgets` does the
1555
2256
  * tolerant read.
1556
2257
  */
1557
- context: z4.unknown().optional()
2258
+ context: z5.unknown().optional()
1558
2259
  }).passthrough();
1559
2260
 
1560
2261
  // src/kb-pins/layers.ts
1561
- import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
1562
- import { homedir as homedir2 } from "os";
1563
- import { dirname, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
2262
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
2263
+ import { homedir as homedir3 } from "os";
2264
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
1564
2265
  function userRoot() {
1565
- return process.env.STRAUSS_KB_USER_ROOT || homedir2();
2266
+ return process.env.STRAUSS_KB_USER_ROOT || homedir3();
1566
2267
  }
1567
2268
  function layerRoot(workspaceDir, layer) {
1568
2269
  return layer === "user" ? userRoot() : resolve2(workspaceDir);
1569
2270
  }
1570
2271
  function layerFile(workspaceDir, layer) {
1571
- return join3(
2272
+ return join5(
1572
2273
  layerRoot(workspaceDir, layer),
1573
2274
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1574
2275
  );
@@ -1577,7 +2278,7 @@ async function readPinsLayer(workspaceDir, layer) {
1577
2278
  const file = layerFile(workspaceDir, layer);
1578
2279
  let raw;
1579
2280
  try {
1580
- raw = await readFile2(file, "utf8");
2281
+ raw = await readFile4(file, "utf8");
1581
2282
  } catch {
1582
2283
  return { pins: [] };
1583
2284
  }
@@ -1601,8 +2302,8 @@ async function readPinsLayer(workspaceDir, layer) {
1601
2302
  }
1602
2303
  async function writePinsLayer(workspaceDir, layer, manifest) {
1603
2304
  const file = layerFile(workspaceDir, layer);
1604
- await mkdir2(dirname(file), { recursive: true });
1605
- await writeFile(file, `${JSON.stringify(manifest, null, 2)}
2305
+ await mkdir3(dirname3(file), { recursive: true });
2306
+ await writeFile2(file, `${JSON.stringify(manifest, null, 2)}
1606
2307
  `, "utf8");
1607
2308
  }
1608
2309
  function resolvePinPath(rootDir, path) {
@@ -1668,7 +2369,7 @@ async function listPins(store, workspaceDir) {
1668
2369
  }
1669
2370
 
1670
2371
  // src/kb-pins/pin.ts
1671
- async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2372
+ async function pinBase(store, workspaceDir, bundlePath2, at2, options = {}) {
1672
2373
  const layer = options.layer ?? "project";
1673
2374
  const root = layerRoot(workspaceDir, layer);
1674
2375
  const manifest = await readPinsLayer(workspaceDir, layer);
@@ -1696,7 +2397,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1696
2397
  return {
1697
2398
  path: existing.path,
1698
2399
  layer,
1699
- pinnedAt: existing.pinnedAt ?? at,
2400
+ pinnedAt: existing.pinnedAt ?? at2,
1700
2401
  alreadyPinned: true,
1701
2402
  ...updated.mode ? { mode: updated.mode } : {},
1702
2403
  ...updated.profiles ? { profiles: updated.profiles } : {},
@@ -1706,7 +2407,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1706
2407
  }
1707
2408
  const entry = {
1708
2409
  path: storablePath(root, bundlePath2),
1709
- pinnedAt: at,
2410
+ pinnedAt: at2,
1710
2411
  ...fields
1711
2412
  };
1712
2413
  await writePinsLayer(workspaceDir, layer, {
@@ -1716,7 +2417,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1716
2417
  return {
1717
2418
  path: entry.path,
1718
2419
  layer,
1719
- pinnedAt: at,
2420
+ pinnedAt: at2,
1720
2421
  alreadyPinned: false,
1721
2422
  ...fields,
1722
2423
  ...warning ? { warning } : {}
@@ -1816,7 +2517,8 @@ function warningAnchor(entry) {
1816
2517
  diffSize,
1817
2518
  ...reason !== void 0 ? { reason } : {},
1818
2519
  ...repo !== void 0 ? { repo } : {},
1819
- ...remoteState !== void 0 ? { remoteState } : {}
2520
+ ...remoteState !== void 0 ? { remoteState } : {},
2521
+ ...entry.class !== void 0 ? { class: entry.class } : {}
1820
2522
  };
1821
2523
  }
1822
2524
  function resolveHeads(from, byId) {
@@ -1827,8 +2529,8 @@ function resolveHeads(from, byId) {
1827
2529
  while (queue.length) {
1828
2530
  const current = queue.shift();
1829
2531
  const next = successors(current, byId);
1830
- for (const missing of next.missing) {
1831
- warnings.push({ kind: "broken-chain", missing });
2532
+ for (const missing2 of next.missing) {
2533
+ warnings.push({ kind: "broken-chain", missing: missing2 });
1832
2534
  }
1833
2535
  if (!next.records.length) {
1834
2536
  if (current.conceptId !== from.conceptId)
@@ -1859,13 +2561,13 @@ function successors(record, byId) {
1859
2561
  }
1860
2562
  }
1861
2563
  const records = [];
1862
- const missing = [];
2564
+ const missing2 = [];
1863
2565
  for (const id of ids) {
1864
2566
  const found = byId.get(id);
1865
2567
  if (found) records.push(found);
1866
- else missing.push(id);
2568
+ else missing2.push(id);
1867
2569
  }
1868
- return { records, missing };
2570
+ return { records, missing: missing2 };
1869
2571
  }
1870
2572
 
1871
2573
  // src/catalog.ts
@@ -1936,7 +2638,7 @@ function indexIsStale(stored, expected) {
1936
2638
  }
1937
2639
 
1938
2640
  // src/kb-context.ts
1939
- import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
2641
+ import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
1940
2642
  var HEADING2 = "## Knowledge bases (pinned)";
1941
2643
  var DEFAULT_CONTEXT_BUDGET = 4e3;
1942
2644
  var CONTEXT_PROFILES = {
@@ -2140,13 +2842,13 @@ function toHookJson(block, event) {
2140
2842
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2141
2843
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2142
2844
  async function syncInstructions(file, block) {
2143
- const existing = await readFile3(file, "utf8").catch(() => null);
2845
+ const existing = await readFile5(file, "utf8").catch(() => null);
2144
2846
  const region = block ? `${CONTEXT_BEGIN}
2145
2847
  ${block.trim()}
2146
2848
  ${CONTEXT_END}` : null;
2147
2849
  if (existing === null) {
2148
2850
  if (!region) return { file, action: "unchanged" };
2149
- await writeFile2(file, `${region}
2851
+ await writeFile3(file, `${region}
2150
2852
  `, "utf8");
2151
2853
  return { file, action: "created" };
2152
2854
  }
@@ -2157,11 +2859,11 @@ ${CONTEXT_END}` : null;
2157
2859
  const after = existing.slice(end + CONTEXT_END.length);
2158
2860
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2159
2861
  if (next === existing) return { file, action: "unchanged" };
2160
- await writeFile2(file, next, "utf8");
2862
+ await writeFile3(file, next, "utf8");
2161
2863
  return { file, action: region ? "replaced" : "removed" };
2162
2864
  }
2163
2865
  if (!region) return { file, action: "unchanged" };
2164
- await writeFile2(
2866
+ await writeFile3(
2165
2867
  file,
2166
2868
  `${existing.replace(/\n*$/, "\n\n")}${region}
2167
2869
  `,
@@ -2170,6 +2872,405 @@ ${CONTEXT_END}` : null;
2170
2872
  return { file, action: "appended" };
2171
2873
  }
2172
2874
 
2875
+ // src/drift/git.ts
2876
+ import { execFile as execFile3 } from "child_process";
2877
+ import { promisify as promisify3 } from "util";
2878
+ var execFileAsync3 = promisify3(execFile3);
2879
+ var MAX_GIT_OUTPUT_BYTES = 1048576;
2880
+ var GIT_TIMEOUT_MS = 5e3;
2881
+ async function git2(cwd, args) {
2882
+ const env = { ...process.env };
2883
+ delete env["GIT_DIR"];
2884
+ delete env["GIT_WORK_TREE"];
2885
+ delete env["GIT_INDEX_FILE"];
2886
+ try {
2887
+ const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
2888
+ timeout: GIT_TIMEOUT_MS,
2889
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
2890
+ env
2891
+ });
2892
+ return { ok: true, stdout };
2893
+ } catch {
2894
+ return { ok: false };
2895
+ }
2896
+ }
2897
+ async function listRepoFiles(repoRoot) {
2898
+ const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
2899
+ if (!result.ok) return [];
2900
+ return result.stdout.split("\0").filter(Boolean);
2901
+ }
2902
+ async function readOldSource(repoRoot, anchor) {
2903
+ if (!filePathIsSafe(anchor.file))
2904
+ return { ok: false, reason: "unrecoverable" };
2905
+ if (anchor.ref && refShapeIsSafe(anchor.ref)) {
2906
+ const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
2907
+ if (shown2 !== null) {
2908
+ return {
2909
+ ok: true,
2910
+ source: shown2,
2911
+ origin: { kind: "ref", ref: anchor.ref }
2912
+ };
2913
+ }
2914
+ }
2915
+ const at2 = anchor.resolved_at;
2916
+ if (!at2 || Number.isNaN(Date.parse(at2))) {
2917
+ return { ok: false, reason: "unrecoverable" };
2918
+ }
2919
+ const found = await git2(repoRoot, [
2920
+ "log",
2921
+ "-1",
2922
+ "--format=%H",
2923
+ `--before=${at2}`,
2924
+ "--end-of-options",
2925
+ "HEAD",
2926
+ "--",
2927
+ anchor.file
2928
+ ]);
2929
+ const sha = found.ok ? found.stdout.trim() : "";
2930
+ if (!sha || !refShapeIsSafe(sha))
2931
+ return { ok: false, reason: "unrecoverable" };
2932
+ const shown = await showFile(repoRoot, sha, anchor.file);
2933
+ if (shown === null) return { ok: false, reason: "unrecoverable" };
2934
+ return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
2935
+ }
2936
+ async function showFile(repoRoot, ref, file) {
2937
+ const path = file.replace(/^\.\//, "");
2938
+ const result = await git2(repoRoot, [
2939
+ "show",
2940
+ "--end-of-options",
2941
+ `${ref}:${path}`
2942
+ ]);
2943
+ return result.ok ? result.stdout : null;
2944
+ }
2945
+
2946
+ // src/drift/moved.ts
2947
+ import { stat as stat2 } from "fs/promises";
2948
+ var MAX_MOVED_SEARCH_FILES = 2e3;
2949
+ var SEARCH_BATCH = 64;
2950
+ function movedSearch(repoRoot, options = {}) {
2951
+ const read = options.reader ?? anchorFileReader(repoRoot);
2952
+ const sizeOf = options.sizeOf ?? diskSize(repoRoot);
2953
+ const resolver = new TreeSitterResolver();
2954
+ let repoFiles;
2955
+ const prepared = /* @__PURE__ */ new Set();
2956
+ const filesForLanguage = async (language) => {
2957
+ repoFiles ??= listRepoFiles(repoRoot);
2958
+ return (await repoFiles).filter((file) => languageForFile(file) === language).slice(0, MAX_MOVED_SEARCH_FILES);
2959
+ };
2960
+ return {
2961
+ async find(anchor) {
2962
+ const stored = anchor.hash;
2963
+ if (!stored) return void 0;
2964
+ const language = languageForFile(anchor.file);
2965
+ if (!language) return sameFileWindow(anchor, read, stored);
2966
+ const candidates = await filesForLanguage(language);
2967
+ if (!prepared.has(language)) {
2968
+ await resolver.prepare(candidates.length ? candidates : [anchor.file]);
2969
+ prepared.add(language);
2970
+ }
2971
+ const floor = anchor.lines ?? 0;
2972
+ for (let at2 = 0; at2 < candidates.length; at2 += SEARCH_BATCH) {
2973
+ const batch = candidates.slice(at2, at2 + SEARCH_BATCH);
2974
+ const hits = await mapLimit(
2975
+ batch,
2976
+ DEFAULT_IO_CONCURRENCY,
2977
+ async (file) => {
2978
+ const size2 = await sizeOf(file);
2979
+ if (size2 !== null && size2 < floor) return void 0;
2980
+ return matchIn(resolver, read, anchor, stored, file);
2981
+ }
2982
+ );
2983
+ const found = hits.find((hit) => hit !== void 0);
2984
+ if (found) return found;
2985
+ }
2986
+ return void 0;
2987
+ }
2988
+ };
2989
+ }
2990
+ async function matchIn(resolver, read, anchor, stored, file) {
2991
+ const source = await read(file);
2992
+ if (!source.ok) return void 0;
2993
+ const normalized = source.source.replace(/\r\n/g, "\n");
2994
+ for (const found of resolver.spans(normalized, file)) {
2995
+ const text = anchor.hash_kind === "ast" ? resolver.normalize(found.span.text, file) : found.span.text;
2996
+ if (text === null || hashAnchorText(text) !== stored) continue;
2997
+ if (file === anchor.file && found.symbol === anchor.symbol) continue;
2998
+ return {
2999
+ file,
3000
+ symbol: found.symbol,
3001
+ startLine: found.span.startLine,
3002
+ endLine: found.span.endLine
3003
+ };
3004
+ }
3005
+ return void 0;
3006
+ }
3007
+ function diskSize(repoRoot) {
3008
+ return async (file) => {
3009
+ const path = anchorFilePath(repoRoot, file);
3010
+ if (path === null) return null;
3011
+ try {
3012
+ return (await stat2(path)).size;
3013
+ } catch {
3014
+ return null;
3015
+ }
3016
+ };
3017
+ }
3018
+ async function sameFileWindow(anchor, read, stored) {
3019
+ const height = anchor.lines;
3020
+ if (!height || anchor.hash_kind === "ast") return void 0;
3021
+ const source = await read(anchor.file);
3022
+ if (!source.ok) return void 0;
3023
+ const lines = source.source.replace(/\r\n/g, "\n").split("\n");
3024
+ for (let at2 = 0; at2 + height <= lines.length; at2++) {
3025
+ if (hashAnchorText(lines.slice(at2, at2 + height).join("\n")) !== stored) {
3026
+ continue;
3027
+ }
3028
+ return {
3029
+ file: anchor.file,
3030
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
3031
+ startLine: at2 + 1,
3032
+ endLine: at2 + height
3033
+ };
3034
+ }
3035
+ return void 0;
3036
+ }
3037
+
3038
+ // src/drift/classify.ts
3039
+ async function classifyDrift(repoRoot, record, entries, options = {}) {
3040
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
3041
+ (anchor) => anchor.hash
3042
+ );
3043
+ const reader = options.reader ?? anchorFileReader(repoRoot);
3044
+ const treeSitter = new TreeSitterResolver();
3045
+ const resolvers = [treeSitter, regexResolver];
3046
+ const search = options.search ?? movedSearch(repoRoot, { ...options.reader ? { reader } : {} });
3047
+ const wanted = [];
3048
+ entries.forEach((entry, at2) => {
3049
+ const anchor = anchors[at2];
3050
+ if (!anchor) return;
3051
+ if (entry.state === "match" || isUncheckedReason(entry.reason)) return;
3052
+ wanted.push({ anchor, entry });
3053
+ });
3054
+ if (!wanted.length) return [];
3055
+ await prepareResolvers(
3056
+ resolvers,
3057
+ wanted.map(({ anchor }) => anchor.file)
3058
+ );
3059
+ const out = [];
3060
+ for (const { anchor, entry } of wanted) {
3061
+ const movedTo = await search.find(anchor);
3062
+ if (movedTo) {
3063
+ out.push({
3064
+ anchor,
3065
+ entry: { ...entry, class: "moved", movedTo },
3066
+ class: "moved"
3067
+ });
3068
+ continue;
3069
+ }
3070
+ const newText = await currentText(reader, anchor, resolvers);
3071
+ const old = options.withHistory === false ? { ok: false, reason: "unrecoverable" } : await readOldSource(repoRoot, anchor);
3072
+ const oldText = old.ok ? spanIn(old.source, anchor, resolvers) : void 0;
3073
+ const settled = newText !== void 0 && oldText !== void 0 && sameTokens(treeSitter, anchor.file, oldText, newText) ? "cosmetic" : entry.class ?? "changed";
3074
+ out.push({
3075
+ anchor,
3076
+ entry: { ...entry, class: settled },
3077
+ class: settled,
3078
+ ...newText !== void 0 ? { newText } : {},
3079
+ ...oldText !== void 0 ? { oldText } : {},
3080
+ ...old.ok ? { oldOrigin: old.origin } : {}
3081
+ });
3082
+ }
3083
+ return out;
3084
+ }
3085
+ function sameTokens(resolver, file, before, after) {
3086
+ if (before === after) return false;
3087
+ const left = resolver.normalize(before, file);
3088
+ const right = resolver.normalize(after, file);
3089
+ return left !== null && left === right;
3090
+ }
3091
+ async function currentText(reader, anchor, resolvers) {
3092
+ const read = await reader(anchor.file);
3093
+ if (!read.ok) return void 0;
3094
+ return spanIn(read.source, anchor, resolvers);
3095
+ }
3096
+ function spanIn(source, anchor, resolvers) {
3097
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
3098
+ return outcome.ok ? outcome.span.text : void 0;
3099
+ }
3100
+
3101
+ // src/drift/diff.ts
3102
+ var MAX_ANCHOR_DIFF_LINES = 200;
3103
+ var PACKET_DIFF_LINE_BUDGET = 200;
3104
+ var MIN_ANCHOR_DIFF_LINES = 12;
3105
+ function diffBudget(anchors) {
3106
+ if (anchors <= 0) return MAX_ANCHOR_DIFF_LINES;
3107
+ return Math.min(
3108
+ MAX_ANCHOR_DIFF_LINES,
3109
+ Math.max(
3110
+ MIN_ANCHOR_DIFF_LINES,
3111
+ Math.floor(PACKET_DIFF_LINE_BUDGET / anchors)
3112
+ )
3113
+ );
3114
+ }
3115
+ function unifiedDiff(before, after, options = {}) {
3116
+ const max = options.maxLines ?? MAX_ANCHOR_DIFF_LINES;
3117
+ const left = before.replace(/\r\n/g, "\n").split("\n");
3118
+ const right = after.replace(/\r\n/g, "\n").split("\n");
3119
+ const body = [];
3120
+ let added = 0;
3121
+ let removed = 0;
3122
+ for (const edit of edits(left, right)) {
3123
+ if (edit.kind === "same") body.push(` ${edit.line}`);
3124
+ else if (edit.kind === "remove") {
3125
+ body.push(`-${edit.line}`);
3126
+ removed += 1;
3127
+ } else {
3128
+ body.push(`+${edit.line}`);
3129
+ added += 1;
3130
+ }
3131
+ }
3132
+ const truncated = body.length > max;
3133
+ const shown = truncated ? body.slice(0, max) : body;
3134
+ const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
3135
+ const lines = [header, ...shown];
3136
+ if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
3137
+ return { text: lines.join("\n"), added, removed, truncated };
3138
+ }
3139
+ function edits(left, right) {
3140
+ const rows = left.length;
3141
+ const cols = right.length;
3142
+ const table2 = Array.from(
3143
+ { length: rows + 1 },
3144
+ () => new Array(cols + 1).fill(0)
3145
+ );
3146
+ for (let row2 = rows - 1; row2 >= 0; row2--) {
3147
+ for (let col2 = cols - 1; col2 >= 0; col2--) {
3148
+ table2[row2][col2] = left[row2] === right[col2] ? table2[row2 + 1][col2 + 1] + 1 : Math.max(
3149
+ table2[row2 + 1][col2],
3150
+ table2[row2][col2 + 1]
3151
+ );
3152
+ }
3153
+ }
3154
+ const out = [];
3155
+ let row = 0;
3156
+ let col = 0;
3157
+ while (row < rows && col < cols) {
3158
+ if (left[row] === right[col]) {
3159
+ out.push({ kind: "same", line: left[row] });
3160
+ row += 1;
3161
+ col += 1;
3162
+ } else if (table2[row + 1][col] >= table2[row][col + 1]) {
3163
+ out.push({ kind: "remove", line: left[row] });
3164
+ row += 1;
3165
+ } else {
3166
+ out.push({ kind: "add", line: right[col] });
3167
+ col += 1;
3168
+ }
3169
+ }
3170
+ for (; row < rows; row++)
3171
+ out.push({ kind: "remove", line: left[row] });
3172
+ for (; col < cols; col++)
3173
+ out.push({ kind: "add", line: right[col] });
3174
+ return out;
3175
+ }
3176
+
3177
+ // src/drift/packet.ts
3178
+ var PRESUMED_INVALID = [
3179
+ "fact",
3180
+ "constraint",
3181
+ "contract"
3182
+ ];
3183
+ var RATIONALE_SURVIVES = ["decision", "risk"];
3184
+ var DEFAULT_NOTES = {
3185
+ "presumed-invalidated": "the code this claim was taken from changed; presume it no longer holds until re-read",
3186
+ "rationale-may-survive": "the reasoning may outlive the code that implemented it; check whether it does",
3187
+ review: "re-read the record against the new code"
3188
+ };
3189
+ async function reassessPacket(repoRoot, record, entries, options = {}) {
3190
+ const classified = await classifyDrift(repoRoot, record, entries, {
3191
+ ...options.reader ? { reader: options.reader } : {},
3192
+ ...options.search ? { search: options.search } : {},
3193
+ withHistory: options.withDiff !== false
3194
+ });
3195
+ const open = classified.filter(
3196
+ (found) => found.class === "changed" || found.class === "gone"
3197
+ );
3198
+ if (!open.length) return { packet: null, classified };
3199
+ const budget = diffBudget(open.length);
3200
+ const anchors = open.map(
3201
+ (found) => anchorPacket(found, options.withDiff === true, budget)
3202
+ );
3203
+ const type = record.frontmatter.type;
3204
+ const fallback = isKbRecordType(type) ? PRESUMED_INVALID.includes(type) ? "presumed-invalidated" : RATIONALE_SURVIVES.includes(type) ? "rationale-may-survive" : "review" : "review";
3205
+ return {
3206
+ classified,
3207
+ packet: {
3208
+ conceptId: record.conceptId,
3209
+ title: record.frontmatter.title ?? null,
3210
+ type,
3211
+ standing: options.standing ?? "unsettled",
3212
+ why: record.frontmatter.description ?? null,
3213
+ claim: claimOf(record),
3214
+ anchors,
3215
+ impact: (options.impact?.impacted ?? []).map((entry) => ({
3216
+ conceptId: entry.conceptId,
3217
+ title: entry.title,
3218
+ standing: entry.standing,
3219
+ depth: entry.depth
3220
+ })),
3221
+ impactTruncated: options.impact?.truncated ?? false,
3222
+ default: fallback,
3223
+ defaultNote: DEFAULT_NOTES[fallback]
3224
+ }
3225
+ };
3226
+ }
3227
+ function anchorPacket(found, withDiff, maxLines) {
3228
+ const { entry } = found;
3229
+ const base2 = {
3230
+ file: entry.file,
3231
+ ...entry.symbol ? { symbol: entry.symbol } : {},
3232
+ class: found.class,
3233
+ ...entry.reason ? { reason: entry.reason } : {},
3234
+ storedHash: entry.storedHash,
3235
+ ...entry.currentHash ? { currentHash: entry.currentHash } : {},
3236
+ diffSize: entry.diffSize,
3237
+ ...entry.movedTo ? { movedTo: entry.movedTo } : {}
3238
+ };
3239
+ if (!withDiff) return base2;
3240
+ if (found.oldText === void 0 || !found.oldOrigin) {
3241
+ return { ...base2, diff: { status: "unrecoverable" } };
3242
+ }
3243
+ const rendered = unifiedDiff(found.oldText, found.newText ?? "", {
3244
+ maxLines
3245
+ });
3246
+ return {
3247
+ ...base2,
3248
+ diff: {
3249
+ status: "ok",
3250
+ source: found.oldOrigin.kind,
3251
+ ref: found.oldOrigin.ref,
3252
+ unified: rendered.text,
3253
+ added: rendered.added,
3254
+ removed: rendered.removed,
3255
+ truncated: rendered.truncated
3256
+ }
3257
+ };
3258
+ }
3259
+ function claimOf(record) {
3260
+ const type = record.frontmatter.type;
3261
+ const section = isKbRecordType(type) ? RECORD_TYPES[type].sections[0] : void 0;
3262
+ if (!section) return null;
3263
+ const lines = record.body.replace(/\r\n/g, "\n").split("\n");
3264
+ const start = lines.findIndex(
3265
+ (line) => line.trim().toLowerCase() === `## ${section}`.toLowerCase()
3266
+ );
3267
+ if (start < 0) return null;
3268
+ const rest = lines.slice(start + 1);
3269
+ const end = rest.findIndex((line) => line.startsWith("## "));
3270
+ const text = (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
3271
+ return text ? { section, text } : null;
3272
+ }
3273
+
2173
3274
  // src/kb-edges.ts
2174
3275
  var KB_EDGE_KINDS = [
2175
3276
  "body-link",
@@ -2365,6 +3466,18 @@ var CHECK_HEADLINES = {
2365
3466
  unchecked: "an anchor in another repository nothing could reach"
2366
3467
  };
2367
3468
  var DAY_MS = 864e5;
3469
+ function anchorResolverCounts(bundle) {
3470
+ let treeSitter = 0;
3471
+ let regex = 0;
3472
+ for (const record of bundle) {
3473
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
3474
+ if (!anchor.hash || !anchor.symbol) continue;
3475
+ if (anchor.resolver === "tree-sitter") treeSitter += 1;
3476
+ else regex += 1;
3477
+ }
3478
+ }
3479
+ return { total: treeSitter + regex, treeSitter, regex };
3480
+ }
2368
3481
  function doctor(bundle, options = {}) {
2369
3482
  const thresholds = {
2370
3483
  expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
@@ -2400,6 +3513,7 @@ function doctor(bundle, options = {}) {
2400
3513
  counts,
2401
3514
  groups,
2402
3515
  findingCount,
3516
+ anchorResolvers: anchorResolverCounts(bundle),
2403
3517
  healthy: findingCount === 0
2404
3518
  };
2405
3519
  }
@@ -2416,18 +3530,18 @@ function expired(hits, now) {
2416
3530
  for (const hit of hits) {
2417
3531
  const raw = hit.record.frontmatter.stale_after;
2418
3532
  if (!raw) continue;
2419
- const at = Date.parse(raw);
2420
- if (Number.isNaN(at)) {
3533
+ const at2 = Date.parse(raw);
3534
+ if (Number.isNaN(at2)) {
2421
3535
  findings.push(
2422
3536
  finding(hit.record, `stale_after "${raw}" is not a readable date`)
2423
3537
  );
2424
3538
  continue;
2425
3539
  }
2426
- if (at < now.getTime()) {
3540
+ if (at2 < now.getTime()) {
2427
3541
  findings.push(
2428
3542
  finding(
2429
3543
  hit.record,
2430
- `stale since ${raw} (${daysBetween(at, now.getTime())} days ago)`
3544
+ `stale since ${raw} (${daysBetween(at2, now.getTime())} days ago)`
2431
3545
  )
2432
3546
  );
2433
3547
  }
@@ -2440,12 +3554,12 @@ function expiring(hits, now, withinDays) {
2440
3554
  for (const hit of hits) {
2441
3555
  const raw = hit.record.frontmatter.stale_after;
2442
3556
  if (!raw) continue;
2443
- const at = Date.parse(raw);
2444
- if (Number.isNaN(at) || at < now.getTime() || at > horizon) continue;
3557
+ const at2 = Date.parse(raw);
3558
+ if (Number.isNaN(at2) || at2 < now.getTime() || at2 > horizon) continue;
2445
3559
  findings.push(
2446
3560
  finding(
2447
3561
  hit.record,
2448
- `goes stale ${raw} (in ${daysBetween(now.getTime(), at)} days)`
3562
+ `goes stale ${raw} (in ${daysBetween(now.getTime(), at2)} days)`
2449
3563
  )
2450
3564
  );
2451
3565
  }
@@ -2615,13 +3729,16 @@ function anchorFindings(hits, kind, headline) {
2615
3729
  );
2616
3730
  }
2617
3731
  function describeAnchor(anchor) {
2618
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
2619
- if (anchor.reason) return `${at} (${anchor.reason})`;
3732
+ const at2 = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3733
+ if (anchor.class === "gone") {
3734
+ return `${at2} gone${anchor.reason ? ` (${anchor.reason})` : ""}`;
3735
+ }
3736
+ if (anchor.reason) return `${at2} (${anchor.reason})`;
2620
3737
  if (anchor.remoteState === "drifted-on-default") {
2621
- return `${at} (matches ref, moved on the default branch)`;
3738
+ return `${at2} (matches ref, moved on the default branch)`;
2622
3739
  }
2623
- if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
2624
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3740
+ if (anchor.diffSize === null) return `${at2} (changed, size unrecorded)`;
3741
+ return anchor.diffSize === 0 ? `${at2} (content changed, same line count)` : `${at2} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
2625
3742
  }
2626
3743
  function replaces(later, earlier) {
2627
3744
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
@@ -2638,17 +3755,17 @@ function daysBetween(from, to) {
2638
3755
  return Math.max(0, Math.floor((to - from) / DAY_MS));
2639
3756
  }
2640
3757
  function ageInDays(record, now) {
2641
- const at = record.frontmatter.generated?.at;
2642
- if (!at) return null;
2643
- const written = Date.parse(at);
3758
+ const at2 = record.frontmatter.generated?.at;
3759
+ if (!at2) return null;
3760
+ const written = Date.parse(at2);
2644
3761
  if (Number.isNaN(written)) return null;
2645
3762
  return daysBetween(written, now.getTime());
2646
3763
  }
2647
3764
 
2648
3765
  // src/kb-log.ts
2649
- import { z as z5 } from "zod";
3766
+ import { z as z6 } from "zod";
2650
3767
  var LOG_FILE = "log.jsonl";
2651
- var kbLogEntrySchema = z5.object({
3768
+ var kbLogEntrySchema = z6.object({
2652
3769
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
2653
3770
  // below), and a value that isn't actually chronological — a Unix
2654
3771
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -2657,12 +3774,12 @@ var kbLogEntrySchema = z5.object({
2657
3774
  // and rejects everything else, including a non-`Z` offset — so a
2658
3775
  // malformed `at` is reported the same way a malformed line already is,
2659
3776
  // rather than silently sorting into the wrong place.
2660
- at: z5.iso.datetime(),
2661
- by: z5.string().min(1),
2662
- operation: z5.string().min(1),
2663
- conceptId: z5.string().min(1),
3777
+ at: z6.iso.datetime(),
3778
+ by: z6.string().min(1),
3779
+ operation: z6.string().min(1),
3780
+ conceptId: z6.string().min(1),
2664
3781
  /** Second concept id, where the operation relates two — supersession. */
2665
- target: z5.string().min(1).optional()
3782
+ target: z6.string().min(1).optional()
2666
3783
  }).strict();
2667
3784
  function renderLogEntry(entry) {
2668
3785
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -2672,18 +3789,18 @@ function parseLog(raw) {
2672
3789
  const entries = [];
2673
3790
  const malformed = [];
2674
3791
  const seen = /* @__PURE__ */ new Set();
2675
- raw.split("\n").forEach((text, index) => {
3792
+ raw.split("\n").forEach((text, index2) => {
2676
3793
  if (!text.trim()) return;
2677
3794
  let value;
2678
3795
  try {
2679
3796
  value = JSON.parse(text);
2680
3797
  } catch {
2681
- malformed.push({ line: index + 1, text });
3798
+ malformed.push({ line: index2 + 1, text });
2682
3799
  return;
2683
3800
  }
2684
3801
  const parsed = kbLogEntrySchema.safeParse(value);
2685
3802
  if (!parsed.success) {
2686
- malformed.push({ line: index + 1, text });
3803
+ malformed.push({ line: index2 + 1, text });
2687
3804
  return;
2688
3805
  }
2689
3806
  const key = JSON.stringify(parsed.data);
@@ -2698,14 +3815,14 @@ function parseLog(raw) {
2698
3815
  }
2699
3816
 
2700
3817
  // src/json-schema.ts
2701
- import { z as z6 } from "zod";
3818
+ import { z as z7 } from "zod";
2702
3819
  function kbJsonSchemas() {
2703
3820
  return {
2704
- recordFrontmatter: z6.toJSONSchema(kbRecordFrontmatterSchema, {
3821
+ recordFrontmatter: z7.toJSONSchema(kbRecordFrontmatterSchema, {
2705
3822
  io: "input"
2706
3823
  }),
2707
- composeInput: z6.toJSONSchema(composeInputSchema, { io: "input" }),
2708
- logEntry: z6.toJSONSchema(kbLogEntrySchema, { io: "input" })
3824
+ composeInput: z7.toJSONSchema(composeInputSchema, { io: "input" }),
3825
+ logEntry: z7.toJSONSchema(kbLogEntrySchema, { io: "input" })
2709
3826
  };
2710
3827
  }
2711
3828
 
@@ -2753,18 +3870,18 @@ function trace(seedId, bundle, options = {}) {
2753
3870
  return [...reached.values()].sort(byGeneratedAt);
2754
3871
  }
2755
3872
  function byGeneratedAt(left, right) {
2756
- const at = (step) => step.record.frontmatter.generated?.at ?? "";
2757
- return at(left).localeCompare(at(right)) || left.depth - right.depth;
3873
+ const at2 = (step) => step.record.frontmatter.generated?.at ?? "";
3874
+ return at2(left).localeCompare(at2(right)) || left.depth - right.depth;
2758
3875
  }
2759
3876
 
2760
3877
  // src/commands/anchor-resolve.ts
2761
- import { z as z8 } from "zod";
3878
+ import { z as z9 } from "zod";
2762
3879
 
2763
3880
  // src/commands/model.ts
2764
- import { z as z7 } from "zod";
2765
- var bundlePath = z7.string().min(1).describe("Absolute path to the knowledge base directory.");
2766
- var conceptId = z7.string().min(1).describe("e.g. decision.cursor-v2");
2767
- var REPO_ROOT = z7.string().min(1).optional().describe(
3881
+ import { z as z8 } from "zod";
3882
+ var bundlePath = z8.string().min(1).describe("Absolute path to the knowledge base directory.");
3883
+ var conceptId = z8.string().min(1).describe("e.g. decision.cursor-v2");
3884
+ var REPO_ROOT = z8.string().min(1).optional().describe(
2768
3885
  "Where the anchored source lives, for the drift check. Defaults to the working directory."
2769
3886
  );
2770
3887
  function define(command) {
@@ -2777,9 +3894,9 @@ function argvFlag(argv, name) {
2777
3894
  if (!value2) throw new KbMissingFlagValueError(name);
2778
3895
  return value2;
2779
3896
  }
2780
- const at = argv.indexOf(name);
2781
- if (at === -1) return void 0;
2782
- const value = argv[at + 1];
3897
+ const at2 = argv.indexOf(name);
3898
+ if (at2 === -1) return void 0;
3899
+ const value = argv[at2 + 1];
2783
3900
  if (value === void 0 || value.startsWith("--")) {
2784
3901
  throw new KbMissingFlagValueError(name);
2785
3902
  }
@@ -2787,22 +3904,30 @@ function argvFlag(argv, name) {
2787
3904
  }
2788
3905
 
2789
3906
  // src/commands/anchor-resolve.ts
3907
+ function resolverSummary(results) {
3908
+ const names = [
3909
+ ...new Set(
3910
+ results.flatMap((entry) => entry.resolver ? [entry.resolver] : [])
3911
+ )
3912
+ ].sort();
3913
+ return names.length ? `${names.join(" + ")} resolver` : "whole-file";
3914
+ }
2790
3915
  var anchorResolveCommand = define({
2791
3916
  name: "anchor-resolve",
2792
3917
  tool: "kb_anchor_resolve",
2793
3918
  usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
2794
3919
  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.",
2795
- input: z8.object({
3920
+ input: z9.object({
2796
3921
  bundlePath,
2797
3922
  conceptId,
2798
- repoRoot: z8.string().min(1).optional(),
2799
- offline: z8.boolean().optional().describe(
3923
+ repoRoot: z9.string().min(1).optional(),
3924
+ offline: z9.boolean().optional().describe(
2800
3925
  "Resolve foreign anchors from the local repo cache only, never fetching."
2801
3926
  ),
2802
- rebaseline: z8.boolean().optional().describe(
3927
+ rebaseline: z9.boolean().optional().describe(
2803
3928
  "Accept the current code as the new baseline for anchors that drifted."
2804
3929
  ),
2805
- restamp: z8.boolean().optional().describe(
3930
+ restamp: z9.boolean().optional().describe(
2806
3931
  "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
2807
3932
  )
2808
3933
  }),
@@ -2831,6 +3956,11 @@ var anchorResolveCommand = define({
2831
3956
  const updated = [];
2832
3957
  let dirty = false;
2833
3958
  const sources = await readSources(anchors, root, offline === true);
3959
+ const resolvers = defaultAnchorResolvers({ offline: offline === true });
3960
+ await prepareResolvers(
3961
+ resolvers,
3962
+ anchors.map((anchor) => anchor.file)
3963
+ );
2834
3964
  for (const anchor of anchors) {
2835
3965
  const base2 = {
2836
3966
  file: anchor.file,
@@ -2847,27 +3977,39 @@ var anchorResolveCommand = define({
2847
3977
  updated.push(anchor);
2848
3978
  continue;
2849
3979
  }
2850
- const resolved = resolveAnchor(source.source, anchor);
2851
- if (!resolved) {
3980
+ const outcome = resolveAnchorSpan(source.source, anchor, resolvers);
3981
+ if (!outcome.ok) {
2852
3982
  results.push({
2853
3983
  ...base2,
2854
3984
  state: "unresolved",
2855
- reason: "symbol-not-found"
3985
+ reason: outcome.reason
2856
3986
  });
2857
3987
  updated.push(anchor);
2858
3988
  continue;
2859
3989
  }
2860
- const currentHash = hashAnchorText(resolved.text);
3990
+ const resolved = outcome.span;
3991
+ const producedBy = outcome.resolver;
3992
+ const { hash: currentHash, kind } = anchorHashOf(anchor, outcome);
2861
3993
  const currentLines = resolved.endLine - resolved.startLine + 1;
3994
+ const stampedKind = outcome.normalized ? "ast" : "raw";
3995
+ const stampedHash = outcome.normalized ? anchorHashOf({ ...anchor, hash: void 0 }, outcome).hash : currentHash;
2862
3996
  const stamped = {
2863
3997
  ...anchor,
2864
- hash: currentHash,
3998
+ hash: stampedHash,
3999
+ hash_kind: stampedKind,
2865
4000
  lines: currentLines,
2866
- resolved_at: now()
4001
+ resolved_at: now(),
4002
+ ...producedBy ? { resolver: producedBy } : {}
2867
4003
  };
2868
4004
  const pinned = anchor.ref !== void 0 && source.repo !== void 0;
2869
4005
  if (!anchor.hash) {
2870
- results.push({ ...base2, state: "stamped", currentHash });
4006
+ results.push({
4007
+ ...base2,
4008
+ state: "stamped",
4009
+ currentHash: stampedHash,
4010
+ hashKind: stampedKind,
4011
+ ...producedBy ? { resolver: producedBy } : {}
4012
+ });
2871
4013
  updated.push(stamped);
2872
4014
  dirty = true;
2873
4015
  continue;
@@ -2877,7 +4019,12 @@ var anchorResolveCommand = define({
2877
4019
  ...base2,
2878
4020
  state: "drifted",
2879
4021
  currentHash,
4022
+ hashKind: kind,
2880
4023
  diffSize: lineDelta(anchor, currentLines),
4024
+ ...producedBy ? { resolver: producedBy } : {},
4025
+ // A regex-stamped anchor re-read by tree-sitter drifts because the
4026
+ // resolver changed, not because the code did.
4027
+ ...resolverChanged(source.source, anchor, producedBy) ? { reason: "resolver-changed" } : {},
2881
4028
  ...pinned ? { remoteState: "drifted-from-ref" } : {},
2882
4029
  ...rebaseline ? { rebaselined: true } : {}
2883
4030
  });
@@ -2885,7 +4032,7 @@ var anchorResolveCommand = define({
2885
4032
  if (rebaseline) dirty = true;
2886
4033
  continue;
2887
4034
  }
2888
- const onDefault = pinned ? headHash(source, anchor) : void 0;
4035
+ const onDefault = pinned ? headHash(source, anchor, resolvers) : void 0;
2889
4036
  if (onDefault && onDefault.hash !== anchor.hash) {
2890
4037
  results.push({
2891
4038
  ...base2,
@@ -2901,6 +4048,8 @@ var anchorResolveCommand = define({
2901
4048
  ...base2,
2902
4049
  state: "match",
2903
4050
  currentHash,
4051
+ hashKind: kind,
4052
+ ...producedBy ? { resolver: producedBy } : {},
2904
4053
  ...pinned ? { remoteState: "matches-ref" } : {}
2905
4054
  });
2906
4055
  const refresh = restamp || anchor.resolved_at === void 0;
@@ -2918,19 +4067,21 @@ var anchorResolveCommand = define({
2918
4067
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
2919
4068
  }
2920
4069
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
4070
+ const hints = grammarHints();
4071
+ const hintNote = hints.length ? { hints } : {};
2921
4072
  const unreachable = results.filter(
2922
4073
  (entry) => isUncheckedReason(entry.reason)
2923
4074
  ).length;
2924
4075
  const checked = results.length - unreachable;
2925
- const matches2 = results.filter((entry) => entry.state === "match").length;
2926
- const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
2927
- const clean = checked > 0 && matches2 === checked && unreachable === 0;
4076
+ const matches3 = results.filter((entry) => entry.state === "match").length;
4077
+ const note = `${matches3}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
4078
+ const clean = checked > 0 && matches3 === checked && unreachable === 0;
2928
4079
  if (clean) {
2929
4080
  try {
2930
4081
  await store.verify(
2931
4082
  path,
2932
4083
  id,
2933
- `anchor-resolve: ${note} (regex resolver)`,
4084
+ `anchor-resolve: ${note} (${resolverSummary(results)})`,
2934
4085
  actor,
2935
4086
  now()
2936
4087
  );
@@ -2941,17 +4092,25 @@ var anchorResolveCommand = define({
2941
4092
  results,
2942
4093
  verified: false,
2943
4094
  verifyRefused: "self-verification",
2944
- ...frozenNote
4095
+ ...frozenNote,
4096
+ ...hintNote
2945
4097
  };
2946
4098
  }
2947
- return { conceptId: id, results, verified: true, ...frozenNote };
4099
+ return {
4100
+ conceptId: id,
4101
+ results,
4102
+ verified: true,
4103
+ ...frozenNote,
4104
+ ...hintNote
4105
+ };
2948
4106
  }
2949
4107
  return {
2950
4108
  conceptId: id,
2951
4109
  results,
2952
4110
  verified: false,
2953
4111
  ...unreachable ? { note } : {},
2954
- ...frozenNote
4112
+ ...frozenNote,
4113
+ ...hintNote
2955
4114
  };
2956
4115
  },
2957
4116
  // A stored hash that no longer resolves is a broken anchor, not an absence:
@@ -2967,13 +4126,13 @@ var anchorResolveCommand = define({
2967
4126
  function lineDelta(anchor, current) {
2968
4127
  return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
2969
4128
  }
2970
- function headHash(source, anchor) {
4129
+ function headHash(source, anchor, resolvers) {
2971
4130
  if (source.head === void 0) return void 0;
2972
- const resolved = resolveAnchor(source.head, anchor);
2973
- if (!resolved) return void 0;
4131
+ const outcome = resolveAnchorSpan(source.head, anchor, resolvers);
4132
+ if (!outcome.ok) return void 0;
2974
4133
  return {
2975
- hash: hashAnchorText(resolved.text),
2976
- lines: resolved.endLine - resolved.startLine + 1
4134
+ hash: hashAnchorText(outcome.span.text),
4135
+ lines: outcome.span.endLine - outcome.span.startLine + 1
2977
4136
  };
2978
4137
  }
2979
4138
  async function readSources(anchors, root, offline) {
@@ -3023,13 +4182,13 @@ async function readSources(anchors, root, offline) {
3023
4182
  }
3024
4183
 
3025
4184
  // src/commands/answer.ts
3026
- import { z as z9 } from "zod";
4185
+ import { z as z10 } from "zod";
3027
4186
  var answerCommand = define({
3028
4187
  name: "answer",
3029
4188
  tool: "kb_answer",
3030
4189
  usage: "answer <concept-id> <answer...>",
3031
4190
  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.",
3032
- input: z9.object({ bundlePath, conceptId, answer: z9.string().min(1) }),
4191
+ input: z10.object({ bundlePath, conceptId, answer: z10.string().min(1) }),
3033
4192
  fromArgv: (argv, path) => ({
3034
4193
  bundlePath: path,
3035
4194
  conceptId: argv[1],
@@ -3043,27 +4202,27 @@ var answerCommand = define({
3043
4202
  });
3044
4203
 
3045
4204
  // src/commands/backlinks.ts
3046
- import { z as z10 } from "zod";
4205
+ import { z as z11 } from "zod";
3047
4206
  var backlinksCommand = define({
3048
4207
  name: "backlinks",
3049
4208
  tool: "kb_backlinks",
3050
4209
  usage: "backlinks <concept-id>",
3051
4210
  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.",
3052
- input: z10.object({ bundlePath, conceptId }),
4211
+ input: z11.object({ bundlePath, conceptId }),
3053
4212
  fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
3054
4213
  run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
3055
4214
  });
3056
4215
 
3057
4216
  // src/commands/catalog.ts
3058
- import { z as z11 } from "zod";
4217
+ import { z as z12 } from "zod";
3059
4218
  var catalogCommand = define({
3060
4219
  name: "catalog",
3061
4220
  tool: "kb_catalog",
3062
4221
  usage: "catalog [type]",
3063
4222
  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.",
3064
- input: z11.object({
4223
+ input: z12.object({
3065
4224
  bundlePath,
3066
- type: z11.enum(KB_RECORD_TYPES).optional()
4225
+ type: z12.enum(KB_RECORD_TYPES).optional()
3067
4226
  }),
3068
4227
  fromArgv: (argv, path) => ({
3069
4228
  bundlePath: path,
@@ -3118,26 +4277,26 @@ function count(value, noun) {
3118
4277
  }
3119
4278
 
3120
4279
  // src/commands/context.ts
3121
- import { z as z12 } from "zod";
4280
+ import { z as z13 } from "zod";
3122
4281
  var contextCommand = define({
3123
4282
  name: "context",
3124
4283
  tool: "kb_context",
3125
4284
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
3126
4285
  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.",
3127
- input: z12.object({
3128
- budgetTokens: z12.number().int().positive().optional().describe(
4286
+ input: z13.object({
4287
+ budgetTokens: z13.number().int().positive().optional().describe(
3129
4288
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
3130
4289
  ),
3131
- fullUnderTokens: z12.number().int().positive().optional().describe(
4290
+ fullUnderTokens: z13.number().int().positive().optional().describe(
3132
4291
  "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."
3133
4292
  ),
3134
- profile: z12.string().optional().describe(
4293
+ profile: z13.string().optional().describe(
3135
4294
  "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."
3136
4295
  ),
3137
- format: z12.enum(["markdown", "json"]).optional().describe(
4296
+ format: z13.enum(["markdown", "json"]).optional().describe(
3138
4297
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
3139
4298
  ),
3140
- event: z12.string().optional().describe(
4299
+ event: z13.string().optional().describe(
3141
4300
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
3142
4301
  )
3143
4302
  }),
@@ -3173,14 +4332,168 @@ var contextCommand = define({
3173
4332
  });
3174
4333
 
3175
4334
  // src/commands/doctor.ts
3176
- import { z as z13 } from "zod";
3177
- var days = (what, fallback) => z13.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
4335
+ import { z as z15 } from "zod";
4336
+
4337
+ // src/commands/reassess.ts
4338
+ import { z as z14 } from "zod";
4339
+ var reassessCommand = define({
4340
+ name: "reassess",
4341
+ tool: "kb_reassess",
4342
+ usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
4343
+ 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.",
4344
+ input: z14.object({
4345
+ bundlePath,
4346
+ conceptId,
4347
+ repoRoot: REPO_ROOT,
4348
+ withDiff: z14.boolean().optional().describe(
4349
+ "Recover each anchor's committed span and render the diff. Reads git history."
4350
+ )
4351
+ }),
4352
+ fromArgv: (argv, path) => {
4353
+ const repoRoot = argvFlag(argv, "--repo-root");
4354
+ return {
4355
+ bundlePath: path,
4356
+ conceptId: argv[1],
4357
+ ...repoRoot !== void 0 ? { repoRoot } : {},
4358
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
4359
+ };
4360
+ },
4361
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
4362
+ const root = repoRoot ?? process.cwd();
4363
+ const bundle = await store.list(path);
4364
+ const record = bundle.find((entry) => entry.conceptId === id);
4365
+ if (!record) throw new KbRecordNotFoundError(id);
4366
+ const drift = await store.detectDrift([record], repoRoot);
4367
+ const entries = drift?.get(id) ?? [];
4368
+ if (!entries.some((entry) => entry.state !== "match")) {
4369
+ return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
4370
+ }
4371
+ const standing = adjudicate(bundle, bundle).find(
4372
+ (hit) => hit.record.conceptId === id
4373
+ )?.standing;
4374
+ const impact2 = await store.impact(path, id);
4375
+ const { packet, classified } = await reassessPacket(root, record, entries, {
4376
+ ...withDiff ? { withDiff: true } : {},
4377
+ impact: impact2,
4378
+ ...standing ? { standing } : {}
4379
+ });
4380
+ const moves = classified.filter((found) => found.class === "moved");
4381
+ let frozen = false;
4382
+ const rebaselined = [];
4383
+ if (moves.length) {
4384
+ const relocated = /* @__PURE__ */ new Map();
4385
+ for (const found of moves) {
4386
+ const to = found.entry.movedTo;
4387
+ if (!to) continue;
4388
+ relocated.set(found.anchor, {
4389
+ ...found.anchor,
4390
+ file: to.file,
4391
+ ...to.symbol ? { symbol: to.symbol } : {}
4392
+ });
4393
+ rebaselined.push({
4394
+ file: found.anchor.file,
4395
+ ...found.anchor.symbol ? { symbol: found.anchor.symbol } : {},
4396
+ toFile: to.file,
4397
+ ...to.symbol ? { toSymbol: to.symbol } : {}
4398
+ });
4399
+ }
4400
+ try {
4401
+ await assertBaseNotFrozen(process.cwd(), path);
4402
+ } catch (error) {
4403
+ if (!(error instanceof KbBaseFrozenError)) throw error;
4404
+ frozen = true;
4405
+ }
4406
+ if (!frozen) {
4407
+ await store.updateAnchors(
4408
+ path,
4409
+ id,
4410
+ (record.frontmatter.strauss_anchors ?? []).map(
4411
+ (anchor) => relocated.get(anchor) ?? anchor
4412
+ ),
4413
+ actor
4414
+ );
4415
+ }
4416
+ }
4417
+ return {
4418
+ conceptId: id,
4419
+ packet,
4420
+ rebaselined: frozen ? [] : rebaselined,
4421
+ cosmetic: classified.filter((found) => found.class === "cosmetic").length,
4422
+ ...frozen ? {
4423
+ frozen: true,
4424
+ note: "base is frozen: nothing was rebaselined"
4425
+ } : {}
4426
+ };
4427
+ },
4428
+ render: (result) => renderReassess(result)
4429
+ });
4430
+ function renderReassess(result) {
4431
+ const lines = [];
4432
+ for (const move of result.rebaselined) {
4433
+ lines.push(
4434
+ `rebaselined: ${at(move.file, move.symbol)} \u2192 ${at(move.toFile, move.toSymbol)} (same code, new address)`
4435
+ );
4436
+ }
4437
+ if (result.cosmetic) {
4438
+ lines.push(
4439
+ `${result.cosmetic} anchor${result.cosmetic === 1 ? "" : "s"} changed formatting only.`
4440
+ );
4441
+ }
4442
+ if (result.note) lines.push(result.note);
4443
+ const packet = result.packet;
4444
+ if (!packet) {
4445
+ lines.push(`${result.conceptId}: nothing to reassess.`);
4446
+ return lines.join("\n");
4447
+ }
4448
+ lines.push(
4449
+ "",
4450
+ `# ${packet.conceptId}${packet.title ? ` \u2014 ${packet.title}` : ""}`,
4451
+ `type: ${packet.type} standing: ${packet.standing}`,
4452
+ ...packet.why ? [`why: ${packet.why}`] : [],
4453
+ ...packet.claim ? ["", `## ${packet.claim.section}`, packet.claim.text] : [],
4454
+ "",
4455
+ `## Anchors (${packet.anchors.length})`
4456
+ );
4457
+ for (const anchor of packet.anchors) {
4458
+ lines.push(
4459
+ `- ${at(anchor.file, anchor.symbol)} \u2014 ${anchor.class}${anchor.reason ? ` (${anchor.reason})` : ""}`
4460
+ );
4461
+ if (!anchor.diff) continue;
4462
+ if (anchor.diff.status === "unrecoverable") {
4463
+ lines.push(
4464
+ " diff: unrecoverable \u2014 no committed span to compare against"
4465
+ );
4466
+ continue;
4467
+ }
4468
+ lines.push(
4469
+ ` diff vs ${anchor.diff.ref} (${anchor.diff.source}): +${anchor.diff.added} \u2212${anchor.diff.removed}`,
4470
+ ...anchor.diff.unified.split("\n").map((line) => ` ${line}`)
4471
+ );
4472
+ }
4473
+ if (packet.impact.length) {
4474
+ lines.push("", `## Impact (${packet.impact.length})`);
4475
+ for (const entry of packet.impact) {
4476
+ lines.push(
4477
+ `- ${entry.conceptId} [${entry.standing}]${entry.title ? ` \u2014 ${entry.title}` : ""}`
4478
+ );
4479
+ }
4480
+ if (packet.impactTruncated) lines.push("- \u2026 walk truncated");
4481
+ }
4482
+ lines.push("", `Default: ${packet.default} \u2014 ${packet.defaultNote}.`);
4483
+ return lines.join("\n");
4484
+ }
4485
+ function at(file, symbol) {
4486
+ return symbol ? `${file}:${symbol}` : file;
4487
+ }
4488
+
4489
+ // src/commands/doctor.ts
4490
+ var days = (what, fallback) => z15.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3178
4491
  var doctorCommand = define({
3179
4492
  name: "doctor",
3180
4493
  tool: "kb_doctor",
3181
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3182
- 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.",
3183
- input: z13.object({
4494
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
4495
+ 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.",
4496
+ input: z15.object({
3184
4497
  bundlePath,
3185
4498
  repoRoot: REPO_ROOT,
3186
4499
  expiringDays: days(
@@ -3195,11 +4508,17 @@ var doctorCommand = define({
3195
4508
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3196
4509
  DEFAULT_AGING_DAYS
3197
4510
  ),
3198
- offline: z13.boolean().optional().describe(
4511
+ offline: z15.boolean().optional().describe(
3199
4512
  "Read foreign anchors from the local repo cache only, never fetching."
3200
4513
  ),
3201
- strict: z13.boolean().optional().describe(
4514
+ strict: z15.boolean().optional().describe(
3202
4515
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
4516
+ ),
4517
+ drifted: z15.boolean().optional().describe(
4518
+ "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
4519
+ ),
4520
+ withDiff: z15.boolean().optional().describe(
4521
+ "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
3203
4522
  )
3204
4523
  }),
3205
4524
  // Presence, not truthiness: `--expiring-days ""` is a caller who meant
@@ -3218,7 +4537,9 @@ var doctorCommand = define({
3218
4537
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
3219
4538
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
3220
4539
  ...argv.includes("--offline") ? { offline: true } : {},
3221
- ...argv.includes("--strict") ? { strict: true } : {}
4540
+ ...argv.includes("--strict") ? { strict: true } : {},
4541
+ ...argv.includes("--drifted") ? { drifted: true } : {},
4542
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
3222
4543
  };
3223
4544
  },
3224
4545
  run: async ({ store, now }, {
@@ -3227,7 +4548,9 @@ var doctorCommand = define({
3227
4548
  unverifiedDays,
3228
4549
  agingDays,
3229
4550
  repoRoot,
3230
- offline
4551
+ offline,
4552
+ drifted: drifted2,
4553
+ withDiff
3231
4554
  }) => {
3232
4555
  const checkedAt = now();
3233
4556
  const records = await store.list(path);
@@ -3241,7 +4564,54 @@ var doctorCommand = define({
3241
4564
  ...anchorDrift !== void 0 ? { anchorDrift } : {},
3242
4565
  now: new Date(checkedAt)
3243
4566
  });
3244
- return { bundlePath: path, checkedAt, ...report };
4567
+ const hints = grammarHints();
4568
+ if (!drifted2) {
4569
+ return {
4570
+ bundlePath: path,
4571
+ checkedAt,
4572
+ ...report,
4573
+ ...hints.length ? { hints } : {}
4574
+ };
4575
+ }
4576
+ const standings = new Map(
4577
+ adjudicate(records, records, new Date(checkedAt)).map((hit) => [
4578
+ hit.record.conceptId,
4579
+ hit.standing
4580
+ ])
4581
+ );
4582
+ const packets = [];
4583
+ const rebaselinable = [];
4584
+ const search = movedSearch(repoRoot ?? process.cwd());
4585
+ for (const found of report.groups.find((g) => g.check === "drifted")?.findings ?? []) {
4586
+ const record = records.find(
4587
+ (entry) => entry.conceptId === found.conceptId
4588
+ );
4589
+ if (!record) continue;
4590
+ const standing = standings.get(record.conceptId);
4591
+ const built = await reassessPacket(
4592
+ repoRoot ?? process.cwd(),
4593
+ record,
4594
+ anchorDrift?.get(record.conceptId) ?? [],
4595
+ {
4596
+ ...withDiff ? { withDiff: true } : {},
4597
+ impact: await store.impact(path, record.conceptId),
4598
+ ...standing ? { standing } : {},
4599
+ search
4600
+ }
4601
+ );
4602
+ if (built.packet) packets.push(built.packet);
4603
+ if (built.classified.some((entry) => entry.class === "moved")) {
4604
+ rebaselinable.push(record.conceptId);
4605
+ }
4606
+ }
4607
+ return {
4608
+ bundlePath: path,
4609
+ checkedAt,
4610
+ ...report,
4611
+ packets,
4612
+ rebaselinable,
4613
+ ...hints.length ? { hints } : {}
4614
+ };
3245
4615
  },
3246
4616
  render: (result) => render2(result),
3247
4617
  // Only expiry, and only under --strict. The other seven checks report debt a
@@ -3253,18 +4623,22 @@ var doctorCommand = define({
3253
4623
  failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
3254
4624
  });
3255
4625
  function render2(result) {
4626
+ if (result.packets) return renderPackets(result);
3256
4627
  const { thresholds } = result;
3257
4628
  const lines = [
3258
4629
  `# KB Doctor \u2014 ${result.bundlePath}`,
3259
4630
  `records: ${result.recordCount}`,
3260
4631
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
3261
4632
  `checked: ${result.checkedAt}`,
4633
+ ...result.anchorResolvers.total ? [
4634
+ `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
4635
+ ] : [],
3262
4636
  ""
3263
4637
  ];
3264
- const width = Math.max(...result.groups.map((group2) => group2.check.length));
4638
+ const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
3265
4639
  for (const group2 of result.groups) {
3266
4640
  lines.push(
3267
- ` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
4641
+ ` ${group2.check.padEnd(width2)} ${String(group2.count).padStart(3)} ${group2.headline}`
3268
4642
  );
3269
4643
  }
3270
4644
  for (const group2 of result.groups) {
@@ -3276,27 +4650,52 @@ function render2(result) {
3276
4650
  );
3277
4651
  }
3278
4652
  }
4653
+ for (const hint of result.hints ?? []) lines.push("", hint);
3279
4654
  lines.push(
3280
4655
  "",
3281
4656
  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.`
3282
4657
  );
3283
4658
  return lines.join("\n");
3284
4659
  }
4660
+ function renderPackets(result) {
4661
+ const packets = result.packets ?? [];
4662
+ const lines = [
4663
+ `# KB Drift \u2014 ${result.bundlePath}`,
4664
+ `checked: ${result.checkedAt}`,
4665
+ `${packets.length} record${packets.length === 1 ? "" : "s"} need a reading; ${result.counts.drifted} drifted in all.`
4666
+ ];
4667
+ if (result.rebaselinable?.length) {
4668
+ lines.push(
4669
+ `moved, rebaseline with \`kb_reassess\`: ${result.rebaselinable.join(", ")}`
4670
+ );
4671
+ }
4672
+ for (const packet of packets) {
4673
+ lines.push(
4674
+ renderReassess({
4675
+ conceptId: packet.conceptId,
4676
+ packet,
4677
+ rebaselined: [],
4678
+ cosmetic: 0
4679
+ })
4680
+ );
4681
+ }
4682
+ return lines.join("\n");
4683
+ }
3285
4684
 
3286
4685
  // src/commands/impact.ts
3287
- import { z as z14 } from "zod";
4686
+ import { z as z16 } from "zod";
3288
4687
  var impactCommand = define({
3289
4688
  name: "impact",
3290
4689
  tool: "kb_impact",
3291
4690
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
3292
4691
  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.",
3293
- input: z14.object({
4692
+ input: z16.object({
3294
4693
  bundlePath,
3295
4694
  conceptId,
3296
- depth: z14.number().int().positive().optional().describe(
4695
+ depth: z16.number().int().positive().optional().describe(
3297
4696
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
3298
4697
  ),
3299
- rels: z14.array(z14.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
4698
+ rels: z16.array(z16.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3300
4699
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
3301
4700
  )
3302
4701
  }),
@@ -3317,13 +4716,13 @@ var impactCommand = define({
3317
4716
  });
3318
4717
 
3319
4718
  // src/commands/list.ts
3320
- import { z as z15 } from "zod";
4719
+ import { z as z17 } from "zod";
3321
4720
  var listCommand = define({
3322
4721
  name: "list",
3323
4722
  tool: "kb_list",
3324
4723
  usage: "list [type]",
3325
4724
  description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
3326
- input: z15.object({ bundlePath, type: z15.enum(KB_RECORD_TYPES).optional() }),
4725
+ input: z17.object({ bundlePath, type: z17.enum(KB_RECORD_TYPES).optional() }),
3327
4726
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
3328
4727
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
3329
4728
  conceptId: record.conceptId,
@@ -3335,17 +4734,17 @@ var listCommand = define({
3335
4734
  });
3336
4735
 
3337
4736
  // src/commands/load.ts
3338
- import { z as z16 } from "zod";
4737
+ import { z as z18 } from "zod";
3339
4738
  var loadCommand = define({
3340
4739
  name: "load",
3341
4740
  tool: "kb_load",
3342
4741
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
3343
4742
  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.",
3344
- input: z16.object({
4743
+ input: z18.object({
3345
4744
  bundlePath,
3346
- type: z16.enum(KB_RECORD_TYPES).optional(),
3347
- budgetTokens: z16.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3348
- all: z16.boolean().optional().describe(
4745
+ type: z18.enum(KB_RECORD_TYPES).optional(),
4746
+ budgetTokens: z18.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
4747
+ all: z18.boolean().optional().describe(
3349
4748
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
3350
4749
  ),
3351
4750
  repoRoot: REPO_ROOT
@@ -3387,25 +4786,25 @@ var loadCommand = define({
3387
4786
  });
3388
4787
 
3389
4788
  // src/commands/log.ts
3390
- import { z as z17 } from "zod";
4789
+ import { z as z19 } from "zod";
3391
4790
  var logCommand = define({
3392
4791
  name: "log",
3393
4792
  tool: "kb_log",
3394
4793
  usage: "log",
3395
4794
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
3396
- input: z17.object({ bundlePath }),
4795
+ input: z19.object({ bundlePath }),
3397
4796
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3398
4797
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
3399
4798
  });
3400
4799
 
3401
4800
  // src/commands/no-decision.ts
3402
- import { z as z18 } from "zod";
4801
+ import { z as z20 } from "zod";
3403
4802
  var noDecisionCommand = define({
3404
4803
  name: "no-decision",
3405
4804
  tool: "kb_no_decision",
3406
4805
  usage: "no-decision <reason...>",
3407
4806
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
3408
- input: z18.object({ bundlePath, reason: z18.string().min(1) }),
4807
+ input: z20.object({ bundlePath, reason: z20.string().min(1) }),
3409
4808
  fromArgv: (argv, path) => ({
3410
4809
  bundlePath: path,
3411
4810
  reason: argv.slice(1).join(" ").trim()
@@ -3422,20 +4821,20 @@ var noDecisionCommand = define({
3422
4821
  });
3423
4822
 
3424
4823
  // src/commands/pack.ts
3425
- import { z as z19 } from "zod";
4824
+ import { z as z21 } from "zod";
3426
4825
  var packCommand = define({
3427
4826
  name: "pack",
3428
4827
  tool: "kb_pack",
3429
4828
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
3430
4829
  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.",
3431
- input: z19.object({
4830
+ input: z21.object({
3432
4831
  bundlePath,
3433
4832
  conceptId,
3434
- hops: z19.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3435
- maxNodes: z19.number().int().positive().optional().describe(
4833
+ hops: z21.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
4834
+ maxNodes: z21.number().int().positive().optional().describe(
3436
4835
  "How many records the pack may hold, root included. Defaults to 20."
3437
4836
  ),
3438
- budgetTokens: z19.number().int().positive().optional().describe(
4837
+ budgetTokens: z21.number().int().positive().optional().describe(
3439
4838
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
3440
4839
  )
3441
4840
  }),
@@ -3460,12 +4859,12 @@ var packCommand = define({
3460
4859
  return render3(result, path, now());
3461
4860
  }
3462
4861
  });
3463
- function render3(result, bundle, at) {
4862
+ function render3(result, bundle, at2) {
3464
4863
  const lines = [
3465
4864
  `# KB Pack \u2014 ${result.root}`,
3466
4865
  `bundle: ${bundle}`,
3467
4866
  `budget: ~${result.tokensLoaded} of ${result.budgetTokens} tokens, ${result.recordCount} records`,
3468
- `packed: ${at}`,
4867
+ `packed: ${at2}`,
3469
4868
  "",
3470
4869
  `## Records (${result.records.length})`
3471
4870
  ];
@@ -3522,22 +4921,22 @@ function warningLabel(warning) {
3522
4921
  }
3523
4922
 
3524
4923
  // src/commands/pin.ts
3525
- import { z as z20 } from "zod";
4924
+ import { z as z22 } from "zod";
3526
4925
  var pinCommand = define({
3527
4926
  name: "pin",
3528
4927
  tool: "kb_pin",
3529
4928
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
3530
4929
  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.",
3531
- input: z20.object({
4930
+ input: z22.object({
3532
4931
  bundlePath,
3533
- mode: z20.enum(["full", "index"]).optional().describe(
4932
+ mode: z22.enum(["full", "index"]).optional().describe(
3534
4933
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
3535
4934
  ),
3536
- profiles: z20.array(z20.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
3537
- layer: z20.enum(["project", "local", "user"]).optional().describe(
4935
+ profiles: z22.array(z22.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4936
+ layer: z22.enum(["project", "local", "user"]).optional().describe(
3538
4937
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
3539
4938
  ),
3540
- frozen: z20.boolean().optional().describe(
4939
+ frozen: z22.boolean().optional().describe(
3541
4940
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
3542
4941
  )
3543
4942
  }),
@@ -3566,29 +4965,29 @@ var pinCommand = define({
3566
4965
  });
3567
4966
 
3568
4967
  // src/commands/pins.ts
3569
- import { z as z21 } from "zod";
4968
+ import { z as z23 } from "zod";
3570
4969
  var pinsCommand = define({
3571
4970
  name: "pins",
3572
4971
  tool: "kb_pins",
3573
4972
  usage: "pins",
3574
4973
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
3575
- input: z21.object({}),
4974
+ input: z23.object({}),
3576
4975
  fromArgv: () => ({}),
3577
4976
  run: ({ store }) => listPins(store, process.cwd())
3578
4977
  });
3579
4978
 
3580
4979
  // src/commands/query.ts
3581
- import { z as z22 } from "zod";
4980
+ import { z as z24 } from "zod";
3582
4981
  var queryCommand = define({
3583
4982
  name: "query",
3584
4983
  tool: "kb_query",
3585
4984
  usage: "query <text...> [--repo-root PATH]",
3586
4985
  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.",
3587
- input: z22.object({
4986
+ input: z24.object({
3588
4987
  bundlePath,
3589
- text: z22.string().optional(),
3590
- type: z22.enum(KB_RECORD_TYPES).optional(),
3591
- includeNonCurrent: z22.boolean().optional(),
4988
+ text: z24.string().optional(),
4989
+ type: z24.enum(KB_RECORD_TYPES).optional(),
4990
+ includeNonCurrent: z24.boolean().optional(),
3592
4991
  repoRoot: REPO_ROOT
3593
4992
  }),
3594
4993
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -3620,43 +5019,43 @@ var queryCommand = define({
3620
5019
  });
3621
5020
 
3622
5021
  // src/commands/read-index.ts
3623
- import { z as z23 } from "zod";
5022
+ import { z as z25 } from "zod";
3624
5023
  var readIndexCommand = define({
3625
5024
  name: "index",
3626
5025
  tool: "kb_index",
3627
5026
  usage: "index",
3628
5027
  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.",
3629
- input: z23.object({ bundlePath }),
5028
+ input: z25.object({ bundlePath }),
3630
5029
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3631
5030
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
3632
5031
  });
3633
5032
 
3634
5033
  // src/commands/schema.ts
3635
- import { z as z24 } from "zod";
5034
+ import { z as z26 } from "zod";
3636
5035
  var schemaCommand = define({
3637
5036
  name: "schema",
3638
5037
  tool: "kb_schema",
3639
5038
  usage: "schema",
3640
5039
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
3641
- input: z24.object({}),
5040
+ input: z26.object({}),
3642
5041
  fromArgv: () => ({}),
3643
5042
  run: () => Promise.resolve(kbJsonSchemas())
3644
5043
  });
3645
5044
 
3646
5045
  // src/commands/stamp.ts
3647
- import { readFile as readFile4 } from "fs/promises";
3648
- import { z as z25 } from "zod";
5046
+ import { readFile as readFile6 } from "fs/promises";
5047
+ import { z as z27 } from "zod";
3649
5048
  var DIGEST = /^[0-9a-f]{64}$/;
3650
5049
  var stampCommand = define({
3651
5050
  name: "stamp",
3652
5051
  tool: "kb_stamp",
3653
5052
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
3654
- 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.",
3655
- input: z25.object({
3656
- bundlePath: z25.string().min(1).optional().describe(
5053
+ 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.",
5054
+ input: z27.object({
5055
+ bundlePath: z27.string().min(1).optional().describe(
3657
5056
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
3658
5057
  ),
3659
- since: z25.string().min(1).optional().describe(
5058
+ since: z27.string().min(1).optional().describe(
3660
5059
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
3661
5060
  )
3662
5061
  }),
@@ -3695,7 +5094,7 @@ var stampCommand = define({
3695
5094
  return reports;
3696
5095
  },
3697
5096
  render: (result) => result.map((report) => {
3698
- const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
5097
+ const counts = `${report.recordCount} record(s), ${report.superseded} superseded${report.drifted ? `, ${report.drifted} drifted` : ""}`;
3699
5098
  const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
3700
5099
  return report.changed?.length ? `${head}
3701
5100
  changed: ${report.changed.join(", ")}` : head;
@@ -3718,7 +5117,7 @@ async function readBaseline(since) {
3718
5117
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
3719
5118
  let parsed;
3720
5119
  try {
3721
- parsed = JSON.parse(await readFile4(since, "utf8"));
5120
+ parsed = JSON.parse(await readFile6(since, "utf8"));
3722
5121
  } catch {
3723
5122
  throw new KbStampBaselineError(since);
3724
5123
  }
@@ -3742,16 +5141,16 @@ async function readBaseline(since) {
3742
5141
  }
3743
5142
 
3744
5143
  // src/commands/status.ts
3745
- import { z as z26 } from "zod";
5144
+ import { z as z28 } from "zod";
3746
5145
  var statusCommand = define({
3747
5146
  name: "status",
3748
5147
  tool: "kb_status",
3749
5148
  usage: "status <concept-id> <status>",
3750
5149
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
3751
- input: z26.object({
5150
+ input: z28.object({
3752
5151
  bundlePath,
3753
5152
  conceptId,
3754
- status: z26.enum(KB_RECORD_STATUSES)
5153
+ status: z28.enum(KB_RECORD_STATUSES)
3755
5154
  }),
3756
5155
  fromArgv: (argv, path) => ({
3757
5156
  bundlePath: path,
@@ -3766,13 +5165,13 @@ var statusCommand = define({
3766
5165
  });
3767
5166
 
3768
5167
  // src/commands/supersede.ts
3769
- import { z as z27 } from "zod";
5168
+ import { z as z29 } from "zod";
3770
5169
  var supersedeCommand = define({
3771
5170
  name: "supersede",
3772
5171
  tool: "kb_supersede",
3773
5172
  usage: "supersede <concept-id> <replacement-id>",
3774
5173
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
3775
- input: z27.object({ bundlePath, conceptId, replacementId: conceptId }),
5174
+ input: z29.object({ bundlePath, conceptId, replacementId: conceptId }),
3776
5175
  fromArgv: (argv, path) => ({
3777
5176
  bundlePath: path,
3778
5177
  conceptId: argv[1],
@@ -3786,16 +5185,16 @@ var supersedeCommand = define({
3786
5185
  });
3787
5186
 
3788
5187
  // src/commands/sync-instructions.ts
3789
- import { z as z28 } from "zod";
5188
+ import { z as z30 } from "zod";
3790
5189
  var syncInstructionsCommand = define({
3791
5190
  name: "sync-instructions",
3792
5191
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
3793
5192
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
3794
- input: z28.object({
3795
- file: z28.string().min(1).describe("The instruction file to edit in place."),
3796
- budgetTokens: z28.number().int().positive().optional(),
3797
- fullUnderTokens: z28.number().int().positive().optional(),
3798
- profile: z28.string().optional()
5193
+ input: z30.object({
5194
+ file: z30.string().min(1).describe("The instruction file to edit in place."),
5195
+ budgetTokens: z30.number().int().positive().optional(),
5196
+ fullUnderTokens: z30.number().int().positive().optional(),
5197
+ profile: z30.string().optional()
3799
5198
  }),
3800
5199
  fromArgv: (argv) => {
3801
5200
  const budget = argvFlag(argv, "--budget");
@@ -3821,17 +5220,17 @@ var syncInstructionsCommand = define({
3821
5220
  });
3822
5221
 
3823
5222
  // src/commands/trace.ts
3824
- import { z as z29 } from "zod";
5223
+ import { z as z31 } from "zod";
3825
5224
  var traceCommand = define({
3826
5225
  name: "trace",
3827
5226
  tool: "kb_trace",
3828
5227
  usage: "trace <concept-id> [edges...]",
3829
5228
  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".',
3830
- input: z29.object({
5229
+ input: z31.object({
3831
5230
  bundlePath,
3832
5231
  conceptId,
3833
- edges: z29.array(z29.enum(TRACE_EDGES)).optional(),
3834
- depth: z29.number().int().positive().optional()
5232
+ edges: z31.array(z31.enum(TRACE_EDGES)).optional(),
5233
+ depth: z31.number().int().positive().optional()
3835
5234
  }),
3836
5235
  fromArgv: (argv, path) => ({
3837
5236
  bundlePath: path,
@@ -3853,37 +5252,37 @@ var traceCommand = define({
3853
5252
  });
3854
5253
 
3855
5254
  // src/commands/types.ts
3856
- import { z as z30 } from "zod";
5255
+ import { z as z32 } from "zod";
3857
5256
  var typesCommand = define({
3858
5257
  name: "types",
3859
5258
  tool: "kb_types",
3860
5259
  usage: "types",
3861
5260
  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.",
3862
- input: z30.object({}),
5261
+ input: z32.object({}),
3863
5262
  fromArgv: () => ({}),
3864
5263
  run: () => Promise.resolve(RECORD_TYPES)
3865
5264
  });
3866
5265
 
3867
5266
  // src/commands/unpin.ts
3868
- import { z as z31 } from "zod";
5267
+ import { z as z33 } from "zod";
3869
5268
  var unpinCommand = define({
3870
5269
  name: "unpin",
3871
5270
  tool: "kb_unpin",
3872
5271
  usage: "unpin [bundle-path]",
3873
5272
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
3874
- input: z31.object({ bundlePath }),
5273
+ input: z33.object({ bundlePath }),
3875
5274
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3876
5275
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3877
5276
  });
3878
5277
 
3879
5278
  // src/commands/validate.ts
3880
- import { z as z32 } from "zod";
5279
+ import { z as z34 } from "zod";
3881
5280
  var validateCommand = define({
3882
5281
  name: "validate",
3883
5282
  tool: "kb_validate",
3884
5283
  usage: "validate",
3885
5284
  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.",
3886
- input: z32.object({ bundlePath }),
5285
+ input: z34.object({ bundlePath }),
3887
5286
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3888
5287
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3889
5288
  // Warnings never fail the exit code; every other severity does.
@@ -3893,16 +5292,16 @@ var validateCommand = define({
3893
5292
  });
3894
5293
 
3895
5294
  // src/commands/verify.ts
3896
- import { z as z33 } from "zod";
5295
+ import { z as z35 } from "zod";
3897
5296
  var verifyCommand = define({
3898
5297
  name: "verify",
3899
5298
  tool: "kb_verify",
3900
5299
  usage: "verify <concept-id> --note <text>",
3901
5300
  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.",
3902
- input: z33.object({
5301
+ input: z35.object({
3903
5302
  bundlePath,
3904
5303
  conceptId,
3905
- note: z33.string().refine((s) => s.trim().length > 0, {
5304
+ note: z35.string().refine((s) => s.trim().length > 0, {
3906
5305
  message: "note must say what the check found"
3907
5306
  })
3908
5307
  }),
@@ -3922,15 +5321,15 @@ var verifyCommand = define({
3922
5321
  });
3923
5322
 
3924
5323
  // src/commands/write.ts
3925
- import { z as z34 } from "zod";
5324
+ import { z as z36 } from "zod";
3926
5325
  var writeCommand = define({
3927
5326
  name: "write",
3928
5327
  tool: "kb_write",
3929
5328
  usage: "write <type> < record.json",
3930
5329
  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.",
3931
- input: z34.object({
5330
+ input: z36.object({
3932
5331
  bundlePath,
3933
- type: z34.enum(KB_RECORD_TYPES),
5332
+ type: z36.enum(KB_RECORD_TYPES),
3934
5333
  input: composeInputSchema
3935
5334
  }),
3936
5335
  fromArgv: async (argv, path, stdin) => ({
@@ -3954,13 +5353,13 @@ var writeCommand = define({
3954
5353
  });
3955
5354
 
3956
5355
  // src/commands/write-decision.ts
3957
- import { z as z35 } from "zod";
5356
+ import { z as z37 } from "zod";
3958
5357
  var writeDecisionCommand = define({
3959
5358
  name: "write-decision",
3960
5359
  tool: "kb_write_decision",
3961
5360
  usage: "write-decision < decision.json",
3962
5361
  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.",
3963
- input: z35.object({ bundlePath, input: decisionInputSchema }),
5362
+ input: z37.object({ bundlePath, input: decisionInputSchema }),
3964
5363
  fromArgv: async (_argv, path, stdin) => ({
3965
5364
  bundlePath: path,
3966
5365
  input: JSON.parse(await stdin())
@@ -3990,6 +5389,7 @@ var KB_COMMANDS = [
3990
5389
  answerCommand,
3991
5390
  verifyCommand,
3992
5391
  anchorResolveCommand,
5392
+ reassessCommand,
3993
5393
  loadCommand,
3994
5394
  catalogCommand,
3995
5395
  packCommand,
@@ -4041,8 +5441,8 @@ function parseMarkdownWithFrontmatter(text, schema) {
4041
5441
  }
4042
5442
 
4043
5443
  // src/search-index.ts
4044
- import { stat as stat2 } from "fs/promises";
4045
- import { join as join4 } from "path";
5444
+ import { stat as stat3 } from "fs/promises";
5445
+ import { join as join6 } from "path";
4046
5446
  var SEARCH_INDEX_FILE = ".index.sqlite";
4047
5447
  var COLLECTION = "kb";
4048
5448
  async function searchBase(bundlePath2, query, options = {}) {
@@ -4051,7 +5451,7 @@ async function searchBase(bundlePath2, query, options = {}) {
4051
5451
  let store = null;
4052
5452
  try {
4053
5453
  store = await qmd.createStore({
4054
- dbPath: join4(bundlePath2, SEARCH_INDEX_FILE),
5454
+ dbPath: join6(bundlePath2, SEARCH_INDEX_FILE),
4055
5455
  config: {
4056
5456
  collections: {
4057
5457
  [COLLECTION]: {
@@ -4086,7 +5486,7 @@ async function searchBase(bundlePath2, query, options = {}) {
4086
5486
  }
4087
5487
  }
4088
5488
  async function isStale(bundlePath2) {
4089
- const indexAt = await stat2(join4(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
5489
+ const indexAt = await stat3(join6(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
4090
5490
  if (!indexAt) return true;
4091
5491
  const { readdir: readdir2 } = await import("fs/promises");
4092
5492
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -4095,8 +5495,8 @@ async function isStale(bundlePath2) {
4095
5495
  let stale = false;
4096
5496
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
4097
5497
  if (stale) return;
4098
- const at = await stat2(join4(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
4099
- if (at > indexAt) stale = true;
5498
+ const at2 = await stat3(join6(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
5499
+ if (at2 > indexAt) stale = true;
4100
5500
  });
4101
5501
  return stale;
4102
5502
  }
@@ -4133,25 +5533,25 @@ async function loadQmd(logger) {
4133
5533
  import {
4134
5534
  appendFile,
4135
5535
  link,
4136
- mkdir as mkdir3,
5536
+ mkdir as mkdir4,
4137
5537
  readdir,
4138
- readFile as readFile5,
4139
- rename,
5538
+ readFile as readFile7,
5539
+ rename as rename2,
4140
5540
  unlink,
4141
- writeFile as writeFile3
5541
+ writeFile as writeFile4
4142
5542
  } from "fs/promises";
4143
- import { join as join5, resolve as resolve5, sep as sep3 } from "path";
5543
+ import { join as join7, resolve as resolve5, sep as sep3 } from "path";
4144
5544
 
4145
5545
  // src/kb-stamp.ts
4146
- import { createHash as createHash2 } from "crypto";
4147
- function sha256(contents) {
4148
- return createHash2("sha256").update(contents).digest("hex");
5546
+ import { createHash as createHash4 } from "crypto";
5547
+ function sha2563(contents) {
5548
+ return createHash4("sha256").update(contents).digest("hex");
4149
5549
  }
4150
5550
  function bundleStamp(records, superseded) {
4151
5551
  const entries = [
4152
5552
  ...records.map((hit) => ({
4153
5553
  conceptId: hit.record.conceptId,
4154
- digest: `current:${sha256(
5554
+ digest: `current:${sha2563(
4155
5555
  stringifyMarkdownWithFrontmatter(
4156
5556
  hit.record.body,
4157
5557
  hit.record.frontmatter
@@ -4160,11 +5560,11 @@ function bundleStamp(records, superseded) {
4160
5560
  })),
4161
5561
  ...superseded.map((entry) => ({
4162
5562
  conceptId: entry.conceptId,
4163
- digest: `superseded:${sha256(JSON.stringify(entry))}`
5563
+ digest: `superseded:${sha2563(JSON.stringify(entry))}`
4164
5564
  }))
4165
5565
  ].sort((a, b) => a.conceptId < b.conceptId ? -1 : 1);
4166
5566
  return {
4167
- digest: sha256(
5567
+ digest: sha2563(
4168
5568
  entries.map((entry) => `${entry.conceptId}:${entry.digest}`).join("\n")
4169
5569
  ),
4170
5570
  records: entries
@@ -4334,7 +5734,7 @@ function appendUnionMergeLine(contents) {
4334
5734
  }
4335
5735
 
4336
5736
  // src/kb-store.ts
4337
- var KB_DIR = join5(".strauss", "kb");
5737
+ var KB_DIR = join7(".strauss", "kb");
4338
5738
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
4339
5739
  var DEFAULT_LOAD_BUDGET = 25e3;
4340
5740
  var KbStore = class {
@@ -4365,7 +5765,7 @@ var KbStore = class {
4365
5765
  const conceptId2 = `${input.type}.${input.slug}`;
4366
5766
  const root = this.root(bundlePath2);
4367
5767
  const target = this.recordPath(bundlePath2, conceptId2);
4368
- await mkdir3(root, { recursive: true });
5768
+ await mkdir4(root, { recursive: true });
4369
5769
  await this.publish(
4370
5770
  target,
4371
5771
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -4404,7 +5804,7 @@ var KbStore = class {
4404
5804
  const target = this.recordPath(bundlePath2, conceptId2);
4405
5805
  let raw;
4406
5806
  try {
4407
- raw = await readFile5(target, "utf8");
5807
+ raw = await readFile7(target, "utf8");
4408
5808
  } catch {
4409
5809
  return null;
4410
5810
  }
@@ -4429,7 +5829,7 @@ var KbStore = class {
4429
5829
  const records = await mapLimit(
4430
5830
  wanted,
4431
5831
  DEFAULT_IO_CONCURRENCY,
4432
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile5(join5(root, name), "utf8"))
5832
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile7(join7(root, name), "utf8"))
4433
5833
  );
4434
5834
  return records.filter((record) => record !== null);
4435
5835
  }
@@ -4477,8 +5877,8 @@ var KbStore = class {
4477
5877
  * and the refusal is logged under its own operation name — `mutate` only
4478
5878
  * logs what it publishes.
4479
5879
  */
4480
- async verify(bundlePath2, conceptId2, note, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
4481
- const event = kbVerifiedEventSchema.parse({ by: actor, at, note });
5880
+ async verify(bundlePath2, conceptId2, note, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
5881
+ const event = kbVerifiedEventSchema.parse({ by: actor, at: at2, note });
4482
5882
  const existing = await this.read(bundlePath2, conceptId2);
4483
5883
  if (!existing) throw new KbRecordNotFoundError(conceptId2);
4484
5884
  const generatedBy = existing.frontmatter.generated?.by;
@@ -4530,14 +5930,14 @@ var KbStore = class {
4530
5930
  return superseded;
4531
5931
  }
4532
5932
  /** Resolves an open question, stamping who answered and when. */
4533
- async answer(bundlePath2, conceptId2, answer, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
5933
+ async answer(bundlePath2, conceptId2, answer, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
4534
5934
  return this.mutate(
4535
5935
  bundlePath2,
4536
5936
  conceptId2,
4537
5937
  (frontmatter) => ({
4538
5938
  ...frontmatter,
4539
5939
  strauss_status: "resolved",
4540
- strauss_answered: { by: actor, at }
5940
+ strauss_answered: { by: actor, at: at2 }
4541
5941
  }),
4542
5942
  { operation: "answer", by: actor },
4543
5943
  (body) => `${body.trimEnd()}
@@ -4586,7 +5986,7 @@ ${answer}
4586
5986
  if (found.length) return found;
4587
5987
  }
4588
5988
  const lowered = needle.toLowerCase();
4589
- return bundle.filter((record) => matches(record, lowered));
5989
+ return bundle.filter((record) => matches2(record, lowered));
4590
5990
  }
4591
5991
  /**
4592
5992
  * Anchor drift over the records about to be handed back. Like the search
@@ -4706,24 +6106,34 @@ ${answer}
4706
6106
  }
4707
6107
  /**
4708
6108
  * `load`'s digest without `load`'s bodies — the same records, adjudicated
4709
- * the same way, handed back as a stamp. Skips the anchor drift pass, which
4710
- * reads source files and only ever adds warnings: no warning reaches the
4711
- * digest, so the value is identical to the one `load` returns.
6109
+ * the same way, handed back as a stamp.
6110
+ *
6111
+ * Drift is counted but kept out of the digest, which is what lets the reload
6112
+ * hook ask one question and get two answers: whether the base moved, and
6113
+ * whether the code under it did. A `load` and a `stamp` of the same base
6114
+ * still agree on the digest, because no warning has ever reached it.
4712
6115
  */
4713
- async stamp(bundlePath2) {
6116
+ async stamp(bundlePath2, options = {}) {
4714
6117
  const bundle = await this.list(bundlePath2);
4715
6118
  const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
4716
6119
  const current = adjudicated.filter((hit) => hit.standing !== "superseded");
4717
6120
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
4718
6121
  const stamped = bundleStamp(current, superseded);
4719
- const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
6122
+ const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at2) => typeof at2 === "string").sort();
6123
+ const drift = await this.detectDrift(bundle, options.repoRoot);
6124
+ const drifted2 = drift === void 0 ? null : [...drift.values()].filter(
6125
+ (entries) => entries.some(
6126
+ (entry) => entry.state !== "match" && !isUncheckedReason(entry.reason)
6127
+ )
6128
+ ).length;
4720
6129
  return {
4721
6130
  path: bundlePath2,
4722
6131
  digest: stamped.digest,
4723
6132
  recordCount: bundle.length,
4724
6133
  superseded: superseded.length,
4725
6134
  newestAt: dates.at(-1) ?? null,
4726
- records: stamped.records
6135
+ records: stamped.records,
6136
+ drifted: drifted2
4727
6137
  };
4728
6138
  }
4729
6139
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
@@ -4756,11 +6166,11 @@ ${answer}
4756
6166
  async readIndex(bundlePath2) {
4757
6167
  const root = this.root(bundlePath2);
4758
6168
  const expected = renderIndex(await this.list(bundlePath2));
4759
- const stored = await readFile5(join5(root, INDEX_FILE), "utf8").catch(
6169
+ const stored = await readFile7(join7(root, INDEX_FILE), "utf8").catch(
4760
6170
  () => null
4761
6171
  );
4762
6172
  if (indexIsStale(stored, expected)) {
4763
- await this.publish(join5(root, INDEX_FILE), expected, true, INDEX_FILE);
6173
+ await this.publish(join7(root, INDEX_FILE), expected, true, INDEX_FILE);
4764
6174
  this.logger.info?.({
4765
6175
  operation: "kb.index.repair",
4766
6176
  bundlePath: root,
@@ -4777,8 +6187,8 @@ ${answer}
4777
6187
  * knows which agent touched what. So a bad line is surfaced and left alone.
4778
6188
  */
4779
6189
  async readLog(bundlePath2) {
4780
- const raw = await readFile5(
4781
- join5(this.root(bundlePath2), LOG_FILE),
6190
+ const raw = await readFile7(
6191
+ join7(this.root(bundlePath2), LOG_FILE),
4782
6192
  "utf8"
4783
6193
  ).catch(() => "");
4784
6194
  const result = parseLog(raw);
@@ -4829,15 +6239,15 @@ ${answer}
4829
6239
  }
4830
6240
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
4831
6241
  const target = this.recordPath(bundlePath2, conceptId2);
4832
- const before = await readFile5(target, "utf8").catch(() => null);
6242
+ const before = await readFile7(target, "utf8").catch(() => null);
4833
6243
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
4834
6244
  const parsed = this.parse(conceptId2, before);
4835
6245
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
4836
6246
  const frontmatter = change(parsed.frontmatter);
4837
6247
  const body = changeBody(parsed.body);
4838
6248
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
4839
- const witness = await readFile5(target, "utf8").catch(() => null);
4840
- if (witness === null || sha256(witness) !== sha256(before)) {
6249
+ const witness = await readFile7(target, "utf8").catch(() => null);
6250
+ if (witness === null || sha2563(witness) !== sha2563(before)) {
4841
6251
  throw new KbWriteConflictError(conceptId2);
4842
6252
  }
4843
6253
  await this.publish(target, contents, true, conceptId2);
@@ -4862,10 +6272,10 @@ ${answer}
4862
6272
  */
4863
6273
  async publish(target, contents, overwrite, conceptId2) {
4864
6274
  const staging = `${target}.${process.pid}.tmp`;
4865
- await writeFile3(staging, contents, "utf8");
6275
+ await writeFile4(staging, contents, "utf8");
4866
6276
  try {
4867
6277
  if (overwrite) {
4868
- await rename(staging, target);
6278
+ await rename2(staging, target);
4869
6279
  return;
4870
6280
  }
4871
6281
  await link(staging, target);
@@ -4919,18 +6329,18 @@ ${answer}
4919
6329
  * file must not fail the mutation it guards.
4920
6330
  */
4921
6331
  async ensureGitattributes(root) {
4922
- const target = join5(root, GITATTRIBUTES_FILE);
6332
+ const target = join7(root, GITATTRIBUTES_FILE);
4923
6333
  try {
4924
6334
  let existing;
4925
6335
  try {
4926
- existing = await readFile5(target, "utf8");
6336
+ existing = await readFile7(target, "utf8");
4927
6337
  } catch (error) {
4928
6338
  if (error.code !== "ENOENT") throw error;
4929
6339
  existing = null;
4930
6340
  }
4931
6341
  if (existing === null) {
4932
6342
  try {
4933
- await writeFile3(target, appendUnionMergeLine(""), {
6343
+ await writeFile4(target, appendUnionMergeLine(""), {
4934
6344
  encoding: "utf8",
4935
6345
  flag: "wx"
4936
6346
  });
@@ -4970,7 +6380,7 @@ ${answer}
4970
6380
  async record(root, entry) {
4971
6381
  await this.ensureGitattributes(root);
4972
6382
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4973
- await appendFile(join5(root, LOG_FILE), line, "utf8").catch((error) => {
6383
+ await appendFile(join7(root, LOG_FILE), line, "utf8").catch((error) => {
4974
6384
  this.logger.warn?.({
4975
6385
  operation: "kb.log.append",
4976
6386
  outcome: "failed",
@@ -5007,7 +6417,7 @@ ${answer}
5007
6417
  { conceptId: conceptId2 }
5008
6418
  );
5009
6419
  }
5010
- return join5(this.root(bundlePath2), `${conceptId2}.md`);
6420
+ return join7(this.root(bundlePath2), `${conceptId2}.md`);
5011
6421
  }
5012
6422
  };
5013
6423
  function estimateTokens(record) {
@@ -5034,7 +6444,7 @@ function stub(hit) {
5034
6444
  at: hit.record.frontmatter.generated?.at ?? null
5035
6445
  };
5036
6446
  }
5037
- function matches(record, needle) {
6447
+ function matches2(record, needle) {
5038
6448
  const { title, description } = record.frontmatter;
5039
6449
  return [record.conceptId, title, description, record.body].some(
5040
6450
  (field) => field?.toLowerCase().includes(needle)
@@ -5126,12 +6536,12 @@ function byRank(left, right) {
5126
6536
  ) || left.record.conceptId.localeCompare(right.record.conceptId);
5127
6537
  }
5128
6538
  function typeRank(record) {
5129
- const index = TYPE_PRIORITY.indexOf(record.frontmatter.type);
5130
- return index === -1 ? TYPE_PRIORITY.length : index;
6539
+ const index2 = TYPE_PRIORITY.indexOf(record.frontmatter.type);
6540
+ return index2 === -1 ? TYPE_PRIORITY.length : index2;
5131
6541
  }
5132
6542
 
5133
6543
  // src/version.ts
5134
- var VERSION = true ? "0.1.16" : "0.0.0-dev";
6544
+ var VERSION = true ? "0.1.18" : "0.0.0-dev";
5135
6545
 
5136
6546
  export {
5137
6547
  kbSourceSchema,
@@ -5167,9 +6577,19 @@ export {
5167
6577
  repoCacheDir,
5168
6578
  readRemoteAnchors,
5169
6579
  anchorFilePath,
6580
+ grammarsCacheRoot,
6581
+ grammarManifest,
6582
+ ensureGrammar,
6583
+ grammarHints,
6584
+ languageForFile,
6585
+ treeSitterLanguages,
6586
+ TreeSitterResolver,
5170
6587
  regexResolver,
5171
6588
  hashAnchorText,
5172
6589
  resolveAnchor,
6590
+ resolveAnchorSpan,
6591
+ prepareResolvers,
6592
+ defaultAnchorResolvers,
5173
6593
  detectAnchorDrift,
5174
6594
  Fault,
5175
6595
  ErrorTypes,
@@ -5210,6 +6630,9 @@ export {
5210
6630
  CONTEXT_BEGIN,
5211
6631
  CONTEXT_END,
5212
6632
  syncInstructions,
6633
+ classifyDrift,
6634
+ unifiedDiff,
6635
+ reassessPacket,
5213
6636
  KB_EDGE_KINDS,
5214
6637
  DEFAULT_TYPED_LINK_RELS,
5215
6638
  neighbours,
@@ -5247,4 +6670,4 @@ export {
5247
6670
  KbStore,
5248
6671
  VERSION
5249
6672
  };
5250
- //# sourceMappingURL=chunk-H5W53NVU.js.map
6673
+ //# sourceMappingURL=chunk-GSOTMWZZ.js.map