@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.
package/dist/mcp-main.cjs CHANGED
@@ -23,6 +23,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  mod
24
24
  ));
25
25
 
26
+ // ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.26_supports-color@8.1.1_typescript@6.0.3_yaml@2.9.0/node_modules/tsup/assets/cjs_shims.js
27
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
28
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
+
26
30
  // src/mcp.ts
27
31
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
28
32
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
@@ -78,7 +82,13 @@ var kbAnchorSchema = import_zod.z.object({
78
82
  /** ISO 8601 timestamp of the last successful resolution. */
79
83
  resolved_at: import_zod.z.string().min(1).optional(),
80
84
  /** Line count of the text the hash was taken over. */
81
- lines: import_zod.z.number().int().positive().optional()
85
+ lines: import_zod.z.number().int().positive().optional(),
86
+ /**
87
+ * Which resolver produced the hashed span. Absent means an anchor stamped
88
+ * before resolvers were named, which is read as `regex` — the only one
89
+ * there was. A hash from a different resolver is drift, not a match.
90
+ */
91
+ resolver: import_zod.z.enum(["tree-sitter", "regex"]).optional()
82
92
  }).strict();
83
93
  var kbLinkSchema = import_zod.z.object({
84
94
  target: import_zod.z.string().min(1),
@@ -442,7 +452,7 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
442
452
  }
443
453
 
444
454
  // src/commands/anchor-resolve.ts
445
- var import_zod6 = require("zod");
455
+ var import_zod7 = require("zod");
446
456
 
447
457
  // src/concurrency.ts
448
458
  var DEFAULT_IO_CONCURRENCY = 16;
@@ -739,18 +749,18 @@ async function readOneRepo(repo, url, declared, context) {
739
749
  if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
740
750
  const cache = cachePathFor(repo, context.cacheDir);
741
751
  if (!cache) return all({ ok: false, reason: "remote-unreachable" });
742
- const rejected = /* @__PURE__ */ new Map();
752
+ const rejected2 = /* @__PURE__ */ new Map();
743
753
  const usable = [];
744
754
  for (const want of wants) {
745
755
  const reason = wantReason(want);
746
756
  if (reason)
747
- rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
757
+ rejected2.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
748
758
  else usable.push(want);
749
759
  }
750
- if (!usable.length) return rejected;
760
+ if (!usable.length) return rejected2;
751
761
  wants = usable;
752
762
  const opened = await openCache(cache, url, context);
753
- if (opened) return new Map([...rejected, ...all(opened)]);
763
+ if (opened) return new Map([...rejected2, ...all(opened)]);
754
764
  const wantsDefault = wants.some((want) => want.ref === void 0);
755
765
  const branch = wantsDefault ? await defaultBranch(cache, context) : {};
756
766
  const revs = /* @__PURE__ */ new Map();
@@ -777,7 +787,7 @@ async function readOneRepo(repo, url, declared, context) {
777
787
  }
778
788
  );
779
789
  return new Map([
780
- ...rejected,
790
+ ...rejected2,
781
791
  ...wants.map(
782
792
  (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
783
793
  )
@@ -829,13 +839,13 @@ async function defaultBranch(cache, context) {
829
839
  if (!listed.ok) {
830
840
  const reason = transportReason(listed.stderr);
831
841
  if (reason !== "ref-not-found") {
832
- const cached2 = await cachedBranch(cache);
833
- return cached2 ? { name: cached2 } : { reason };
842
+ const cached3 = await cachedBranch(cache);
843
+ return cached3 ? { name: cached3 } : { reason };
834
844
  }
835
845
  }
836
846
  }
837
- const cached = await cachedBranch(cache);
838
- if (cached) return { name: cached };
847
+ const cached2 = await cachedBranch(cache);
848
+ if (cached2) return { name: cached2 };
839
849
  return {
840
850
  reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
841
851
  };
@@ -862,8 +872,8 @@ async function ensureRev(cache, rev, context) {
862
872
  cwd: cache
863
873
  }
864
874
  );
865
- const cached = have.ok && have.stdout.trim().length > 0;
866
- if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
875
+ const cached2 = have.ok && have.stdout.trim().length > 0;
876
+ if (cached2 && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
867
877
  if (context.offline) return { ok: false, reason: "remote-unreachable" };
868
878
  const fetched = await git(
869
879
  [
@@ -879,7 +889,7 @@ async function ensureRev(cache, rev, context) {
879
889
  );
880
890
  if (!fetched.ok) {
881
891
  const reason = transportReason(fetched.stderr);
882
- if (cached && reason !== "ref-not-found") return void 0;
892
+ if (cached2 && reason !== "ref-not-found") return void 0;
883
893
  return { ok: false, reason };
884
894
  }
885
895
  const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
@@ -994,63 +1004,577 @@ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY
994
1004
  }
995
1005
 
996
1006
  // src/anchor-resolver/resolver.ts
1007
+ var import_node_crypto3 = require("crypto");
1008
+
1009
+ // src/tree-sitter-resolver/languages.ts
1010
+ var import_node_path5 = require("path");
1011
+
1012
+ // src/grammars/index.ts
1013
+ var import_promises4 = require("fs/promises");
1014
+
1015
+ // src/grammars/store.ts
997
1016
  var import_node_crypto = require("crypto");
1017
+ var import_promises3 = require("fs/promises");
1018
+ var import_node_os2 = require("os");
1019
+ var import_node_path3 = require("path");
1020
+ function grammarsCacheRoot(override) {
1021
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_DIR"] ?? (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".strauss", "grammars");
1022
+ }
1023
+ function grammarCachePath(root, language, sha2564, extension = "wasm") {
1024
+ return (0, import_node_path3.join)(root, language, `${sha2564.slice(0, 12)}.${extension}`);
1025
+ }
1026
+ function sha256(bytes) {
1027
+ return (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
1028
+ }
1029
+ function matches(bytes, entry) {
1030
+ if (entry.bytes !== void 0 && bytes.byteLength !== entry.bytes)
1031
+ return false;
1032
+ return sha256(bytes) === entry.sha256;
1033
+ }
1034
+ async function verifyCached(path, entry) {
1035
+ let bytes;
1036
+ try {
1037
+ bytes = await (0, import_promises3.readFile)(path);
1038
+ } catch {
1039
+ return false;
1040
+ }
1041
+ if (matches(bytes, entry)) return true;
1042
+ await (0, import_promises3.rm)(path, { force: true });
1043
+ return false;
1044
+ }
1045
+ async function writeCached(path, bytes) {
1046
+ await (0, import_promises3.mkdir)((0, import_node_path3.dirname)(path), { recursive: true });
1047
+ const temporary = `${path}.${process.pid}.${(0, import_node_crypto.randomBytes)(4).toString("hex")}.tmp`;
1048
+ try {
1049
+ await (0, import_promises3.writeFile)(temporary, bytes);
1050
+ await (0, import_promises3.rename)(temporary, path);
1051
+ } catch (error) {
1052
+ await (0, import_promises3.rm)(temporary, { force: true });
1053
+ throw error;
1054
+ }
1055
+ }
1056
+
1057
+ // src/grammars/fetch.ts
1058
+ var ATTEMPTS = 3;
1059
+ var BACKOFF_MS = 250;
1060
+ function grammarUrl(url, override) {
1061
+ const base2 = grammarsBaseUrl(override);
1062
+ if (!base2) return url;
1063
+ let root = base2;
1064
+ while (root.endsWith("/")) root = root.slice(0, -1);
1065
+ const pinned = new URL(url);
1066
+ return `${root}${pinned.pathname}${pinned.search}`;
1067
+ }
1068
+ function grammarsBaseUrl(override) {
1069
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_URL"];
1070
+ }
1071
+ async function downloadPart(url, name, entry, options = {}) {
1072
+ const log = options.log ?? ((line) => void process.stderr.write(line));
1073
+ const weight = entry.bytes === void 0 ? "" : ` (${size(entry.bytes)} from manifest)`;
1074
+ log(`strauss-kb: downloading ${name}${weight} from ${url}
1075
+ `);
1076
+ let cause = "";
1077
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
1078
+ const outcome = await attemptDownload(url, entry, options.fetchTimeoutMs);
1079
+ if ("bytes" in outcome) return outcome;
1080
+ cause = outcome.cause;
1081
+ log(
1082
+ `strauss-kb: ${name} attempt ${attempt}/${ATTEMPTS} failed: ${cause}
1083
+ `
1084
+ );
1085
+ if (!outcome.retry) break;
1086
+ if (attempt < ATTEMPTS) await pause(BACKOFF_MS * attempt);
1087
+ }
1088
+ log(`strauss-kb: ${name} not downloaded: ${cause}
1089
+ `);
1090
+ return { cause };
1091
+ }
1092
+ async function attemptDownload(url, entry, timeoutMs) {
1093
+ try {
1094
+ const response = await fetch(url, {
1095
+ signal: AbortSignal.timeout(fetchTimeoutMs(timeoutMs))
1096
+ });
1097
+ if (!response.ok) {
1098
+ return {
1099
+ cause: `HTTP ${response.status}`,
1100
+ retry: response.status >= 500 || response.status === 429
1101
+ };
1102
+ }
1103
+ const bytes = new Uint8Array(await response.arrayBuffer());
1104
+ if (!matches(bytes, entry))
1105
+ return { cause: "sha256 mismatch", retry: false };
1106
+ return { bytes };
1107
+ } catch (error) {
1108
+ const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
1109
+ return { cause: timedOut ? "timeout" : "network error", retry: true };
1110
+ }
1111
+ }
1112
+ function size(bytes) {
1113
+ return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1114
+ }
1115
+ function pause(ms) {
1116
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
1117
+ }
1118
+
1119
+ // src/grammars/manifest.ts
1120
+ var import_node_fs = require("fs");
1121
+ var import_node_path4 = require("path");
1122
+ var import_node_url = require("url");
1123
+
1124
+ // src/grammars/model.ts
1125
+ var import_zod4 = require("zod");
1126
+ var sha2562 = import_zod4.z.string().regex(/^[0-9a-f]{64}$/);
1127
+ var grammarWasmSchema = import_zod4.z.object({
1128
+ url: import_zod4.z.string().min(1),
1129
+ sha256: sha2562,
1130
+ bytes: import_zod4.z.number().int().positive()
1131
+ });
1132
+ var grammarTagsSchema = import_zod4.z.object({ url: import_zod4.z.string().min(1), sha256: sha2562 });
1133
+ var grammarPackSchema = import_zod4.z.object({
1134
+ package: import_zod4.z.string().min(1),
1135
+ wasm: grammarWasmSchema,
1136
+ tags: import_zod4.z.array(grammarTagsSchema),
1137
+ license: import_zod4.z.string().min(1),
1138
+ extensions: import_zod4.z.array(import_zod4.z.string().min(1))
1139
+ });
1140
+ var grammarManifestSchema = import_zod4.z.object({
1141
+ /** The runtime the packs were proved against. */
1142
+ webTreeSitter: import_zod4.z.string().min(1),
1143
+ linguist: import_zod4.z.object({ tag: import_zod4.z.string().min(1), commit: import_zod4.z.string().min(1) }),
1144
+ packs: import_zod4.z.record(import_zod4.z.string().min(1), grammarPackSchema)
1145
+ });
1146
+
1147
+ // src/grammars/manifest.ts
1148
+ var cached;
1149
+ function grammarManifest() {
1150
+ cached ??= grammarManifestSchema.parse(
1151
+ JSON.parse((0, import_node_fs.readFileSync)(grammarsDataPath("manifest.json"), "utf8"))
1152
+ );
1153
+ return cached;
1154
+ }
1155
+ function grammarsDataPath(...segments) {
1156
+ let dir = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
1157
+ for (let up = 0; up < 5; up++) {
1158
+ const candidate = (0, import_node_path4.join)(dir, "grammars");
1159
+ if ((0, import_node_fs.existsSync)((0, import_node_path4.join)(candidate, "manifest.json")))
1160
+ return (0, import_node_path4.join)(candidate, ...segments);
1161
+ dir = (0, import_node_path4.dirname)(dir);
1162
+ }
1163
+ throw new Error("grammars/manifest.json is missing from the package");
1164
+ }
1165
+
1166
+ // src/grammars/index.ts
1167
+ var inFlight = /* @__PURE__ */ new Map();
1168
+ var missing = /* @__PURE__ */ new Map();
1169
+ var uncompilable = /* @__PURE__ */ new Map();
1170
+ var rejected = /* @__PURE__ */ new Map();
1171
+ function grammarsDownloadDisabled() {
1172
+ return process.env["STRAUSS_KB_GRAMMARS"] === "off";
1173
+ }
1174
+ async function ensureGrammar(language, options = {}) {
1175
+ const pack2 = grammarManifest().packs[language];
1176
+ if (!pack2) return null;
1177
+ const root = grammarsCacheRoot(options.cacheRoot);
1178
+ const wasm = grammarCachePath(root, language, pack2.wasm.sha256);
1179
+ const key = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1180
+ const existing = inFlight.get(key);
1181
+ if (existing) return existing;
1182
+ const pending = (async () => {
1183
+ const grammar = await ensurePart(
1184
+ wasm,
1185
+ `tree-sitter-${language}`,
1186
+ pack2.wasm,
1187
+ options
1188
+ );
1189
+ if (grammar !== true)
1190
+ return miss(language, `grammar tree-sitter-${language}`, grammar);
1191
+ const parts = [];
1192
+ const total = pack2.tags.length;
1193
+ for (const [at, part] of pack2.tags.entries()) {
1194
+ const name = `${language} tags${total > 1 ? ` part ${at + 1}/${total}` : ""}`;
1195
+ const path = grammarCachePath(root, language, part.sha256, "scm");
1196
+ const held = await ensurePart(path, name, part, options);
1197
+ if (held !== true) return miss(language, name, held);
1198
+ parts.push(`; ${part.url}
1199
+ ${lf(await (0, import_promises4.readFile)(path, "utf8"))}`);
1200
+ }
1201
+ missing.delete(language);
1202
+ return { wasm, query: total ? parts.join("\n") : void 0 };
1203
+ })();
1204
+ inFlight.set(key, pending);
1205
+ const result = await pending;
1206
+ if (result === null) inFlight.delete(key);
1207
+ return result;
1208
+ }
1209
+ async function ensurePart(path, name, entry, options) {
1210
+ if (await verifyCached(path, entry)) return true;
1211
+ if (options.offline === true || grammarsDownloadDisabled()) return {};
1212
+ const download = await downloadPart(
1213
+ grammarUrl(entry.url, options.baseUrl),
1214
+ name,
1215
+ entry,
1216
+ options
1217
+ );
1218
+ if ("cause" in download) return { cause: download.cause };
1219
+ await writeCached(path, download.bytes).catch(() => null);
1220
+ return true;
1221
+ }
1222
+ function miss(language, subject, failure) {
1223
+ missing.set(language, { subject, ...failure });
1224
+ return null;
1225
+ }
1226
+ function lf(body) {
1227
+ return body.replace(/\r\n/g, "\n");
1228
+ }
1229
+ function noteUncompilableQuery(language, cause) {
1230
+ uncompilable.set(language, cause);
1231
+ }
1232
+ function noteRejectedGrammar(language, cause) {
1233
+ rejected.set(language, cause);
1234
+ }
1235
+ function grammarHints() {
1236
+ const manifest = grammarManifest();
1237
+ const packs = manifest.packs;
1238
+ const lines = /* @__PURE__ */ new Map();
1239
+ for (const [language, { subject, cause }] of missing)
1240
+ lines.set(
1241
+ language,
1242
+ `${subject} not cached${cause ? ` (${cause})` : ""}; run online once, or set STRAUSS_KB_GRAMMARS_DIR`
1243
+ );
1244
+ for (const [language, cause] of rejected)
1245
+ lines.set(
1246
+ language,
1247
+ `${packs[language]?.package ?? `tree-sitter-${language}`} rejected by web-tree-sitter ${manifest.webTreeSitter}${cause ? `: ${cause}` : ""}; re-pin with pnpm grammars pin ${language}`
1248
+ );
1249
+ for (const [language, cause] of uncompilable)
1250
+ lines.set(
1251
+ language,
1252
+ `tags query for ${language} does not compile against ${packs[language]?.package ?? `tree-sitter-${language}`}: ${cause}; re-pin with pnpm grammars pin ${language}`
1253
+ );
1254
+ return [...lines].sort(([a], [b]) => a.localeCompare(b)).map(([, line]) => line);
1255
+ }
1256
+
1257
+ // src/tree-sitter-resolver/languages.ts
1258
+ var table;
1259
+ function extensionTable() {
1260
+ const manifest = grammarManifest();
1261
+ if (table?.of !== manifest)
1262
+ table = {
1263
+ of: manifest,
1264
+ extensions: Object.fromEntries(
1265
+ Object.entries(manifest.packs).flatMap(
1266
+ ([language, pack2]) => pack2.extensions.map((extension) => [extension, language])
1267
+ )
1268
+ )
1269
+ };
1270
+ return table.extensions;
1271
+ }
1272
+ function hasQuery(language) {
1273
+ return (grammarManifest().packs[language]?.tags.length ?? 0) > 0;
1274
+ }
1275
+ function languageForFile(file) {
1276
+ const language = extensionTable()[(0, import_node_path5.extname)(file).toLowerCase()];
1277
+ return language && hasQuery(language) ? language : void 0;
1278
+ }
1279
+
1280
+ // src/tree-sitter-resolver/resolver.ts
1281
+ var import_node_crypto2 = require("crypto");
1282
+ var import_web_tree_sitter = require("web-tree-sitter");
1283
+
1284
+ // src/tree-sitter-resolver/definitions.ts
1285
+ var SCOPE_ONLY = "reference.implementation";
1286
+ function index(tree, query) {
1287
+ const byName = /* @__PURE__ */ new Map();
1288
+ for (const match of query.matches(tree.rootNode)) {
1289
+ const nameNode = match.captures.find((capture) => capture.name === "name");
1290
+ const defNode = match.captures.find(
1291
+ (capture) => capture.name.startsWith("definition.") || capture.name === SCOPE_ONLY
1292
+ );
1293
+ if (!nameNode || !defNode) continue;
1294
+ const candidate = {
1295
+ node: defNode.node,
1296
+ name: nameNode.node.text,
1297
+ target: defNode.name !== SCOPE_ONLY
1298
+ };
1299
+ const existing = byName.get(nameNode.node.id);
1300
+ if (existing && width(existing.node) <= width(candidate.node)) continue;
1301
+ byName.set(nameNode.node.id, candidate);
1302
+ }
1303
+ const definitions = [...byName.values()];
1304
+ return {
1305
+ tree,
1306
+ byNodeId: new Map(
1307
+ definitions.map((definition) => [definition.node.id, definition])
1308
+ ),
1309
+ definitions
1310
+ };
1311
+ }
1312
+ function select(parsed, wanted) {
1313
+ const matches3 = parsed.definitions.filter(
1314
+ (definition) => definition.target && endsWith(chainOf(definition, parsed.byNodeId), wanted)
1315
+ );
1316
+ if (matches3.length < 2) return matches3;
1317
+ const bodied = matches3.filter(
1318
+ (definition) => definition.node.childForFieldName("body") !== null
1319
+ );
1320
+ return bodied.length === 1 ? bodied : matches3;
1321
+ }
1322
+ function chainOf(definition, byNodeId) {
1323
+ const chain = [definition.name];
1324
+ const receiver = definition.node.childForFieldName("receiver");
1325
+ const type = receiver && typeNameIn(receiver);
1326
+ if (type) chain.unshift(type);
1327
+ for (let node = definition.node.parent; node; node = node.parent) {
1328
+ const enclosing = byNodeId.get(node.id);
1329
+ if (enclosing && enclosing.node !== definition.node)
1330
+ chain.unshift(enclosing.name);
1331
+ }
1332
+ return chain;
1333
+ }
1334
+ function typeNameIn(receiver) {
1335
+ const stack = [receiver];
1336
+ while (stack.length) {
1337
+ const node = stack.pop();
1338
+ if (node.type === "type_identifier") return node.text;
1339
+ for (let at = 0; at < node.childCount; at++) {
1340
+ const child = node.child(at);
1341
+ if (child) stack.push(child);
1342
+ }
1343
+ }
1344
+ return void 0;
1345
+ }
1346
+ function endsWith(chain, wanted) {
1347
+ if (wanted.length > chain.length) return false;
1348
+ const offset = chain.length - wanted.length;
1349
+ return wanted.every((segment, at) => chain[offset + at] === segment);
1350
+ }
1351
+ function width(node) {
1352
+ return node.endIndex - node.startIndex;
1353
+ }
1354
+ function spanOf(definition, source) {
1355
+ let start = definition.node;
1356
+ let end = definition.node;
1357
+ for (let sibling = start.previousSibling; sibling?.type === "decorator"; sibling = sibling.previousSibling) {
1358
+ start = sibling;
1359
+ }
1360
+ const parent = end.parent;
1361
+ if (parent?.type === "export_statement" && parent.childForFieldName("declaration")?.id === end.id) {
1362
+ start = parent;
1363
+ end = parent;
1364
+ }
1365
+ const lines = source.split("\n");
1366
+ const startLine = start.startPosition.row;
1367
+ const endLine = end.endPosition.column === 0 && end.endPosition.row > startLine ? end.endPosition.row - 1 : end.endPosition.row;
1368
+ return {
1369
+ text: lines.slice(startLine, endLine + 1).join("\n"),
1370
+ startLine: startLine + 1,
1371
+ endLine: endLine + 1
1372
+ };
1373
+ }
1374
+
1375
+ // src/tree-sitter-resolver/resolver.ts
1376
+ var TREE_CACHE_LIMIT = 32;
1377
+ var TreeSitterResolver = class {
1378
+ name = "tree-sitter";
1379
+ grammars;
1380
+ loaded = /* @__PURE__ */ new Map();
1381
+ trees = /* @__PURE__ */ new Map();
1382
+ parser;
1383
+ initialized = false;
1384
+ /** Cache effectiveness, for tests and for the latency numbers. */
1385
+ stats = { parses: 0, cacheHits: 0 };
1386
+ constructor(options = {}) {
1387
+ this.grammars = options;
1388
+ }
1389
+ /**
1390
+ * Loads the grammars these files need, once per language per process,
1391
+ * downloading each one on first use.
1392
+ *
1393
+ * A grammar that will not load is remembered as unavailable rather than
1394
+ * retried per anchor, and never throws: an unobtainable WASM is a finding.
1395
+ */
1396
+ async prepare(files) {
1397
+ const wanted = /* @__PURE__ */ new Set();
1398
+ for (const file of files) {
1399
+ const language = languageForFile(file);
1400
+ if (language && !this.loaded.has(language)) wanted.add(language);
1401
+ }
1402
+ if (!wanted.size) return;
1403
+ if (!this.initialized) {
1404
+ try {
1405
+ await import_web_tree_sitter.Parser.init();
1406
+ this.parser = new import_web_tree_sitter.Parser();
1407
+ this.initialized = true;
1408
+ } catch {
1409
+ for (const language of wanted) this.loaded.set(language, null);
1410
+ return;
1411
+ }
1412
+ }
1413
+ const languages = [...wanted];
1414
+ const loaded = await mapLimit(
1415
+ languages,
1416
+ Math.min(DEFAULT_IO_CONCURRENCY, languages.length),
1417
+ (language) => this.load(language)
1418
+ );
1419
+ languages.forEach(
1420
+ (language, at) => this.loaded.set(language, loaded[at] ?? null)
1421
+ );
1422
+ }
1423
+ /**
1424
+ * An unobtainable grammar, one this runtime refuses, and a query that will
1425
+ * not compile are three faults with three repairs; all are reported through
1426
+ * the grammars module so every hint has one home.
1427
+ */
1428
+ async load(language) {
1429
+ let pack2;
1430
+ try {
1431
+ pack2 = await ensureGrammar(language, this.grammars);
1432
+ } catch {
1433
+ return null;
1434
+ }
1435
+ if (!pack2?.query) return null;
1436
+ let grammar;
1437
+ try {
1438
+ grammar = await import_web_tree_sitter.Language.load(pack2.wasm);
1439
+ } catch (error) {
1440
+ noteRejectedGrammar(language, why(error));
1441
+ return null;
1442
+ }
1443
+ try {
1444
+ return { language: grammar, query: new import_web_tree_sitter.Query(grammar, pack2.query) };
1445
+ } catch (error) {
1446
+ noteUncompilableQuery(language, why(error));
1447
+ return null;
1448
+ }
1449
+ }
1450
+ /**
1451
+ * Abstains on an extension with no grammar so the regex resolver gets a
1452
+ * turn; reports `resolver-unavailable` when the grammar exists in principle
1453
+ * but could not be loaded, because falling back there would silently trade a
1454
+ * precise span for a guessed one.
1455
+ */
1456
+ attempt(source, symbol, file) {
1457
+ const language = file ? languageForFile(file) : void 0;
1458
+ if (!language) return { kind: "abstain" };
1459
+ if (!this.loaded.has(language)) return { kind: "abstain" };
1460
+ const loaded = this.loaded.get(language);
1461
+ if (!loaded) return { kind: "unresolved", reason: "resolver-unavailable" };
1462
+ const parsed = this.parse(language, loaded, source);
1463
+ if (!parsed) return { kind: "unresolved", reason: "resolver-unavailable" };
1464
+ const wanted = symbol.split(".").filter(Boolean);
1465
+ if (!wanted.length)
1466
+ return { kind: "unresolved", reason: "symbol-not-found" };
1467
+ const matches3 = select(parsed, wanted);
1468
+ if (!matches3.length)
1469
+ return { kind: "unresolved", reason: "symbol-not-found" };
1470
+ if (matches3.length > 1)
1471
+ return { kind: "unresolved", reason: "symbol-ambiguous" };
1472
+ return { kind: "resolved", span: spanOf(matches3[0], source) };
1473
+ }
1474
+ resolve(source, symbol, file) {
1475
+ const attempt = this.attempt(source, symbol, file);
1476
+ return attempt.kind === "resolved" ? attempt.span : null;
1477
+ }
1478
+ /** Parsed trees are keyed by content hash, so an unchanged file parses once. */
1479
+ parse(language, loaded, source) {
1480
+ const key = `${language}:${(0, import_node_crypto2.createHash)("sha256").update(source).digest("hex")}`;
1481
+ const cached2 = this.trees.get(key);
1482
+ if (cached2) {
1483
+ this.stats.cacheHits += 1;
1484
+ return cached2;
1485
+ }
1486
+ const parser = this.parser;
1487
+ if (!parser) return null;
1488
+ let parsed;
1489
+ try {
1490
+ parser.setLanguage(loaded.language);
1491
+ const tree = parser.parse(source);
1492
+ if (!tree) return null;
1493
+ parsed = index(tree, loaded.query);
1494
+ } catch {
1495
+ return null;
1496
+ }
1497
+ this.stats.parses += 1;
1498
+ if (this.trees.size >= TREE_CACHE_LIMIT) {
1499
+ const oldest = this.trees.keys().next();
1500
+ if (!oldest.done) {
1501
+ this.trees.get(oldest.value)?.tree.delete();
1502
+ this.trees.delete(oldest.value);
1503
+ }
1504
+ }
1505
+ this.trees.set(key, parsed);
1506
+ return parsed;
1507
+ }
1508
+ /** Drops cached trees. Grammars stay loaded — they are immutable. */
1509
+ reset() {
1510
+ for (const parsed of this.trees.values()) parsed.tree.delete();
1511
+ this.trees.clear();
1512
+ this.stats.parses = 0;
1513
+ this.stats.cacheHits = 0;
1514
+ }
1515
+ };
1516
+ function why(error) {
1517
+ const text = error instanceof Error ? error.message : String(error);
1518
+ return text || "no reason given";
1519
+ }
1520
+
1521
+ // src/anchor-resolver/resolver.ts
998
1522
  var PARENT_SCOPE_LINES = 50;
999
1523
  var CLEAN_STATE = { blockComment: false, template: false };
1000
1524
  function stripLine(line, state) {
1001
1525
  let out = "";
1002
- let index = 0;
1526
+ let index2 = 0;
1003
1527
  let { blockComment, template } = state;
1004
- while (index < line.length) {
1005
- const char = line[index];
1006
- const next = line[index + 1];
1528
+ while (index2 < line.length) {
1529
+ const char = line[index2];
1530
+ const next = line[index2 + 1];
1007
1531
  if (blockComment) {
1008
1532
  if (char === "*" && next === "/") {
1009
1533
  blockComment = false;
1010
- index += 2;
1534
+ index2 += 2;
1011
1535
  continue;
1012
1536
  }
1013
- index += 1;
1537
+ index2 += 1;
1014
1538
  continue;
1015
1539
  }
1016
1540
  if (template) {
1017
1541
  if (char === "\\") {
1018
- index += 2;
1542
+ index2 += 2;
1019
1543
  continue;
1020
1544
  }
1021
1545
  if (char === "`") template = false;
1022
- index += 1;
1546
+ index2 += 1;
1023
1547
  continue;
1024
1548
  }
1025
1549
  if (char === "/" && next === "*") {
1026
1550
  blockComment = true;
1027
- index += 2;
1551
+ index2 += 2;
1028
1552
  continue;
1029
1553
  }
1030
1554
  if (char === "/" && next === "/") break;
1031
1555
  if (char === "`") {
1032
1556
  template = true;
1033
- index += 1;
1557
+ index2 += 1;
1034
1558
  continue;
1035
1559
  }
1036
1560
  if (char === "'" || char === '"') {
1037
1561
  const quote = char;
1038
- index += 1;
1039
- while (index < line.length) {
1040
- if (line[index] === "\\") {
1041
- index += 2;
1562
+ index2 += 1;
1563
+ while (index2 < line.length) {
1564
+ if (line[index2] === "\\") {
1565
+ index2 += 2;
1042
1566
  continue;
1043
1567
  }
1044
- if (line[index] === quote) {
1045
- index += 1;
1568
+ if (line[index2] === quote) {
1569
+ index2 += 1;
1046
1570
  break;
1047
1571
  }
1048
- index += 1;
1572
+ index2 += 1;
1049
1573
  }
1050
1574
  continue;
1051
1575
  }
1052
1576
  out += char;
1053
- index += 1;
1577
+ index2 += 1;
1054
1578
  }
1055
1579
  return { code: out, state: { blockComment, template } };
1056
1580
  }
@@ -1065,8 +1589,8 @@ function captureBraceBlock(lines, matchLine) {
1065
1589
  let depth = 0;
1066
1590
  let opened = false;
1067
1591
  let state = CLEAN_STATE;
1068
- for (let index = matchLine; index < lines.length; index++) {
1069
- const stripped = stripLine(lines[index] ?? "", state);
1592
+ for (let index2 = matchLine; index2 < lines.length; index2++) {
1593
+ const stripped = stripLine(lines[index2] ?? "", state);
1070
1594
  state = stripped.state;
1071
1595
  for (const char of stripped.code) {
1072
1596
  if (char === "{") {
@@ -1075,10 +1599,10 @@ function captureBraceBlock(lines, matchLine) {
1075
1599
  } else if (char === "}") {
1076
1600
  depth = Math.max(0, depth - 1);
1077
1601
  } else if (char === ";" && !opened) {
1078
- return span(lines, matchLine, index);
1602
+ return span(lines, matchLine, index2);
1079
1603
  }
1080
1604
  }
1081
- if (opened && depth === 0) return span(lines, matchLine, index);
1605
+ if (opened && depth === 0) return span(lines, matchLine, index2);
1082
1606
  }
1083
1607
  return null;
1084
1608
  }
@@ -1087,22 +1611,22 @@ function captureIndentedBlock(lines, matchLine) {
1087
1611
  const header = lines[matchLine] ?? "";
1088
1612
  const indent = header.length - header.trimStart().length;
1089
1613
  let headerEnd = -1;
1090
- for (let index = matchLine; index < lines.length && index <= matchLine + 20; index++) {
1091
- const code = stripLine(lines[index] ?? "", CLEAN_STATE).code.trimEnd();
1614
+ for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1615
+ const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
1092
1616
  if (code.endsWith(":")) {
1093
- headerEnd = index;
1617
+ headerEnd = index2;
1094
1618
  break;
1095
1619
  }
1096
- if (code.includes(":")) return span(lines, matchLine, index);
1620
+ if (code.includes(":")) return span(lines, matchLine, index2);
1097
1621
  }
1098
1622
  if (headerEnd === -1) return null;
1099
1623
  let end = headerEnd;
1100
- for (let index = headerEnd + 1; index < lines.length; index++) {
1101
- const line = lines[index] ?? "";
1624
+ for (let index2 = headerEnd + 1; index2 < lines.length; index2++) {
1625
+ const line = lines[index2] ?? "";
1102
1626
  if (line.trim() === "") continue;
1103
1627
  const lineIndent = line.length - line.trimStart().length;
1104
1628
  if (lineIndent <= indent) break;
1105
- end = index;
1629
+ end = index2;
1106
1630
  }
1107
1631
  return end === headerEnd ? null : span(lines, matchLine, end);
1108
1632
  }
@@ -1126,11 +1650,11 @@ var regexResolver = {
1126
1650
  const lines = source.split("\n");
1127
1651
  for (const tier of TIERS) {
1128
1652
  const pattern = tier(escaped);
1129
- let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1653
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1130
1654
  if (!candidates.length) continue;
1131
1655
  if (parentPattern && candidates.length > 1) {
1132
1656
  const distances = candidates.map(
1133
- (index) => distanceToParent(lines, index, parentPattern)
1657
+ (index2) => distanceToParent(lines, index2, parentPattern)
1134
1658
  );
1135
1659
  const nearest = Math.min(...distances);
1136
1660
  if (Number.isFinite(nearest)) {
@@ -1147,34 +1671,75 @@ var regexResolver = {
1147
1671
  function escapeRegExp(value) {
1148
1672
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1149
1673
  }
1150
- function distanceToParent(lines, index, parent) {
1151
- const floor = Math.max(0, index - PARENT_SCOPE_LINES);
1152
- for (let at = index; at >= floor; at--) {
1153
- if (parent.test(lines[at] ?? "")) return index - at;
1674
+ function distanceToParent(lines, index2, parent) {
1675
+ const floor = Math.max(0, index2 - PARENT_SCOPE_LINES);
1676
+ for (let at = index2; at >= floor; at--) {
1677
+ if (parent.test(lines[at] ?? "")) return index2 - at;
1154
1678
  }
1155
1679
  return Number.POSITIVE_INFINITY;
1156
1680
  }
1157
1681
  function hashAnchorText(text) {
1158
- return `sha256:${(0, import_node_crypto.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1682
+ return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1159
1683
  }
1160
- function resolveAnchor(source, anchor, resolver = regexResolver) {
1684
+ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1161
1685
  const normalized = source.replace(/\r\n/g, "\n");
1162
1686
  if (!anchor.symbol) {
1163
1687
  const lines = normalized.split("\n");
1164
1688
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1165
1689
  return {
1166
- text: normalized,
1167
- startLine: 1,
1168
- endLine: Math.max(1, lines.length)
1690
+ ok: true,
1691
+ span: {
1692
+ text: normalized,
1693
+ startLine: 1,
1694
+ endLine: Math.max(1, lines.length)
1695
+ }
1696
+ };
1697
+ }
1698
+ for (const resolver of resolvers) {
1699
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1700
+ if (attempt.kind === "abstain") continue;
1701
+ if (attempt.kind === "unresolved") {
1702
+ if (attempt.reason === "symbol-not-found") continue;
1703
+ return { ok: false, reason: attempt.reason };
1704
+ }
1705
+ return {
1706
+ ok: true,
1707
+ span: attempt.span,
1708
+ ...isResolverName(resolver.name) ? { resolver: resolver.name } : {}
1169
1709
  };
1170
1710
  }
1171
- return resolver.resolve(normalized, anchor.symbol);
1711
+ return { ok: false, reason: "symbol-not-found" };
1712
+ }
1713
+ function fromResolve(resolver, source, symbol, file) {
1714
+ const span2 = resolver.resolve(source, symbol, file);
1715
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1716
+ }
1717
+ function isResolverName(name) {
1718
+ return name === "tree-sitter" || name === "regex";
1719
+ }
1720
+ async function prepareResolvers(resolvers, files) {
1721
+ for (const resolver of resolvers) await resolver.prepare?.(files);
1722
+ }
1723
+ function defaultAnchorResolvers(grammars = {}) {
1724
+ return [new TreeSitterResolver(grammars), regexResolver];
1725
+ }
1726
+ function resolverChanged(source, anchor, produced) {
1727
+ const previous = anchor.resolver ?? "regex";
1728
+ if (!produced || !anchor.symbol || previous === produced) return false;
1729
+ if (previous !== "regex") return false;
1730
+ const before = regexResolver.resolve(
1731
+ source.replace(/\r\n/g, "\n"),
1732
+ anchor.symbol
1733
+ );
1734
+ return before !== null && hashAnchorText(before.text) === anchor.hash;
1172
1735
  }
1173
1736
 
1174
1737
  // src/anchor-resolver/drift.ts
1175
1738
  async function detectAnchorDrift(records, options = {}) {
1176
1739
  const repoRoot = options.repoRoot ?? process.cwd();
1177
- const resolver = options.resolver ?? regexResolver;
1740
+ const resolvers = options.resolvers ?? (options.resolver ? [options.resolver] : defaultAnchorResolvers({
1741
+ offline: options.remote?.offline === true
1742
+ }));
1178
1743
  const origin = new LazyOrigin(repoRoot);
1179
1744
  const planned = /* @__PURE__ */ new Map();
1180
1745
  let declaresRepo = false;
@@ -1212,12 +1777,16 @@ async function detectAnchorDrift(records, options = {}) {
1212
1777
  ),
1213
1778
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1214
1779
  ]);
1780
+ await prepareResolvers(resolvers, [
1781
+ ...files,
1782
+ ...wants.map((want) => want.file)
1783
+ ]);
1215
1784
  const drift = /* @__PURE__ */ new Map();
1216
1785
  for (const record of records) {
1217
1786
  const entries = [];
1218
1787
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
1219
1788
  entries.push(
1220
- foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1789
+ foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
1221
1790
  );
1222
1791
  }
1223
1792
  if (entries.length) drift.set(record.conceptId, entries);
@@ -1246,12 +1815,22 @@ function unresolved(anchor, reason, repo) {
1246
1815
  ...repo ? { repo } : {}
1247
1816
  };
1248
1817
  }
1249
- function hashIn(source, anchor, resolver) {
1250
- const resolved = resolveAnchor(source, anchor, resolver);
1251
- if (!resolved) return null;
1818
+ function hashIn(source, anchor, resolvers) {
1819
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
1820
+ if (!outcome.ok) return { ok: false, reason: outcome.reason };
1821
+ return {
1822
+ ok: true,
1823
+ current: {
1824
+ hash: hashAnchorText(outcome.span.text),
1825
+ lines: outcome.span.endLine - outcome.span.startLine + 1,
1826
+ ...outcome.resolver ? { resolver: outcome.resolver } : {}
1827
+ }
1828
+ };
1829
+ }
1830
+ function resolverExtras(source, anchor, current) {
1252
1831
  return {
1253
- hash: hashAnchorText(resolved.text),
1254
- lines: resolved.endLine - resolved.startLine + 1
1832
+ ...current.resolver ? { resolver: current.resolver } : {},
1833
+ ...current.hash !== anchor.hash && resolverChanged(source, anchor, current.resolver) ? { reason: "resolver-changed" } : {}
1255
1834
  };
1256
1835
  }
1257
1836
  function compared(anchor, current, extra = {}) {
@@ -1263,30 +1842,48 @@ function compared(anchor, current, extra = {}) {
1263
1842
  ...extra
1264
1843
  };
1265
1844
  }
1266
- function localEntry(anchor, read, resolver) {
1845
+ function localEntry(anchor, read, resolvers) {
1267
1846
  if (!read.ok) return unresolved(anchor, read.reason);
1268
- const current = hashIn(read.source, anchor, resolver);
1269
- return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1847
+ const found = hashIn(read.source, anchor, resolvers);
1848
+ if (!found.ok) return unresolved(anchor, found.reason);
1849
+ return compared(
1850
+ anchor,
1851
+ found.current,
1852
+ resolverExtras(read.source, anchor, found.current)
1853
+ );
1270
1854
  }
1271
- function remoteEntry(anchor, remote, resolver) {
1855
+ function remoteEntry(anchor, remote, resolvers) {
1272
1856
  const repo = anchor.repo;
1273
1857
  const key = normalizeRepoUrl(repo);
1274
1858
  const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1275
1859
  const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1276
1860
  if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1277
1861
  if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1278
- const current = hashIn(primary.source, anchor, resolver);
1279
- if (!current) return unresolved(anchor, "symbol-not-found", repo);
1280
- if (!anchor.ref) return compared(anchor, current, { repo });
1862
+ const found = hashIn(primary.source, anchor, resolvers);
1863
+ if (!found.ok) return unresolved(anchor, found.reason, repo);
1864
+ const current = found.current;
1865
+ const extras = resolverExtras(primary.source, anchor, current);
1866
+ if (!anchor.ref) return compared(anchor, current, { repo, ...extras });
1281
1867
  if (current.hash !== anchor.hash) {
1282
- return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1868
+ return compared(anchor, current, {
1869
+ repo,
1870
+ ...extras,
1871
+ remoteState: "drifted-from-ref"
1872
+ });
1283
1873
  }
1284
- const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1285
- return head && head.hash !== anchor.hash ? {
1286
- ...compared(anchor, head, { repo }),
1874
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
1875
+ return head?.ok && head.current.hash !== anchor.hash ? {
1876
+ ...compared(anchor, head.current, {
1877
+ repo,
1878
+ ...head.current.resolver ? { resolver: head.current.resolver } : {}
1879
+ }),
1287
1880
  state: "drifted",
1288
1881
  remoteState: "drifted-on-default"
1289
- } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1882
+ } : compared(anchor, current, {
1883
+ repo,
1884
+ ...extras,
1885
+ remoteState: "matches-ref"
1886
+ });
1290
1887
  }
1291
1888
 
1292
1889
  // src/errors.ts
@@ -1440,13 +2037,43 @@ var KbInvalidConceptIdError = class extends BaseError {
1440
2037
  });
1441
2038
  }
1442
2039
  };
2040
+ var KbStampBaselineError = class extends BaseError {
2041
+ constructor(since) {
2042
+ super({
2043
+ message: `kb: --since ${since} is neither a 64-character digest nor a readable stamp file`,
2044
+ errorType: "KbStampBaselineUnreadable" /* KbStampBaselineUnreadable */,
2045
+ code: 400,
2046
+ fault: "User" /* User */,
2047
+ retriable: false,
2048
+ reportToUser: true,
2049
+ details: { since }
2050
+ });
2051
+ this.since = since;
2052
+ }
2053
+ since;
2054
+ };
2055
+ var KbStampDigestBaselineError = class extends BaseError {
2056
+ constructor(since) {
2057
+ super({
2058
+ message: `kb: --since ${since} is a digest, which needs --bundle (one base) \u2014 a file baseline works for many`,
2059
+ errorType: "KbStampDigestBaselineAmbiguous" /* KbStampDigestBaselineAmbiguous */,
2060
+ code: 400,
2061
+ fault: "User" /* User */,
2062
+ retriable: false,
2063
+ reportToUser: true,
2064
+ details: { since }
2065
+ });
2066
+ this.since = since;
2067
+ }
2068
+ since;
2069
+ };
1443
2070
 
1444
2071
  // src/kb-pins/budgets.ts
1445
2072
  function asBudgets(value) {
1446
2073
  if (value === null || typeof value !== "object") return {};
1447
- const table = value;
2074
+ const table2 = value;
1448
2075
  const pick = (key, min) => {
1449
- const raw = table[key];
2076
+ const raw = table2[key];
1450
2077
  return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
1451
2078
  };
1452
2079
  const budgetTokens = pick("budgetTokens", 1);
@@ -1457,9 +2084,9 @@ function asBudgets(value) {
1457
2084
  };
1458
2085
  }
1459
2086
  function contextProfileBudgets(manifest, profile) {
1460
- const table = manifest.context;
1461
- if (table === null || typeof table !== "object") return {};
1462
- const entries = table;
2087
+ const table2 = manifest.context;
2088
+ if (table2 === null || typeof table2 !== "object") return {};
2089
+ const entries = table2;
1463
2090
  return {
1464
2091
  ...asBudgets(entries["default"]),
1465
2092
  ...profile ? asBudgets(entries[profile]) : {}
@@ -1490,23 +2117,23 @@ var KbBaseFrozenError = class extends Error {
1490
2117
  };
1491
2118
 
1492
2119
  // src/kb-pins/frozen.ts
1493
- var import_node_path5 = require("path");
2120
+ var import_node_path8 = require("path");
1494
2121
 
1495
2122
  // src/kb-pins/layers.ts
1496
- var import_promises3 = require("fs/promises");
1497
- var import_node_os2 = require("os");
1498
- var import_node_path4 = require("path");
2123
+ var import_promises5 = require("fs/promises");
2124
+ var import_node_os3 = require("os");
2125
+ var import_node_path7 = require("path");
1499
2126
 
1500
2127
  // src/kb-pins/model.ts
1501
- var import_node_path3 = require("path");
1502
- var import_zod4 = require("zod");
1503
- var PINS_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.json");
1504
- var PINS_LOCAL_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.local.json");
2128
+ var import_node_path6 = require("path");
2129
+ var import_zod5 = require("zod");
2130
+ var PINS_FILE = (0, import_node_path6.join)(".strauss", "kb-pins.json");
2131
+ var PINS_LOCAL_FILE = (0, import_node_path6.join)(".strauss", "kb-pins.local.json");
1505
2132
  var PIN_LAYERS = ["project", "local", "user"];
1506
- var pinSchema = import_zod4.z.object({
2133
+ var pinSchema = import_zod5.z.object({
1507
2134
  /** Relative to the manifest's root, so the file is committable. */
1508
- path: import_zod4.z.string().min(1),
1509
- pinnedAt: import_zod4.z.string().min(1).optional(),
2135
+ path: import_zod5.z.string().min(1),
2136
+ pinnedAt: import_zod5.z.string().min(1).optional(),
1510
2137
  /**
1511
2138
  * How `context` renders this base. `full` preloads the whole base into
1512
2139
  * the block regardless of the full-under threshold — for a base whose
@@ -1516,7 +2143,7 @@ var pinSchema = import_zod4.z.object({
1516
2143
  * Absent: the profile's full-under threshold decides. Invalid values
1517
2144
  * degrade to absent rather than failing the manifest.
1518
2145
  */
1519
- mode: import_zod4.z.enum(["full", "index"]).optional().catch(void 0),
2146
+ mode: import_zod5.z.enum(["full", "index"]).optional().catch(void 0),
1520
2147
  /**
1521
2148
  * Context profiles this pin surfaces in (e.g. only at session-start,
1522
2149
  * not per turn). Absent: every profile. A run without a profile sees
@@ -1524,17 +2151,17 @@ var pinSchema = import_zod4.z.object({
1524
2151
  * that skill at point of use than pinned at all — pins are what every
1525
2152
  * session should see.
1526
2153
  */
1527
- profiles: import_zod4.z.array(import_zod4.z.string()).optional().catch(void 0),
2154
+ profiles: import_zod5.z.array(import_zod5.z.string()).optional().catch(void 0),
1528
2155
  /**
1529
2156
  * The base is concluded — a finished piece of research, a frozen ADR
1530
2157
  * set. Write commands against it refuse while this workspace holds the
1531
2158
  * pin, and `context` labels it read-only. Workspace policy, not base
1532
2159
  * state: the base itself stays copyable and writable elsewhere.
1533
2160
  */
1534
- frozen: import_zod4.z.boolean().optional().catch(void 0)
2161
+ frozen: import_zod5.z.boolean().optional().catch(void 0)
1535
2162
  }).passthrough();
1536
- var pinsManifestSchema = import_zod4.z.object({
1537
- pins: import_zod4.z.array(pinSchema).default([]),
2163
+ var pinsManifestSchema = import_zod5.z.object({
2164
+ pins: import_zod5.z.array(pinSchema).default([]),
1538
2165
  /**
1539
2166
  * Per-repo budgets for the `context` command, keyed by profile —
1540
2167
  * `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
@@ -1543,18 +2170,18 @@ var pinsManifestSchema = import_zod4.z.object({
1543
2170
  * the index at every session start. `contextProfileBudgets` does the
1544
2171
  * tolerant read.
1545
2172
  */
1546
- context: import_zod4.z.unknown().optional()
2173
+ context: import_zod5.z.unknown().optional()
1547
2174
  }).passthrough();
1548
2175
 
1549
2176
  // src/kb-pins/layers.ts
1550
2177
  function userRoot() {
1551
- return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
2178
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os3.homedir)();
1552
2179
  }
1553
2180
  function layerRoot(workspaceDir, layer) {
1554
- return layer === "user" ? userRoot() : (0, import_node_path4.resolve)(workspaceDir);
2181
+ return layer === "user" ? userRoot() : (0, import_node_path7.resolve)(workspaceDir);
1555
2182
  }
1556
2183
  function layerFile(workspaceDir, layer) {
1557
- return (0, import_node_path4.join)(
2184
+ return (0, import_node_path7.join)(
1558
2185
  layerRoot(workspaceDir, layer),
1559
2186
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1560
2187
  );
@@ -1563,7 +2190,7 @@ async function readPinsLayer(workspaceDir, layer) {
1563
2190
  const file = layerFile(workspaceDir, layer);
1564
2191
  let raw;
1565
2192
  try {
1566
- raw = await (0, import_promises3.readFile)(file, "utf8");
2193
+ raw = await (0, import_promises5.readFile)(file, "utf8");
1567
2194
  } catch {
1568
2195
  return { pins: [] };
1569
2196
  }
@@ -1587,16 +2214,16 @@ async function readPinsLayer(workspaceDir, layer) {
1587
2214
  }
1588
2215
  async function writePinsLayer(workspaceDir, layer, manifest) {
1589
2216
  const file = layerFile(workspaceDir, layer);
1590
- await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(file), { recursive: true });
1591
- await (0, import_promises3.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
2217
+ await (0, import_promises5.mkdir)((0, import_node_path7.dirname)(file), { recursive: true });
2218
+ await (0, import_promises5.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1592
2219
  `, "utf8");
1593
2220
  }
1594
2221
  function resolvePinPath(rootDir, path) {
1595
- return (0, import_node_path4.isAbsolute)(path) ? (0, import_node_path4.resolve)(path) : (0, import_node_path4.resolve)(rootDir, path.split("/").join(import_node_path4.sep));
2222
+ return (0, import_node_path7.isAbsolute)(path) ? (0, import_node_path7.resolve)(path) : (0, import_node_path7.resolve)(rootDir, path.split("/").join(import_node_path7.sep));
1596
2223
  }
1597
2224
  function storablePath(rootDir, bundlePath2) {
1598
- const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(rootDir), (0, import_node_path4.resolve)(bundlePath2));
1599
- return (rel === "" ? "." : rel).split(import_node_path4.sep).join("/");
2225
+ const rel = (0, import_node_path7.relative)((0, import_node_path7.resolve)(rootDir), (0, import_node_path7.resolve)(bundlePath2));
2226
+ return (rel === "" ? "." : rel).split(import_node_path7.sep).join("/");
1600
2227
  }
1601
2228
  async function readMergedPins(workspaceDir) {
1602
2229
  const manifests = {};
@@ -1624,7 +2251,7 @@ async function readMergedPins(workspaceDir) {
1624
2251
  // src/kb-pins/frozen.ts
1625
2252
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
1626
2253
  const merged = await readMergedPins(workspaceDir);
1627
- const absolute = (0, import_node_path5.resolve)(bundlePath2);
2254
+ const absolute = (0, import_node_path8.resolve)(bundlePath2);
1628
2255
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
1629
2256
  if (pin?.frozen === true) {
1630
2257
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -1709,7 +2336,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1709
2336
  }
1710
2337
 
1711
2338
  // src/kb-pins/unpin.ts
1712
- var import_node_path6 = require("path");
2339
+ var import_node_path9 = require("path");
1713
2340
  async function unpinBase(workspaceDir, bundlePath2) {
1714
2341
  const layers = [];
1715
2342
  for (const layer of PIN_LAYERS) {
@@ -1730,17 +2357,17 @@ async function unpinBase(workspaceDir, bundlePath2) {
1730
2357
  }
1731
2358
  }
1732
2359
  return {
1733
- path: storablePath((0, import_node_path6.resolve)(workspaceDir), bundlePath2),
2360
+ path: storablePath((0, import_node_path9.resolve)(workspaceDir), bundlePath2),
1734
2361
  removed: layers.length > 0,
1735
2362
  layers
1736
2363
  };
1737
2364
  }
1738
2365
 
1739
2366
  // src/commands/model.ts
1740
- var import_zod5 = require("zod");
1741
- var bundlePath = import_zod5.z.string().min(1).describe("Absolute path to the knowledge base directory.");
1742
- var conceptId = import_zod5.z.string().min(1).describe("e.g. decision.cursor-v2");
1743
- var REPO_ROOT = import_zod5.z.string().min(1).optional().describe(
2367
+ var import_zod6 = require("zod");
2368
+ var bundlePath = import_zod6.z.string().min(1).describe("Absolute path to the knowledge base directory.");
2369
+ var conceptId = import_zod6.z.string().min(1).describe("e.g. decision.cursor-v2");
2370
+ var REPO_ROOT = import_zod6.z.string().min(1).optional().describe(
1744
2371
  "Where the anchored source lives, for the drift check. Defaults to the working directory."
1745
2372
  );
1746
2373
  function define(command) {
@@ -1763,22 +2390,30 @@ function argvFlag(argv, name) {
1763
2390
  }
1764
2391
 
1765
2392
  // src/commands/anchor-resolve.ts
2393
+ function resolverSummary(results) {
2394
+ const names = [
2395
+ ...new Set(
2396
+ results.flatMap((entry) => entry.resolver ? [entry.resolver] : [])
2397
+ )
2398
+ ].sort();
2399
+ return names.length ? `${names.join(" + ")} resolver` : "whole-file";
2400
+ }
1766
2401
  var anchorResolveCommand = define({
1767
2402
  name: "anchor-resolve",
1768
2403
  tool: "kb_anchor_resolve",
1769
2404
  usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
1770
2405
  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.",
1771
- input: import_zod6.z.object({
2406
+ input: import_zod7.z.object({
1772
2407
  bundlePath,
1773
2408
  conceptId,
1774
- repoRoot: import_zod6.z.string().min(1).optional(),
1775
- offline: import_zod6.z.boolean().optional().describe(
2409
+ repoRoot: import_zod7.z.string().min(1).optional(),
2410
+ offline: import_zod7.z.boolean().optional().describe(
1776
2411
  "Resolve foreign anchors from the local repo cache only, never fetching."
1777
2412
  ),
1778
- rebaseline: import_zod6.z.boolean().optional().describe(
2413
+ rebaseline: import_zod7.z.boolean().optional().describe(
1779
2414
  "Accept the current code as the new baseline for anchors that drifted."
1780
2415
  ),
1781
- restamp: import_zod6.z.boolean().optional().describe(
2416
+ restamp: import_zod7.z.boolean().optional().describe(
1782
2417
  "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
1783
2418
  )
1784
2419
  }),
@@ -1807,6 +2442,11 @@ var anchorResolveCommand = define({
1807
2442
  const updated = [];
1808
2443
  let dirty = false;
1809
2444
  const sources = await readSources(anchors, root, offline === true);
2445
+ const resolvers = defaultAnchorResolvers({ offline: offline === true });
2446
+ await prepareResolvers(
2447
+ resolvers,
2448
+ anchors.map((anchor) => anchor.file)
2449
+ );
1810
2450
  for (const anchor of anchors) {
1811
2451
  const base2 = {
1812
2452
  file: anchor.file,
@@ -1823,27 +2463,35 @@ var anchorResolveCommand = define({
1823
2463
  updated.push(anchor);
1824
2464
  continue;
1825
2465
  }
1826
- const resolved = resolveAnchor(source.source, anchor);
1827
- if (!resolved) {
2466
+ const outcome = resolveAnchorSpan(source.source, anchor, resolvers);
2467
+ if (!outcome.ok) {
1828
2468
  results.push({
1829
2469
  ...base2,
1830
2470
  state: "unresolved",
1831
- reason: "symbol-not-found"
2471
+ reason: outcome.reason
1832
2472
  });
1833
2473
  updated.push(anchor);
1834
2474
  continue;
1835
2475
  }
2476
+ const resolved = outcome.span;
2477
+ const producedBy = outcome.resolver;
1836
2478
  const currentHash = hashAnchorText(resolved.text);
1837
2479
  const currentLines = resolved.endLine - resolved.startLine + 1;
1838
2480
  const stamped = {
1839
2481
  ...anchor,
1840
2482
  hash: currentHash,
1841
2483
  lines: currentLines,
1842
- resolved_at: now()
2484
+ resolved_at: now(),
2485
+ ...producedBy ? { resolver: producedBy } : {}
1843
2486
  };
1844
2487
  const pinned = anchor.ref !== void 0 && source.repo !== void 0;
1845
2488
  if (!anchor.hash) {
1846
- results.push({ ...base2, state: "stamped", currentHash });
2489
+ results.push({
2490
+ ...base2,
2491
+ state: "stamped",
2492
+ currentHash,
2493
+ ...producedBy ? { resolver: producedBy } : {}
2494
+ });
1847
2495
  updated.push(stamped);
1848
2496
  dirty = true;
1849
2497
  continue;
@@ -1854,6 +2502,10 @@ var anchorResolveCommand = define({
1854
2502
  state: "drifted",
1855
2503
  currentHash,
1856
2504
  diffSize: lineDelta(anchor, currentLines),
2505
+ ...producedBy ? { resolver: producedBy } : {},
2506
+ // A regex-stamped anchor re-read by tree-sitter drifts because the
2507
+ // resolver changed, not because the code did.
2508
+ ...resolverChanged(source.source, anchor, producedBy) ? { reason: "resolver-changed" } : {},
1857
2509
  ...pinned ? { remoteState: "drifted-from-ref" } : {},
1858
2510
  ...rebaseline ? { rebaselined: true } : {}
1859
2511
  });
@@ -1861,7 +2513,7 @@ var anchorResolveCommand = define({
1861
2513
  if (rebaseline) dirty = true;
1862
2514
  continue;
1863
2515
  }
1864
- const onDefault = pinned ? headHash(source, anchor) : void 0;
2516
+ const onDefault = pinned ? headHash(source, anchor, resolvers) : void 0;
1865
2517
  if (onDefault && onDefault.hash !== anchor.hash) {
1866
2518
  results.push({
1867
2519
  ...base2,
@@ -1877,6 +2529,7 @@ var anchorResolveCommand = define({
1877
2529
  ...base2,
1878
2530
  state: "match",
1879
2531
  currentHash,
2532
+ ...producedBy ? { resolver: producedBy } : {},
1880
2533
  ...pinned ? { remoteState: "matches-ref" } : {}
1881
2534
  });
1882
2535
  const refresh = restamp || anchor.resolved_at === void 0;
@@ -1894,19 +2547,21 @@ var anchorResolveCommand = define({
1894
2547
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
1895
2548
  }
1896
2549
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
2550
+ const hints = grammarHints();
2551
+ const hintNote = hints.length ? { hints } : {};
1897
2552
  const unreachable = results.filter(
1898
2553
  (entry) => isUncheckedReason(entry.reason)
1899
2554
  ).length;
1900
2555
  const checked = results.length - unreachable;
1901
- const matches2 = results.filter((entry) => entry.state === "match").length;
1902
- const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
1903
- const clean = checked > 0 && matches2 === checked && unreachable === 0;
2556
+ const matches3 = results.filter((entry) => entry.state === "match").length;
2557
+ const note = `${matches3}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
2558
+ const clean = checked > 0 && matches3 === checked && unreachable === 0;
1904
2559
  if (clean) {
1905
2560
  try {
1906
2561
  await store.verify(
1907
2562
  path,
1908
2563
  id,
1909
- `anchor-resolve: ${note} (regex resolver)`,
2564
+ `anchor-resolve: ${note} (${resolverSummary(results)})`,
1910
2565
  actor,
1911
2566
  now()
1912
2567
  );
@@ -1917,17 +2572,25 @@ var anchorResolveCommand = define({
1917
2572
  results,
1918
2573
  verified: false,
1919
2574
  verifyRefused: "self-verification",
1920
- ...frozenNote
2575
+ ...frozenNote,
2576
+ ...hintNote
1921
2577
  };
1922
2578
  }
1923
- return { conceptId: id, results, verified: true, ...frozenNote };
2579
+ return {
2580
+ conceptId: id,
2581
+ results,
2582
+ verified: true,
2583
+ ...frozenNote,
2584
+ ...hintNote
2585
+ };
1924
2586
  }
1925
2587
  return {
1926
2588
  conceptId: id,
1927
2589
  results,
1928
2590
  verified: false,
1929
2591
  ...unreachable ? { note } : {},
1930
- ...frozenNote
2592
+ ...frozenNote,
2593
+ ...hintNote
1931
2594
  };
1932
2595
  },
1933
2596
  // A stored hash that no longer resolves is a broken anchor, not an absence:
@@ -1943,13 +2606,13 @@ var anchorResolveCommand = define({
1943
2606
  function lineDelta(anchor, current) {
1944
2607
  return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
1945
2608
  }
1946
- function headHash(source, anchor) {
2609
+ function headHash(source, anchor, resolvers) {
1947
2610
  if (source.head === void 0) return void 0;
1948
- const resolved = resolveAnchor(source.head, anchor);
1949
- if (!resolved) return void 0;
2611
+ const outcome = resolveAnchorSpan(source.head, anchor, resolvers);
2612
+ if (!outcome.ok) return void 0;
1950
2613
  return {
1951
- hash: hashAnchorText(resolved.text),
1952
- lines: resolved.endLine - resolved.startLine + 1
2614
+ hash: hashAnchorText(outcome.span.text),
2615
+ lines: outcome.span.endLine - outcome.span.startLine + 1
1953
2616
  };
1954
2617
  }
1955
2618
  async function readSources(anchors, root, offline) {
@@ -1999,13 +2662,13 @@ async function readSources(anchors, root, offline) {
1999
2662
  }
2000
2663
 
2001
2664
  // src/commands/answer.ts
2002
- var import_zod7 = require("zod");
2665
+ var import_zod8 = require("zod");
2003
2666
  var answerCommand = define({
2004
2667
  name: "answer",
2005
2668
  tool: "kb_answer",
2006
2669
  usage: "answer <concept-id> <answer...>",
2007
2670
  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.",
2008
- input: import_zod7.z.object({ bundlePath, conceptId, answer: import_zod7.z.string().min(1) }),
2671
+ input: import_zod8.z.object({ bundlePath, conceptId, answer: import_zod8.z.string().min(1) }),
2009
2672
  fromArgv: (argv, path) => ({
2010
2673
  bundlePath: path,
2011
2674
  conceptId: argv[1],
@@ -2019,19 +2682,19 @@ var answerCommand = define({
2019
2682
  });
2020
2683
 
2021
2684
  // src/commands/backlinks.ts
2022
- var import_zod8 = require("zod");
2685
+ var import_zod9 = require("zod");
2023
2686
  var backlinksCommand = define({
2024
2687
  name: "backlinks",
2025
2688
  tool: "kb_backlinks",
2026
2689
  usage: "backlinks <concept-id>",
2027
2690
  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.",
2028
- input: import_zod8.z.object({ bundlePath, conceptId }),
2691
+ input: import_zod9.z.object({ bundlePath, conceptId }),
2029
2692
  fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
2030
2693
  run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
2031
2694
  });
2032
2695
 
2033
2696
  // src/commands/catalog.ts
2034
- var import_zod9 = require("zod");
2697
+ var import_zod10 = require("zod");
2035
2698
 
2036
2699
  // src/adjudicate.ts
2037
2700
  var STANDING = {
@@ -2109,8 +2772,8 @@ function resolveHeads(from, byId) {
2109
2772
  while (queue.length) {
2110
2773
  const current = queue.shift();
2111
2774
  const next = successors(current, byId);
2112
- for (const missing of next.missing) {
2113
- warnings.push({ kind: "broken-chain", missing });
2775
+ for (const missing2 of next.missing) {
2776
+ warnings.push({ kind: "broken-chain", missing: missing2 });
2114
2777
  }
2115
2778
  if (!next.records.length) {
2116
2779
  if (current.conceptId !== from.conceptId)
@@ -2141,13 +2804,13 @@ function successors(record, byId) {
2141
2804
  }
2142
2805
  }
2143
2806
  const records = [];
2144
- const missing = [];
2807
+ const missing2 = [];
2145
2808
  for (const id of ids) {
2146
2809
  const found = byId.get(id);
2147
2810
  if (found) records.push(found);
2148
- else missing.push(id);
2811
+ else missing2.push(id);
2149
2812
  }
2150
- return { records, missing };
2813
+ return { records, missing: missing2 };
2151
2814
  }
2152
2815
 
2153
2816
  // src/catalog.ts
@@ -2202,9 +2865,9 @@ var catalogCommand = define({
2202
2865
  tool: "kb_catalog",
2203
2866
  usage: "catalog [type]",
2204
2867
  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.",
2205
- input: import_zod9.z.object({
2868
+ input: import_zod10.z.object({
2206
2869
  bundlePath,
2207
- type: import_zod9.z.enum(KB_RECORD_TYPES).optional()
2870
+ type: import_zod10.z.enum(KB_RECORD_TYPES).optional()
2208
2871
  }),
2209
2872
  fromArgv: (argv, path) => ({
2210
2873
  bundlePath: path,
@@ -2259,10 +2922,10 @@ function count(value, noun) {
2259
2922
  }
2260
2923
 
2261
2924
  // src/commands/context.ts
2262
- var import_zod10 = require("zod");
2925
+ var import_zod11 = require("zod");
2263
2926
 
2264
2927
  // src/kb-context.ts
2265
- var import_promises4 = require("fs/promises");
2928
+ var import_promises6 = require("fs/promises");
2266
2929
 
2267
2930
  // src/kb-index.ts
2268
2931
  var INDEX_FILE = "INDEX.md";
@@ -2489,13 +3152,13 @@ function toHookJson(block, event) {
2489
3152
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2490
3153
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2491
3154
  async function syncInstructions(file, block) {
2492
- const existing = await (0, import_promises4.readFile)(file, "utf8").catch(() => null);
3155
+ const existing = await (0, import_promises6.readFile)(file, "utf8").catch(() => null);
2493
3156
  const region = block ? `${CONTEXT_BEGIN}
2494
3157
  ${block.trim()}
2495
3158
  ${CONTEXT_END}` : null;
2496
3159
  if (existing === null) {
2497
3160
  if (!region) return { file, action: "unchanged" };
2498
- await (0, import_promises4.writeFile)(file, `${region}
3161
+ await (0, import_promises6.writeFile)(file, `${region}
2499
3162
  `, "utf8");
2500
3163
  return { file, action: "created" };
2501
3164
  }
@@ -2506,11 +3169,11 @@ ${CONTEXT_END}` : null;
2506
3169
  const after = existing.slice(end + CONTEXT_END.length);
2507
3170
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2508
3171
  if (next === existing) return { file, action: "unchanged" };
2509
- await (0, import_promises4.writeFile)(file, next, "utf8");
3172
+ await (0, import_promises6.writeFile)(file, next, "utf8");
2510
3173
  return { file, action: region ? "replaced" : "removed" };
2511
3174
  }
2512
3175
  if (!region) return { file, action: "unchanged" };
2513
- await (0, import_promises4.writeFile)(
3176
+ await (0, import_promises6.writeFile)(
2514
3177
  file,
2515
3178
  `${existing.replace(/\n*$/, "\n\n")}${region}
2516
3179
  `,
@@ -2525,20 +3188,20 @@ var contextCommand = define({
2525
3188
  tool: "kb_context",
2526
3189
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
2527
3190
  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.",
2528
- input: import_zod10.z.object({
2529
- budgetTokens: import_zod10.z.number().int().positive().optional().describe(
3191
+ input: import_zod11.z.object({
3192
+ budgetTokens: import_zod11.z.number().int().positive().optional().describe(
2530
3193
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
2531
3194
  ),
2532
- fullUnderTokens: import_zod10.z.number().int().positive().optional().describe(
3195
+ fullUnderTokens: import_zod11.z.number().int().positive().optional().describe(
2533
3196
  "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."
2534
3197
  ),
2535
- profile: import_zod10.z.string().optional().describe(
3198
+ profile: import_zod11.z.string().optional().describe(
2536
3199
  "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."
2537
3200
  ),
2538
- format: import_zod10.z.enum(["markdown", "json"]).optional().describe(
3201
+ format: import_zod11.z.enum(["markdown", "json"]).optional().describe(
2539
3202
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
2540
3203
  ),
2541
- event: import_zod10.z.string().optional().describe(
3204
+ event: import_zod11.z.string().optional().describe(
2542
3205
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
2543
3206
  )
2544
3207
  }),
@@ -2574,7 +3237,7 @@ var contextCommand = define({
2574
3237
  });
2575
3238
 
2576
3239
  // src/commands/doctor.ts
2577
- var import_zod11 = require("zod");
3240
+ var import_zod12 = require("zod");
2578
3241
 
2579
3242
  // src/kb-edges.ts
2580
3243
  var KB_EDGE_KINDS = [
@@ -2760,6 +3423,18 @@ var CHECK_HEADLINES = {
2760
3423
  unchecked: "an anchor in another repository nothing could reach"
2761
3424
  };
2762
3425
  var DAY_MS = 864e5;
3426
+ function anchorResolverCounts(bundle) {
3427
+ let treeSitter = 0;
3428
+ let regex = 0;
3429
+ for (const record of bundle) {
3430
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
3431
+ if (!anchor.hash || !anchor.symbol) continue;
3432
+ if (anchor.resolver === "tree-sitter") treeSitter += 1;
3433
+ else regex += 1;
3434
+ }
3435
+ }
3436
+ return { total: treeSitter + regex, treeSitter, regex };
3437
+ }
2763
3438
  function doctor(bundle, options = {}) {
2764
3439
  const thresholds = {
2765
3440
  expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
@@ -2795,6 +3470,7 @@ function doctor(bundle, options = {}) {
2795
3470
  counts,
2796
3471
  groups,
2797
3472
  findingCount,
3473
+ anchorResolvers: anchorResolverCounts(bundle),
2798
3474
  healthy: findingCount === 0
2799
3475
  };
2800
3476
  }
@@ -3041,13 +3717,13 @@ function ageInDays(record, now) {
3041
3717
  }
3042
3718
 
3043
3719
  // src/commands/doctor.ts
3044
- var days = (what, fallback) => import_zod11.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3720
+ var days = (what, fallback) => import_zod12.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3045
3721
  var doctorCommand = define({
3046
3722
  name: "doctor",
3047
3723
  tool: "kb_doctor",
3048
3724
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3049
3725
  description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
3050
- input: import_zod11.z.object({
3726
+ input: import_zod12.z.object({
3051
3727
  bundlePath,
3052
3728
  repoRoot: REPO_ROOT,
3053
3729
  expiringDays: days(
@@ -3062,10 +3738,10 @@ var doctorCommand = define({
3062
3738
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3063
3739
  DEFAULT_AGING_DAYS
3064
3740
  ),
3065
- offline: import_zod11.z.boolean().optional().describe(
3741
+ offline: import_zod12.z.boolean().optional().describe(
3066
3742
  "Read foreign anchors from the local repo cache only, never fetching."
3067
3743
  ),
3068
- strict: import_zod11.z.boolean().optional().describe(
3744
+ strict: import_zod12.z.boolean().optional().describe(
3069
3745
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
3070
3746
  )
3071
3747
  }),
@@ -3108,7 +3784,13 @@ var doctorCommand = define({
3108
3784
  ...anchorDrift !== void 0 ? { anchorDrift } : {},
3109
3785
  now: new Date(checkedAt)
3110
3786
  });
3111
- return { bundlePath: path, checkedAt, ...report };
3787
+ const hints = grammarHints();
3788
+ return {
3789
+ bundlePath: path,
3790
+ checkedAt,
3791
+ ...report,
3792
+ ...hints.length ? { hints } : {}
3793
+ };
3112
3794
  },
3113
3795
  render: (result) => render2(result),
3114
3796
  // Only expiry, and only under --strict. The other seven checks report debt a
@@ -3126,12 +3808,15 @@ function render2(result) {
3126
3808
  `records: ${result.recordCount}`,
3127
3809
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
3128
3810
  `checked: ${result.checkedAt}`,
3811
+ ...result.anchorResolvers.total ? [
3812
+ `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
3813
+ ] : [],
3129
3814
  ""
3130
3815
  ];
3131
- const width = Math.max(...result.groups.map((group2) => group2.check.length));
3816
+ const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
3132
3817
  for (const group2 of result.groups) {
3133
3818
  lines.push(
3134
- ` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
3819
+ ` ${group2.check.padEnd(width2)} ${String(group2.count).padStart(3)} ${group2.headline}`
3135
3820
  );
3136
3821
  }
3137
3822
  for (const group2 of result.groups) {
@@ -3143,6 +3828,7 @@ function render2(result) {
3143
3828
  );
3144
3829
  }
3145
3830
  }
3831
+ for (const hint of result.hints ?? []) lines.push("", hint);
3146
3832
  lines.push(
3147
3833
  "",
3148
3834
  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.`
@@ -3151,19 +3837,19 @@ function render2(result) {
3151
3837
  }
3152
3838
 
3153
3839
  // src/commands/impact.ts
3154
- var import_zod12 = require("zod");
3840
+ var import_zod13 = require("zod");
3155
3841
  var impactCommand = define({
3156
3842
  name: "impact",
3157
3843
  tool: "kb_impact",
3158
3844
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
3159
3845
  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.",
3160
- input: import_zod12.z.object({
3846
+ input: import_zod13.z.object({
3161
3847
  bundlePath,
3162
3848
  conceptId,
3163
- depth: import_zod12.z.number().int().positive().optional().describe(
3849
+ depth: import_zod13.z.number().int().positive().optional().describe(
3164
3850
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
3165
3851
  ),
3166
- rels: import_zod12.z.array(import_zod12.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3852
+ rels: import_zod13.z.array(import_zod13.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3167
3853
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
3168
3854
  )
3169
3855
  }),
@@ -3184,13 +3870,13 @@ var impactCommand = define({
3184
3870
  });
3185
3871
 
3186
3872
  // src/commands/list.ts
3187
- var import_zod13 = require("zod");
3873
+ var import_zod14 = require("zod");
3188
3874
  var listCommand = define({
3189
3875
  name: "list",
3190
3876
  tool: "kb_list",
3191
3877
  usage: "list [type]",
3192
3878
  description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
3193
- input: import_zod13.z.object({ bundlePath, type: import_zod13.z.enum(KB_RECORD_TYPES).optional() }),
3879
+ input: import_zod14.z.object({ bundlePath, type: import_zod14.z.enum(KB_RECORD_TYPES).optional() }),
3194
3880
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
3195
3881
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
3196
3882
  conceptId: record.conceptId,
@@ -3202,17 +3888,17 @@ var listCommand = define({
3202
3888
  });
3203
3889
 
3204
3890
  // src/commands/load.ts
3205
- var import_zod14 = require("zod");
3891
+ var import_zod15 = require("zod");
3206
3892
  var loadCommand = define({
3207
3893
  name: "load",
3208
3894
  tool: "kb_load",
3209
3895
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
3210
3896
  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.",
3211
- input: import_zod14.z.object({
3897
+ input: import_zod15.z.object({
3212
3898
  bundlePath,
3213
- type: import_zod14.z.enum(KB_RECORD_TYPES).optional(),
3214
- budgetTokens: import_zod14.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3215
- all: import_zod14.z.boolean().optional().describe(
3899
+ type: import_zod15.z.enum(KB_RECORD_TYPES).optional(),
3900
+ budgetTokens: import_zod15.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3901
+ all: import_zod15.z.boolean().optional().describe(
3216
3902
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
3217
3903
  ),
3218
3904
  repoRoot: REPO_ROOT
@@ -3254,25 +3940,25 @@ var loadCommand = define({
3254
3940
  });
3255
3941
 
3256
3942
  // src/commands/log.ts
3257
- var import_zod15 = require("zod");
3943
+ var import_zod16 = require("zod");
3258
3944
  var logCommand = define({
3259
3945
  name: "log",
3260
3946
  tool: "kb_log",
3261
3947
  usage: "log",
3262
3948
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
3263
- input: import_zod15.z.object({ bundlePath }),
3949
+ input: import_zod16.z.object({ bundlePath }),
3264
3950
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3265
3951
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
3266
3952
  });
3267
3953
 
3268
3954
  // src/commands/no-decision.ts
3269
- var import_zod16 = require("zod");
3955
+ var import_zod17 = require("zod");
3270
3956
  var noDecisionCommand = define({
3271
3957
  name: "no-decision",
3272
3958
  tool: "kb_no_decision",
3273
3959
  usage: "no-decision <reason...>",
3274
3960
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
3275
- input: import_zod16.z.object({ bundlePath, reason: import_zod16.z.string().min(1) }),
3961
+ input: import_zod17.z.object({ bundlePath, reason: import_zod17.z.string().min(1) }),
3276
3962
  fromArgv: (argv, path) => ({
3277
3963
  bundlePath: path,
3278
3964
  reason: argv.slice(1).join(" ").trim()
@@ -3289,20 +3975,20 @@ var noDecisionCommand = define({
3289
3975
  });
3290
3976
 
3291
3977
  // src/commands/pack.ts
3292
- var import_zod17 = require("zod");
3978
+ var import_zod18 = require("zod");
3293
3979
  var packCommand = define({
3294
3980
  name: "pack",
3295
3981
  tool: "kb_pack",
3296
3982
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
3297
3983
  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.",
3298
- input: import_zod17.z.object({
3984
+ input: import_zod18.z.object({
3299
3985
  bundlePath,
3300
3986
  conceptId,
3301
- hops: import_zod17.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3302
- maxNodes: import_zod17.z.number().int().positive().optional().describe(
3987
+ hops: import_zod18.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3988
+ maxNodes: import_zod18.z.number().int().positive().optional().describe(
3303
3989
  "How many records the pack may hold, root included. Defaults to 20."
3304
3990
  ),
3305
- budgetTokens: import_zod17.z.number().int().positive().optional().describe(
3991
+ budgetTokens: import_zod18.z.number().int().positive().optional().describe(
3306
3992
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
3307
3993
  )
3308
3994
  }),
@@ -3389,22 +4075,22 @@ function warningLabel(warning) {
3389
4075
  }
3390
4076
 
3391
4077
  // src/commands/pin.ts
3392
- var import_zod18 = require("zod");
4078
+ var import_zod19 = require("zod");
3393
4079
  var pinCommand = define({
3394
4080
  name: "pin",
3395
4081
  tool: "kb_pin",
3396
4082
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
3397
4083
  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.",
3398
- input: import_zod18.z.object({
4084
+ input: import_zod19.z.object({
3399
4085
  bundlePath,
3400
- mode: import_zod18.z.enum(["full", "index"]).optional().describe(
4086
+ mode: import_zod19.z.enum(["full", "index"]).optional().describe(
3401
4087
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
3402
4088
  ),
3403
- profiles: import_zod18.z.array(import_zod18.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
3404
- layer: import_zod18.z.enum(["project", "local", "user"]).optional().describe(
4089
+ profiles: import_zod19.z.array(import_zod19.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4090
+ layer: import_zod19.z.enum(["project", "local", "user"]).optional().describe(
3405
4091
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
3406
4092
  ),
3407
- frozen: import_zod18.z.boolean().optional().describe(
4093
+ frozen: import_zod19.z.boolean().optional().describe(
3408
4094
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
3409
4095
  )
3410
4096
  }),
@@ -3433,29 +4119,29 @@ var pinCommand = define({
3433
4119
  });
3434
4120
 
3435
4121
  // src/commands/pins.ts
3436
- var import_zod19 = require("zod");
4122
+ var import_zod20 = require("zod");
3437
4123
  var pinsCommand = define({
3438
4124
  name: "pins",
3439
4125
  tool: "kb_pins",
3440
4126
  usage: "pins",
3441
4127
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
3442
- input: import_zod19.z.object({}),
4128
+ input: import_zod20.z.object({}),
3443
4129
  fromArgv: () => ({}),
3444
4130
  run: ({ store }) => listPins(store, process.cwd())
3445
4131
  });
3446
4132
 
3447
4133
  // src/commands/query.ts
3448
- var import_zod20 = require("zod");
4134
+ var import_zod21 = require("zod");
3449
4135
  var queryCommand = define({
3450
4136
  name: "query",
3451
4137
  tool: "kb_query",
3452
4138
  usage: "query <text...> [--repo-root PATH]",
3453
4139
  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.",
3454
- input: import_zod20.z.object({
4140
+ input: import_zod21.z.object({
3455
4141
  bundlePath,
3456
- text: import_zod20.z.string().optional(),
3457
- type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
3458
- includeNonCurrent: import_zod20.z.boolean().optional(),
4142
+ text: import_zod21.z.string().optional(),
4143
+ type: import_zod21.z.enum(KB_RECORD_TYPES).optional(),
4144
+ includeNonCurrent: import_zod21.z.boolean().optional(),
3459
4145
  repoRoot: REPO_ROOT
3460
4146
  }),
3461
4147
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -3487,27 +4173,27 @@ var queryCommand = define({
3487
4173
  });
3488
4174
 
3489
4175
  // src/commands/read-index.ts
3490
- var import_zod21 = require("zod");
4176
+ var import_zod22 = require("zod");
3491
4177
  var readIndexCommand = define({
3492
4178
  name: "index",
3493
4179
  tool: "kb_index",
3494
4180
  usage: "index",
3495
4181
  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.",
3496
- input: import_zod21.z.object({ bundlePath }),
4182
+ input: import_zod22.z.object({ bundlePath }),
3497
4183
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3498
4184
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
3499
4185
  });
3500
4186
 
3501
4187
  // src/commands/schema.ts
3502
- var import_zod24 = require("zod");
4188
+ var import_zod25 = require("zod");
3503
4189
 
3504
4190
  // src/json-schema.ts
3505
- var import_zod23 = require("zod");
4191
+ var import_zod24 = require("zod");
3506
4192
 
3507
4193
  // src/kb-log.ts
3508
- var import_zod22 = require("zod");
4194
+ var import_zod23 = require("zod");
3509
4195
  var LOG_FILE = "log.jsonl";
3510
- var kbLogEntrySchema = import_zod22.z.object({
4196
+ var kbLogEntrySchema = import_zod23.z.object({
3511
4197
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
3512
4198
  // below), and a value that isn't actually chronological — a Unix
3513
4199
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -3516,12 +4202,12 @@ var kbLogEntrySchema = import_zod22.z.object({
3516
4202
  // and rejects everything else, including a non-`Z` offset — so a
3517
4203
  // malformed `at` is reported the same way a malformed line already is,
3518
4204
  // rather than silently sorting into the wrong place.
3519
- at: import_zod22.z.iso.datetime(),
3520
- by: import_zod22.z.string().min(1),
3521
- operation: import_zod22.z.string().min(1),
3522
- conceptId: import_zod22.z.string().min(1),
4205
+ at: import_zod23.z.iso.datetime(),
4206
+ by: import_zod23.z.string().min(1),
4207
+ operation: import_zod23.z.string().min(1),
4208
+ conceptId: import_zod23.z.string().min(1),
3523
4209
  /** Second concept id, where the operation relates two — supersession. */
3524
- target: import_zod22.z.string().min(1).optional()
4210
+ target: import_zod23.z.string().min(1).optional()
3525
4211
  }).strict();
3526
4212
  function renderLogEntry(entry) {
3527
4213
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -3531,18 +4217,18 @@ function parseLog(raw) {
3531
4217
  const entries = [];
3532
4218
  const malformed = [];
3533
4219
  const seen = /* @__PURE__ */ new Set();
3534
- raw.split("\n").forEach((text, index) => {
4220
+ raw.split("\n").forEach((text, index2) => {
3535
4221
  if (!text.trim()) return;
3536
4222
  let value;
3537
4223
  try {
3538
4224
  value = JSON.parse(text);
3539
4225
  } catch {
3540
- malformed.push({ line: index + 1, text });
4226
+ malformed.push({ line: index2 + 1, text });
3541
4227
  return;
3542
4228
  }
3543
4229
  const parsed = kbLogEntrySchema.safeParse(value);
3544
4230
  if (!parsed.success) {
3545
- malformed.push({ line: index + 1, text });
4231
+ malformed.push({ line: index2 + 1, text });
3546
4232
  return;
3547
4233
  }
3548
4234
  const key = JSON.stringify(parsed.data);
@@ -3559,11 +4245,11 @@ function parseLog(raw) {
3559
4245
  // src/json-schema.ts
3560
4246
  function kbJsonSchemas() {
3561
4247
  return {
3562
- recordFrontmatter: import_zod23.z.toJSONSchema(kbRecordFrontmatterSchema, {
4248
+ recordFrontmatter: import_zod24.z.toJSONSchema(kbRecordFrontmatterSchema, {
3563
4249
  io: "input"
3564
4250
  }),
3565
- composeInput: import_zod23.z.toJSONSchema(composeInputSchema, { io: "input" }),
3566
- logEntry: import_zod23.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
4251
+ composeInput: import_zod24.z.toJSONSchema(composeInputSchema, { io: "input" }),
4252
+ logEntry: import_zod24.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
3567
4253
  };
3568
4254
  }
3569
4255
 
@@ -3573,22 +4259,120 @@ var schemaCommand = define({
3573
4259
  tool: "kb_schema",
3574
4260
  usage: "schema",
3575
4261
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
3576
- input: import_zod24.z.object({}),
4262
+ input: import_zod25.z.object({}),
3577
4263
  fromArgv: () => ({}),
3578
4264
  run: () => Promise.resolve(kbJsonSchemas())
3579
4265
  });
3580
4266
 
4267
+ // src/commands/stamp.ts
4268
+ var import_promises7 = require("fs/promises");
4269
+ var import_zod26 = require("zod");
4270
+ var DIGEST = /^[0-9a-f]{64}$/;
4271
+ var stampCommand = define({
4272
+ name: "stamp",
4273
+ tool: "kb_stamp",
4274
+ usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
4275
+ description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids when the baseline is a prior stamp; silent when nothing changed. Reads, never writes.",
4276
+ input: import_zod26.z.object({
4277
+ bundlePath: import_zod26.z.string().min(1).optional().describe(
4278
+ "Absolute path to one knowledge base. Omit to stamp every pinned base."
4279
+ ),
4280
+ since: import_zod26.z.string().min(1).optional().describe(
4281
+ "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
4282
+ )
4283
+ }),
4284
+ fromArgv: (argv, path, _stdin, bundleExplicit) => {
4285
+ const since = argvFlag(argv, "--since");
4286
+ return {
4287
+ ...bundleExplicit ? { bundlePath: path } : {},
4288
+ ...since !== void 0 ? { since } : {}
4289
+ };
4290
+ },
4291
+ run: async ({ store }, { bundlePath: bundlePath2, since }) => {
4292
+ const targets = bundlePath2 ? [bundlePath2] : (await readMergedPins(process.cwd())).pins.map(
4293
+ (pin) => pin.absolutePath
4294
+ );
4295
+ if (since !== void 0 && DIGEST.test(since) && targets.length > 1) {
4296
+ throw new KbStampDigestBaselineError(since);
4297
+ }
4298
+ const stamps = await Promise.all(
4299
+ targets.map((target) => store.stamp(target))
4300
+ );
4301
+ if (since === void 0) {
4302
+ return stamps.map((stamp) => ({ ...stamp, changed: null }));
4303
+ }
4304
+ const baseline = await readBaseline(since);
4305
+ const reports = [];
4306
+ for (const stamp of stamps) {
4307
+ const before = baseline.byPath.get(stamp.path);
4308
+ if (baseline.digest !== null) {
4309
+ if (baseline.digest === stamp.digest) continue;
4310
+ reports.push({ ...stamp, changed: null });
4311
+ continue;
4312
+ }
4313
+ if (before && before.digest === stamp.digest) continue;
4314
+ reports.push({ ...stamp, changed: changedIds(before?.records, stamp) });
4315
+ }
4316
+ return reports;
4317
+ },
4318
+ render: (result) => result.map((report) => {
4319
+ const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
4320
+ const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
4321
+ return report.changed?.length ? `${head}
4322
+ changed: ${report.changed.join(", ")}` : head;
4323
+ }).join("\n")
4324
+ });
4325
+ function changedIds(before, stamp) {
4326
+ const now = new Map(
4327
+ stamp.records.map((record) => [record.conceptId, record.digest])
4328
+ );
4329
+ const ids = /* @__PURE__ */ new Set();
4330
+ for (const [conceptId2, digest] of now) {
4331
+ if (before?.get(conceptId2) !== digest) ids.add(conceptId2);
4332
+ }
4333
+ for (const conceptId2 of before?.keys() ?? []) {
4334
+ if (!now.has(conceptId2)) ids.add(conceptId2);
4335
+ }
4336
+ return [...ids].sort();
4337
+ }
4338
+ async function readBaseline(since) {
4339
+ if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
4340
+ let parsed;
4341
+ try {
4342
+ parsed = JSON.parse(await (0, import_promises7.readFile)(since, "utf8"));
4343
+ } catch {
4344
+ throw new KbStampBaselineError(since);
4345
+ }
4346
+ const entries = Array.isArray(parsed) ? parsed : parsed?.stamps ?? [];
4347
+ const byPath = /* @__PURE__ */ new Map();
4348
+ for (const entry of entries) {
4349
+ if (typeof entry?.path !== "string" || typeof entry?.digest !== "string") {
4350
+ continue;
4351
+ }
4352
+ byPath.set(entry.path, {
4353
+ digest: entry.digest,
4354
+ records: new Map(
4355
+ (entry.records ?? []).map((record) => [
4356
+ record.conceptId,
4357
+ record.digest
4358
+ ])
4359
+ )
4360
+ });
4361
+ }
4362
+ return { digest: null, byPath };
4363
+ }
4364
+
3581
4365
  // src/commands/status.ts
3582
- var import_zod25 = require("zod");
4366
+ var import_zod27 = require("zod");
3583
4367
  var statusCommand = define({
3584
4368
  name: "status",
3585
4369
  tool: "kb_status",
3586
4370
  usage: "status <concept-id> <status>",
3587
4371
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
3588
- input: import_zod25.z.object({
4372
+ input: import_zod27.z.object({
3589
4373
  bundlePath,
3590
4374
  conceptId,
3591
- status: import_zod25.z.enum(KB_RECORD_STATUSES)
4375
+ status: import_zod27.z.enum(KB_RECORD_STATUSES)
3592
4376
  }),
3593
4377
  fromArgv: (argv, path) => ({
3594
4378
  bundlePath: path,
@@ -3603,13 +4387,13 @@ var statusCommand = define({
3603
4387
  });
3604
4388
 
3605
4389
  // src/commands/supersede.ts
3606
- var import_zod26 = require("zod");
4390
+ var import_zod28 = require("zod");
3607
4391
  var supersedeCommand = define({
3608
4392
  name: "supersede",
3609
4393
  tool: "kb_supersede",
3610
4394
  usage: "supersede <concept-id> <replacement-id>",
3611
4395
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
3612
- input: import_zod26.z.object({ bundlePath, conceptId, replacementId: conceptId }),
4396
+ input: import_zod28.z.object({ bundlePath, conceptId, replacementId: conceptId }),
3613
4397
  fromArgv: (argv, path) => ({
3614
4398
  bundlePath: path,
3615
4399
  conceptId: argv[1],
@@ -3623,16 +4407,16 @@ var supersedeCommand = define({
3623
4407
  });
3624
4408
 
3625
4409
  // src/commands/sync-instructions.ts
3626
- var import_zod27 = require("zod");
4410
+ var import_zod29 = require("zod");
3627
4411
  var syncInstructionsCommand = define({
3628
4412
  name: "sync-instructions",
3629
4413
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
3630
4414
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
3631
- input: import_zod27.z.object({
3632
- file: import_zod27.z.string().min(1).describe("The instruction file to edit in place."),
3633
- budgetTokens: import_zod27.z.number().int().positive().optional(),
3634
- fullUnderTokens: import_zod27.z.number().int().positive().optional(),
3635
- profile: import_zod27.z.string().optional()
4415
+ input: import_zod29.z.object({
4416
+ file: import_zod29.z.string().min(1).describe("The instruction file to edit in place."),
4417
+ budgetTokens: import_zod29.z.number().int().positive().optional(),
4418
+ fullUnderTokens: import_zod29.z.number().int().positive().optional(),
4419
+ profile: import_zod29.z.string().optional()
3636
4420
  }),
3637
4421
  fromArgv: (argv) => {
3638
4422
  const budget = argvFlag(argv, "--budget");
@@ -3658,7 +4442,7 @@ var syncInstructionsCommand = define({
3658
4442
  });
3659
4443
 
3660
4444
  // src/commands/trace.ts
3661
- var import_zod28 = require("zod");
4445
+ var import_zod30 = require("zod");
3662
4446
 
3663
4447
  // src/trace.ts
3664
4448
  var TRACE_EDGES = [
@@ -3714,11 +4498,11 @@ var traceCommand = define({
3714
4498
  tool: "kb_trace",
3715
4499
  usage: "trace <concept-id> [edges...]",
3716
4500
  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".',
3717
- input: import_zod28.z.object({
4501
+ input: import_zod30.z.object({
3718
4502
  bundlePath,
3719
4503
  conceptId,
3720
- edges: import_zod28.z.array(import_zod28.z.enum(TRACE_EDGES)).optional(),
3721
- depth: import_zod28.z.number().int().positive().optional()
4504
+ edges: import_zod30.z.array(import_zod30.z.enum(TRACE_EDGES)).optional(),
4505
+ depth: import_zod30.z.number().int().positive().optional()
3722
4506
  }),
3723
4507
  fromArgv: (argv, path) => ({
3724
4508
  bundlePath: path,
@@ -3740,37 +4524,37 @@ var traceCommand = define({
3740
4524
  });
3741
4525
 
3742
4526
  // src/commands/types.ts
3743
- var import_zod29 = require("zod");
4527
+ var import_zod31 = require("zod");
3744
4528
  var typesCommand = define({
3745
4529
  name: "types",
3746
4530
  tool: "kb_types",
3747
4531
  usage: "types",
3748
4532
  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.",
3749
- input: import_zod29.z.object({}),
4533
+ input: import_zod31.z.object({}),
3750
4534
  fromArgv: () => ({}),
3751
4535
  run: () => Promise.resolve(RECORD_TYPES)
3752
4536
  });
3753
4537
 
3754
4538
  // src/commands/unpin.ts
3755
- var import_zod30 = require("zod");
4539
+ var import_zod32 = require("zod");
3756
4540
  var unpinCommand = define({
3757
4541
  name: "unpin",
3758
4542
  tool: "kb_unpin",
3759
4543
  usage: "unpin [bundle-path]",
3760
4544
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
3761
- input: import_zod30.z.object({ bundlePath }),
4545
+ input: import_zod32.z.object({ bundlePath }),
3762
4546
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3763
4547
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3764
4548
  });
3765
4549
 
3766
4550
  // src/commands/validate.ts
3767
- var import_zod31 = require("zod");
4551
+ var import_zod33 = require("zod");
3768
4552
  var validateCommand = define({
3769
4553
  name: "validate",
3770
4554
  tool: "kb_validate",
3771
4555
  usage: "validate",
3772
4556
  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.",
3773
- input: import_zod31.z.object({ bundlePath }),
4557
+ input: import_zod33.z.object({ bundlePath }),
3774
4558
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3775
4559
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3776
4560
  // Warnings never fail the exit code; every other severity does.
@@ -3780,16 +4564,16 @@ var validateCommand = define({
3780
4564
  });
3781
4565
 
3782
4566
  // src/commands/verify.ts
3783
- var import_zod32 = require("zod");
4567
+ var import_zod34 = require("zod");
3784
4568
  var verifyCommand = define({
3785
4569
  name: "verify",
3786
4570
  tool: "kb_verify",
3787
4571
  usage: "verify <concept-id> --note <text>",
3788
4572
  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.",
3789
- input: import_zod32.z.object({
4573
+ input: import_zod34.z.object({
3790
4574
  bundlePath,
3791
4575
  conceptId,
3792
- note: import_zod32.z.string().refine((s) => s.trim().length > 0, {
4576
+ note: import_zod34.z.string().refine((s) => s.trim().length > 0, {
3793
4577
  message: "note must say what the check found"
3794
4578
  })
3795
4579
  }),
@@ -3809,15 +4593,15 @@ var verifyCommand = define({
3809
4593
  });
3810
4594
 
3811
4595
  // src/commands/write.ts
3812
- var import_zod33 = require("zod");
4596
+ var import_zod35 = require("zod");
3813
4597
  var writeCommand = define({
3814
4598
  name: "write",
3815
4599
  tool: "kb_write",
3816
4600
  usage: "write <type> < record.json",
3817
4601
  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.",
3818
- input: import_zod33.z.object({
4602
+ input: import_zod35.z.object({
3819
4603
  bundlePath,
3820
- type: import_zod33.z.enum(KB_RECORD_TYPES),
4604
+ type: import_zod35.z.enum(KB_RECORD_TYPES),
3821
4605
  input: composeInputSchema
3822
4606
  }),
3823
4607
  fromArgv: async (argv, path, stdin) => ({
@@ -3841,13 +4625,13 @@ var writeCommand = define({
3841
4625
  });
3842
4626
 
3843
4627
  // src/commands/write-decision.ts
3844
- var import_zod34 = require("zod");
4628
+ var import_zod36 = require("zod");
3845
4629
  var writeDecisionCommand = define({
3846
4630
  name: "write-decision",
3847
4631
  tool: "kb_write_decision",
3848
4632
  usage: "write-decision < decision.json",
3849
4633
  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.",
3850
- input: import_zod34.z.object({ bundlePath, input: decisionInputSchema }),
4634
+ input: import_zod36.z.object({ bundlePath, input: decisionInputSchema }),
3851
4635
  fromArgv: async (_argv, path, stdin) => ({
3852
4636
  bundlePath: path,
3853
4637
  input: JSON.parse(await stdin())
@@ -3887,6 +4671,7 @@ var KB_COMMANDS = [
3887
4671
  listCommand,
3888
4672
  readIndexCommand,
3889
4673
  logCommand,
4674
+ stampCommand,
3890
4675
  validateCommand,
3891
4676
  doctorCommand,
3892
4677
  schemaCommand,
@@ -3902,9 +4687,8 @@ var KB_COMMANDS_BY_NAME = new Map(
3902
4687
  );
3903
4688
 
3904
4689
  // src/kb-store.ts
3905
- var import_node_crypto2 = require("crypto");
3906
- var import_promises6 = require("fs/promises");
3907
- var import_node_path8 = require("path");
4690
+ var import_promises9 = require("fs/promises");
4691
+ var import_node_path11 = require("path");
3908
4692
 
3909
4693
  // src/markdown.ts
3910
4694
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -3931,9 +4715,41 @@ function parseMarkdownWithFrontmatter(text, schema) {
3931
4715
  };
3932
4716
  }
3933
4717
 
4718
+ // src/kb-stamp.ts
4719
+ var import_node_crypto4 = require("crypto");
4720
+ function sha2563(contents) {
4721
+ return (0, import_node_crypto4.createHash)("sha256").update(contents).digest("hex");
4722
+ }
4723
+ function bundleStamp(records, superseded) {
4724
+ const entries = [
4725
+ ...records.map((hit) => ({
4726
+ conceptId: hit.record.conceptId,
4727
+ digest: `current:${sha2563(
4728
+ stringifyMarkdownWithFrontmatter(
4729
+ hit.record.body,
4730
+ hit.record.frontmatter
4731
+ )
4732
+ )}`
4733
+ })),
4734
+ ...superseded.map((entry) => ({
4735
+ conceptId: entry.conceptId,
4736
+ digest: `superseded:${sha2563(JSON.stringify(entry))}`
4737
+ }))
4738
+ ].sort((a, b) => a.conceptId < b.conceptId ? -1 : 1);
4739
+ return {
4740
+ digest: sha2563(
4741
+ entries.map((entry) => `${entry.conceptId}:${entry.digest}`).join("\n")
4742
+ ),
4743
+ records: entries
4744
+ };
4745
+ }
4746
+ function bundleDigest(records, superseded) {
4747
+ return bundleStamp(records, superseded).digest;
4748
+ }
4749
+
3934
4750
  // src/search-index.ts
3935
- var import_promises5 = require("fs/promises");
3936
- var import_node_path7 = require("path");
4751
+ var import_promises8 = require("fs/promises");
4752
+ var import_node_path10 = require("path");
3937
4753
  var SEARCH_INDEX_FILE = ".index.sqlite";
3938
4754
  var COLLECTION = "kb";
3939
4755
  async function searchBase(bundlePath2, query, options = {}) {
@@ -3942,7 +4758,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3942
4758
  let store = null;
3943
4759
  try {
3944
4760
  store = await qmd.createStore({
3945
- dbPath: (0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE),
4761
+ dbPath: (0, import_node_path10.join)(bundlePath2, SEARCH_INDEX_FILE),
3946
4762
  config: {
3947
4763
  collections: {
3948
4764
  [COLLECTION]: {
@@ -3977,7 +4793,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3977
4793
  }
3978
4794
  }
3979
4795
  async function isStale(bundlePath2) {
3980
- const indexAt = await (0, import_promises5.stat)((0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
4796
+ const indexAt = await (0, import_promises8.stat)((0, import_node_path10.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3981
4797
  if (!indexAt) return true;
3982
4798
  const { readdir: readdir2 } = await import("fs/promises");
3983
4799
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -3986,7 +4802,7 @@ async function isStale(bundlePath2) {
3986
4802
  let stale = false;
3987
4803
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
3988
4804
  if (stale) return;
3989
- const at = await (0, import_promises5.stat)((0, import_node_path7.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
4805
+ const at = await (0, import_promises8.stat)((0, import_node_path10.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3990
4806
  if (at > indexAt) stale = true;
3991
4807
  });
3992
4808
  return stale;
@@ -4100,8 +4916,8 @@ function byRank(left, right) {
4100
4916
  ) || left.record.conceptId.localeCompare(right.record.conceptId);
4101
4917
  }
4102
4918
  function typeRank(record) {
4103
- const index = TYPE_PRIORITY.indexOf(record.frontmatter.type);
4104
- return index === -1 ? TYPE_PRIORITY.length : index;
4919
+ const index2 = TYPE_PRIORITY.indexOf(record.frontmatter.type);
4920
+ return index2 === -1 ? TYPE_PRIORITY.length : index2;
4105
4921
  }
4106
4922
 
4107
4923
  // src/kb-links/inbound.ts
@@ -4264,7 +5080,7 @@ function appendUnionMergeLine(contents) {
4264
5080
  }
4265
5081
 
4266
5082
  // src/kb-store.ts
4267
- var KB_DIR = (0, import_node_path8.join)(".strauss", "kb");
5083
+ var KB_DIR = (0, import_node_path11.join)(".strauss", "kb");
4268
5084
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
4269
5085
  var DEFAULT_LOAD_BUDGET = 25e3;
4270
5086
  var KbStore = class {
@@ -4295,7 +5111,7 @@ var KbStore = class {
4295
5111
  const conceptId2 = `${input.type}.${input.slug}`;
4296
5112
  const root = this.root(bundlePath2);
4297
5113
  const target = this.recordPath(bundlePath2, conceptId2);
4298
- await (0, import_promises6.mkdir)(root, { recursive: true });
5114
+ await (0, import_promises9.mkdir)(root, { recursive: true });
4299
5115
  await this.publish(
4300
5116
  target,
4301
5117
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -4334,7 +5150,7 @@ var KbStore = class {
4334
5150
  const target = this.recordPath(bundlePath2, conceptId2);
4335
5151
  let raw;
4336
5152
  try {
4337
- raw = await (0, import_promises6.readFile)(target, "utf8");
5153
+ raw = await (0, import_promises9.readFile)(target, "utf8");
4338
5154
  } catch {
4339
5155
  return null;
4340
5156
  }
@@ -4351,7 +5167,7 @@ var KbStore = class {
4351
5167
  const root = this.root(bundlePath2);
4352
5168
  let names;
4353
5169
  try {
4354
- names = await (0, import_promises6.readdir)(root);
5170
+ names = await (0, import_promises9.readdir)(root);
4355
5171
  } catch {
4356
5172
  return [];
4357
5173
  }
@@ -4359,7 +5175,7 @@ var KbStore = class {
4359
5175
  const records = await mapLimit(
4360
5176
  wanted,
4361
5177
  DEFAULT_IO_CONCURRENCY,
4362
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises6.readFile)((0, import_node_path8.join)(root, name), "utf8"))
5178
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises9.readFile)((0, import_node_path11.join)(root, name), "utf8"))
4363
5179
  );
4364
5180
  return records.filter((record) => record !== null);
4365
5181
  }
@@ -4516,7 +5332,7 @@ ${answer}
4516
5332
  if (found.length) return found;
4517
5333
  }
4518
5334
  const lowered = needle.toLowerCase();
4519
- return bundle.filter((record) => matches(record, lowered));
5335
+ return bundle.filter((record) => matches2(record, lowered));
4520
5336
  }
4521
5337
  /**
4522
5338
  * Anchor drift over the records about to be handed back. Like the search
@@ -4634,6 +5450,28 @@ ${answer}
4634
5450
  digest: bundleDigestValue
4635
5451
  };
4636
5452
  }
5453
+ /**
5454
+ * `load`'s digest without `load`'s bodies — the same records, adjudicated
5455
+ * the same way, handed back as a stamp. Skips the anchor drift pass, which
5456
+ * reads source files and only ever adds warnings: no warning reaches the
5457
+ * digest, so the value is identical to the one `load` returns.
5458
+ */
5459
+ async stamp(bundlePath2) {
5460
+ const bundle = await this.list(bundlePath2);
5461
+ const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
5462
+ const current = adjudicated.filter((hit) => hit.standing !== "superseded");
5463
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
5464
+ const stamped = bundleStamp(current, superseded);
5465
+ const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
5466
+ return {
5467
+ path: bundlePath2,
5468
+ digest: stamped.digest,
5469
+ recordCount: bundle.length,
5470
+ superseded: superseded.length,
5471
+ newestAt: dates.at(-1) ?? null,
5472
+ records: stamped.records
5473
+ };
5474
+ }
4637
5475
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
4638
5476
  async trace(bundlePath2, seedId, options = {}) {
4639
5477
  return trace(seedId, await this.list(bundlePath2), options);
@@ -4664,11 +5502,11 @@ ${answer}
4664
5502
  async readIndex(bundlePath2) {
4665
5503
  const root = this.root(bundlePath2);
4666
5504
  const expected = renderIndex(await this.list(bundlePath2));
4667
- const stored = await (0, import_promises6.readFile)((0, import_node_path8.join)(root, INDEX_FILE), "utf8").catch(
5505
+ const stored = await (0, import_promises9.readFile)((0, import_node_path11.join)(root, INDEX_FILE), "utf8").catch(
4668
5506
  () => null
4669
5507
  );
4670
5508
  if (indexIsStale(stored, expected)) {
4671
- await this.publish((0, import_node_path8.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
5509
+ await this.publish((0, import_node_path11.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
4672
5510
  this.logger.info?.({
4673
5511
  operation: "kb.index.repair",
4674
5512
  bundlePath: root,
@@ -4685,8 +5523,8 @@ ${answer}
4685
5523
  * knows which agent touched what. So a bad line is surfaced and left alone.
4686
5524
  */
4687
5525
  async readLog(bundlePath2) {
4688
- const raw = await (0, import_promises6.readFile)(
4689
- (0, import_node_path8.join)(this.root(bundlePath2), LOG_FILE),
5526
+ const raw = await (0, import_promises9.readFile)(
5527
+ (0, import_node_path11.join)(this.root(bundlePath2), LOG_FILE),
4690
5528
  "utf8"
4691
5529
  ).catch(() => "");
4692
5530
  const result = parseLog(raw);
@@ -4737,15 +5575,15 @@ ${answer}
4737
5575
  }
4738
5576
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
4739
5577
  const target = this.recordPath(bundlePath2, conceptId2);
4740
- const before = await (0, import_promises6.readFile)(target, "utf8").catch(() => null);
5578
+ const before = await (0, import_promises9.readFile)(target, "utf8").catch(() => null);
4741
5579
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
4742
5580
  const parsed = this.parse(conceptId2, before);
4743
5581
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
4744
5582
  const frontmatter = change(parsed.frontmatter);
4745
5583
  const body = changeBody(parsed.body);
4746
5584
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
4747
- const witness = await (0, import_promises6.readFile)(target, "utf8").catch(() => null);
4748
- if (witness === null || digest(witness) !== digest(before)) {
5585
+ const witness = await (0, import_promises9.readFile)(target, "utf8").catch(() => null);
5586
+ if (witness === null || sha2563(witness) !== sha2563(before)) {
4749
5587
  throw new KbWriteConflictError(conceptId2);
4750
5588
  }
4751
5589
  await this.publish(target, contents, true, conceptId2);
@@ -4770,20 +5608,20 @@ ${answer}
4770
5608
  */
4771
5609
  async publish(target, contents, overwrite, conceptId2) {
4772
5610
  const staging = `${target}.${process.pid}.tmp`;
4773
- await (0, import_promises6.writeFile)(staging, contents, "utf8");
5611
+ await (0, import_promises9.writeFile)(staging, contents, "utf8");
4774
5612
  try {
4775
5613
  if (overwrite) {
4776
- await (0, import_promises6.rename)(staging, target);
5614
+ await (0, import_promises9.rename)(staging, target);
4777
5615
  return;
4778
5616
  }
4779
- await (0, import_promises6.link)(staging, target);
5617
+ await (0, import_promises9.link)(staging, target);
4780
5618
  } catch (error) {
4781
5619
  if (error.code === "EEXIST") {
4782
5620
  throw new KbRecordAlreadyExistsError(conceptId2);
4783
5621
  }
4784
5622
  throw error;
4785
5623
  } finally {
4786
- await (0, import_promises6.unlink)(staging).catch(() => void 0);
5624
+ await (0, import_promises9.unlink)(staging).catch(() => void 0);
4787
5625
  }
4788
5626
  }
4789
5627
  /**
@@ -4827,18 +5665,18 @@ ${answer}
4827
5665
  * file must not fail the mutation it guards.
4828
5666
  */
4829
5667
  async ensureGitattributes(root) {
4830
- const target = (0, import_node_path8.join)(root, GITATTRIBUTES_FILE);
5668
+ const target = (0, import_node_path11.join)(root, GITATTRIBUTES_FILE);
4831
5669
  try {
4832
5670
  let existing;
4833
5671
  try {
4834
- existing = await (0, import_promises6.readFile)(target, "utf8");
5672
+ existing = await (0, import_promises9.readFile)(target, "utf8");
4835
5673
  } catch (error) {
4836
5674
  if (error.code !== "ENOENT") throw error;
4837
5675
  existing = null;
4838
5676
  }
4839
5677
  if (existing === null) {
4840
5678
  try {
4841
- await (0, import_promises6.writeFile)(target, appendUnionMergeLine(""), {
5679
+ await (0, import_promises9.writeFile)(target, appendUnionMergeLine(""), {
4842
5680
  encoding: "utf8",
4843
5681
  flag: "wx"
4844
5682
  });
@@ -4859,7 +5697,7 @@ ${answer}
4859
5697
  return;
4860
5698
  }
4861
5699
  if (!hasMergeDeclaration(existing)) {
4862
- await (0, import_promises6.appendFile)(target, appendUnionMergeLine(existing), "utf8");
5700
+ await (0, import_promises9.appendFile)(target, appendUnionMergeLine(existing), "utf8");
4863
5701
  this.logger.info?.({
4864
5702
  operation: "kb.gitattributes.ensure",
4865
5703
  bundlePath: root,
@@ -4878,7 +5716,7 @@ ${answer}
4878
5716
  async record(root, entry) {
4879
5717
  await this.ensureGitattributes(root);
4880
5718
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4881
- await (0, import_promises6.appendFile)((0, import_node_path8.join)(root, LOG_FILE), line, "utf8").catch((error) => {
5719
+ await (0, import_promises9.appendFile)((0, import_node_path11.join)(root, LOG_FILE), line, "utf8").catch((error) => {
4882
5720
  this.logger.warn?.({
4883
5721
  operation: "kb.log.append",
4884
5722
  outcome: "failed",
@@ -4904,18 +5742,18 @@ ${answer}
4904
5742
  };
4905
5743
  }
4906
5744
  root(bundlePath2) {
4907
- return (0, import_node_path8.resolve)(bundlePath2);
5745
+ return (0, import_node_path11.resolve)(bundlePath2);
4908
5746
  }
4909
5747
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
4910
5748
  // bundle root; anything carrying a separator would escape it.
4911
5749
  recordPath(bundlePath2, conceptId2) {
4912
- if (conceptId2.includes(import_node_path8.sep) || conceptId2.includes("/")) {
5750
+ if (conceptId2.includes(import_node_path11.sep) || conceptId2.includes("/")) {
4913
5751
  throw new KbInvalidConceptIdError(
4914
5752
  "concept id must not contain a path separator",
4915
5753
  { conceptId: conceptId2 }
4916
5754
  );
4917
5755
  }
4918
- return (0, import_node_path8.join)(this.root(bundlePath2), `${conceptId2}.md`);
5756
+ return (0, import_node_path11.join)(this.root(bundlePath2), `${conceptId2}.md`);
4919
5757
  }
4920
5758
  };
4921
5759
  function estimateTokens(record) {
@@ -4942,7 +5780,7 @@ function stub(hit) {
4942
5780
  at: hit.record.frontmatter.generated?.at ?? null
4943
5781
  };
4944
5782
  }
4945
- function matches(record, needle) {
5783
+ function matches2(record, needle) {
4946
5784
  const { title, description } = record.frontmatter;
4947
5785
  return [record.conceptId, title, description, record.body].some(
4948
5786
  (field) => field?.toLowerCase().includes(needle)
@@ -4953,28 +5791,9 @@ function normalizeActor(id) {
4953
5791
  if (colon === -1) return id.toLowerCase();
4954
5792
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
4955
5793
  }
4956
- function digest(contents) {
4957
- return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
4958
- }
4959
- function bundleDigest(records, superseded) {
4960
- const entries = [
4961
- ...records.map(
4962
- (hit) => `${hit.record.conceptId}:current:${digest(
4963
- stringifyMarkdownWithFrontmatter(
4964
- hit.record.body,
4965
- hit.record.frontmatter
4966
- )
4967
- )}`
4968
- ),
4969
- ...superseded.map(
4970
- (entry) => `${entry.conceptId}:superseded:${digest(JSON.stringify(entry))}`
4971
- )
4972
- ].sort();
4973
- return digest(entries.join("\n"));
4974
- }
4975
5794
 
4976
5795
  // src/version.ts
4977
- var VERSION = true ? "0.1.15" : "0.0.0-dev";
5796
+ var VERSION = true ? "0.1.17" : "0.0.0-dev";
4978
5797
 
4979
5798
  // src/mcp.ts
4980
5799
  function createKbMcpServer() {