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