@saasontools/strauss-kb 0.1.15 → 0.1.17

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.
@@ -43,7 +43,13 @@ var kbAnchorSchema = z.object({
43
43
  /** ISO 8601 timestamp of the last successful resolution. */
44
44
  resolved_at: z.string().min(1).optional(),
45
45
  /** Line count of the text the hash was taken over. */
46
- lines: z.number().int().positive().optional()
46
+ lines: z.number().int().positive().optional(),
47
+ /**
48
+ * Which resolver produced the hashed span. Absent means an anchor stamped
49
+ * before resolvers were named, which is read as `regex` — the only one
50
+ * there was. A hash from a different resolver is drift, not a match.
51
+ */
52
+ resolver: z.enum(["tree-sitter", "regex"]).optional()
47
53
  }).strict();
48
54
  var kbLinkSchema = z.object({
49
55
  target: z.string().min(1),
@@ -709,18 +715,18 @@ async function readOneRepo(repo, url, declared, context) {
709
715
  if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
710
716
  const cache = cachePathFor(repo, context.cacheDir);
711
717
  if (!cache) return all({ ok: false, reason: "remote-unreachable" });
712
- const rejected = /* @__PURE__ */ new Map();
718
+ const rejected2 = /* @__PURE__ */ new Map();
713
719
  const usable = [];
714
720
  for (const want of wants) {
715
721
  const reason = wantReason(want);
716
722
  if (reason)
717
- rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
723
+ rejected2.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
718
724
  else usable.push(want);
719
725
  }
720
- if (!usable.length) return rejected;
726
+ if (!usable.length) return rejected2;
721
727
  wants = usable;
722
728
  const opened = await openCache(cache, url, context);
723
- if (opened) return new Map([...rejected, ...all(opened)]);
729
+ if (opened) return new Map([...rejected2, ...all(opened)]);
724
730
  const wantsDefault = wants.some((want) => want.ref === void 0);
725
731
  const branch = wantsDefault ? await defaultBranch(cache, context) : {};
726
732
  const revs = /* @__PURE__ */ new Map();
@@ -747,7 +753,7 @@ async function readOneRepo(repo, url, declared, context) {
747
753
  }
748
754
  );
749
755
  return new Map([
750
- ...rejected,
756
+ ...rejected2,
751
757
  ...wants.map(
752
758
  (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
753
759
  )
@@ -799,13 +805,13 @@ async function defaultBranch(cache, context) {
799
805
  if (!listed.ok) {
800
806
  const reason = transportReason(listed.stderr);
801
807
  if (reason !== "ref-not-found") {
802
- const cached2 = await cachedBranch(cache);
803
- return cached2 ? { name: cached2 } : { reason };
808
+ const cached3 = await cachedBranch(cache);
809
+ return cached3 ? { name: cached3 } : { reason };
804
810
  }
805
811
  }
806
812
  }
807
- const cached = await cachedBranch(cache);
808
- if (cached) return { name: cached };
813
+ const cached2 = await cachedBranch(cache);
814
+ if (cached2) return { name: cached2 };
809
815
  return {
810
816
  reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
811
817
  };
@@ -832,8 +838,8 @@ async function ensureRev(cache, rev, context) {
832
838
  cwd: cache
833
839
  }
834
840
  );
835
- const cached = have.ok && have.stdout.trim().length > 0;
836
- if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
841
+ const cached2 = have.ok && have.stdout.trim().length > 0;
842
+ if (cached2 && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
837
843
  if (context.offline) return { ok: false, reason: "remote-unreachable" };
838
844
  const fetched = await git(
839
845
  [
@@ -849,7 +855,7 @@ async function ensureRev(cache, rev, context) {
849
855
  );
850
856
  if (!fetched.ok) {
851
857
  const reason = transportReason(fetched.stderr);
852
- if (cached && reason !== "ref-not-found") return void 0;
858
+ if (cached2 && reason !== "ref-not-found") return void 0;
853
859
  return { ok: false, reason };
854
860
  }
855
861
  const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
@@ -963,64 +969,577 @@ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY
963
969
  return new Map(wanted.map((file, at) => [file, results[at]]));
964
970
  }
965
971
 
972
+ // src/grammars/store.ts
973
+ import { createHash, randomBytes } from "crypto";
974
+ import { mkdir as mkdir2, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
975
+ import { homedir as homedir2 } from "os";
976
+ import { dirname, join as join2 } from "path";
977
+ function grammarsCacheRoot(override) {
978
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_DIR"] ?? join2(homedir2(), ".strauss", "grammars");
979
+ }
980
+ function grammarCachePath(root, language, sha2564, extension = "wasm") {
981
+ return join2(root, language, `${sha2564.slice(0, 12)}.${extension}`);
982
+ }
983
+ function sha256(bytes) {
984
+ return createHash("sha256").update(bytes).digest("hex");
985
+ }
986
+ function matches(bytes, entry) {
987
+ if (entry.bytes !== void 0 && bytes.byteLength !== entry.bytes)
988
+ return false;
989
+ return sha256(bytes) === entry.sha256;
990
+ }
991
+ async function verifyCached(path, entry) {
992
+ let bytes;
993
+ try {
994
+ bytes = await readFile2(path);
995
+ } catch {
996
+ return false;
997
+ }
998
+ if (matches(bytes, entry)) return true;
999
+ await rm(path, { force: true });
1000
+ return false;
1001
+ }
1002
+ async function writeCached(path, bytes) {
1003
+ await mkdir2(dirname(path), { recursive: true });
1004
+ const temporary = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
1005
+ try {
1006
+ await writeFile(temporary, bytes);
1007
+ await rename(temporary, path);
1008
+ } catch (error) {
1009
+ await rm(temporary, { force: true });
1010
+ throw error;
1011
+ }
1012
+ }
1013
+
1014
+ // src/grammars/manifest.ts
1015
+ import { existsSync, readFileSync } from "fs";
1016
+ import { dirname as dirname2, join as join3 } from "path";
1017
+ import { fileURLToPath } from "url";
1018
+
1019
+ // src/grammars/model.ts
1020
+ import { z as z4 } from "zod";
1021
+ var sha2562 = z4.string().regex(/^[0-9a-f]{64}$/);
1022
+ var grammarWasmSchema = z4.object({
1023
+ url: z4.string().min(1),
1024
+ sha256: sha2562,
1025
+ bytes: z4.number().int().positive()
1026
+ });
1027
+ var grammarTagsSchema = z4.object({ url: z4.string().min(1), sha256: sha2562 });
1028
+ var grammarPackSchema = z4.object({
1029
+ package: z4.string().min(1),
1030
+ wasm: grammarWasmSchema,
1031
+ tags: z4.array(grammarTagsSchema),
1032
+ license: z4.string().min(1),
1033
+ extensions: z4.array(z4.string().min(1))
1034
+ });
1035
+ var grammarManifestSchema = z4.object({
1036
+ /** The runtime the packs were proved against. */
1037
+ webTreeSitter: z4.string().min(1),
1038
+ linguist: z4.object({ tag: z4.string().min(1), commit: z4.string().min(1) }),
1039
+ packs: z4.record(z4.string().min(1), grammarPackSchema)
1040
+ });
1041
+
1042
+ // src/grammars/manifest.ts
1043
+ var cached;
1044
+ function grammarManifest() {
1045
+ cached ??= grammarManifestSchema.parse(
1046
+ JSON.parse(readFileSync(grammarsDataPath("manifest.json"), "utf8"))
1047
+ );
1048
+ return cached;
1049
+ }
1050
+ function grammarsDataPath(...segments) {
1051
+ let dir = dirname2(fileURLToPath(import.meta.url));
1052
+ for (let up = 0; up < 5; up++) {
1053
+ const candidate = join3(dir, "grammars");
1054
+ if (existsSync(join3(candidate, "manifest.json")))
1055
+ return join3(candidate, ...segments);
1056
+ dir = dirname2(dir);
1057
+ }
1058
+ throw new Error("grammars/manifest.json is missing from the package");
1059
+ }
1060
+
1061
+ // src/grammars/index.ts
1062
+ import { readFile as readFile3 } from "fs/promises";
1063
+
1064
+ // src/grammars/fetch.ts
1065
+ var ATTEMPTS = 3;
1066
+ var BACKOFF_MS = 250;
1067
+ function grammarUrl(url, override) {
1068
+ const base2 = grammarsBaseUrl(override);
1069
+ if (!base2) return url;
1070
+ let root = base2;
1071
+ while (root.endsWith("/")) root = root.slice(0, -1);
1072
+ const pinned = new URL(url);
1073
+ return `${root}${pinned.pathname}${pinned.search}`;
1074
+ }
1075
+ function grammarsBaseUrl(override) {
1076
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_URL"];
1077
+ }
1078
+ async function downloadPart(url, name, entry, options = {}) {
1079
+ const log = options.log ?? ((line) => void process.stderr.write(line));
1080
+ const weight = entry.bytes === void 0 ? "" : ` (${size(entry.bytes)} from manifest)`;
1081
+ log(`strauss-kb: downloading ${name}${weight} from ${url}
1082
+ `);
1083
+ let cause = "";
1084
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
1085
+ const outcome = await attemptDownload(url, entry, options.fetchTimeoutMs);
1086
+ if ("bytes" in outcome) return outcome;
1087
+ cause = outcome.cause;
1088
+ log(
1089
+ `strauss-kb: ${name} attempt ${attempt}/${ATTEMPTS} failed: ${cause}
1090
+ `
1091
+ );
1092
+ if (!outcome.retry) break;
1093
+ if (attempt < ATTEMPTS) await pause(BACKOFF_MS * attempt);
1094
+ }
1095
+ log(`strauss-kb: ${name} not downloaded: ${cause}
1096
+ `);
1097
+ return { cause };
1098
+ }
1099
+ async function attemptDownload(url, entry, timeoutMs) {
1100
+ try {
1101
+ const response = await fetch(url, {
1102
+ signal: AbortSignal.timeout(fetchTimeoutMs(timeoutMs))
1103
+ });
1104
+ if (!response.ok) {
1105
+ return {
1106
+ cause: `HTTP ${response.status}`,
1107
+ retry: response.status >= 500 || response.status === 429
1108
+ };
1109
+ }
1110
+ const bytes = new Uint8Array(await response.arrayBuffer());
1111
+ if (!matches(bytes, entry))
1112
+ return { cause: "sha256 mismatch", retry: false };
1113
+ return { bytes };
1114
+ } catch (error) {
1115
+ const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
1116
+ return { cause: timedOut ? "timeout" : "network error", retry: true };
1117
+ }
1118
+ }
1119
+ function size(bytes) {
1120
+ return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1121
+ }
1122
+ function pause(ms) {
1123
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
1124
+ }
1125
+
1126
+ // src/grammars/index.ts
1127
+ var inFlight = /* @__PURE__ */ new Map();
1128
+ var missing = /* @__PURE__ */ new Map();
1129
+ var uncompilable = /* @__PURE__ */ new Map();
1130
+ var rejected = /* @__PURE__ */ new Map();
1131
+ function grammarsDownloadDisabled() {
1132
+ return process.env["STRAUSS_KB_GRAMMARS"] === "off";
1133
+ }
1134
+ async function ensureGrammar(language, options = {}) {
1135
+ const pack2 = grammarManifest().packs[language];
1136
+ if (!pack2) return null;
1137
+ const root = grammarsCacheRoot(options.cacheRoot);
1138
+ const wasm = grammarCachePath(root, language, pack2.wasm.sha256);
1139
+ const key = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1140
+ const existing = inFlight.get(key);
1141
+ if (existing) return existing;
1142
+ const pending = (async () => {
1143
+ const grammar = await ensurePart(
1144
+ wasm,
1145
+ `tree-sitter-${language}`,
1146
+ pack2.wasm,
1147
+ options
1148
+ );
1149
+ if (grammar !== true)
1150
+ return miss(language, `grammar tree-sitter-${language}`, grammar);
1151
+ const parts = [];
1152
+ const total = pack2.tags.length;
1153
+ for (const [at, part] of pack2.tags.entries()) {
1154
+ const name = `${language} tags${total > 1 ? ` part ${at + 1}/${total}` : ""}`;
1155
+ const path = grammarCachePath(root, language, part.sha256, "scm");
1156
+ const held = await ensurePart(path, name, part, options);
1157
+ if (held !== true) return miss(language, name, held);
1158
+ parts.push(`; ${part.url}
1159
+ ${lf(await readFile3(path, "utf8"))}`);
1160
+ }
1161
+ missing.delete(language);
1162
+ return { wasm, query: total ? parts.join("\n") : void 0 };
1163
+ })();
1164
+ inFlight.set(key, pending);
1165
+ const result = await pending;
1166
+ if (result === null) inFlight.delete(key);
1167
+ return result;
1168
+ }
1169
+ async function ensurePart(path, name, entry, options) {
1170
+ if (await verifyCached(path, entry)) return true;
1171
+ if (options.offline === true || grammarsDownloadDisabled()) return {};
1172
+ const download = await downloadPart(
1173
+ grammarUrl(entry.url, options.baseUrl),
1174
+ name,
1175
+ entry,
1176
+ options
1177
+ );
1178
+ if ("cause" in download) return { cause: download.cause };
1179
+ await writeCached(path, download.bytes).catch(() => null);
1180
+ return true;
1181
+ }
1182
+ function miss(language, subject, failure) {
1183
+ missing.set(language, { subject, ...failure });
1184
+ return null;
1185
+ }
1186
+ function lf(body) {
1187
+ return body.replace(/\r\n/g, "\n");
1188
+ }
1189
+ function noteUncompilableQuery(language, cause) {
1190
+ uncompilable.set(language, cause);
1191
+ }
1192
+ function noteRejectedGrammar(language, cause) {
1193
+ rejected.set(language, cause);
1194
+ }
1195
+ function grammarHints() {
1196
+ const manifest = grammarManifest();
1197
+ const packs = manifest.packs;
1198
+ const lines = /* @__PURE__ */ new Map();
1199
+ for (const [language, { subject, cause }] of missing)
1200
+ lines.set(
1201
+ language,
1202
+ `${subject} not cached${cause ? ` (${cause})` : ""}; run online once, or set STRAUSS_KB_GRAMMARS_DIR`
1203
+ );
1204
+ for (const [language, cause] of rejected)
1205
+ lines.set(
1206
+ language,
1207
+ `${packs[language]?.package ?? `tree-sitter-${language}`} rejected by web-tree-sitter ${manifest.webTreeSitter}${cause ? `: ${cause}` : ""}; re-pin with pnpm grammars pin ${language}`
1208
+ );
1209
+ for (const [language, cause] of uncompilable)
1210
+ lines.set(
1211
+ language,
1212
+ `tags query for ${language} does not compile against ${packs[language]?.package ?? `tree-sitter-${language}`}: ${cause}; re-pin with pnpm grammars pin ${language}`
1213
+ );
1214
+ return [...lines].sort(([a], [b]) => a.localeCompare(b)).map(([, line]) => line);
1215
+ }
1216
+
1217
+ // src/tree-sitter-resolver/languages.ts
1218
+ import { extname } from "path";
1219
+ var table;
1220
+ function extensionTable() {
1221
+ const manifest = grammarManifest();
1222
+ if (table?.of !== manifest)
1223
+ table = {
1224
+ of: manifest,
1225
+ extensions: Object.fromEntries(
1226
+ Object.entries(manifest.packs).flatMap(
1227
+ ([language, pack2]) => pack2.extensions.map((extension) => [extension, language])
1228
+ )
1229
+ )
1230
+ };
1231
+ return table.extensions;
1232
+ }
1233
+ function hasQuery(language) {
1234
+ return (grammarManifest().packs[language]?.tags.length ?? 0) > 0;
1235
+ }
1236
+ function languageForFile(file) {
1237
+ const language = extensionTable()[extname(file).toLowerCase()];
1238
+ return language && hasQuery(language) ? language : void 0;
1239
+ }
1240
+ function treeSitterLanguages() {
1241
+ return [...new Set(Object.values(extensionTable()))].filter(hasQuery).sort();
1242
+ }
1243
+
1244
+ // src/tree-sitter-resolver/resolver.ts
1245
+ import { createHash as createHash2 } from "crypto";
1246
+ import { Language, Parser, Query } from "web-tree-sitter";
1247
+
1248
+ // src/tree-sitter-resolver/definitions.ts
1249
+ var SCOPE_ONLY = "reference.implementation";
1250
+ function index(tree, query) {
1251
+ const byName = /* @__PURE__ */ new Map();
1252
+ for (const match of query.matches(tree.rootNode)) {
1253
+ const nameNode = match.captures.find((capture) => capture.name === "name");
1254
+ const defNode = match.captures.find(
1255
+ (capture) => capture.name.startsWith("definition.") || capture.name === SCOPE_ONLY
1256
+ );
1257
+ if (!nameNode || !defNode) continue;
1258
+ const candidate = {
1259
+ node: defNode.node,
1260
+ name: nameNode.node.text,
1261
+ target: defNode.name !== SCOPE_ONLY
1262
+ };
1263
+ const existing = byName.get(nameNode.node.id);
1264
+ if (existing && width(existing.node) <= width(candidate.node)) continue;
1265
+ byName.set(nameNode.node.id, candidate);
1266
+ }
1267
+ const definitions = [...byName.values()];
1268
+ return {
1269
+ tree,
1270
+ byNodeId: new Map(
1271
+ definitions.map((definition) => [definition.node.id, definition])
1272
+ ),
1273
+ definitions
1274
+ };
1275
+ }
1276
+ function select(parsed, wanted) {
1277
+ const matches3 = parsed.definitions.filter(
1278
+ (definition) => definition.target && endsWith(chainOf(definition, parsed.byNodeId), wanted)
1279
+ );
1280
+ if (matches3.length < 2) return matches3;
1281
+ const bodied = matches3.filter(
1282
+ (definition) => definition.node.childForFieldName("body") !== null
1283
+ );
1284
+ return bodied.length === 1 ? bodied : matches3;
1285
+ }
1286
+ function chainOf(definition, byNodeId) {
1287
+ const chain = [definition.name];
1288
+ const receiver = definition.node.childForFieldName("receiver");
1289
+ const type = receiver && typeNameIn(receiver);
1290
+ if (type) chain.unshift(type);
1291
+ for (let node = definition.node.parent; node; node = node.parent) {
1292
+ const enclosing = byNodeId.get(node.id);
1293
+ if (enclosing && enclosing.node !== definition.node)
1294
+ chain.unshift(enclosing.name);
1295
+ }
1296
+ return chain;
1297
+ }
1298
+ function typeNameIn(receiver) {
1299
+ const stack = [receiver];
1300
+ while (stack.length) {
1301
+ const node = stack.pop();
1302
+ if (node.type === "type_identifier") return node.text;
1303
+ for (let at = 0; at < node.childCount; at++) {
1304
+ const child = node.child(at);
1305
+ if (child) stack.push(child);
1306
+ }
1307
+ }
1308
+ return void 0;
1309
+ }
1310
+ function endsWith(chain, wanted) {
1311
+ if (wanted.length > chain.length) return false;
1312
+ const offset = chain.length - wanted.length;
1313
+ return wanted.every((segment, at) => chain[offset + at] === segment);
1314
+ }
1315
+ function width(node) {
1316
+ return node.endIndex - node.startIndex;
1317
+ }
1318
+ function spanOf(definition, source) {
1319
+ let start = definition.node;
1320
+ let end = definition.node;
1321
+ for (let sibling = start.previousSibling; sibling?.type === "decorator"; sibling = sibling.previousSibling) {
1322
+ start = sibling;
1323
+ }
1324
+ const parent = end.parent;
1325
+ if (parent?.type === "export_statement" && parent.childForFieldName("declaration")?.id === end.id) {
1326
+ start = parent;
1327
+ end = parent;
1328
+ }
1329
+ const lines = source.split("\n");
1330
+ const startLine = start.startPosition.row;
1331
+ const endLine = end.endPosition.column === 0 && end.endPosition.row > startLine ? end.endPosition.row - 1 : end.endPosition.row;
1332
+ return {
1333
+ text: lines.slice(startLine, endLine + 1).join("\n"),
1334
+ startLine: startLine + 1,
1335
+ endLine: endLine + 1
1336
+ };
1337
+ }
1338
+
1339
+ // src/tree-sitter-resolver/resolver.ts
1340
+ var TREE_CACHE_LIMIT = 32;
1341
+ var TreeSitterResolver = class {
1342
+ name = "tree-sitter";
1343
+ grammars;
1344
+ loaded = /* @__PURE__ */ new Map();
1345
+ trees = /* @__PURE__ */ new Map();
1346
+ parser;
1347
+ initialized = false;
1348
+ /** Cache effectiveness, for tests and for the latency numbers. */
1349
+ stats = { parses: 0, cacheHits: 0 };
1350
+ constructor(options = {}) {
1351
+ this.grammars = options;
1352
+ }
1353
+ /**
1354
+ * Loads the grammars these files need, once per language per process,
1355
+ * downloading each one on first use.
1356
+ *
1357
+ * A grammar that will not load is remembered as unavailable rather than
1358
+ * retried per anchor, and never throws: an unobtainable WASM is a finding.
1359
+ */
1360
+ async prepare(files) {
1361
+ const wanted = /* @__PURE__ */ new Set();
1362
+ for (const file of files) {
1363
+ const language = languageForFile(file);
1364
+ if (language && !this.loaded.has(language)) wanted.add(language);
1365
+ }
1366
+ if (!wanted.size) return;
1367
+ if (!this.initialized) {
1368
+ try {
1369
+ await Parser.init();
1370
+ this.parser = new Parser();
1371
+ this.initialized = true;
1372
+ } catch {
1373
+ for (const language of wanted) this.loaded.set(language, null);
1374
+ return;
1375
+ }
1376
+ }
1377
+ const languages = [...wanted];
1378
+ const loaded = await mapLimit(
1379
+ languages,
1380
+ Math.min(DEFAULT_IO_CONCURRENCY, languages.length),
1381
+ (language) => this.load(language)
1382
+ );
1383
+ languages.forEach(
1384
+ (language, at) => this.loaded.set(language, loaded[at] ?? null)
1385
+ );
1386
+ }
1387
+ /**
1388
+ * An unobtainable grammar, one this runtime refuses, and a query that will
1389
+ * not compile are three faults with three repairs; all are reported through
1390
+ * the grammars module so every hint has one home.
1391
+ */
1392
+ async load(language) {
1393
+ let pack2;
1394
+ try {
1395
+ pack2 = await ensureGrammar(language, this.grammars);
1396
+ } catch {
1397
+ return null;
1398
+ }
1399
+ if (!pack2?.query) return null;
1400
+ let grammar;
1401
+ try {
1402
+ grammar = await Language.load(pack2.wasm);
1403
+ } catch (error) {
1404
+ noteRejectedGrammar(language, why(error));
1405
+ return null;
1406
+ }
1407
+ try {
1408
+ return { language: grammar, query: new Query(grammar, pack2.query) };
1409
+ } catch (error) {
1410
+ noteUncompilableQuery(language, why(error));
1411
+ return null;
1412
+ }
1413
+ }
1414
+ /**
1415
+ * Abstains on an extension with no grammar so the regex resolver gets a
1416
+ * turn; reports `resolver-unavailable` when the grammar exists in principle
1417
+ * but could not be loaded, because falling back there would silently trade a
1418
+ * precise span for a guessed one.
1419
+ */
1420
+ attempt(source, symbol, file) {
1421
+ const language = file ? languageForFile(file) : void 0;
1422
+ if (!language) return { kind: "abstain" };
1423
+ if (!this.loaded.has(language)) return { kind: "abstain" };
1424
+ const loaded = this.loaded.get(language);
1425
+ if (!loaded) return { kind: "unresolved", reason: "resolver-unavailable" };
1426
+ const parsed = this.parse(language, loaded, source);
1427
+ if (!parsed) return { kind: "unresolved", reason: "resolver-unavailable" };
1428
+ const wanted = symbol.split(".").filter(Boolean);
1429
+ if (!wanted.length)
1430
+ return { kind: "unresolved", reason: "symbol-not-found" };
1431
+ const matches3 = select(parsed, wanted);
1432
+ if (!matches3.length)
1433
+ return { kind: "unresolved", reason: "symbol-not-found" };
1434
+ if (matches3.length > 1)
1435
+ return { kind: "unresolved", reason: "symbol-ambiguous" };
1436
+ return { kind: "resolved", span: spanOf(matches3[0], source) };
1437
+ }
1438
+ resolve(source, symbol, file) {
1439
+ const attempt = this.attempt(source, symbol, file);
1440
+ return attempt.kind === "resolved" ? attempt.span : null;
1441
+ }
1442
+ /** Parsed trees are keyed by content hash, so an unchanged file parses once. */
1443
+ parse(language, loaded, source) {
1444
+ const key = `${language}:${createHash2("sha256").update(source).digest("hex")}`;
1445
+ const cached2 = this.trees.get(key);
1446
+ if (cached2) {
1447
+ this.stats.cacheHits += 1;
1448
+ return cached2;
1449
+ }
1450
+ const parser = this.parser;
1451
+ if (!parser) return null;
1452
+ let parsed;
1453
+ try {
1454
+ parser.setLanguage(loaded.language);
1455
+ const tree = parser.parse(source);
1456
+ if (!tree) return null;
1457
+ parsed = index(tree, loaded.query);
1458
+ } catch {
1459
+ return null;
1460
+ }
1461
+ this.stats.parses += 1;
1462
+ if (this.trees.size >= TREE_CACHE_LIMIT) {
1463
+ const oldest = this.trees.keys().next();
1464
+ if (!oldest.done) {
1465
+ this.trees.get(oldest.value)?.tree.delete();
1466
+ this.trees.delete(oldest.value);
1467
+ }
1468
+ }
1469
+ this.trees.set(key, parsed);
1470
+ return parsed;
1471
+ }
1472
+ /** Drops cached trees. Grammars stay loaded — they are immutable. */
1473
+ reset() {
1474
+ for (const parsed of this.trees.values()) parsed.tree.delete();
1475
+ this.trees.clear();
1476
+ this.stats.parses = 0;
1477
+ this.stats.cacheHits = 0;
1478
+ }
1479
+ };
1480
+ function why(error) {
1481
+ const text = error instanceof Error ? error.message : String(error);
1482
+ return text || "no reason given";
1483
+ }
1484
+
966
1485
  // src/anchor-resolver/resolver.ts
967
- import { createHash } from "crypto";
1486
+ import { createHash as createHash3 } from "crypto";
968
1487
  var PARENT_SCOPE_LINES = 50;
969
1488
  var CLEAN_STATE = { blockComment: false, template: false };
970
1489
  function stripLine(line, state) {
971
1490
  let out = "";
972
- let index = 0;
1491
+ let index2 = 0;
973
1492
  let { blockComment, template } = state;
974
- while (index < line.length) {
975
- const char = line[index];
976
- const next = line[index + 1];
1493
+ while (index2 < line.length) {
1494
+ const char = line[index2];
1495
+ const next = line[index2 + 1];
977
1496
  if (blockComment) {
978
1497
  if (char === "*" && next === "/") {
979
1498
  blockComment = false;
980
- index += 2;
1499
+ index2 += 2;
981
1500
  continue;
982
1501
  }
983
- index += 1;
1502
+ index2 += 1;
984
1503
  continue;
985
1504
  }
986
1505
  if (template) {
987
1506
  if (char === "\\") {
988
- index += 2;
1507
+ index2 += 2;
989
1508
  continue;
990
1509
  }
991
1510
  if (char === "`") template = false;
992
- index += 1;
1511
+ index2 += 1;
993
1512
  continue;
994
1513
  }
995
1514
  if (char === "/" && next === "*") {
996
1515
  blockComment = true;
997
- index += 2;
1516
+ index2 += 2;
998
1517
  continue;
999
1518
  }
1000
1519
  if (char === "/" && next === "/") break;
1001
1520
  if (char === "`") {
1002
1521
  template = true;
1003
- index += 1;
1522
+ index2 += 1;
1004
1523
  continue;
1005
1524
  }
1006
1525
  if (char === "'" || char === '"') {
1007
1526
  const quote = char;
1008
- index += 1;
1009
- while (index < line.length) {
1010
- if (line[index] === "\\") {
1011
- index += 2;
1527
+ index2 += 1;
1528
+ while (index2 < line.length) {
1529
+ if (line[index2] === "\\") {
1530
+ index2 += 2;
1012
1531
  continue;
1013
1532
  }
1014
- if (line[index] === quote) {
1015
- index += 1;
1533
+ if (line[index2] === quote) {
1534
+ index2 += 1;
1016
1535
  break;
1017
1536
  }
1018
- index += 1;
1537
+ index2 += 1;
1019
1538
  }
1020
1539
  continue;
1021
1540
  }
1022
1541
  out += char;
1023
- index += 1;
1542
+ index2 += 1;
1024
1543
  }
1025
1544
  return { code: out, state: { blockComment, template } };
1026
1545
  }
@@ -1035,8 +1554,8 @@ function captureBraceBlock(lines, matchLine) {
1035
1554
  let depth = 0;
1036
1555
  let opened = false;
1037
1556
  let state = CLEAN_STATE;
1038
- for (let index = matchLine; index < lines.length; index++) {
1039
- const stripped = stripLine(lines[index] ?? "", state);
1557
+ for (let index2 = matchLine; index2 < lines.length; index2++) {
1558
+ const stripped = stripLine(lines[index2] ?? "", state);
1040
1559
  state = stripped.state;
1041
1560
  for (const char of stripped.code) {
1042
1561
  if (char === "{") {
@@ -1045,10 +1564,10 @@ function captureBraceBlock(lines, matchLine) {
1045
1564
  } else if (char === "}") {
1046
1565
  depth = Math.max(0, depth - 1);
1047
1566
  } else if (char === ";" && !opened) {
1048
- return span(lines, matchLine, index);
1567
+ return span(lines, matchLine, index2);
1049
1568
  }
1050
1569
  }
1051
- if (opened && depth === 0) return span(lines, matchLine, index);
1570
+ if (opened && depth === 0) return span(lines, matchLine, index2);
1052
1571
  }
1053
1572
  return null;
1054
1573
  }
@@ -1057,22 +1576,22 @@ function captureIndentedBlock(lines, matchLine) {
1057
1576
  const header = lines[matchLine] ?? "";
1058
1577
  const indent = header.length - header.trimStart().length;
1059
1578
  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();
1579
+ for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1580
+ const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
1062
1581
  if (code.endsWith(":")) {
1063
- headerEnd = index;
1582
+ headerEnd = index2;
1064
1583
  break;
1065
1584
  }
1066
- if (code.includes(":")) return span(lines, matchLine, index);
1585
+ if (code.includes(":")) return span(lines, matchLine, index2);
1067
1586
  }
1068
1587
  if (headerEnd === -1) return null;
1069
1588
  let end = headerEnd;
1070
- for (let index = headerEnd + 1; index < lines.length; index++) {
1071
- const line = lines[index] ?? "";
1589
+ for (let index2 = headerEnd + 1; index2 < lines.length; index2++) {
1590
+ const line = lines[index2] ?? "";
1072
1591
  if (line.trim() === "") continue;
1073
1592
  const lineIndent = line.length - line.trimStart().length;
1074
1593
  if (lineIndent <= indent) break;
1075
- end = index;
1594
+ end = index2;
1076
1595
  }
1077
1596
  return end === headerEnd ? null : span(lines, matchLine, end);
1078
1597
  }
@@ -1096,11 +1615,11 @@ var regexResolver = {
1096
1615
  const lines = source.split("\n");
1097
1616
  for (const tier of TIERS) {
1098
1617
  const pattern = tier(escaped);
1099
- let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1618
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1100
1619
  if (!candidates.length) continue;
1101
1620
  if (parentPattern && candidates.length > 1) {
1102
1621
  const distances = candidates.map(
1103
- (index) => distanceToParent(lines, index, parentPattern)
1622
+ (index2) => distanceToParent(lines, index2, parentPattern)
1104
1623
  );
1105
1624
  const nearest = Math.min(...distances);
1106
1625
  if (Number.isFinite(nearest)) {
@@ -1117,34 +1636,79 @@ var regexResolver = {
1117
1636
  function escapeRegExp(value) {
1118
1637
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1119
1638
  }
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;
1639
+ function distanceToParent(lines, index2, parent) {
1640
+ const floor = Math.max(0, index2 - PARENT_SCOPE_LINES);
1641
+ for (let at = index2; at >= floor; at--) {
1642
+ if (parent.test(lines[at] ?? "")) return index2 - at;
1124
1643
  }
1125
1644
  return Number.POSITIVE_INFINITY;
1126
1645
  }
1127
1646
  function hashAnchorText(text) {
1128
- return `sha256:${createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1647
+ return `sha256:${createHash3("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1129
1648
  }
1130
1649
  function resolveAnchor(source, anchor, resolver = regexResolver) {
1650
+ const outcome = resolveAnchorSpan(source, anchor, [resolver]);
1651
+ return outcome.ok ? outcome.span : null;
1652
+ }
1653
+ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1131
1654
  const normalized = source.replace(/\r\n/g, "\n");
1132
1655
  if (!anchor.symbol) {
1133
1656
  const lines = normalized.split("\n");
1134
1657
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1135
1658
  return {
1136
- text: normalized,
1137
- startLine: 1,
1138
- endLine: Math.max(1, lines.length)
1659
+ ok: true,
1660
+ span: {
1661
+ text: normalized,
1662
+ startLine: 1,
1663
+ endLine: Math.max(1, lines.length)
1664
+ }
1139
1665
  };
1140
1666
  }
1141
- return resolver.resolve(normalized, anchor.symbol);
1667
+ for (const resolver of resolvers) {
1668
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1669
+ if (attempt.kind === "abstain") continue;
1670
+ if (attempt.kind === "unresolved") {
1671
+ if (attempt.reason === "symbol-not-found") continue;
1672
+ return { ok: false, reason: attempt.reason };
1673
+ }
1674
+ return {
1675
+ ok: true,
1676
+ span: attempt.span,
1677
+ ...isResolverName(resolver.name) ? { resolver: resolver.name } : {}
1678
+ };
1679
+ }
1680
+ return { ok: false, reason: "symbol-not-found" };
1681
+ }
1682
+ function fromResolve(resolver, source, symbol, file) {
1683
+ const span2 = resolver.resolve(source, symbol, file);
1684
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1685
+ }
1686
+ function isResolverName(name) {
1687
+ return name === "tree-sitter" || name === "regex";
1688
+ }
1689
+ async function prepareResolvers(resolvers, files) {
1690
+ for (const resolver of resolvers) await resolver.prepare?.(files);
1691
+ }
1692
+ function defaultAnchorResolvers(grammars = {}) {
1693
+ return [new TreeSitterResolver(grammars), regexResolver];
1694
+ }
1695
+ function resolverChanged(source, anchor, produced) {
1696
+ const previous = anchor.resolver ?? "regex";
1697
+ if (!produced || !anchor.symbol || previous === produced) return false;
1698
+ if (previous !== "regex") return false;
1699
+ const before = regexResolver.resolve(
1700
+ source.replace(/\r\n/g, "\n"),
1701
+ anchor.symbol
1702
+ );
1703
+ return before !== null && hashAnchorText(before.text) === anchor.hash;
1142
1704
  }
1143
1705
 
1144
1706
  // src/anchor-resolver/drift.ts
1145
1707
  async function detectAnchorDrift(records, options = {}) {
1146
1708
  const repoRoot = options.repoRoot ?? process.cwd();
1147
- const resolver = options.resolver ?? regexResolver;
1709
+ const resolvers = options.resolvers ?? (options.resolver ? [options.resolver] : defaultAnchorResolvers({
1710
+ offline: options.remote?.offline === true
1711
+ }));
1148
1712
  const origin = new LazyOrigin(repoRoot);
1149
1713
  const planned = /* @__PURE__ */ new Map();
1150
1714
  let declaresRepo = false;
@@ -1182,12 +1746,16 @@ async function detectAnchorDrift(records, options = {}) {
1182
1746
  ),
1183
1747
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1184
1748
  ]);
1749
+ await prepareResolvers(resolvers, [
1750
+ ...files,
1751
+ ...wants.map((want) => want.file)
1752
+ ]);
1185
1753
  const drift = /* @__PURE__ */ new Map();
1186
1754
  for (const record of records) {
1187
1755
  const entries = [];
1188
1756
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
1189
1757
  entries.push(
1190
- foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1758
+ foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
1191
1759
  );
1192
1760
  }
1193
1761
  if (entries.length) drift.set(record.conceptId, entries);
@@ -1216,12 +1784,22 @@ function unresolved(anchor, reason, repo) {
1216
1784
  ...repo ? { repo } : {}
1217
1785
  };
1218
1786
  }
1219
- function hashIn(source, anchor, resolver) {
1220
- const resolved = resolveAnchor(source, anchor, resolver);
1221
- if (!resolved) return null;
1787
+ function hashIn(source, anchor, resolvers) {
1788
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
1789
+ if (!outcome.ok) return { ok: false, reason: outcome.reason };
1222
1790
  return {
1223
- hash: hashAnchorText(resolved.text),
1224
- lines: resolved.endLine - resolved.startLine + 1
1791
+ ok: true,
1792
+ current: {
1793
+ hash: hashAnchorText(outcome.span.text),
1794
+ lines: outcome.span.endLine - outcome.span.startLine + 1,
1795
+ ...outcome.resolver ? { resolver: outcome.resolver } : {}
1796
+ }
1797
+ };
1798
+ }
1799
+ function resolverExtras(source, anchor, current) {
1800
+ return {
1801
+ ...current.resolver ? { resolver: current.resolver } : {},
1802
+ ...current.hash !== anchor.hash && resolverChanged(source, anchor, current.resolver) ? { reason: "resolver-changed" } : {}
1225
1803
  };
1226
1804
  }
1227
1805
  function compared(anchor, current, extra = {}) {
@@ -1233,30 +1811,48 @@ function compared(anchor, current, extra = {}) {
1233
1811
  ...extra
1234
1812
  };
1235
1813
  }
1236
- function localEntry(anchor, read, resolver) {
1814
+ function localEntry(anchor, read, resolvers) {
1237
1815
  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");
1816
+ const found = hashIn(read.source, anchor, resolvers);
1817
+ if (!found.ok) return unresolved(anchor, found.reason);
1818
+ return compared(
1819
+ anchor,
1820
+ found.current,
1821
+ resolverExtras(read.source, anchor, found.current)
1822
+ );
1240
1823
  }
1241
- function remoteEntry(anchor, remote, resolver) {
1824
+ function remoteEntry(anchor, remote, resolvers) {
1242
1825
  const repo = anchor.repo;
1243
1826
  const key = normalizeRepoUrl(repo);
1244
1827
  const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1245
1828
  const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1246
1829
  if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1247
1830
  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 });
1831
+ const found = hashIn(primary.source, anchor, resolvers);
1832
+ if (!found.ok) return unresolved(anchor, found.reason, repo);
1833
+ const current = found.current;
1834
+ const extras = resolverExtras(primary.source, anchor, current);
1835
+ if (!anchor.ref) return compared(anchor, current, { repo, ...extras });
1251
1836
  if (current.hash !== anchor.hash) {
1252
- return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1837
+ return compared(anchor, current, {
1838
+ repo,
1839
+ ...extras,
1840
+ remoteState: "drifted-from-ref"
1841
+ });
1253
1842
  }
1254
- const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1255
- return head && head.hash !== anchor.hash ? {
1256
- ...compared(anchor, head, { repo }),
1843
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
1844
+ return head?.ok && head.current.hash !== anchor.hash ? {
1845
+ ...compared(anchor, head.current, {
1846
+ repo,
1847
+ ...head.current.resolver ? { resolver: head.current.resolver } : {}
1848
+ }),
1257
1849
  state: "drifted",
1258
1850
  remoteState: "drifted-on-default"
1259
- } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1851
+ } : compared(anchor, current, {
1852
+ repo,
1853
+ ...extras,
1854
+ remoteState: "matches-ref"
1855
+ });
1260
1856
  }
1261
1857
 
1262
1858
  // src/errors.ts
@@ -1273,6 +1869,8 @@ var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
1273
1869
  ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
1274
1870
  ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
1275
1871
  ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
1872
+ ErrorTypes2["KbStampBaselineUnreadable"] = "KbStampBaselineUnreadable";
1873
+ ErrorTypes2["KbStampDigestBaselineAmbiguous"] = "KbStampDigestBaselineAmbiguous";
1276
1874
  ErrorTypes2["KbUnknownLinkRel"] = "KbUnknownLinkRel";
1277
1875
  ErrorTypes2["KbWriteConflict"] = "KbWriteConflict";
1278
1876
  return ErrorTypes2;
@@ -1427,13 +2025,43 @@ var KbInvalidConceptIdError = class extends BaseError {
1427
2025
  });
1428
2026
  }
1429
2027
  };
2028
+ var KbStampBaselineError = class extends BaseError {
2029
+ constructor(since) {
2030
+ super({
2031
+ message: `kb: --since ${since} is neither a 64-character digest nor a readable stamp file`,
2032
+ errorType: "KbStampBaselineUnreadable" /* KbStampBaselineUnreadable */,
2033
+ code: 400,
2034
+ fault: "User" /* User */,
2035
+ retriable: false,
2036
+ reportToUser: true,
2037
+ details: { since }
2038
+ });
2039
+ this.since = since;
2040
+ }
2041
+ since;
2042
+ };
2043
+ var KbStampDigestBaselineError = class extends BaseError {
2044
+ constructor(since) {
2045
+ super({
2046
+ message: `kb: --since ${since} is a digest, which needs --bundle (one base) \u2014 a file baseline works for many`,
2047
+ errorType: "KbStampDigestBaselineAmbiguous" /* KbStampDigestBaselineAmbiguous */,
2048
+ code: 400,
2049
+ fault: "User" /* User */,
2050
+ retriable: false,
2051
+ reportToUser: true,
2052
+ details: { since }
2053
+ });
2054
+ this.since = since;
2055
+ }
2056
+ since;
2057
+ };
1430
2058
 
1431
2059
  // src/kb-pins/budgets.ts
1432
2060
  function asBudgets(value) {
1433
2061
  if (value === null || typeof value !== "object") return {};
1434
- const table = value;
2062
+ const table2 = value;
1435
2063
  const pick = (key, min) => {
1436
- const raw = table[key];
2064
+ const raw = table2[key];
1437
2065
  return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
1438
2066
  };
1439
2067
  const budgetTokens = pick("budgetTokens", 1);
@@ -1444,9 +2072,9 @@ function asBudgets(value) {
1444
2072
  };
1445
2073
  }
1446
2074
  function contextProfileBudgets(manifest, profile) {
1447
- const table = manifest.context;
1448
- if (table === null || typeof table !== "object") return {};
1449
- const entries = table;
2075
+ const table2 = manifest.context;
2076
+ if (table2 === null || typeof table2 !== "object") return {};
2077
+ const entries = table2;
1450
2078
  return {
1451
2079
  ...asBudgets(entries["default"]),
1452
2080
  ...profile ? asBudgets(entries[profile]) : {}
@@ -1477,15 +2105,15 @@ var KbBaseFrozenError = class extends Error {
1477
2105
  };
1478
2106
 
1479
2107
  // src/kb-pins/model.ts
1480
- import { join as join2 } from "path";
1481
- import { z as z4 } from "zod";
1482
- var PINS_FILE = join2(".strauss", "kb-pins.json");
1483
- var PINS_LOCAL_FILE = join2(".strauss", "kb-pins.local.json");
2108
+ import { join as join4 } from "path";
2109
+ import { z as z5 } from "zod";
2110
+ var PINS_FILE = join4(".strauss", "kb-pins.json");
2111
+ var PINS_LOCAL_FILE = join4(".strauss", "kb-pins.local.json");
1484
2112
  var PIN_LAYERS = ["project", "local", "user"];
1485
- var pinSchema = z4.object({
2113
+ var pinSchema = z5.object({
1486
2114
  /** Relative to the manifest's root, so the file is committable. */
1487
- path: z4.string().min(1),
1488
- pinnedAt: z4.string().min(1).optional(),
2115
+ path: z5.string().min(1),
2116
+ pinnedAt: z5.string().min(1).optional(),
1489
2117
  /**
1490
2118
  * How `context` renders this base. `full` preloads the whole base into
1491
2119
  * the block regardless of the full-under threshold — for a base whose
@@ -1495,7 +2123,7 @@ var pinSchema = z4.object({
1495
2123
  * Absent: the profile's full-under threshold decides. Invalid values
1496
2124
  * degrade to absent rather than failing the manifest.
1497
2125
  */
1498
- mode: z4.enum(["full", "index"]).optional().catch(void 0),
2126
+ mode: z5.enum(["full", "index"]).optional().catch(void 0),
1499
2127
  /**
1500
2128
  * Context profiles this pin surfaces in (e.g. only at session-start,
1501
2129
  * not per turn). Absent: every profile. A run without a profile sees
@@ -1503,17 +2131,17 @@ var pinSchema = z4.object({
1503
2131
  * that skill at point of use than pinned at all — pins are what every
1504
2132
  * session should see.
1505
2133
  */
1506
- profiles: z4.array(z4.string()).optional().catch(void 0),
2134
+ profiles: z5.array(z5.string()).optional().catch(void 0),
1507
2135
  /**
1508
2136
  * The base is concluded — a finished piece of research, a frozen ADR
1509
2137
  * set. Write commands against it refuse while this workspace holds the
1510
2138
  * pin, and `context` labels it read-only. Workspace policy, not base
1511
2139
  * state: the base itself stays copyable and writable elsewhere.
1512
2140
  */
1513
- frozen: z4.boolean().optional().catch(void 0)
2141
+ frozen: z5.boolean().optional().catch(void 0)
1514
2142
  }).passthrough();
1515
- var pinsManifestSchema = z4.object({
1516
- pins: z4.array(pinSchema).default([]),
2143
+ var pinsManifestSchema = z5.object({
2144
+ pins: z5.array(pinSchema).default([]),
1517
2145
  /**
1518
2146
  * Per-repo budgets for the `context` command, keyed by profile —
1519
2147
  * `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
@@ -1522,21 +2150,21 @@ var pinsManifestSchema = z4.object({
1522
2150
  * the index at every session start. `contextProfileBudgets` does the
1523
2151
  * tolerant read.
1524
2152
  */
1525
- context: z4.unknown().optional()
2153
+ context: z5.unknown().optional()
1526
2154
  }).passthrough();
1527
2155
 
1528
2156
  // src/kb-pins/layers.ts
1529
- import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
1530
- import { homedir as homedir2 } from "os";
1531
- import { dirname, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
2157
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
2158
+ import { homedir as homedir3 } from "os";
2159
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
1532
2160
  function userRoot() {
1533
- return process.env.STRAUSS_KB_USER_ROOT || homedir2();
2161
+ return process.env.STRAUSS_KB_USER_ROOT || homedir3();
1534
2162
  }
1535
2163
  function layerRoot(workspaceDir, layer) {
1536
2164
  return layer === "user" ? userRoot() : resolve2(workspaceDir);
1537
2165
  }
1538
2166
  function layerFile(workspaceDir, layer) {
1539
- return join3(
2167
+ return join5(
1540
2168
  layerRoot(workspaceDir, layer),
1541
2169
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1542
2170
  );
@@ -1545,7 +2173,7 @@ async function readPinsLayer(workspaceDir, layer) {
1545
2173
  const file = layerFile(workspaceDir, layer);
1546
2174
  let raw;
1547
2175
  try {
1548
- raw = await readFile2(file, "utf8");
2176
+ raw = await readFile4(file, "utf8");
1549
2177
  } catch {
1550
2178
  return { pins: [] };
1551
2179
  }
@@ -1569,8 +2197,8 @@ async function readPinsLayer(workspaceDir, layer) {
1569
2197
  }
1570
2198
  async function writePinsLayer(workspaceDir, layer, manifest) {
1571
2199
  const file = layerFile(workspaceDir, layer);
1572
- await mkdir2(dirname(file), { recursive: true });
1573
- await writeFile(file, `${JSON.stringify(manifest, null, 2)}
2200
+ await mkdir3(dirname3(file), { recursive: true });
2201
+ await writeFile2(file, `${JSON.stringify(manifest, null, 2)}
1574
2202
  `, "utf8");
1575
2203
  }
1576
2204
  function resolvePinPath(rootDir, path) {
@@ -1795,8 +2423,8 @@ function resolveHeads(from, byId) {
1795
2423
  while (queue.length) {
1796
2424
  const current = queue.shift();
1797
2425
  const next = successors(current, byId);
1798
- for (const missing of next.missing) {
1799
- warnings.push({ kind: "broken-chain", missing });
2426
+ for (const missing2 of next.missing) {
2427
+ warnings.push({ kind: "broken-chain", missing: missing2 });
1800
2428
  }
1801
2429
  if (!next.records.length) {
1802
2430
  if (current.conceptId !== from.conceptId)
@@ -1827,13 +2455,13 @@ function successors(record, byId) {
1827
2455
  }
1828
2456
  }
1829
2457
  const records = [];
1830
- const missing = [];
2458
+ const missing2 = [];
1831
2459
  for (const id of ids) {
1832
2460
  const found = byId.get(id);
1833
2461
  if (found) records.push(found);
1834
- else missing.push(id);
2462
+ else missing2.push(id);
1835
2463
  }
1836
- return { records, missing };
2464
+ return { records, missing: missing2 };
1837
2465
  }
1838
2466
 
1839
2467
  // src/catalog.ts
@@ -1904,7 +2532,7 @@ function indexIsStale(stored, expected) {
1904
2532
  }
1905
2533
 
1906
2534
  // src/kb-context.ts
1907
- import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
2535
+ import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
1908
2536
  var HEADING2 = "## Knowledge bases (pinned)";
1909
2537
  var DEFAULT_CONTEXT_BUDGET = 4e3;
1910
2538
  var CONTEXT_PROFILES = {
@@ -2108,13 +2736,13 @@ function toHookJson(block, event) {
2108
2736
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2109
2737
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2110
2738
  async function syncInstructions(file, block) {
2111
- const existing = await readFile3(file, "utf8").catch(() => null);
2739
+ const existing = await readFile5(file, "utf8").catch(() => null);
2112
2740
  const region = block ? `${CONTEXT_BEGIN}
2113
2741
  ${block.trim()}
2114
2742
  ${CONTEXT_END}` : null;
2115
2743
  if (existing === null) {
2116
2744
  if (!region) return { file, action: "unchanged" };
2117
- await writeFile2(file, `${region}
2745
+ await writeFile3(file, `${region}
2118
2746
  `, "utf8");
2119
2747
  return { file, action: "created" };
2120
2748
  }
@@ -2125,11 +2753,11 @@ ${CONTEXT_END}` : null;
2125
2753
  const after = existing.slice(end + CONTEXT_END.length);
2126
2754
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2127
2755
  if (next === existing) return { file, action: "unchanged" };
2128
- await writeFile2(file, next, "utf8");
2756
+ await writeFile3(file, next, "utf8");
2129
2757
  return { file, action: region ? "replaced" : "removed" };
2130
2758
  }
2131
2759
  if (!region) return { file, action: "unchanged" };
2132
- await writeFile2(
2760
+ await writeFile3(
2133
2761
  file,
2134
2762
  `${existing.replace(/\n*$/, "\n\n")}${region}
2135
2763
  `,
@@ -2333,6 +2961,18 @@ var CHECK_HEADLINES = {
2333
2961
  unchecked: "an anchor in another repository nothing could reach"
2334
2962
  };
2335
2963
  var DAY_MS = 864e5;
2964
+ function anchorResolverCounts(bundle) {
2965
+ let treeSitter = 0;
2966
+ let regex = 0;
2967
+ for (const record of bundle) {
2968
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
2969
+ if (!anchor.hash || !anchor.symbol) continue;
2970
+ if (anchor.resolver === "tree-sitter") treeSitter += 1;
2971
+ else regex += 1;
2972
+ }
2973
+ }
2974
+ return { total: treeSitter + regex, treeSitter, regex };
2975
+ }
2336
2976
  function doctor(bundle, options = {}) {
2337
2977
  const thresholds = {
2338
2978
  expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
@@ -2368,6 +3008,7 @@ function doctor(bundle, options = {}) {
2368
3008
  counts,
2369
3009
  groups,
2370
3010
  findingCount,
3011
+ anchorResolvers: anchorResolverCounts(bundle),
2371
3012
  healthy: findingCount === 0
2372
3013
  };
2373
3014
  }
@@ -2614,9 +3255,9 @@ function ageInDays(record, now) {
2614
3255
  }
2615
3256
 
2616
3257
  // src/kb-log.ts
2617
- import { z as z5 } from "zod";
3258
+ import { z as z6 } from "zod";
2618
3259
  var LOG_FILE = "log.jsonl";
2619
- var kbLogEntrySchema = z5.object({
3260
+ var kbLogEntrySchema = z6.object({
2620
3261
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
2621
3262
  // below), and a value that isn't actually chronological — a Unix
2622
3263
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -2625,12 +3266,12 @@ var kbLogEntrySchema = z5.object({
2625
3266
  // and rejects everything else, including a non-`Z` offset — so a
2626
3267
  // malformed `at` is reported the same way a malformed line already is,
2627
3268
  // rather than silently sorting into the wrong place.
2628
- at: z5.iso.datetime(),
2629
- by: z5.string().min(1),
2630
- operation: z5.string().min(1),
2631
- conceptId: z5.string().min(1),
3269
+ at: z6.iso.datetime(),
3270
+ by: z6.string().min(1),
3271
+ operation: z6.string().min(1),
3272
+ conceptId: z6.string().min(1),
2632
3273
  /** Second concept id, where the operation relates two — supersession. */
2633
- target: z5.string().min(1).optional()
3274
+ target: z6.string().min(1).optional()
2634
3275
  }).strict();
2635
3276
  function renderLogEntry(entry) {
2636
3277
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -2640,18 +3281,18 @@ function parseLog(raw) {
2640
3281
  const entries = [];
2641
3282
  const malformed = [];
2642
3283
  const seen = /* @__PURE__ */ new Set();
2643
- raw.split("\n").forEach((text, index) => {
3284
+ raw.split("\n").forEach((text, index2) => {
2644
3285
  if (!text.trim()) return;
2645
3286
  let value;
2646
3287
  try {
2647
3288
  value = JSON.parse(text);
2648
3289
  } catch {
2649
- malformed.push({ line: index + 1, text });
3290
+ malformed.push({ line: index2 + 1, text });
2650
3291
  return;
2651
3292
  }
2652
3293
  const parsed = kbLogEntrySchema.safeParse(value);
2653
3294
  if (!parsed.success) {
2654
- malformed.push({ line: index + 1, text });
3295
+ malformed.push({ line: index2 + 1, text });
2655
3296
  return;
2656
3297
  }
2657
3298
  const key = JSON.stringify(parsed.data);
@@ -2666,14 +3307,14 @@ function parseLog(raw) {
2666
3307
  }
2667
3308
 
2668
3309
  // src/json-schema.ts
2669
- import { z as z6 } from "zod";
3310
+ import { z as z7 } from "zod";
2670
3311
  function kbJsonSchemas() {
2671
3312
  return {
2672
- recordFrontmatter: z6.toJSONSchema(kbRecordFrontmatterSchema, {
3313
+ recordFrontmatter: z7.toJSONSchema(kbRecordFrontmatterSchema, {
2673
3314
  io: "input"
2674
3315
  }),
2675
- composeInput: z6.toJSONSchema(composeInputSchema, { io: "input" }),
2676
- logEntry: z6.toJSONSchema(kbLogEntrySchema, { io: "input" })
3316
+ composeInput: z7.toJSONSchema(composeInputSchema, { io: "input" }),
3317
+ logEntry: z7.toJSONSchema(kbLogEntrySchema, { io: "input" })
2677
3318
  };
2678
3319
  }
2679
3320
 
@@ -2726,13 +3367,13 @@ function byGeneratedAt(left, right) {
2726
3367
  }
2727
3368
 
2728
3369
  // src/commands/anchor-resolve.ts
2729
- import { z as z8 } from "zod";
3370
+ import { z as z9 } from "zod";
2730
3371
 
2731
3372
  // src/commands/model.ts
2732
- import { z as z7 } from "zod";
2733
- var bundlePath = z7.string().min(1).describe("Absolute path to the knowledge base directory.");
2734
- var conceptId = z7.string().min(1).describe("e.g. decision.cursor-v2");
2735
- var REPO_ROOT = z7.string().min(1).optional().describe(
3373
+ import { z as z8 } from "zod";
3374
+ var bundlePath = z8.string().min(1).describe("Absolute path to the knowledge base directory.");
3375
+ var conceptId = z8.string().min(1).describe("e.g. decision.cursor-v2");
3376
+ var REPO_ROOT = z8.string().min(1).optional().describe(
2736
3377
  "Where the anchored source lives, for the drift check. Defaults to the working directory."
2737
3378
  );
2738
3379
  function define(command) {
@@ -2755,22 +3396,30 @@ function argvFlag(argv, name) {
2755
3396
  }
2756
3397
 
2757
3398
  // src/commands/anchor-resolve.ts
3399
+ function resolverSummary(results) {
3400
+ const names = [
3401
+ ...new Set(
3402
+ results.flatMap((entry) => entry.resolver ? [entry.resolver] : [])
3403
+ )
3404
+ ].sort();
3405
+ return names.length ? `${names.join(" + ")} resolver` : "whole-file";
3406
+ }
2758
3407
  var anchorResolveCommand = define({
2759
3408
  name: "anchor-resolve",
2760
3409
  tool: "kb_anchor_resolve",
2761
3410
  usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
2762
3411
  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.",
2763
- input: z8.object({
3412
+ input: z9.object({
2764
3413
  bundlePath,
2765
3414
  conceptId,
2766
- repoRoot: z8.string().min(1).optional(),
2767
- offline: z8.boolean().optional().describe(
3415
+ repoRoot: z9.string().min(1).optional(),
3416
+ offline: z9.boolean().optional().describe(
2768
3417
  "Resolve foreign anchors from the local repo cache only, never fetching."
2769
3418
  ),
2770
- rebaseline: z8.boolean().optional().describe(
3419
+ rebaseline: z9.boolean().optional().describe(
2771
3420
  "Accept the current code as the new baseline for anchors that drifted."
2772
3421
  ),
2773
- restamp: z8.boolean().optional().describe(
3422
+ restamp: z9.boolean().optional().describe(
2774
3423
  "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
2775
3424
  )
2776
3425
  }),
@@ -2799,6 +3448,11 @@ var anchorResolveCommand = define({
2799
3448
  const updated = [];
2800
3449
  let dirty = false;
2801
3450
  const sources = await readSources(anchors, root, offline === true);
3451
+ const resolvers = defaultAnchorResolvers({ offline: offline === true });
3452
+ await prepareResolvers(
3453
+ resolvers,
3454
+ anchors.map((anchor) => anchor.file)
3455
+ );
2802
3456
  for (const anchor of anchors) {
2803
3457
  const base2 = {
2804
3458
  file: anchor.file,
@@ -2815,27 +3469,35 @@ var anchorResolveCommand = define({
2815
3469
  updated.push(anchor);
2816
3470
  continue;
2817
3471
  }
2818
- const resolved = resolveAnchor(source.source, anchor);
2819
- if (!resolved) {
3472
+ const outcome = resolveAnchorSpan(source.source, anchor, resolvers);
3473
+ if (!outcome.ok) {
2820
3474
  results.push({
2821
3475
  ...base2,
2822
3476
  state: "unresolved",
2823
- reason: "symbol-not-found"
3477
+ reason: outcome.reason
2824
3478
  });
2825
3479
  updated.push(anchor);
2826
3480
  continue;
2827
3481
  }
3482
+ const resolved = outcome.span;
3483
+ const producedBy = outcome.resolver;
2828
3484
  const currentHash = hashAnchorText(resolved.text);
2829
3485
  const currentLines = resolved.endLine - resolved.startLine + 1;
2830
3486
  const stamped = {
2831
3487
  ...anchor,
2832
3488
  hash: currentHash,
2833
3489
  lines: currentLines,
2834
- resolved_at: now()
3490
+ resolved_at: now(),
3491
+ ...producedBy ? { resolver: producedBy } : {}
2835
3492
  };
2836
3493
  const pinned = anchor.ref !== void 0 && source.repo !== void 0;
2837
3494
  if (!anchor.hash) {
2838
- results.push({ ...base2, state: "stamped", currentHash });
3495
+ results.push({
3496
+ ...base2,
3497
+ state: "stamped",
3498
+ currentHash,
3499
+ ...producedBy ? { resolver: producedBy } : {}
3500
+ });
2839
3501
  updated.push(stamped);
2840
3502
  dirty = true;
2841
3503
  continue;
@@ -2846,6 +3508,10 @@ var anchorResolveCommand = define({
2846
3508
  state: "drifted",
2847
3509
  currentHash,
2848
3510
  diffSize: lineDelta(anchor, currentLines),
3511
+ ...producedBy ? { resolver: producedBy } : {},
3512
+ // A regex-stamped anchor re-read by tree-sitter drifts because the
3513
+ // resolver changed, not because the code did.
3514
+ ...resolverChanged(source.source, anchor, producedBy) ? { reason: "resolver-changed" } : {},
2849
3515
  ...pinned ? { remoteState: "drifted-from-ref" } : {},
2850
3516
  ...rebaseline ? { rebaselined: true } : {}
2851
3517
  });
@@ -2853,7 +3519,7 @@ var anchorResolveCommand = define({
2853
3519
  if (rebaseline) dirty = true;
2854
3520
  continue;
2855
3521
  }
2856
- const onDefault = pinned ? headHash(source, anchor) : void 0;
3522
+ const onDefault = pinned ? headHash(source, anchor, resolvers) : void 0;
2857
3523
  if (onDefault && onDefault.hash !== anchor.hash) {
2858
3524
  results.push({
2859
3525
  ...base2,
@@ -2869,6 +3535,7 @@ var anchorResolveCommand = define({
2869
3535
  ...base2,
2870
3536
  state: "match",
2871
3537
  currentHash,
3538
+ ...producedBy ? { resolver: producedBy } : {},
2872
3539
  ...pinned ? { remoteState: "matches-ref" } : {}
2873
3540
  });
2874
3541
  const refresh = restamp || anchor.resolved_at === void 0;
@@ -2886,19 +3553,21 @@ var anchorResolveCommand = define({
2886
3553
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
2887
3554
  }
2888
3555
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
3556
+ const hints = grammarHints();
3557
+ const hintNote = hints.length ? { hints } : {};
2889
3558
  const unreachable = results.filter(
2890
3559
  (entry) => isUncheckedReason(entry.reason)
2891
3560
  ).length;
2892
3561
  const checked = results.length - unreachable;
2893
- const matches2 = results.filter((entry) => entry.state === "match").length;
2894
- const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
2895
- const clean = checked > 0 && matches2 === checked && unreachable === 0;
3562
+ const matches3 = results.filter((entry) => entry.state === "match").length;
3563
+ const note = `${matches3}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
3564
+ const clean = checked > 0 && matches3 === checked && unreachable === 0;
2896
3565
  if (clean) {
2897
3566
  try {
2898
3567
  await store.verify(
2899
3568
  path,
2900
3569
  id,
2901
- `anchor-resolve: ${note} (regex resolver)`,
3570
+ `anchor-resolve: ${note} (${resolverSummary(results)})`,
2902
3571
  actor,
2903
3572
  now()
2904
3573
  );
@@ -2909,17 +3578,25 @@ var anchorResolveCommand = define({
2909
3578
  results,
2910
3579
  verified: false,
2911
3580
  verifyRefused: "self-verification",
2912
- ...frozenNote
3581
+ ...frozenNote,
3582
+ ...hintNote
2913
3583
  };
2914
3584
  }
2915
- return { conceptId: id, results, verified: true, ...frozenNote };
3585
+ return {
3586
+ conceptId: id,
3587
+ results,
3588
+ verified: true,
3589
+ ...frozenNote,
3590
+ ...hintNote
3591
+ };
2916
3592
  }
2917
3593
  return {
2918
3594
  conceptId: id,
2919
3595
  results,
2920
3596
  verified: false,
2921
3597
  ...unreachable ? { note } : {},
2922
- ...frozenNote
3598
+ ...frozenNote,
3599
+ ...hintNote
2923
3600
  };
2924
3601
  },
2925
3602
  // A stored hash that no longer resolves is a broken anchor, not an absence:
@@ -2935,13 +3612,13 @@ var anchorResolveCommand = define({
2935
3612
  function lineDelta(anchor, current) {
2936
3613
  return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
2937
3614
  }
2938
- function headHash(source, anchor) {
3615
+ function headHash(source, anchor, resolvers) {
2939
3616
  if (source.head === void 0) return void 0;
2940
- const resolved = resolveAnchor(source.head, anchor);
2941
- if (!resolved) return void 0;
3617
+ const outcome = resolveAnchorSpan(source.head, anchor, resolvers);
3618
+ if (!outcome.ok) return void 0;
2942
3619
  return {
2943
- hash: hashAnchorText(resolved.text),
2944
- lines: resolved.endLine - resolved.startLine + 1
3620
+ hash: hashAnchorText(outcome.span.text),
3621
+ lines: outcome.span.endLine - outcome.span.startLine + 1
2945
3622
  };
2946
3623
  }
2947
3624
  async function readSources(anchors, root, offline) {
@@ -2991,13 +3668,13 @@ async function readSources(anchors, root, offline) {
2991
3668
  }
2992
3669
 
2993
3670
  // src/commands/answer.ts
2994
- import { z as z9 } from "zod";
3671
+ import { z as z10 } from "zod";
2995
3672
  var answerCommand = define({
2996
3673
  name: "answer",
2997
3674
  tool: "kb_answer",
2998
3675
  usage: "answer <concept-id> <answer...>",
2999
3676
  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.",
3000
- input: z9.object({ bundlePath, conceptId, answer: z9.string().min(1) }),
3677
+ input: z10.object({ bundlePath, conceptId, answer: z10.string().min(1) }),
3001
3678
  fromArgv: (argv, path) => ({
3002
3679
  bundlePath: path,
3003
3680
  conceptId: argv[1],
@@ -3011,27 +3688,27 @@ var answerCommand = define({
3011
3688
  });
3012
3689
 
3013
3690
  // src/commands/backlinks.ts
3014
- import { z as z10 } from "zod";
3691
+ import { z as z11 } from "zod";
3015
3692
  var backlinksCommand = define({
3016
3693
  name: "backlinks",
3017
3694
  tool: "kb_backlinks",
3018
3695
  usage: "backlinks <concept-id>",
3019
3696
  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.",
3020
- input: z10.object({ bundlePath, conceptId }),
3697
+ input: z11.object({ bundlePath, conceptId }),
3021
3698
  fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
3022
3699
  run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
3023
3700
  });
3024
3701
 
3025
3702
  // src/commands/catalog.ts
3026
- import { z as z11 } from "zod";
3703
+ import { z as z12 } from "zod";
3027
3704
  var catalogCommand = define({
3028
3705
  name: "catalog",
3029
3706
  tool: "kb_catalog",
3030
3707
  usage: "catalog [type]",
3031
3708
  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.",
3032
- input: z11.object({
3709
+ input: z12.object({
3033
3710
  bundlePath,
3034
- type: z11.enum(KB_RECORD_TYPES).optional()
3711
+ type: z12.enum(KB_RECORD_TYPES).optional()
3035
3712
  }),
3036
3713
  fromArgv: (argv, path) => ({
3037
3714
  bundlePath: path,
@@ -3086,26 +3763,26 @@ function count(value, noun) {
3086
3763
  }
3087
3764
 
3088
3765
  // src/commands/context.ts
3089
- import { z as z12 } from "zod";
3766
+ import { z as z13 } from "zod";
3090
3767
  var contextCommand = define({
3091
3768
  name: "context",
3092
3769
  tool: "kb_context",
3093
3770
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
3094
3771
  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.",
3095
- input: z12.object({
3096
- budgetTokens: z12.number().int().positive().optional().describe(
3772
+ input: z13.object({
3773
+ budgetTokens: z13.number().int().positive().optional().describe(
3097
3774
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
3098
3775
  ),
3099
- fullUnderTokens: z12.number().int().positive().optional().describe(
3776
+ fullUnderTokens: z13.number().int().positive().optional().describe(
3100
3777
  "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."
3101
3778
  ),
3102
- profile: z12.string().optional().describe(
3779
+ profile: z13.string().optional().describe(
3103
3780
  "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."
3104
3781
  ),
3105
- format: z12.enum(["markdown", "json"]).optional().describe(
3782
+ format: z13.enum(["markdown", "json"]).optional().describe(
3106
3783
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
3107
3784
  ),
3108
- event: z12.string().optional().describe(
3785
+ event: z13.string().optional().describe(
3109
3786
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
3110
3787
  )
3111
3788
  }),
@@ -3141,14 +3818,14 @@ var contextCommand = define({
3141
3818
  });
3142
3819
 
3143
3820
  // src/commands/doctor.ts
3144
- import { z as z13 } from "zod";
3145
- var days = (what, fallback) => z13.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3821
+ import { z as z14 } from "zod";
3822
+ var days = (what, fallback) => z14.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3146
3823
  var doctorCommand = define({
3147
3824
  name: "doctor",
3148
3825
  tool: "kb_doctor",
3149
3826
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3150
3827
  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.",
3151
- input: z13.object({
3828
+ input: z14.object({
3152
3829
  bundlePath,
3153
3830
  repoRoot: REPO_ROOT,
3154
3831
  expiringDays: days(
@@ -3163,10 +3840,10 @@ var doctorCommand = define({
3163
3840
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3164
3841
  DEFAULT_AGING_DAYS
3165
3842
  ),
3166
- offline: z13.boolean().optional().describe(
3843
+ offline: z14.boolean().optional().describe(
3167
3844
  "Read foreign anchors from the local repo cache only, never fetching."
3168
3845
  ),
3169
- strict: z13.boolean().optional().describe(
3846
+ strict: z14.boolean().optional().describe(
3170
3847
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
3171
3848
  )
3172
3849
  }),
@@ -3209,7 +3886,13 @@ var doctorCommand = define({
3209
3886
  ...anchorDrift !== void 0 ? { anchorDrift } : {},
3210
3887
  now: new Date(checkedAt)
3211
3888
  });
3212
- return { bundlePath: path, checkedAt, ...report };
3889
+ const hints = grammarHints();
3890
+ return {
3891
+ bundlePath: path,
3892
+ checkedAt,
3893
+ ...report,
3894
+ ...hints.length ? { hints } : {}
3895
+ };
3213
3896
  },
3214
3897
  render: (result) => render2(result),
3215
3898
  // Only expiry, and only under --strict. The other seven checks report debt a
@@ -3227,12 +3910,15 @@ function render2(result) {
3227
3910
  `records: ${result.recordCount}`,
3228
3911
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
3229
3912
  `checked: ${result.checkedAt}`,
3913
+ ...result.anchorResolvers.total ? [
3914
+ `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
3915
+ ] : [],
3230
3916
  ""
3231
3917
  ];
3232
- const width = Math.max(...result.groups.map((group2) => group2.check.length));
3918
+ const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
3233
3919
  for (const group2 of result.groups) {
3234
3920
  lines.push(
3235
- ` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
3921
+ ` ${group2.check.padEnd(width2)} ${String(group2.count).padStart(3)} ${group2.headline}`
3236
3922
  );
3237
3923
  }
3238
3924
  for (const group2 of result.groups) {
@@ -3244,6 +3930,7 @@ function render2(result) {
3244
3930
  );
3245
3931
  }
3246
3932
  }
3933
+ for (const hint of result.hints ?? []) lines.push("", hint);
3247
3934
  lines.push(
3248
3935
  "",
3249
3936
  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.`
@@ -3252,19 +3939,19 @@ function render2(result) {
3252
3939
  }
3253
3940
 
3254
3941
  // src/commands/impact.ts
3255
- import { z as z14 } from "zod";
3942
+ import { z as z15 } from "zod";
3256
3943
  var impactCommand = define({
3257
3944
  name: "impact",
3258
3945
  tool: "kb_impact",
3259
3946
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
3260
3947
  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.",
3261
- input: z14.object({
3948
+ input: z15.object({
3262
3949
  bundlePath,
3263
3950
  conceptId,
3264
- depth: z14.number().int().positive().optional().describe(
3951
+ depth: z15.number().int().positive().optional().describe(
3265
3952
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
3266
3953
  ),
3267
- rels: z14.array(z14.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3954
+ rels: z15.array(z15.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3268
3955
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
3269
3956
  )
3270
3957
  }),
@@ -3285,13 +3972,13 @@ var impactCommand = define({
3285
3972
  });
3286
3973
 
3287
3974
  // src/commands/list.ts
3288
- import { z as z15 } from "zod";
3975
+ import { z as z16 } from "zod";
3289
3976
  var listCommand = define({
3290
3977
  name: "list",
3291
3978
  tool: "kb_list",
3292
3979
  usage: "list [type]",
3293
3980
  description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
3294
- input: z15.object({ bundlePath, type: z15.enum(KB_RECORD_TYPES).optional() }),
3981
+ input: z16.object({ bundlePath, type: z16.enum(KB_RECORD_TYPES).optional() }),
3295
3982
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
3296
3983
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
3297
3984
  conceptId: record.conceptId,
@@ -3303,17 +3990,17 @@ var listCommand = define({
3303
3990
  });
3304
3991
 
3305
3992
  // src/commands/load.ts
3306
- import { z as z16 } from "zod";
3993
+ import { z as z17 } from "zod";
3307
3994
  var loadCommand = define({
3308
3995
  name: "load",
3309
3996
  tool: "kb_load",
3310
3997
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
3311
3998
  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.",
3312
- input: z16.object({
3999
+ input: z17.object({
3313
4000
  bundlePath,
3314
- type: z16.enum(KB_RECORD_TYPES).optional(),
3315
- budgetTokens: z16.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3316
- all: z16.boolean().optional().describe(
4001
+ type: z17.enum(KB_RECORD_TYPES).optional(),
4002
+ budgetTokens: z17.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
4003
+ all: z17.boolean().optional().describe(
3317
4004
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
3318
4005
  ),
3319
4006
  repoRoot: REPO_ROOT
@@ -3355,25 +4042,25 @@ var loadCommand = define({
3355
4042
  });
3356
4043
 
3357
4044
  // src/commands/log.ts
3358
- import { z as z17 } from "zod";
4045
+ import { z as z18 } from "zod";
3359
4046
  var logCommand = define({
3360
4047
  name: "log",
3361
4048
  tool: "kb_log",
3362
4049
  usage: "log",
3363
4050
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
3364
- input: z17.object({ bundlePath }),
4051
+ input: z18.object({ bundlePath }),
3365
4052
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3366
4053
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
3367
4054
  });
3368
4055
 
3369
4056
  // src/commands/no-decision.ts
3370
- import { z as z18 } from "zod";
4057
+ import { z as z19 } from "zod";
3371
4058
  var noDecisionCommand = define({
3372
4059
  name: "no-decision",
3373
4060
  tool: "kb_no_decision",
3374
4061
  usage: "no-decision <reason...>",
3375
4062
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
3376
- input: z18.object({ bundlePath, reason: z18.string().min(1) }),
4063
+ input: z19.object({ bundlePath, reason: z19.string().min(1) }),
3377
4064
  fromArgv: (argv, path) => ({
3378
4065
  bundlePath: path,
3379
4066
  reason: argv.slice(1).join(" ").trim()
@@ -3390,20 +4077,20 @@ var noDecisionCommand = define({
3390
4077
  });
3391
4078
 
3392
4079
  // src/commands/pack.ts
3393
- import { z as z19 } from "zod";
4080
+ import { z as z20 } from "zod";
3394
4081
  var packCommand = define({
3395
4082
  name: "pack",
3396
4083
  tool: "kb_pack",
3397
4084
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
3398
4085
  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.",
3399
- input: z19.object({
4086
+ input: z20.object({
3400
4087
  bundlePath,
3401
4088
  conceptId,
3402
- hops: z19.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3403
- maxNodes: z19.number().int().positive().optional().describe(
4089
+ hops: z20.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
4090
+ maxNodes: z20.number().int().positive().optional().describe(
3404
4091
  "How many records the pack may hold, root included. Defaults to 20."
3405
4092
  ),
3406
- budgetTokens: z19.number().int().positive().optional().describe(
4093
+ budgetTokens: z20.number().int().positive().optional().describe(
3407
4094
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
3408
4095
  )
3409
4096
  }),
@@ -3490,22 +4177,22 @@ function warningLabel(warning) {
3490
4177
  }
3491
4178
 
3492
4179
  // src/commands/pin.ts
3493
- import { z as z20 } from "zod";
4180
+ import { z as z21 } from "zod";
3494
4181
  var pinCommand = define({
3495
4182
  name: "pin",
3496
4183
  tool: "kb_pin",
3497
4184
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
3498
4185
  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.",
3499
- input: z20.object({
4186
+ input: z21.object({
3500
4187
  bundlePath,
3501
- mode: z20.enum(["full", "index"]).optional().describe(
4188
+ mode: z21.enum(["full", "index"]).optional().describe(
3502
4189
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
3503
4190
  ),
3504
- profiles: z20.array(z20.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
3505
- layer: z20.enum(["project", "local", "user"]).optional().describe(
4191
+ profiles: z21.array(z21.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4192
+ layer: z21.enum(["project", "local", "user"]).optional().describe(
3506
4193
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
3507
4194
  ),
3508
- frozen: z20.boolean().optional().describe(
4195
+ frozen: z21.boolean().optional().describe(
3509
4196
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
3510
4197
  )
3511
4198
  }),
@@ -3534,29 +4221,29 @@ var pinCommand = define({
3534
4221
  });
3535
4222
 
3536
4223
  // src/commands/pins.ts
3537
- import { z as z21 } from "zod";
4224
+ import { z as z22 } from "zod";
3538
4225
  var pinsCommand = define({
3539
4226
  name: "pins",
3540
4227
  tool: "kb_pins",
3541
4228
  usage: "pins",
3542
4229
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
3543
- input: z21.object({}),
4230
+ input: z22.object({}),
3544
4231
  fromArgv: () => ({}),
3545
4232
  run: ({ store }) => listPins(store, process.cwd())
3546
4233
  });
3547
4234
 
3548
4235
  // src/commands/query.ts
3549
- import { z as z22 } from "zod";
4236
+ import { z as z23 } from "zod";
3550
4237
  var queryCommand = define({
3551
4238
  name: "query",
3552
4239
  tool: "kb_query",
3553
4240
  usage: "query <text...> [--repo-root PATH]",
3554
4241
  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.",
3555
- input: z22.object({
4242
+ input: z23.object({
3556
4243
  bundlePath,
3557
- text: z22.string().optional(),
3558
- type: z22.enum(KB_RECORD_TYPES).optional(),
3559
- includeNonCurrent: z22.boolean().optional(),
4244
+ text: z23.string().optional(),
4245
+ type: z23.enum(KB_RECORD_TYPES).optional(),
4246
+ includeNonCurrent: z23.boolean().optional(),
3560
4247
  repoRoot: REPO_ROOT
3561
4248
  }),
3562
4249
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -3588,40 +4275,138 @@ var queryCommand = define({
3588
4275
  });
3589
4276
 
3590
4277
  // src/commands/read-index.ts
3591
- import { z as z23 } from "zod";
4278
+ import { z as z24 } from "zod";
3592
4279
  var readIndexCommand = define({
3593
4280
  name: "index",
3594
4281
  tool: "kb_index",
3595
4282
  usage: "index",
3596
4283
  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.",
3597
- input: z23.object({ bundlePath }),
4284
+ input: z24.object({ bundlePath }),
3598
4285
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3599
4286
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
3600
4287
  });
3601
4288
 
3602
4289
  // src/commands/schema.ts
3603
- import { z as z24 } from "zod";
4290
+ import { z as z25 } from "zod";
3604
4291
  var schemaCommand = define({
3605
4292
  name: "schema",
3606
4293
  tool: "kb_schema",
3607
4294
  usage: "schema",
3608
4295
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
3609
- input: z24.object({}),
4296
+ input: z25.object({}),
3610
4297
  fromArgv: () => ({}),
3611
4298
  run: () => Promise.resolve(kbJsonSchemas())
3612
4299
  });
3613
4300
 
4301
+ // src/commands/stamp.ts
4302
+ import { readFile as readFile6 } from "fs/promises";
4303
+ import { z as z26 } from "zod";
4304
+ var DIGEST = /^[0-9a-f]{64}$/;
4305
+ var stampCommand = define({
4306
+ name: "stamp",
4307
+ tool: "kb_stamp",
4308
+ usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
4309
+ 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.",
4310
+ input: z26.object({
4311
+ bundlePath: z26.string().min(1).optional().describe(
4312
+ "Absolute path to one knowledge base. Omit to stamp every pinned base."
4313
+ ),
4314
+ since: z26.string().min(1).optional().describe(
4315
+ "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
4316
+ )
4317
+ }),
4318
+ fromArgv: (argv, path, _stdin, bundleExplicit) => {
4319
+ const since = argvFlag(argv, "--since");
4320
+ return {
4321
+ ...bundleExplicit ? { bundlePath: path } : {},
4322
+ ...since !== void 0 ? { since } : {}
4323
+ };
4324
+ },
4325
+ run: async ({ store }, { bundlePath: bundlePath2, since }) => {
4326
+ const targets = bundlePath2 ? [bundlePath2] : (await readMergedPins(process.cwd())).pins.map(
4327
+ (pin) => pin.absolutePath
4328
+ );
4329
+ if (since !== void 0 && DIGEST.test(since) && targets.length > 1) {
4330
+ throw new KbStampDigestBaselineError(since);
4331
+ }
4332
+ const stamps = await Promise.all(
4333
+ targets.map((target) => store.stamp(target))
4334
+ );
4335
+ if (since === void 0) {
4336
+ return stamps.map((stamp) => ({ ...stamp, changed: null }));
4337
+ }
4338
+ const baseline = await readBaseline(since);
4339
+ const reports = [];
4340
+ for (const stamp of stamps) {
4341
+ const before = baseline.byPath.get(stamp.path);
4342
+ if (baseline.digest !== null) {
4343
+ if (baseline.digest === stamp.digest) continue;
4344
+ reports.push({ ...stamp, changed: null });
4345
+ continue;
4346
+ }
4347
+ if (before && before.digest === stamp.digest) continue;
4348
+ reports.push({ ...stamp, changed: changedIds(before?.records, stamp) });
4349
+ }
4350
+ return reports;
4351
+ },
4352
+ render: (result) => result.map((report) => {
4353
+ const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
4354
+ const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
4355
+ return report.changed?.length ? `${head}
4356
+ changed: ${report.changed.join(", ")}` : head;
4357
+ }).join("\n")
4358
+ });
4359
+ function changedIds(before, stamp) {
4360
+ const now = new Map(
4361
+ stamp.records.map((record) => [record.conceptId, record.digest])
4362
+ );
4363
+ const ids = /* @__PURE__ */ new Set();
4364
+ for (const [conceptId2, digest] of now) {
4365
+ if (before?.get(conceptId2) !== digest) ids.add(conceptId2);
4366
+ }
4367
+ for (const conceptId2 of before?.keys() ?? []) {
4368
+ if (!now.has(conceptId2)) ids.add(conceptId2);
4369
+ }
4370
+ return [...ids].sort();
4371
+ }
4372
+ async function readBaseline(since) {
4373
+ if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
4374
+ let parsed;
4375
+ try {
4376
+ parsed = JSON.parse(await readFile6(since, "utf8"));
4377
+ } catch {
4378
+ throw new KbStampBaselineError(since);
4379
+ }
4380
+ const entries = Array.isArray(parsed) ? parsed : parsed?.stamps ?? [];
4381
+ const byPath = /* @__PURE__ */ new Map();
4382
+ for (const entry of entries) {
4383
+ if (typeof entry?.path !== "string" || typeof entry?.digest !== "string") {
4384
+ continue;
4385
+ }
4386
+ byPath.set(entry.path, {
4387
+ digest: entry.digest,
4388
+ records: new Map(
4389
+ (entry.records ?? []).map((record) => [
4390
+ record.conceptId,
4391
+ record.digest
4392
+ ])
4393
+ )
4394
+ });
4395
+ }
4396
+ return { digest: null, byPath };
4397
+ }
4398
+
3614
4399
  // src/commands/status.ts
3615
- import { z as z25 } from "zod";
4400
+ import { z as z27 } from "zod";
3616
4401
  var statusCommand = define({
3617
4402
  name: "status",
3618
4403
  tool: "kb_status",
3619
4404
  usage: "status <concept-id> <status>",
3620
4405
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
3621
- input: z25.object({
4406
+ input: z27.object({
3622
4407
  bundlePath,
3623
4408
  conceptId,
3624
- status: z25.enum(KB_RECORD_STATUSES)
4409
+ status: z27.enum(KB_RECORD_STATUSES)
3625
4410
  }),
3626
4411
  fromArgv: (argv, path) => ({
3627
4412
  bundlePath: path,
@@ -3636,13 +4421,13 @@ var statusCommand = define({
3636
4421
  });
3637
4422
 
3638
4423
  // src/commands/supersede.ts
3639
- import { z as z26 } from "zod";
4424
+ import { z as z28 } from "zod";
3640
4425
  var supersedeCommand = define({
3641
4426
  name: "supersede",
3642
4427
  tool: "kb_supersede",
3643
4428
  usage: "supersede <concept-id> <replacement-id>",
3644
4429
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
3645
- input: z26.object({ bundlePath, conceptId, replacementId: conceptId }),
4430
+ input: z28.object({ bundlePath, conceptId, replacementId: conceptId }),
3646
4431
  fromArgv: (argv, path) => ({
3647
4432
  bundlePath: path,
3648
4433
  conceptId: argv[1],
@@ -3656,16 +4441,16 @@ var supersedeCommand = define({
3656
4441
  });
3657
4442
 
3658
4443
  // src/commands/sync-instructions.ts
3659
- import { z as z27 } from "zod";
4444
+ import { z as z29 } from "zod";
3660
4445
  var syncInstructionsCommand = define({
3661
4446
  name: "sync-instructions",
3662
4447
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
3663
4448
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
3664
- input: z27.object({
3665
- file: z27.string().min(1).describe("The instruction file to edit in place."),
3666
- budgetTokens: z27.number().int().positive().optional(),
3667
- fullUnderTokens: z27.number().int().positive().optional(),
3668
- profile: z27.string().optional()
4449
+ input: z29.object({
4450
+ file: z29.string().min(1).describe("The instruction file to edit in place."),
4451
+ budgetTokens: z29.number().int().positive().optional(),
4452
+ fullUnderTokens: z29.number().int().positive().optional(),
4453
+ profile: z29.string().optional()
3669
4454
  }),
3670
4455
  fromArgv: (argv) => {
3671
4456
  const budget = argvFlag(argv, "--budget");
@@ -3691,17 +4476,17 @@ var syncInstructionsCommand = define({
3691
4476
  });
3692
4477
 
3693
4478
  // src/commands/trace.ts
3694
- import { z as z28 } from "zod";
4479
+ import { z as z30 } from "zod";
3695
4480
  var traceCommand = define({
3696
4481
  name: "trace",
3697
4482
  tool: "kb_trace",
3698
4483
  usage: "trace <concept-id> [edges...]",
3699
4484
  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".',
3700
- input: z28.object({
4485
+ input: z30.object({
3701
4486
  bundlePath,
3702
4487
  conceptId,
3703
- edges: z28.array(z28.enum(TRACE_EDGES)).optional(),
3704
- depth: z28.number().int().positive().optional()
4488
+ edges: z30.array(z30.enum(TRACE_EDGES)).optional(),
4489
+ depth: z30.number().int().positive().optional()
3705
4490
  }),
3706
4491
  fromArgv: (argv, path) => ({
3707
4492
  bundlePath: path,
@@ -3723,37 +4508,37 @@ var traceCommand = define({
3723
4508
  });
3724
4509
 
3725
4510
  // src/commands/types.ts
3726
- import { z as z29 } from "zod";
4511
+ import { z as z31 } from "zod";
3727
4512
  var typesCommand = define({
3728
4513
  name: "types",
3729
4514
  tool: "kb_types",
3730
4515
  usage: "types",
3731
4516
  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.",
3732
- input: z29.object({}),
4517
+ input: z31.object({}),
3733
4518
  fromArgv: () => ({}),
3734
4519
  run: () => Promise.resolve(RECORD_TYPES)
3735
4520
  });
3736
4521
 
3737
4522
  // src/commands/unpin.ts
3738
- import { z as z30 } from "zod";
4523
+ import { z as z32 } from "zod";
3739
4524
  var unpinCommand = define({
3740
4525
  name: "unpin",
3741
4526
  tool: "kb_unpin",
3742
4527
  usage: "unpin [bundle-path]",
3743
4528
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
3744
- input: z30.object({ bundlePath }),
4529
+ input: z32.object({ bundlePath }),
3745
4530
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3746
4531
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3747
4532
  });
3748
4533
 
3749
4534
  // src/commands/validate.ts
3750
- import { z as z31 } from "zod";
4535
+ import { z as z33 } from "zod";
3751
4536
  var validateCommand = define({
3752
4537
  name: "validate",
3753
4538
  tool: "kb_validate",
3754
4539
  usage: "validate",
3755
4540
  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.",
3756
- input: z31.object({ bundlePath }),
4541
+ input: z33.object({ bundlePath }),
3757
4542
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3758
4543
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3759
4544
  // Warnings never fail the exit code; every other severity does.
@@ -3763,16 +4548,16 @@ var validateCommand = define({
3763
4548
  });
3764
4549
 
3765
4550
  // src/commands/verify.ts
3766
- import { z as z32 } from "zod";
4551
+ import { z as z34 } from "zod";
3767
4552
  var verifyCommand = define({
3768
4553
  name: "verify",
3769
4554
  tool: "kb_verify",
3770
4555
  usage: "verify <concept-id> --note <text>",
3771
4556
  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.",
3772
- input: z32.object({
4557
+ input: z34.object({
3773
4558
  bundlePath,
3774
4559
  conceptId,
3775
- note: z32.string().refine((s) => s.trim().length > 0, {
4560
+ note: z34.string().refine((s) => s.trim().length > 0, {
3776
4561
  message: "note must say what the check found"
3777
4562
  })
3778
4563
  }),
@@ -3792,15 +4577,15 @@ var verifyCommand = define({
3792
4577
  });
3793
4578
 
3794
4579
  // src/commands/write.ts
3795
- import { z as z33 } from "zod";
4580
+ import { z as z35 } from "zod";
3796
4581
  var writeCommand = define({
3797
4582
  name: "write",
3798
4583
  tool: "kb_write",
3799
4584
  usage: "write <type> < record.json",
3800
4585
  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.",
3801
- input: z33.object({
4586
+ input: z35.object({
3802
4587
  bundlePath,
3803
- type: z33.enum(KB_RECORD_TYPES),
4588
+ type: z35.enum(KB_RECORD_TYPES),
3804
4589
  input: composeInputSchema
3805
4590
  }),
3806
4591
  fromArgv: async (argv, path, stdin) => ({
@@ -3824,13 +4609,13 @@ var writeCommand = define({
3824
4609
  });
3825
4610
 
3826
4611
  // src/commands/write-decision.ts
3827
- import { z as z34 } from "zod";
4612
+ import { z as z36 } from "zod";
3828
4613
  var writeDecisionCommand = define({
3829
4614
  name: "write-decision",
3830
4615
  tool: "kb_write_decision",
3831
4616
  usage: "write-decision < decision.json",
3832
4617
  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.",
3833
- input: z34.object({ bundlePath, input: decisionInputSchema }),
4618
+ input: z36.object({ bundlePath, input: decisionInputSchema }),
3834
4619
  fromArgv: async (_argv, path, stdin) => ({
3835
4620
  bundlePath: path,
3836
4621
  input: JSON.parse(await stdin())
@@ -3870,6 +4655,7 @@ var KB_COMMANDS = [
3870
4655
  listCommand,
3871
4656
  readIndexCommand,
3872
4657
  logCommand,
4658
+ stampCommand,
3873
4659
  validateCommand,
3874
4660
  doctorCommand,
3875
4661
  schemaCommand,
@@ -3911,7 +4697,7 @@ function parseMarkdownWithFrontmatter(text, schema) {
3911
4697
 
3912
4698
  // src/search-index.ts
3913
4699
  import { stat as stat2 } from "fs/promises";
3914
- import { join as join4 } from "path";
4700
+ import { join as join6 } from "path";
3915
4701
  var SEARCH_INDEX_FILE = ".index.sqlite";
3916
4702
  var COLLECTION = "kb";
3917
4703
  async function searchBase(bundlePath2, query, options = {}) {
@@ -3920,7 +4706,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3920
4706
  let store = null;
3921
4707
  try {
3922
4708
  store = await qmd.createStore({
3923
- dbPath: join4(bundlePath2, SEARCH_INDEX_FILE),
4709
+ dbPath: join6(bundlePath2, SEARCH_INDEX_FILE),
3924
4710
  config: {
3925
4711
  collections: {
3926
4712
  [COLLECTION]: {
@@ -3955,7 +4741,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3955
4741
  }
3956
4742
  }
3957
4743
  async function isStale(bundlePath2) {
3958
- const indexAt = await stat2(join4(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
4744
+ const indexAt = await stat2(join6(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3959
4745
  if (!indexAt) return true;
3960
4746
  const { readdir: readdir2 } = await import("fs/promises");
3961
4747
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -3964,7 +4750,7 @@ async function isStale(bundlePath2) {
3964
4750
  let stale = false;
3965
4751
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
3966
4752
  if (stale) return;
3967
- const at = await stat2(join4(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
4753
+ const at = await stat2(join6(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3968
4754
  if (at > indexAt) stale = true;
3969
4755
  });
3970
4756
  return stale;
@@ -3999,18 +4785,49 @@ async function loadQmd(logger) {
3999
4785
  }
4000
4786
 
4001
4787
  // src/kb-store.ts
4002
- import { createHash as createHash2 } from "crypto";
4003
4788
  import {
4004
4789
  appendFile,
4005
4790
  link,
4006
- mkdir as mkdir3,
4791
+ mkdir as mkdir4,
4007
4792
  readdir,
4008
- readFile as readFile4,
4009
- rename,
4793
+ readFile as readFile7,
4794
+ rename as rename2,
4010
4795
  unlink,
4011
- writeFile as writeFile3
4796
+ writeFile as writeFile4
4012
4797
  } from "fs/promises";
4013
- import { join as join5, resolve as resolve5, sep as sep3 } from "path";
4798
+ import { join as join7, resolve as resolve5, sep as sep3 } from "path";
4799
+
4800
+ // src/kb-stamp.ts
4801
+ import { createHash as createHash4 } from "crypto";
4802
+ function sha2563(contents) {
4803
+ return createHash4("sha256").update(contents).digest("hex");
4804
+ }
4805
+ function bundleStamp(records, superseded) {
4806
+ const entries = [
4807
+ ...records.map((hit) => ({
4808
+ conceptId: hit.record.conceptId,
4809
+ digest: `current:${sha2563(
4810
+ stringifyMarkdownWithFrontmatter(
4811
+ hit.record.body,
4812
+ hit.record.frontmatter
4813
+ )
4814
+ )}`
4815
+ })),
4816
+ ...superseded.map((entry) => ({
4817
+ conceptId: entry.conceptId,
4818
+ digest: `superseded:${sha2563(JSON.stringify(entry))}`
4819
+ }))
4820
+ ].sort((a, b) => a.conceptId < b.conceptId ? -1 : 1);
4821
+ return {
4822
+ digest: sha2563(
4823
+ entries.map((entry) => `${entry.conceptId}:${entry.digest}`).join("\n")
4824
+ ),
4825
+ records: entries
4826
+ };
4827
+ }
4828
+ function bundleDigest(records, superseded) {
4829
+ return bundleStamp(records, superseded).digest;
4830
+ }
4014
4831
 
4015
4832
  // src/kb-links/inbound.ts
4016
4833
  function inboundIndex(bundle) {
@@ -4172,7 +4989,7 @@ function appendUnionMergeLine(contents) {
4172
4989
  }
4173
4990
 
4174
4991
  // src/kb-store.ts
4175
- var KB_DIR = join5(".strauss", "kb");
4992
+ var KB_DIR = join7(".strauss", "kb");
4176
4993
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
4177
4994
  var DEFAULT_LOAD_BUDGET = 25e3;
4178
4995
  var KbStore = class {
@@ -4203,7 +5020,7 @@ var KbStore = class {
4203
5020
  const conceptId2 = `${input.type}.${input.slug}`;
4204
5021
  const root = this.root(bundlePath2);
4205
5022
  const target = this.recordPath(bundlePath2, conceptId2);
4206
- await mkdir3(root, { recursive: true });
5023
+ await mkdir4(root, { recursive: true });
4207
5024
  await this.publish(
4208
5025
  target,
4209
5026
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -4242,7 +5059,7 @@ var KbStore = class {
4242
5059
  const target = this.recordPath(bundlePath2, conceptId2);
4243
5060
  let raw;
4244
5061
  try {
4245
- raw = await readFile4(target, "utf8");
5062
+ raw = await readFile7(target, "utf8");
4246
5063
  } catch {
4247
5064
  return null;
4248
5065
  }
@@ -4267,7 +5084,7 @@ var KbStore = class {
4267
5084
  const records = await mapLimit(
4268
5085
  wanted,
4269
5086
  DEFAULT_IO_CONCURRENCY,
4270
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile4(join5(root, name), "utf8"))
5087
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile7(join7(root, name), "utf8"))
4271
5088
  );
4272
5089
  return records.filter((record) => record !== null);
4273
5090
  }
@@ -4424,7 +5241,7 @@ ${answer}
4424
5241
  if (found.length) return found;
4425
5242
  }
4426
5243
  const lowered = needle.toLowerCase();
4427
- return bundle.filter((record) => matches(record, lowered));
5244
+ return bundle.filter((record) => matches2(record, lowered));
4428
5245
  }
4429
5246
  /**
4430
5247
  * Anchor drift over the records about to be handed back. Like the search
@@ -4542,6 +5359,28 @@ ${answer}
4542
5359
  digest: bundleDigestValue
4543
5360
  };
4544
5361
  }
5362
+ /**
5363
+ * `load`'s digest without `load`'s bodies — the same records, adjudicated
5364
+ * the same way, handed back as a stamp. Skips the anchor drift pass, which
5365
+ * reads source files and only ever adds warnings: no warning reaches the
5366
+ * digest, so the value is identical to the one `load` returns.
5367
+ */
5368
+ async stamp(bundlePath2) {
5369
+ const bundle = await this.list(bundlePath2);
5370
+ const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
5371
+ const current = adjudicated.filter((hit) => hit.standing !== "superseded");
5372
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
5373
+ const stamped = bundleStamp(current, superseded);
5374
+ const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
5375
+ return {
5376
+ path: bundlePath2,
5377
+ digest: stamped.digest,
5378
+ recordCount: bundle.length,
5379
+ superseded: superseded.length,
5380
+ newestAt: dates.at(-1) ?? null,
5381
+ records: stamped.records
5382
+ };
5383
+ }
4545
5384
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
4546
5385
  async trace(bundlePath2, seedId, options = {}) {
4547
5386
  return trace(seedId, await this.list(bundlePath2), options);
@@ -4572,11 +5411,11 @@ ${answer}
4572
5411
  async readIndex(bundlePath2) {
4573
5412
  const root = this.root(bundlePath2);
4574
5413
  const expected = renderIndex(await this.list(bundlePath2));
4575
- const stored = await readFile4(join5(root, INDEX_FILE), "utf8").catch(
5414
+ const stored = await readFile7(join7(root, INDEX_FILE), "utf8").catch(
4576
5415
  () => null
4577
5416
  );
4578
5417
  if (indexIsStale(stored, expected)) {
4579
- await this.publish(join5(root, INDEX_FILE), expected, true, INDEX_FILE);
5418
+ await this.publish(join7(root, INDEX_FILE), expected, true, INDEX_FILE);
4580
5419
  this.logger.info?.({
4581
5420
  operation: "kb.index.repair",
4582
5421
  bundlePath: root,
@@ -4593,8 +5432,8 @@ ${answer}
4593
5432
  * knows which agent touched what. So a bad line is surfaced and left alone.
4594
5433
  */
4595
5434
  async readLog(bundlePath2) {
4596
- const raw = await readFile4(
4597
- join5(this.root(bundlePath2), LOG_FILE),
5435
+ const raw = await readFile7(
5436
+ join7(this.root(bundlePath2), LOG_FILE),
4598
5437
  "utf8"
4599
5438
  ).catch(() => "");
4600
5439
  const result = parseLog(raw);
@@ -4645,15 +5484,15 @@ ${answer}
4645
5484
  }
4646
5485
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
4647
5486
  const target = this.recordPath(bundlePath2, conceptId2);
4648
- const before = await readFile4(target, "utf8").catch(() => null);
5487
+ const before = await readFile7(target, "utf8").catch(() => null);
4649
5488
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
4650
5489
  const parsed = this.parse(conceptId2, before);
4651
5490
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
4652
5491
  const frontmatter = change(parsed.frontmatter);
4653
5492
  const body = changeBody(parsed.body);
4654
5493
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
4655
- const witness = await readFile4(target, "utf8").catch(() => null);
4656
- if (witness === null || digest(witness) !== digest(before)) {
5494
+ const witness = await readFile7(target, "utf8").catch(() => null);
5495
+ if (witness === null || sha2563(witness) !== sha2563(before)) {
4657
5496
  throw new KbWriteConflictError(conceptId2);
4658
5497
  }
4659
5498
  await this.publish(target, contents, true, conceptId2);
@@ -4678,10 +5517,10 @@ ${answer}
4678
5517
  */
4679
5518
  async publish(target, contents, overwrite, conceptId2) {
4680
5519
  const staging = `${target}.${process.pid}.tmp`;
4681
- await writeFile3(staging, contents, "utf8");
5520
+ await writeFile4(staging, contents, "utf8");
4682
5521
  try {
4683
5522
  if (overwrite) {
4684
- await rename(staging, target);
5523
+ await rename2(staging, target);
4685
5524
  return;
4686
5525
  }
4687
5526
  await link(staging, target);
@@ -4735,18 +5574,18 @@ ${answer}
4735
5574
  * file must not fail the mutation it guards.
4736
5575
  */
4737
5576
  async ensureGitattributes(root) {
4738
- const target = join5(root, GITATTRIBUTES_FILE);
5577
+ const target = join7(root, GITATTRIBUTES_FILE);
4739
5578
  try {
4740
5579
  let existing;
4741
5580
  try {
4742
- existing = await readFile4(target, "utf8");
5581
+ existing = await readFile7(target, "utf8");
4743
5582
  } catch (error) {
4744
5583
  if (error.code !== "ENOENT") throw error;
4745
5584
  existing = null;
4746
5585
  }
4747
5586
  if (existing === null) {
4748
5587
  try {
4749
- await writeFile3(target, appendUnionMergeLine(""), {
5588
+ await writeFile4(target, appendUnionMergeLine(""), {
4750
5589
  encoding: "utf8",
4751
5590
  flag: "wx"
4752
5591
  });
@@ -4786,7 +5625,7 @@ ${answer}
4786
5625
  async record(root, entry) {
4787
5626
  await this.ensureGitattributes(root);
4788
5627
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4789
- await appendFile(join5(root, LOG_FILE), line, "utf8").catch((error) => {
5628
+ await appendFile(join7(root, LOG_FILE), line, "utf8").catch((error) => {
4790
5629
  this.logger.warn?.({
4791
5630
  operation: "kb.log.append",
4792
5631
  outcome: "failed",
@@ -4823,7 +5662,7 @@ ${answer}
4823
5662
  { conceptId: conceptId2 }
4824
5663
  );
4825
5664
  }
4826
- return join5(this.root(bundlePath2), `${conceptId2}.md`);
5665
+ return join7(this.root(bundlePath2), `${conceptId2}.md`);
4827
5666
  }
4828
5667
  };
4829
5668
  function estimateTokens(record) {
@@ -4850,7 +5689,7 @@ function stub(hit) {
4850
5689
  at: hit.record.frontmatter.generated?.at ?? null
4851
5690
  };
4852
5691
  }
4853
- function matches(record, needle) {
5692
+ function matches2(record, needle) {
4854
5693
  const { title, description } = record.frontmatter;
4855
5694
  return [record.conceptId, title, description, record.body].some(
4856
5695
  (field) => field?.toLowerCase().includes(needle)
@@ -4861,25 +5700,6 @@ function normalizeActor(id) {
4861
5700
  if (colon === -1) return id.toLowerCase();
4862
5701
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
4863
5702
  }
4864
- function digest(contents) {
4865
- return createHash2("sha256").update(contents).digest("hex");
4866
- }
4867
- function bundleDigest(records, superseded) {
4868
- const entries = [
4869
- ...records.map(
4870
- (hit) => `${hit.record.conceptId}:current:${digest(
4871
- stringifyMarkdownWithFrontmatter(
4872
- hit.record.body,
4873
- hit.record.frontmatter
4874
- )
4875
- )}`
4876
- ),
4877
- ...superseded.map(
4878
- (entry) => `${entry.conceptId}:superseded:${digest(JSON.stringify(entry))}`
4879
- )
4880
- ].sort();
4881
- return digest(entries.join("\n"));
4882
- }
4883
5703
 
4884
5704
  // src/pack.ts
4885
5705
  var DEFAULT_PACK_HOPS = 2;
@@ -4961,12 +5781,12 @@ function byRank(left, right) {
4961
5781
  ) || left.record.conceptId.localeCompare(right.record.conceptId);
4962
5782
  }
4963
5783
  function typeRank(record) {
4964
- const index = TYPE_PRIORITY.indexOf(record.frontmatter.type);
4965
- return index === -1 ? TYPE_PRIORITY.length : index;
5784
+ const index2 = TYPE_PRIORITY.indexOf(record.frontmatter.type);
5785
+ return index2 === -1 ? TYPE_PRIORITY.length : index2;
4966
5786
  }
4967
5787
 
4968
5788
  // src/version.ts
4969
- var VERSION = true ? "0.1.15" : "0.0.0-dev";
5789
+ var VERSION = true ? "0.1.17" : "0.0.0-dev";
4970
5790
 
4971
5791
  export {
4972
5792
  kbSourceSchema,
@@ -5002,9 +5822,19 @@ export {
5002
5822
  repoCacheDir,
5003
5823
  readRemoteAnchors,
5004
5824
  anchorFilePath,
5825
+ grammarsCacheRoot,
5826
+ grammarManifest,
5827
+ ensureGrammar,
5828
+ grammarHints,
5829
+ languageForFile,
5830
+ treeSitterLanguages,
5831
+ TreeSitterResolver,
5005
5832
  regexResolver,
5006
5833
  hashAnchorText,
5007
5834
  resolveAnchor,
5835
+ resolveAnchorSpan,
5836
+ prepareResolvers,
5837
+ defaultAnchorResolvers,
5008
5838
  detectAnchorDrift,
5009
5839
  Fault,
5010
5840
  ErrorTypes,
@@ -5082,4 +5912,4 @@ export {
5082
5912
  KbStore,
5083
5913
  VERSION
5084
5914
  };
5085
- //# sourceMappingURL=chunk-KNIUBCZY.js.map
5915
+ //# sourceMappingURL=chunk-ZKIQOBHT.js.map