@saasontools/strauss-kb 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,7 +43,13 @@ var kbAnchorSchema = z.object({
43
43
  /** ISO 8601 timestamp of the last successful resolution. */
44
44
  resolved_at: z.string().min(1).optional(),
45
45
  /** Line count of the text the hash was taken over. */
46
- lines: z.number().int().positive().optional()
46
+ lines: z.number().int().positive().optional(),
47
+ /**
48
+ * Which resolver produced the hashed span. Absent means an anchor stamped
49
+ * before resolvers were named, which is read as `regex` — the only one
50
+ * there was. A hash from a different resolver is drift, not a match.
51
+ */
52
+ resolver: z.enum(["tree-sitter", "regex"]).optional()
47
53
  }).strict();
48
54
  var kbLinkSchema = z.object({
49
55
  target: z.string().min(1),
@@ -709,18 +715,18 @@ async function readOneRepo(repo, url, declared, context) {
709
715
  if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
710
716
  const cache = cachePathFor(repo, context.cacheDir);
711
717
  if (!cache) return all({ ok: false, reason: "remote-unreachable" });
712
- const rejected = /* @__PURE__ */ new Map();
718
+ const rejected2 = /* @__PURE__ */ new Map();
713
719
  const usable = [];
714
720
  for (const want of wants) {
715
721
  const reason = wantReason(want);
716
722
  if (reason)
717
- rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
723
+ rejected2.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
718
724
  else usable.push(want);
719
725
  }
720
- if (!usable.length) return rejected;
726
+ if (!usable.length) return rejected2;
721
727
  wants = usable;
722
728
  const opened = await openCache(cache, url, context);
723
- if (opened) return new Map([...rejected, ...all(opened)]);
729
+ if (opened) return new Map([...rejected2, ...all(opened)]);
724
730
  const wantsDefault = wants.some((want) => want.ref === void 0);
725
731
  const branch = wantsDefault ? await defaultBranch(cache, context) : {};
726
732
  const revs = /* @__PURE__ */ new Map();
@@ -747,7 +753,7 @@ async function readOneRepo(repo, url, declared, context) {
747
753
  }
748
754
  );
749
755
  return new Map([
750
- ...rejected,
756
+ ...rejected2,
751
757
  ...wants.map(
752
758
  (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
753
759
  )
@@ -799,13 +805,13 @@ async function defaultBranch(cache, context) {
799
805
  if (!listed.ok) {
800
806
  const reason = transportReason(listed.stderr);
801
807
  if (reason !== "ref-not-found") {
802
- const cached2 = await cachedBranch(cache);
803
- return cached2 ? { name: cached2 } : { reason };
808
+ const cached3 = await cachedBranch(cache);
809
+ return cached3 ? { name: cached3 } : { reason };
804
810
  }
805
811
  }
806
812
  }
807
- const cached = await cachedBranch(cache);
808
- if (cached) return { name: cached };
813
+ const cached2 = await cachedBranch(cache);
814
+ if (cached2) return { name: cached2 };
809
815
  return {
810
816
  reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
811
817
  };
@@ -832,8 +838,8 @@ async function ensureRev(cache, rev, context) {
832
838
  cwd: cache
833
839
  }
834
840
  );
835
- const cached = have.ok && have.stdout.trim().length > 0;
836
- if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
841
+ const cached2 = have.ok && have.stdout.trim().length > 0;
842
+ if (cached2 && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
837
843
  if (context.offline) return { ok: false, reason: "remote-unreachable" };
838
844
  const fetched = await git(
839
845
  [
@@ -849,7 +855,7 @@ async function ensureRev(cache, rev, context) {
849
855
  );
850
856
  if (!fetched.ok) {
851
857
  const reason = transportReason(fetched.stderr);
852
- if (cached && reason !== "ref-not-found") return void 0;
858
+ if (cached2 && reason !== "ref-not-found") return void 0;
853
859
  return { ok: false, reason };
854
860
  }
855
861
  const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
@@ -963,64 +969,577 @@ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY
963
969
  return new Map(wanted.map((file, at) => [file, results[at]]));
964
970
  }
965
971
 
972
+ // src/grammars/store.ts
973
+ import { createHash, randomBytes } from "crypto";
974
+ import { mkdir as mkdir2, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
975
+ import { homedir as homedir2 } from "os";
976
+ import { dirname, join as join2 } from "path";
977
+ function grammarsCacheRoot(override) {
978
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_DIR"] ?? join2(homedir2(), ".strauss", "grammars");
979
+ }
980
+ function grammarCachePath(root, language, sha2564, extension = "wasm") {
981
+ return join2(root, language, `${sha2564.slice(0, 12)}.${extension}`);
982
+ }
983
+ function sha256(bytes) {
984
+ return createHash("sha256").update(bytes).digest("hex");
985
+ }
986
+ function matches(bytes, entry) {
987
+ if (entry.bytes !== void 0 && bytes.byteLength !== entry.bytes)
988
+ return false;
989
+ return sha256(bytes) === entry.sha256;
990
+ }
991
+ async function verifyCached(path, entry) {
992
+ let bytes;
993
+ try {
994
+ bytes = await readFile2(path);
995
+ } catch {
996
+ return false;
997
+ }
998
+ if (matches(bytes, entry)) return true;
999
+ await rm(path, { force: true });
1000
+ return false;
1001
+ }
1002
+ async function writeCached(path, bytes) {
1003
+ await mkdir2(dirname(path), { recursive: true });
1004
+ const temporary = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
1005
+ try {
1006
+ await writeFile(temporary, bytes);
1007
+ await rename(temporary, path);
1008
+ } catch (error) {
1009
+ await rm(temporary, { force: true });
1010
+ throw error;
1011
+ }
1012
+ }
1013
+
1014
+ // src/grammars/manifest.ts
1015
+ import { existsSync, readFileSync } from "fs";
1016
+ import { dirname as dirname2, join as join3 } from "path";
1017
+ import { fileURLToPath } from "url";
1018
+
1019
+ // src/grammars/model.ts
1020
+ import { z as z4 } from "zod";
1021
+ var sha2562 = z4.string().regex(/^[0-9a-f]{64}$/);
1022
+ var grammarWasmSchema = z4.object({
1023
+ url: z4.string().min(1),
1024
+ sha256: sha2562,
1025
+ bytes: z4.number().int().positive()
1026
+ });
1027
+ var grammarTagsSchema = z4.object({ url: z4.string().min(1), sha256: sha2562 });
1028
+ var grammarPackSchema = z4.object({
1029
+ package: z4.string().min(1),
1030
+ wasm: grammarWasmSchema,
1031
+ tags: z4.array(grammarTagsSchema),
1032
+ license: z4.string().min(1),
1033
+ extensions: z4.array(z4.string().min(1))
1034
+ });
1035
+ var grammarManifestSchema = z4.object({
1036
+ /** The runtime the packs were proved against. */
1037
+ webTreeSitter: z4.string().min(1),
1038
+ linguist: z4.object({ tag: z4.string().min(1), commit: z4.string().min(1) }),
1039
+ packs: z4.record(z4.string().min(1), grammarPackSchema)
1040
+ });
1041
+
1042
+ // src/grammars/manifest.ts
1043
+ var cached;
1044
+ function grammarManifest() {
1045
+ cached ??= grammarManifestSchema.parse(
1046
+ JSON.parse(readFileSync(grammarsDataPath("manifest.json"), "utf8"))
1047
+ );
1048
+ return cached;
1049
+ }
1050
+ function grammarsDataPath(...segments) {
1051
+ let dir = dirname2(fileURLToPath(import.meta.url));
1052
+ for (let up = 0; up < 5; up++) {
1053
+ const candidate = join3(dir, "grammars");
1054
+ if (existsSync(join3(candidate, "manifest.json")))
1055
+ return join3(candidate, ...segments);
1056
+ dir = dirname2(dir);
1057
+ }
1058
+ throw new Error("grammars/manifest.json is missing from the package");
1059
+ }
1060
+
1061
+ // src/grammars/index.ts
1062
+ import { readFile as readFile3 } from "fs/promises";
1063
+
1064
+ // src/grammars/fetch.ts
1065
+ var ATTEMPTS = 3;
1066
+ var BACKOFF_MS = 250;
1067
+ function grammarUrl(url, override) {
1068
+ const base2 = grammarsBaseUrl(override);
1069
+ if (!base2) return url;
1070
+ let root = base2;
1071
+ while (root.endsWith("/")) root = root.slice(0, -1);
1072
+ const pinned = new URL(url);
1073
+ return `${root}${pinned.pathname}${pinned.search}`;
1074
+ }
1075
+ function grammarsBaseUrl(override) {
1076
+ return override ?? process.env["STRAUSS_KB_GRAMMARS_URL"];
1077
+ }
1078
+ async function downloadPart(url, name, entry, options = {}) {
1079
+ const log = options.log ?? ((line) => void process.stderr.write(line));
1080
+ const weight = entry.bytes === void 0 ? "" : ` (${size(entry.bytes)} from manifest)`;
1081
+ log(`strauss-kb: downloading ${name}${weight} from ${url}
1082
+ `);
1083
+ let cause = "";
1084
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
1085
+ const outcome = await attemptDownload(url, entry, options.fetchTimeoutMs);
1086
+ if ("bytes" in outcome) return outcome;
1087
+ cause = outcome.cause;
1088
+ log(
1089
+ `strauss-kb: ${name} attempt ${attempt}/${ATTEMPTS} failed: ${cause}
1090
+ `
1091
+ );
1092
+ if (!outcome.retry) break;
1093
+ if (attempt < ATTEMPTS) await pause(BACKOFF_MS * attempt);
1094
+ }
1095
+ log(`strauss-kb: ${name} not downloaded: ${cause}
1096
+ `);
1097
+ return { cause };
1098
+ }
1099
+ async function attemptDownload(url, entry, timeoutMs) {
1100
+ try {
1101
+ const response = await fetch(url, {
1102
+ signal: AbortSignal.timeout(fetchTimeoutMs(timeoutMs))
1103
+ });
1104
+ if (!response.ok) {
1105
+ return {
1106
+ cause: `HTTP ${response.status}`,
1107
+ retry: response.status >= 500 || response.status === 429
1108
+ };
1109
+ }
1110
+ const bytes = new Uint8Array(await response.arrayBuffer());
1111
+ if (!matches(bytes, entry))
1112
+ return { cause: "sha256 mismatch", retry: false };
1113
+ return { bytes };
1114
+ } catch (error) {
1115
+ const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
1116
+ return { cause: timedOut ? "timeout" : "network error", retry: true };
1117
+ }
1118
+ }
1119
+ function size(bytes) {
1120
+ return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1121
+ }
1122
+ function pause(ms) {
1123
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
1124
+ }
1125
+
1126
+ // src/grammars/index.ts
1127
+ var inFlight = /* @__PURE__ */ new Map();
1128
+ var missing = /* @__PURE__ */ new Map();
1129
+ var uncompilable = /* @__PURE__ */ new Map();
1130
+ var rejected = /* @__PURE__ */ new Map();
1131
+ function grammarsDownloadDisabled() {
1132
+ return process.env["STRAUSS_KB_GRAMMARS"] === "off";
1133
+ }
1134
+ async function ensureGrammar(language, options = {}) {
1135
+ const pack2 = grammarManifest().packs[language];
1136
+ if (!pack2) return null;
1137
+ const root = grammarsCacheRoot(options.cacheRoot);
1138
+ const wasm = grammarCachePath(root, language, pack2.wasm.sha256);
1139
+ const key = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1140
+ const existing = inFlight.get(key);
1141
+ if (existing) return existing;
1142
+ const pending = (async () => {
1143
+ const grammar = await ensurePart(
1144
+ wasm,
1145
+ `tree-sitter-${language}`,
1146
+ pack2.wasm,
1147
+ options
1148
+ );
1149
+ if (grammar !== true)
1150
+ return miss(language, `grammar tree-sitter-${language}`, grammar);
1151
+ const parts = [];
1152
+ const total = pack2.tags.length;
1153
+ for (const [at, part] of pack2.tags.entries()) {
1154
+ const name = `${language} tags${total > 1 ? ` part ${at + 1}/${total}` : ""}`;
1155
+ const path = grammarCachePath(root, language, part.sha256, "scm");
1156
+ const held = await ensurePart(path, name, part, options);
1157
+ if (held !== true) return miss(language, name, held);
1158
+ parts.push(`; ${part.url}
1159
+ ${lf(await readFile3(path, "utf8"))}`);
1160
+ }
1161
+ missing.delete(language);
1162
+ return { wasm, query: total ? parts.join("\n") : void 0 };
1163
+ })();
1164
+ inFlight.set(key, pending);
1165
+ const result = await pending;
1166
+ if (result === null) inFlight.delete(key);
1167
+ return result;
1168
+ }
1169
+ async function ensurePart(path, name, entry, options) {
1170
+ if (await verifyCached(path, entry)) return true;
1171
+ if (options.offline === true || grammarsDownloadDisabled()) return {};
1172
+ const download = await downloadPart(
1173
+ grammarUrl(entry.url, options.baseUrl),
1174
+ name,
1175
+ entry,
1176
+ options
1177
+ );
1178
+ if ("cause" in download) return { cause: download.cause };
1179
+ await writeCached(path, download.bytes).catch(() => null);
1180
+ return true;
1181
+ }
1182
+ function miss(language, subject, failure) {
1183
+ missing.set(language, { subject, ...failure });
1184
+ return null;
1185
+ }
1186
+ function lf(body) {
1187
+ return body.replace(/\r\n/g, "\n");
1188
+ }
1189
+ function noteUncompilableQuery(language, cause) {
1190
+ uncompilable.set(language, cause);
1191
+ }
1192
+ function noteRejectedGrammar(language, cause) {
1193
+ rejected.set(language, cause);
1194
+ }
1195
+ function grammarHints() {
1196
+ const manifest = grammarManifest();
1197
+ const packs = manifest.packs;
1198
+ const lines = /* @__PURE__ */ new Map();
1199
+ for (const [language, { subject, cause }] of missing)
1200
+ lines.set(
1201
+ language,
1202
+ `${subject} not cached${cause ? ` (${cause})` : ""}; run online once, or set STRAUSS_KB_GRAMMARS_DIR`
1203
+ );
1204
+ for (const [language, cause] of rejected)
1205
+ lines.set(
1206
+ language,
1207
+ `${packs[language]?.package ?? `tree-sitter-${language}`} rejected by web-tree-sitter ${manifest.webTreeSitter}${cause ? `: ${cause}` : ""}; re-pin with pnpm grammars pin ${language}`
1208
+ );
1209
+ for (const [language, cause] of uncompilable)
1210
+ lines.set(
1211
+ language,
1212
+ `tags query for ${language} does not compile against ${packs[language]?.package ?? `tree-sitter-${language}`}: ${cause}; re-pin with pnpm grammars pin ${language}`
1213
+ );
1214
+ return [...lines].sort(([a], [b]) => a.localeCompare(b)).map(([, line]) => line);
1215
+ }
1216
+
1217
+ // src/tree-sitter-resolver/languages.ts
1218
+ import { extname } from "path";
1219
+ var table;
1220
+ function extensionTable() {
1221
+ const manifest = grammarManifest();
1222
+ if (table?.of !== manifest)
1223
+ table = {
1224
+ of: manifest,
1225
+ extensions: Object.fromEntries(
1226
+ Object.entries(manifest.packs).flatMap(
1227
+ ([language, pack2]) => pack2.extensions.map((extension) => [extension, language])
1228
+ )
1229
+ )
1230
+ };
1231
+ return table.extensions;
1232
+ }
1233
+ function hasQuery(language) {
1234
+ return (grammarManifest().packs[language]?.tags.length ?? 0) > 0;
1235
+ }
1236
+ function languageForFile(file) {
1237
+ const language = extensionTable()[extname(file).toLowerCase()];
1238
+ return language && hasQuery(language) ? language : void 0;
1239
+ }
1240
+ function treeSitterLanguages() {
1241
+ return [...new Set(Object.values(extensionTable()))].filter(hasQuery).sort();
1242
+ }
1243
+
1244
+ // src/tree-sitter-resolver/resolver.ts
1245
+ import { createHash as createHash2 } from "crypto";
1246
+ import { Language, Parser, Query } from "web-tree-sitter";
1247
+
1248
+ // src/tree-sitter-resolver/definitions.ts
1249
+ var SCOPE_ONLY = "reference.implementation";
1250
+ function index(tree, query) {
1251
+ const byName = /* @__PURE__ */ new Map();
1252
+ for (const match of query.matches(tree.rootNode)) {
1253
+ const nameNode = match.captures.find((capture) => capture.name === "name");
1254
+ const defNode = match.captures.find(
1255
+ (capture) => capture.name.startsWith("definition.") || capture.name === SCOPE_ONLY
1256
+ );
1257
+ if (!nameNode || !defNode) continue;
1258
+ const candidate = {
1259
+ node: defNode.node,
1260
+ name: nameNode.node.text,
1261
+ target: defNode.name !== SCOPE_ONLY
1262
+ };
1263
+ const existing = byName.get(nameNode.node.id);
1264
+ if (existing && width(existing.node) <= width(candidate.node)) continue;
1265
+ byName.set(nameNode.node.id, candidate);
1266
+ }
1267
+ const definitions = [...byName.values()];
1268
+ return {
1269
+ tree,
1270
+ byNodeId: new Map(
1271
+ definitions.map((definition) => [definition.node.id, definition])
1272
+ ),
1273
+ definitions
1274
+ };
1275
+ }
1276
+ function select(parsed, wanted) {
1277
+ const matches3 = parsed.definitions.filter(
1278
+ (definition) => definition.target && endsWith(chainOf(definition, parsed.byNodeId), wanted)
1279
+ );
1280
+ if (matches3.length < 2) return matches3;
1281
+ const bodied = matches3.filter(
1282
+ (definition) => definition.node.childForFieldName("body") !== null
1283
+ );
1284
+ return bodied.length === 1 ? bodied : matches3;
1285
+ }
1286
+ function chainOf(definition, byNodeId) {
1287
+ const chain = [definition.name];
1288
+ const receiver = definition.node.childForFieldName("receiver");
1289
+ const type = receiver && typeNameIn(receiver);
1290
+ if (type) chain.unshift(type);
1291
+ for (let node = definition.node.parent; node; node = node.parent) {
1292
+ const enclosing = byNodeId.get(node.id);
1293
+ if (enclosing && enclosing.node !== definition.node)
1294
+ chain.unshift(enclosing.name);
1295
+ }
1296
+ return chain;
1297
+ }
1298
+ function typeNameIn(receiver) {
1299
+ const stack = [receiver];
1300
+ while (stack.length) {
1301
+ const node = stack.pop();
1302
+ if (node.type === "type_identifier") return node.text;
1303
+ for (let at = 0; at < node.childCount; at++) {
1304
+ const child = node.child(at);
1305
+ if (child) stack.push(child);
1306
+ }
1307
+ }
1308
+ return void 0;
1309
+ }
1310
+ function endsWith(chain, wanted) {
1311
+ if (wanted.length > chain.length) return false;
1312
+ const offset = chain.length - wanted.length;
1313
+ return wanted.every((segment, at) => chain[offset + at] === segment);
1314
+ }
1315
+ function width(node) {
1316
+ return node.endIndex - node.startIndex;
1317
+ }
1318
+ function spanOf(definition, source) {
1319
+ let start = definition.node;
1320
+ let end = definition.node;
1321
+ for (let sibling = start.previousSibling; sibling?.type === "decorator"; sibling = sibling.previousSibling) {
1322
+ start = sibling;
1323
+ }
1324
+ const parent = end.parent;
1325
+ if (parent?.type === "export_statement" && parent.childForFieldName("declaration")?.id === end.id) {
1326
+ start = parent;
1327
+ end = parent;
1328
+ }
1329
+ const lines = source.split("\n");
1330
+ const startLine = start.startPosition.row;
1331
+ const endLine = end.endPosition.column === 0 && end.endPosition.row > startLine ? end.endPosition.row - 1 : end.endPosition.row;
1332
+ return {
1333
+ text: lines.slice(startLine, endLine + 1).join("\n"),
1334
+ startLine: startLine + 1,
1335
+ endLine: endLine + 1
1336
+ };
1337
+ }
1338
+
1339
+ // src/tree-sitter-resolver/resolver.ts
1340
+ var TREE_CACHE_LIMIT = 32;
1341
+ var TreeSitterResolver = class {
1342
+ name = "tree-sitter";
1343
+ grammars;
1344
+ loaded = /* @__PURE__ */ new Map();
1345
+ trees = /* @__PURE__ */ new Map();
1346
+ parser;
1347
+ initialized = false;
1348
+ /** Cache effectiveness, for tests and for the latency numbers. */
1349
+ stats = { parses: 0, cacheHits: 0 };
1350
+ constructor(options = {}) {
1351
+ this.grammars = options;
1352
+ }
1353
+ /**
1354
+ * Loads the grammars these files need, once per language per process,
1355
+ * downloading each one on first use.
1356
+ *
1357
+ * A grammar that will not load is remembered as unavailable rather than
1358
+ * retried per anchor, and never throws: an unobtainable WASM is a finding.
1359
+ */
1360
+ async prepare(files) {
1361
+ const wanted = /* @__PURE__ */ new Set();
1362
+ for (const file of files) {
1363
+ const language = languageForFile(file);
1364
+ if (language && !this.loaded.has(language)) wanted.add(language);
1365
+ }
1366
+ if (!wanted.size) return;
1367
+ if (!this.initialized) {
1368
+ try {
1369
+ await Parser.init();
1370
+ this.parser = new Parser();
1371
+ this.initialized = true;
1372
+ } catch {
1373
+ for (const language of wanted) this.loaded.set(language, null);
1374
+ return;
1375
+ }
1376
+ }
1377
+ const languages = [...wanted];
1378
+ const loaded = await mapLimit(
1379
+ languages,
1380
+ Math.min(DEFAULT_IO_CONCURRENCY, languages.length),
1381
+ (language) => this.load(language)
1382
+ );
1383
+ languages.forEach(
1384
+ (language, at) => this.loaded.set(language, loaded[at] ?? null)
1385
+ );
1386
+ }
1387
+ /**
1388
+ * An unobtainable grammar, one this runtime refuses, and a query that will
1389
+ * not compile are three faults with three repairs; all are reported through
1390
+ * the grammars module so every hint has one home.
1391
+ */
1392
+ async load(language) {
1393
+ let pack2;
1394
+ try {
1395
+ pack2 = await ensureGrammar(language, this.grammars);
1396
+ } catch {
1397
+ return null;
1398
+ }
1399
+ if (!pack2?.query) return null;
1400
+ let grammar;
1401
+ try {
1402
+ grammar = await Language.load(pack2.wasm);
1403
+ } catch (error) {
1404
+ noteRejectedGrammar(language, why(error));
1405
+ return null;
1406
+ }
1407
+ try {
1408
+ return { language: grammar, query: new Query(grammar, pack2.query) };
1409
+ } catch (error) {
1410
+ noteUncompilableQuery(language, why(error));
1411
+ return null;
1412
+ }
1413
+ }
1414
+ /**
1415
+ * Abstains on an extension with no grammar so the regex resolver gets a
1416
+ * turn; reports `resolver-unavailable` when the grammar exists in principle
1417
+ * but could not be loaded, because falling back there would silently trade a
1418
+ * precise span for a guessed one.
1419
+ */
1420
+ attempt(source, symbol, file) {
1421
+ const language = file ? languageForFile(file) : void 0;
1422
+ if (!language) return { kind: "abstain" };
1423
+ if (!this.loaded.has(language)) return { kind: "abstain" };
1424
+ const loaded = this.loaded.get(language);
1425
+ if (!loaded) return { kind: "unresolved", reason: "resolver-unavailable" };
1426
+ const parsed = this.parse(language, loaded, source);
1427
+ if (!parsed) return { kind: "unresolved", reason: "resolver-unavailable" };
1428
+ const wanted = symbol.split(".").filter(Boolean);
1429
+ if (!wanted.length)
1430
+ return { kind: "unresolved", reason: "symbol-not-found" };
1431
+ const matches3 = select(parsed, wanted);
1432
+ if (!matches3.length)
1433
+ return { kind: "unresolved", reason: "symbol-not-found" };
1434
+ if (matches3.length > 1)
1435
+ return { kind: "unresolved", reason: "symbol-ambiguous" };
1436
+ return { kind: "resolved", span: spanOf(matches3[0], source) };
1437
+ }
1438
+ resolve(source, symbol, file) {
1439
+ const attempt = this.attempt(source, symbol, file);
1440
+ return attempt.kind === "resolved" ? attempt.span : null;
1441
+ }
1442
+ /** Parsed trees are keyed by content hash, so an unchanged file parses once. */
1443
+ parse(language, loaded, source) {
1444
+ const key = `${language}:${createHash2("sha256").update(source).digest("hex")}`;
1445
+ const cached2 = this.trees.get(key);
1446
+ if (cached2) {
1447
+ this.stats.cacheHits += 1;
1448
+ return cached2;
1449
+ }
1450
+ const parser = this.parser;
1451
+ if (!parser) return null;
1452
+ let parsed;
1453
+ try {
1454
+ parser.setLanguage(loaded.language);
1455
+ const tree = parser.parse(source);
1456
+ if (!tree) return null;
1457
+ parsed = index(tree, loaded.query);
1458
+ } catch {
1459
+ return null;
1460
+ }
1461
+ this.stats.parses += 1;
1462
+ if (this.trees.size >= TREE_CACHE_LIMIT) {
1463
+ const oldest = this.trees.keys().next();
1464
+ if (!oldest.done) {
1465
+ this.trees.get(oldest.value)?.tree.delete();
1466
+ this.trees.delete(oldest.value);
1467
+ }
1468
+ }
1469
+ this.trees.set(key, parsed);
1470
+ return parsed;
1471
+ }
1472
+ /** Drops cached trees. Grammars stay loaded — they are immutable. */
1473
+ reset() {
1474
+ for (const parsed of this.trees.values()) parsed.tree.delete();
1475
+ this.trees.clear();
1476
+ this.stats.parses = 0;
1477
+ this.stats.cacheHits = 0;
1478
+ }
1479
+ };
1480
+ function why(error) {
1481
+ const text = error instanceof Error ? error.message : String(error);
1482
+ return text || "no reason given";
1483
+ }
1484
+
966
1485
  // src/anchor-resolver/resolver.ts
967
- import { createHash } from "crypto";
1486
+ import { createHash as createHash3 } from "crypto";
968
1487
  var PARENT_SCOPE_LINES = 50;
969
1488
  var CLEAN_STATE = { blockComment: false, template: false };
970
1489
  function stripLine(line, state) {
971
1490
  let out = "";
972
- let index = 0;
1491
+ let index2 = 0;
973
1492
  let { blockComment, template } = state;
974
- while (index < line.length) {
975
- const char = line[index];
976
- const next = line[index + 1];
1493
+ while (index2 < line.length) {
1494
+ const char = line[index2];
1495
+ const next = line[index2 + 1];
977
1496
  if (blockComment) {
978
1497
  if (char === "*" && next === "/") {
979
1498
  blockComment = false;
980
- index += 2;
1499
+ index2 += 2;
981
1500
  continue;
982
1501
  }
983
- index += 1;
1502
+ index2 += 1;
984
1503
  continue;
985
1504
  }
986
1505
  if (template) {
987
1506
  if (char === "\\") {
988
- index += 2;
1507
+ index2 += 2;
989
1508
  continue;
990
1509
  }
991
1510
  if (char === "`") template = false;
992
- index += 1;
1511
+ index2 += 1;
993
1512
  continue;
994
1513
  }
995
1514
  if (char === "/" && next === "*") {
996
1515
  blockComment = true;
997
- index += 2;
1516
+ index2 += 2;
998
1517
  continue;
999
1518
  }
1000
1519
  if (char === "/" && next === "/") break;
1001
1520
  if (char === "`") {
1002
1521
  template = true;
1003
- index += 1;
1522
+ index2 += 1;
1004
1523
  continue;
1005
1524
  }
1006
1525
  if (char === "'" || char === '"') {
1007
1526
  const quote = char;
1008
- index += 1;
1009
- while (index < line.length) {
1010
- if (line[index] === "\\") {
1011
- index += 2;
1527
+ index2 += 1;
1528
+ while (index2 < line.length) {
1529
+ if (line[index2] === "\\") {
1530
+ index2 += 2;
1012
1531
  continue;
1013
1532
  }
1014
- if (line[index] === quote) {
1015
- index += 1;
1533
+ if (line[index2] === quote) {
1534
+ index2 += 1;
1016
1535
  break;
1017
1536
  }
1018
- index += 1;
1537
+ index2 += 1;
1019
1538
  }
1020
1539
  continue;
1021
1540
  }
1022
1541
  out += char;
1023
- index += 1;
1542
+ index2 += 1;
1024
1543
  }
1025
1544
  return { code: out, state: { blockComment, template } };
1026
1545
  }
@@ -1035,8 +1554,8 @@ function captureBraceBlock(lines, matchLine) {
1035
1554
  let depth = 0;
1036
1555
  let opened = false;
1037
1556
  let state = CLEAN_STATE;
1038
- for (let index = matchLine; index < lines.length; index++) {
1039
- const stripped = stripLine(lines[index] ?? "", state);
1557
+ for (let index2 = matchLine; index2 < lines.length; index2++) {
1558
+ const stripped = stripLine(lines[index2] ?? "", state);
1040
1559
  state = stripped.state;
1041
1560
  for (const char of stripped.code) {
1042
1561
  if (char === "{") {
@@ -1045,10 +1564,10 @@ function captureBraceBlock(lines, matchLine) {
1045
1564
  } else if (char === "}") {
1046
1565
  depth = Math.max(0, depth - 1);
1047
1566
  } else if (char === ";" && !opened) {
1048
- return span(lines, matchLine, index);
1567
+ return span(lines, matchLine, index2);
1049
1568
  }
1050
1569
  }
1051
- if (opened && depth === 0) return span(lines, matchLine, index);
1570
+ if (opened && depth === 0) return span(lines, matchLine, index2);
1052
1571
  }
1053
1572
  return null;
1054
1573
  }
@@ -1057,22 +1576,22 @@ function captureIndentedBlock(lines, matchLine) {
1057
1576
  const header = lines[matchLine] ?? "";
1058
1577
  const indent = header.length - header.trimStart().length;
1059
1578
  let headerEnd = -1;
1060
- for (let index = matchLine; index < lines.length && index <= matchLine + 20; index++) {
1061
- const code = stripLine(lines[index] ?? "", CLEAN_STATE).code.trimEnd();
1579
+ for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1580
+ const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
1062
1581
  if (code.endsWith(":")) {
1063
- headerEnd = index;
1582
+ headerEnd = index2;
1064
1583
  break;
1065
1584
  }
1066
- if (code.includes(":")) return span(lines, matchLine, index);
1585
+ if (code.includes(":")) return span(lines, matchLine, index2);
1067
1586
  }
1068
1587
  if (headerEnd === -1) return null;
1069
1588
  let end = headerEnd;
1070
- for (let index = headerEnd + 1; index < lines.length; index++) {
1071
- const line = lines[index] ?? "";
1589
+ for (let index2 = headerEnd + 1; index2 < lines.length; index2++) {
1590
+ const line = lines[index2] ?? "";
1072
1591
  if (line.trim() === "") continue;
1073
1592
  const lineIndent = line.length - line.trimStart().length;
1074
1593
  if (lineIndent <= indent) break;
1075
- end = index;
1594
+ end = index2;
1076
1595
  }
1077
1596
  return end === headerEnd ? null : span(lines, matchLine, end);
1078
1597
  }
@@ -1096,11 +1615,11 @@ var regexResolver = {
1096
1615
  const lines = source.split("\n");
1097
1616
  for (const tier of TIERS) {
1098
1617
  const pattern = tier(escaped);
1099
- let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1618
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1100
1619
  if (!candidates.length) continue;
1101
1620
  if (parentPattern && candidates.length > 1) {
1102
1621
  const distances = candidates.map(
1103
- (index) => distanceToParent(lines, index, parentPattern)
1622
+ (index2) => distanceToParent(lines, index2, parentPattern)
1104
1623
  );
1105
1624
  const nearest = Math.min(...distances);
1106
1625
  if (Number.isFinite(nearest)) {
@@ -1117,34 +1636,79 @@ var regexResolver = {
1117
1636
  function escapeRegExp(value) {
1118
1637
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1119
1638
  }
1120
- function distanceToParent(lines, index, parent) {
1121
- const floor = Math.max(0, index - PARENT_SCOPE_LINES);
1122
- for (let at = index; at >= floor; at--) {
1123
- if (parent.test(lines[at] ?? "")) return index - at;
1639
+ function distanceToParent(lines, index2, parent) {
1640
+ const floor = Math.max(0, index2 - PARENT_SCOPE_LINES);
1641
+ for (let at = index2; at >= floor; at--) {
1642
+ if (parent.test(lines[at] ?? "")) return index2 - at;
1124
1643
  }
1125
1644
  return Number.POSITIVE_INFINITY;
1126
1645
  }
1127
1646
  function hashAnchorText(text) {
1128
- return `sha256:${createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1647
+ return `sha256:${createHash3("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1129
1648
  }
1130
1649
  function resolveAnchor(source, anchor, resolver = regexResolver) {
1650
+ const outcome = resolveAnchorSpan(source, anchor, [resolver]);
1651
+ return outcome.ok ? outcome.span : null;
1652
+ }
1653
+ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1131
1654
  const normalized = source.replace(/\r\n/g, "\n");
1132
1655
  if (!anchor.symbol) {
1133
1656
  const lines = normalized.split("\n");
1134
1657
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1135
1658
  return {
1136
- text: normalized,
1137
- startLine: 1,
1138
- endLine: Math.max(1, lines.length)
1659
+ ok: true,
1660
+ span: {
1661
+ text: normalized,
1662
+ startLine: 1,
1663
+ endLine: Math.max(1, lines.length)
1664
+ }
1665
+ };
1666
+ }
1667
+ for (const resolver of resolvers) {
1668
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1669
+ if (attempt.kind === "abstain") continue;
1670
+ if (attempt.kind === "unresolved") {
1671
+ if (attempt.reason === "symbol-not-found") continue;
1672
+ return { ok: false, reason: attempt.reason };
1673
+ }
1674
+ return {
1675
+ ok: true,
1676
+ span: attempt.span,
1677
+ ...isResolverName(resolver.name) ? { resolver: resolver.name } : {}
1139
1678
  };
1140
1679
  }
1141
- return resolver.resolve(normalized, anchor.symbol);
1680
+ return { ok: false, reason: "symbol-not-found" };
1681
+ }
1682
+ function fromResolve(resolver, source, symbol, file) {
1683
+ const span2 = resolver.resolve(source, symbol, file);
1684
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1685
+ }
1686
+ function isResolverName(name) {
1687
+ return name === "tree-sitter" || name === "regex";
1688
+ }
1689
+ async function prepareResolvers(resolvers, files) {
1690
+ for (const resolver of resolvers) await resolver.prepare?.(files);
1691
+ }
1692
+ function defaultAnchorResolvers(grammars = {}) {
1693
+ return [new TreeSitterResolver(grammars), regexResolver];
1694
+ }
1695
+ function resolverChanged(source, anchor, produced) {
1696
+ const previous = anchor.resolver ?? "regex";
1697
+ if (!produced || !anchor.symbol || previous === produced) return false;
1698
+ if (previous !== "regex") return false;
1699
+ const before = regexResolver.resolve(
1700
+ source.replace(/\r\n/g, "\n"),
1701
+ anchor.symbol
1702
+ );
1703
+ return before !== null && hashAnchorText(before.text) === anchor.hash;
1142
1704
  }
1143
1705
 
1144
1706
  // src/anchor-resolver/drift.ts
1145
1707
  async function detectAnchorDrift(records, options = {}) {
1146
1708
  const repoRoot = options.repoRoot ?? process.cwd();
1147
- const resolver = options.resolver ?? regexResolver;
1709
+ const resolvers = options.resolvers ?? (options.resolver ? [options.resolver] : defaultAnchorResolvers({
1710
+ offline: options.remote?.offline === true
1711
+ }));
1148
1712
  const origin = new LazyOrigin(repoRoot);
1149
1713
  const planned = /* @__PURE__ */ new Map();
1150
1714
  let declaresRepo = false;
@@ -1182,12 +1746,16 @@ async function detectAnchorDrift(records, options = {}) {
1182
1746
  ),
1183
1747
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1184
1748
  ]);
1749
+ await prepareResolvers(resolvers, [
1750
+ ...files,
1751
+ ...wants.map((want) => want.file)
1752
+ ]);
1185
1753
  const drift = /* @__PURE__ */ new Map();
1186
1754
  for (const record of records) {
1187
1755
  const entries = [];
1188
1756
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
1189
1757
  entries.push(
1190
- foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1758
+ foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
1191
1759
  );
1192
1760
  }
1193
1761
  if (entries.length) drift.set(record.conceptId, entries);
@@ -1216,12 +1784,22 @@ function unresolved(anchor, reason, repo) {
1216
1784
  ...repo ? { repo } : {}
1217
1785
  };
1218
1786
  }
1219
- function hashIn(source, anchor, resolver) {
1220
- const resolved = resolveAnchor(source, anchor, resolver);
1221
- if (!resolved) return null;
1787
+ function hashIn(source, anchor, resolvers) {
1788
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
1789
+ if (!outcome.ok) return { ok: false, reason: outcome.reason };
1790
+ return {
1791
+ ok: true,
1792
+ current: {
1793
+ hash: hashAnchorText(outcome.span.text),
1794
+ lines: outcome.span.endLine - outcome.span.startLine + 1,
1795
+ ...outcome.resolver ? { resolver: outcome.resolver } : {}
1796
+ }
1797
+ };
1798
+ }
1799
+ function resolverExtras(source, anchor, current) {
1222
1800
  return {
1223
- hash: hashAnchorText(resolved.text),
1224
- lines: resolved.endLine - resolved.startLine + 1
1801
+ ...current.resolver ? { resolver: current.resolver } : {},
1802
+ ...current.hash !== anchor.hash && resolverChanged(source, anchor, current.resolver) ? { reason: "resolver-changed" } : {}
1225
1803
  };
1226
1804
  }
1227
1805
  function compared(anchor, current, extra = {}) {
@@ -1233,30 +1811,48 @@ function compared(anchor, current, extra = {}) {
1233
1811
  ...extra
1234
1812
  };
1235
1813
  }
1236
- function localEntry(anchor, read, resolver) {
1814
+ function localEntry(anchor, read, resolvers) {
1237
1815
  if (!read.ok) return unresolved(anchor, read.reason);
1238
- const current = hashIn(read.source, anchor, resolver);
1239
- return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1816
+ const found = hashIn(read.source, anchor, resolvers);
1817
+ if (!found.ok) return unresolved(anchor, found.reason);
1818
+ return compared(
1819
+ anchor,
1820
+ found.current,
1821
+ resolverExtras(read.source, anchor, found.current)
1822
+ );
1240
1823
  }
1241
- function remoteEntry(anchor, remote, resolver) {
1824
+ function remoteEntry(anchor, remote, resolvers) {
1242
1825
  const repo = anchor.repo;
1243
1826
  const key = normalizeRepoUrl(repo);
1244
1827
  const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1245
1828
  const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1246
1829
  if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1247
1830
  if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1248
- const current = hashIn(primary.source, anchor, resolver);
1249
- if (!current) return unresolved(anchor, "symbol-not-found", repo);
1250
- if (!anchor.ref) return compared(anchor, current, { repo });
1831
+ const found = hashIn(primary.source, anchor, resolvers);
1832
+ if (!found.ok) return unresolved(anchor, found.reason, repo);
1833
+ const current = found.current;
1834
+ const extras = resolverExtras(primary.source, anchor, current);
1835
+ if (!anchor.ref) return compared(anchor, current, { repo, ...extras });
1251
1836
  if (current.hash !== anchor.hash) {
1252
- return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1837
+ return compared(anchor, current, {
1838
+ repo,
1839
+ ...extras,
1840
+ remoteState: "drifted-from-ref"
1841
+ });
1253
1842
  }
1254
- const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1255
- return head && head.hash !== anchor.hash ? {
1256
- ...compared(anchor, head, { repo }),
1843
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
1844
+ return head?.ok && head.current.hash !== anchor.hash ? {
1845
+ ...compared(anchor, head.current, {
1846
+ repo,
1847
+ ...head.current.resolver ? { resolver: head.current.resolver } : {}
1848
+ }),
1257
1849
  state: "drifted",
1258
1850
  remoteState: "drifted-on-default"
1259
- } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1851
+ } : compared(anchor, current, {
1852
+ repo,
1853
+ ...extras,
1854
+ remoteState: "matches-ref"
1855
+ });
1260
1856
  }
1261
1857
 
1262
1858
  // src/errors.ts
@@ -1463,9 +2059,9 @@ var KbStampDigestBaselineError = class extends BaseError {
1463
2059
  // src/kb-pins/budgets.ts
1464
2060
  function asBudgets(value) {
1465
2061
  if (value === null || typeof value !== "object") return {};
1466
- const table = value;
2062
+ const table2 = value;
1467
2063
  const pick = (key, min) => {
1468
- const raw = table[key];
2064
+ const raw = table2[key];
1469
2065
  return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
1470
2066
  };
1471
2067
  const budgetTokens = pick("budgetTokens", 1);
@@ -1476,9 +2072,9 @@ function asBudgets(value) {
1476
2072
  };
1477
2073
  }
1478
2074
  function contextProfileBudgets(manifest, profile) {
1479
- const table = manifest.context;
1480
- if (table === null || typeof table !== "object") return {};
1481
- const entries = table;
2075
+ const table2 = manifest.context;
2076
+ if (table2 === null || typeof table2 !== "object") return {};
2077
+ const entries = table2;
1482
2078
  return {
1483
2079
  ...asBudgets(entries["default"]),
1484
2080
  ...profile ? asBudgets(entries[profile]) : {}
@@ -1509,15 +2105,15 @@ var KbBaseFrozenError = class extends Error {
1509
2105
  };
1510
2106
 
1511
2107
  // src/kb-pins/model.ts
1512
- import { join as join2 } from "path";
1513
- import { z as z4 } from "zod";
1514
- var PINS_FILE = join2(".strauss", "kb-pins.json");
1515
- var PINS_LOCAL_FILE = join2(".strauss", "kb-pins.local.json");
2108
+ import { join as join4 } from "path";
2109
+ import { z as z5 } from "zod";
2110
+ var PINS_FILE = join4(".strauss", "kb-pins.json");
2111
+ var PINS_LOCAL_FILE = join4(".strauss", "kb-pins.local.json");
1516
2112
  var PIN_LAYERS = ["project", "local", "user"];
1517
- var pinSchema = z4.object({
2113
+ var pinSchema = z5.object({
1518
2114
  /** Relative to the manifest's root, so the file is committable. */
1519
- path: z4.string().min(1),
1520
- pinnedAt: z4.string().min(1).optional(),
2115
+ path: z5.string().min(1),
2116
+ pinnedAt: z5.string().min(1).optional(),
1521
2117
  /**
1522
2118
  * How `context` renders this base. `full` preloads the whole base into
1523
2119
  * the block regardless of the full-under threshold — for a base whose
@@ -1527,7 +2123,7 @@ var pinSchema = z4.object({
1527
2123
  * Absent: the profile's full-under threshold decides. Invalid values
1528
2124
  * degrade to absent rather than failing the manifest.
1529
2125
  */
1530
- mode: z4.enum(["full", "index"]).optional().catch(void 0),
2126
+ mode: z5.enum(["full", "index"]).optional().catch(void 0),
1531
2127
  /**
1532
2128
  * Context profiles this pin surfaces in (e.g. only at session-start,
1533
2129
  * not per turn). Absent: every profile. A run without a profile sees
@@ -1535,17 +2131,17 @@ var pinSchema = z4.object({
1535
2131
  * that skill at point of use than pinned at all — pins are what every
1536
2132
  * session should see.
1537
2133
  */
1538
- profiles: z4.array(z4.string()).optional().catch(void 0),
2134
+ profiles: z5.array(z5.string()).optional().catch(void 0),
1539
2135
  /**
1540
2136
  * The base is concluded — a finished piece of research, a frozen ADR
1541
2137
  * set. Write commands against it refuse while this workspace holds the
1542
2138
  * pin, and `context` labels it read-only. Workspace policy, not base
1543
2139
  * state: the base itself stays copyable and writable elsewhere.
1544
2140
  */
1545
- frozen: z4.boolean().optional().catch(void 0)
2141
+ frozen: z5.boolean().optional().catch(void 0)
1546
2142
  }).passthrough();
1547
- var pinsManifestSchema = z4.object({
1548
- pins: z4.array(pinSchema).default([]),
2143
+ var pinsManifestSchema = z5.object({
2144
+ pins: z5.array(pinSchema).default([]),
1549
2145
  /**
1550
2146
  * Per-repo budgets for the `context` command, keyed by profile —
1551
2147
  * `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
@@ -1554,21 +2150,21 @@ var pinsManifestSchema = z4.object({
1554
2150
  * the index at every session start. `contextProfileBudgets` does the
1555
2151
  * tolerant read.
1556
2152
  */
1557
- context: z4.unknown().optional()
2153
+ context: z5.unknown().optional()
1558
2154
  }).passthrough();
1559
2155
 
1560
2156
  // src/kb-pins/layers.ts
1561
- import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
1562
- import { homedir as homedir2 } from "os";
1563
- import { dirname, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
2157
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
2158
+ import { homedir as homedir3 } from "os";
2159
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
1564
2160
  function userRoot() {
1565
- return process.env.STRAUSS_KB_USER_ROOT || homedir2();
2161
+ return process.env.STRAUSS_KB_USER_ROOT || homedir3();
1566
2162
  }
1567
2163
  function layerRoot(workspaceDir, layer) {
1568
2164
  return layer === "user" ? userRoot() : resolve2(workspaceDir);
1569
2165
  }
1570
2166
  function layerFile(workspaceDir, layer) {
1571
- return join3(
2167
+ return join5(
1572
2168
  layerRoot(workspaceDir, layer),
1573
2169
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1574
2170
  );
@@ -1577,7 +2173,7 @@ async function readPinsLayer(workspaceDir, layer) {
1577
2173
  const file = layerFile(workspaceDir, layer);
1578
2174
  let raw;
1579
2175
  try {
1580
- raw = await readFile2(file, "utf8");
2176
+ raw = await readFile4(file, "utf8");
1581
2177
  } catch {
1582
2178
  return { pins: [] };
1583
2179
  }
@@ -1601,8 +2197,8 @@ async function readPinsLayer(workspaceDir, layer) {
1601
2197
  }
1602
2198
  async function writePinsLayer(workspaceDir, layer, manifest) {
1603
2199
  const file = layerFile(workspaceDir, layer);
1604
- await mkdir2(dirname(file), { recursive: true });
1605
- await writeFile(file, `${JSON.stringify(manifest, null, 2)}
2200
+ await mkdir3(dirname3(file), { recursive: true });
2201
+ await writeFile2(file, `${JSON.stringify(manifest, null, 2)}
1606
2202
  `, "utf8");
1607
2203
  }
1608
2204
  function resolvePinPath(rootDir, path) {
@@ -1827,8 +2423,8 @@ function resolveHeads(from, byId) {
1827
2423
  while (queue.length) {
1828
2424
  const current = queue.shift();
1829
2425
  const next = successors(current, byId);
1830
- for (const missing of next.missing) {
1831
- warnings.push({ kind: "broken-chain", missing });
2426
+ for (const missing2 of next.missing) {
2427
+ warnings.push({ kind: "broken-chain", missing: missing2 });
1832
2428
  }
1833
2429
  if (!next.records.length) {
1834
2430
  if (current.conceptId !== from.conceptId)
@@ -1859,13 +2455,13 @@ function successors(record, byId) {
1859
2455
  }
1860
2456
  }
1861
2457
  const records = [];
1862
- const missing = [];
2458
+ const missing2 = [];
1863
2459
  for (const id of ids) {
1864
2460
  const found = byId.get(id);
1865
2461
  if (found) records.push(found);
1866
- else missing.push(id);
2462
+ else missing2.push(id);
1867
2463
  }
1868
- return { records, missing };
2464
+ return { records, missing: missing2 };
1869
2465
  }
1870
2466
 
1871
2467
  // src/catalog.ts
@@ -1936,7 +2532,7 @@ function indexIsStale(stored, expected) {
1936
2532
  }
1937
2533
 
1938
2534
  // src/kb-context.ts
1939
- import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
2535
+ import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
1940
2536
  var HEADING2 = "## Knowledge bases (pinned)";
1941
2537
  var DEFAULT_CONTEXT_BUDGET = 4e3;
1942
2538
  var CONTEXT_PROFILES = {
@@ -2140,13 +2736,13 @@ function toHookJson(block, event) {
2140
2736
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2141
2737
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2142
2738
  async function syncInstructions(file, block) {
2143
- const existing = await readFile3(file, "utf8").catch(() => null);
2739
+ const existing = await readFile5(file, "utf8").catch(() => null);
2144
2740
  const region = block ? `${CONTEXT_BEGIN}
2145
2741
  ${block.trim()}
2146
2742
  ${CONTEXT_END}` : null;
2147
2743
  if (existing === null) {
2148
2744
  if (!region) return { file, action: "unchanged" };
2149
- await writeFile2(file, `${region}
2745
+ await writeFile3(file, `${region}
2150
2746
  `, "utf8");
2151
2747
  return { file, action: "created" };
2152
2748
  }
@@ -2157,11 +2753,11 @@ ${CONTEXT_END}` : null;
2157
2753
  const after = existing.slice(end + CONTEXT_END.length);
2158
2754
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2159
2755
  if (next === existing) return { file, action: "unchanged" };
2160
- await writeFile2(file, next, "utf8");
2756
+ await writeFile3(file, next, "utf8");
2161
2757
  return { file, action: region ? "replaced" : "removed" };
2162
2758
  }
2163
2759
  if (!region) return { file, action: "unchanged" };
2164
- await writeFile2(
2760
+ await writeFile3(
2165
2761
  file,
2166
2762
  `${existing.replace(/\n*$/, "\n\n")}${region}
2167
2763
  `,
@@ -2365,6 +2961,18 @@ var CHECK_HEADLINES = {
2365
2961
  unchecked: "an anchor in another repository nothing could reach"
2366
2962
  };
2367
2963
  var DAY_MS = 864e5;
2964
+ function anchorResolverCounts(bundle) {
2965
+ let treeSitter = 0;
2966
+ let regex = 0;
2967
+ for (const record of bundle) {
2968
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
2969
+ if (!anchor.hash || !anchor.symbol) continue;
2970
+ if (anchor.resolver === "tree-sitter") treeSitter += 1;
2971
+ else regex += 1;
2972
+ }
2973
+ }
2974
+ return { total: treeSitter + regex, treeSitter, regex };
2975
+ }
2368
2976
  function doctor(bundle, options = {}) {
2369
2977
  const thresholds = {
2370
2978
  expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
@@ -2400,6 +3008,7 @@ function doctor(bundle, options = {}) {
2400
3008
  counts,
2401
3009
  groups,
2402
3010
  findingCount,
3011
+ anchorResolvers: anchorResolverCounts(bundle),
2403
3012
  healthy: findingCount === 0
2404
3013
  };
2405
3014
  }
@@ -2646,9 +3255,9 @@ function ageInDays(record, now) {
2646
3255
  }
2647
3256
 
2648
3257
  // src/kb-log.ts
2649
- import { z as z5 } from "zod";
3258
+ import { z as z6 } from "zod";
2650
3259
  var LOG_FILE = "log.jsonl";
2651
- var kbLogEntrySchema = z5.object({
3260
+ var kbLogEntrySchema = z6.object({
2652
3261
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
2653
3262
  // below), and a value that isn't actually chronological — a Unix
2654
3263
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -2657,12 +3266,12 @@ var kbLogEntrySchema = z5.object({
2657
3266
  // and rejects everything else, including a non-`Z` offset — so a
2658
3267
  // malformed `at` is reported the same way a malformed line already is,
2659
3268
  // rather than silently sorting into the wrong place.
2660
- at: z5.iso.datetime(),
2661
- by: z5.string().min(1),
2662
- operation: z5.string().min(1),
2663
- conceptId: z5.string().min(1),
3269
+ at: z6.iso.datetime(),
3270
+ by: z6.string().min(1),
3271
+ operation: z6.string().min(1),
3272
+ conceptId: z6.string().min(1),
2664
3273
  /** Second concept id, where the operation relates two — supersession. */
2665
- target: z5.string().min(1).optional()
3274
+ target: z6.string().min(1).optional()
2666
3275
  }).strict();
2667
3276
  function renderLogEntry(entry) {
2668
3277
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -2672,18 +3281,18 @@ function parseLog(raw) {
2672
3281
  const entries = [];
2673
3282
  const malformed = [];
2674
3283
  const seen = /* @__PURE__ */ new Set();
2675
- raw.split("\n").forEach((text, index) => {
3284
+ raw.split("\n").forEach((text, index2) => {
2676
3285
  if (!text.trim()) return;
2677
3286
  let value;
2678
3287
  try {
2679
3288
  value = JSON.parse(text);
2680
3289
  } catch {
2681
- malformed.push({ line: index + 1, text });
3290
+ malformed.push({ line: index2 + 1, text });
2682
3291
  return;
2683
3292
  }
2684
3293
  const parsed = kbLogEntrySchema.safeParse(value);
2685
3294
  if (!parsed.success) {
2686
- malformed.push({ line: index + 1, text });
3295
+ malformed.push({ line: index2 + 1, text });
2687
3296
  return;
2688
3297
  }
2689
3298
  const key = JSON.stringify(parsed.data);
@@ -2698,14 +3307,14 @@ function parseLog(raw) {
2698
3307
  }
2699
3308
 
2700
3309
  // src/json-schema.ts
2701
- import { z as z6 } from "zod";
3310
+ import { z as z7 } from "zod";
2702
3311
  function kbJsonSchemas() {
2703
3312
  return {
2704
- recordFrontmatter: z6.toJSONSchema(kbRecordFrontmatterSchema, {
3313
+ recordFrontmatter: z7.toJSONSchema(kbRecordFrontmatterSchema, {
2705
3314
  io: "input"
2706
3315
  }),
2707
- composeInput: z6.toJSONSchema(composeInputSchema, { io: "input" }),
2708
- logEntry: z6.toJSONSchema(kbLogEntrySchema, { io: "input" })
3316
+ composeInput: z7.toJSONSchema(composeInputSchema, { io: "input" }),
3317
+ logEntry: z7.toJSONSchema(kbLogEntrySchema, { io: "input" })
2709
3318
  };
2710
3319
  }
2711
3320
 
@@ -2758,13 +3367,13 @@ function byGeneratedAt(left, right) {
2758
3367
  }
2759
3368
 
2760
3369
  // src/commands/anchor-resolve.ts
2761
- import { z as z8 } from "zod";
3370
+ import { z as z9 } from "zod";
2762
3371
 
2763
3372
  // src/commands/model.ts
2764
- import { z as z7 } from "zod";
2765
- var bundlePath = z7.string().min(1).describe("Absolute path to the knowledge base directory.");
2766
- var conceptId = z7.string().min(1).describe("e.g. decision.cursor-v2");
2767
- var REPO_ROOT = z7.string().min(1).optional().describe(
3373
+ import { z as z8 } from "zod";
3374
+ var bundlePath = z8.string().min(1).describe("Absolute path to the knowledge base directory.");
3375
+ var conceptId = z8.string().min(1).describe("e.g. decision.cursor-v2");
3376
+ var REPO_ROOT = z8.string().min(1).optional().describe(
2768
3377
  "Where the anchored source lives, for the drift check. Defaults to the working directory."
2769
3378
  );
2770
3379
  function define(command) {
@@ -2787,22 +3396,30 @@ function argvFlag(argv, name) {
2787
3396
  }
2788
3397
 
2789
3398
  // src/commands/anchor-resolve.ts
3399
+ function resolverSummary(results) {
3400
+ const names = [
3401
+ ...new Set(
3402
+ results.flatMap((entry) => entry.resolver ? [entry.resolver] : [])
3403
+ )
3404
+ ].sort();
3405
+ return names.length ? `${names.join(" + ")} resolver` : "whole-file";
3406
+ }
2790
3407
  var anchorResolveCommand = define({
2791
3408
  name: "anchor-resolve",
2792
3409
  tool: "kb_anchor_resolve",
2793
3410
  usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
2794
3411
  description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
2795
- input: z8.object({
3412
+ input: z9.object({
2796
3413
  bundlePath,
2797
3414
  conceptId,
2798
- repoRoot: z8.string().min(1).optional(),
2799
- offline: z8.boolean().optional().describe(
3415
+ repoRoot: z9.string().min(1).optional(),
3416
+ offline: z9.boolean().optional().describe(
2800
3417
  "Resolve foreign anchors from the local repo cache only, never fetching."
2801
3418
  ),
2802
- rebaseline: z8.boolean().optional().describe(
3419
+ rebaseline: z9.boolean().optional().describe(
2803
3420
  "Accept the current code as the new baseline for anchors that drifted."
2804
3421
  ),
2805
- restamp: z8.boolean().optional().describe(
3422
+ restamp: z9.boolean().optional().describe(
2806
3423
  "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
2807
3424
  )
2808
3425
  }),
@@ -2831,6 +3448,11 @@ var anchorResolveCommand = define({
2831
3448
  const updated = [];
2832
3449
  let dirty = false;
2833
3450
  const sources = await readSources(anchors, root, offline === true);
3451
+ const resolvers = defaultAnchorResolvers({ offline: offline === true });
3452
+ await prepareResolvers(
3453
+ resolvers,
3454
+ anchors.map((anchor) => anchor.file)
3455
+ );
2834
3456
  for (const anchor of anchors) {
2835
3457
  const base2 = {
2836
3458
  file: anchor.file,
@@ -2847,27 +3469,35 @@ var anchorResolveCommand = define({
2847
3469
  updated.push(anchor);
2848
3470
  continue;
2849
3471
  }
2850
- const resolved = resolveAnchor(source.source, anchor);
2851
- if (!resolved) {
3472
+ const outcome = resolveAnchorSpan(source.source, anchor, resolvers);
3473
+ if (!outcome.ok) {
2852
3474
  results.push({
2853
3475
  ...base2,
2854
3476
  state: "unresolved",
2855
- reason: "symbol-not-found"
3477
+ reason: outcome.reason
2856
3478
  });
2857
3479
  updated.push(anchor);
2858
3480
  continue;
2859
3481
  }
3482
+ const resolved = outcome.span;
3483
+ const producedBy = outcome.resolver;
2860
3484
  const currentHash = hashAnchorText(resolved.text);
2861
3485
  const currentLines = resolved.endLine - resolved.startLine + 1;
2862
3486
  const stamped = {
2863
3487
  ...anchor,
2864
3488
  hash: currentHash,
2865
3489
  lines: currentLines,
2866
- resolved_at: now()
3490
+ resolved_at: now(),
3491
+ ...producedBy ? { resolver: producedBy } : {}
2867
3492
  };
2868
3493
  const pinned = anchor.ref !== void 0 && source.repo !== void 0;
2869
3494
  if (!anchor.hash) {
2870
- results.push({ ...base2, state: "stamped", currentHash });
3495
+ results.push({
3496
+ ...base2,
3497
+ state: "stamped",
3498
+ currentHash,
3499
+ ...producedBy ? { resolver: producedBy } : {}
3500
+ });
2871
3501
  updated.push(stamped);
2872
3502
  dirty = true;
2873
3503
  continue;
@@ -2878,6 +3508,10 @@ var anchorResolveCommand = define({
2878
3508
  state: "drifted",
2879
3509
  currentHash,
2880
3510
  diffSize: lineDelta(anchor, currentLines),
3511
+ ...producedBy ? { resolver: producedBy } : {},
3512
+ // A regex-stamped anchor re-read by tree-sitter drifts because the
3513
+ // resolver changed, not because the code did.
3514
+ ...resolverChanged(source.source, anchor, producedBy) ? { reason: "resolver-changed" } : {},
2881
3515
  ...pinned ? { remoteState: "drifted-from-ref" } : {},
2882
3516
  ...rebaseline ? { rebaselined: true } : {}
2883
3517
  });
@@ -2885,7 +3519,7 @@ var anchorResolveCommand = define({
2885
3519
  if (rebaseline) dirty = true;
2886
3520
  continue;
2887
3521
  }
2888
- const onDefault = pinned ? headHash(source, anchor) : void 0;
3522
+ const onDefault = pinned ? headHash(source, anchor, resolvers) : void 0;
2889
3523
  if (onDefault && onDefault.hash !== anchor.hash) {
2890
3524
  results.push({
2891
3525
  ...base2,
@@ -2901,6 +3535,7 @@ var anchorResolveCommand = define({
2901
3535
  ...base2,
2902
3536
  state: "match",
2903
3537
  currentHash,
3538
+ ...producedBy ? { resolver: producedBy } : {},
2904
3539
  ...pinned ? { remoteState: "matches-ref" } : {}
2905
3540
  });
2906
3541
  const refresh = restamp || anchor.resolved_at === void 0;
@@ -2918,19 +3553,21 @@ var anchorResolveCommand = define({
2918
3553
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
2919
3554
  }
2920
3555
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
3556
+ const hints = grammarHints();
3557
+ const hintNote = hints.length ? { hints } : {};
2921
3558
  const unreachable = results.filter(
2922
3559
  (entry) => isUncheckedReason(entry.reason)
2923
3560
  ).length;
2924
3561
  const checked = results.length - unreachable;
2925
- const matches2 = results.filter((entry) => entry.state === "match").length;
2926
- const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
2927
- const clean = checked > 0 && matches2 === checked && unreachable === 0;
3562
+ const matches3 = results.filter((entry) => entry.state === "match").length;
3563
+ const note = `${matches3}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
3564
+ const clean = checked > 0 && matches3 === checked && unreachable === 0;
2928
3565
  if (clean) {
2929
3566
  try {
2930
3567
  await store.verify(
2931
3568
  path,
2932
3569
  id,
2933
- `anchor-resolve: ${note} (regex resolver)`,
3570
+ `anchor-resolve: ${note} (${resolverSummary(results)})`,
2934
3571
  actor,
2935
3572
  now()
2936
3573
  );
@@ -2941,17 +3578,25 @@ var anchorResolveCommand = define({
2941
3578
  results,
2942
3579
  verified: false,
2943
3580
  verifyRefused: "self-verification",
2944
- ...frozenNote
3581
+ ...frozenNote,
3582
+ ...hintNote
2945
3583
  };
2946
3584
  }
2947
- return { conceptId: id, results, verified: true, ...frozenNote };
3585
+ return {
3586
+ conceptId: id,
3587
+ results,
3588
+ verified: true,
3589
+ ...frozenNote,
3590
+ ...hintNote
3591
+ };
2948
3592
  }
2949
3593
  return {
2950
3594
  conceptId: id,
2951
3595
  results,
2952
3596
  verified: false,
2953
3597
  ...unreachable ? { note } : {},
2954
- ...frozenNote
3598
+ ...frozenNote,
3599
+ ...hintNote
2955
3600
  };
2956
3601
  },
2957
3602
  // A stored hash that no longer resolves is a broken anchor, not an absence:
@@ -2967,13 +3612,13 @@ var anchorResolveCommand = define({
2967
3612
  function lineDelta(anchor, current) {
2968
3613
  return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
2969
3614
  }
2970
- function headHash(source, anchor) {
3615
+ function headHash(source, anchor, resolvers) {
2971
3616
  if (source.head === void 0) return void 0;
2972
- const resolved = resolveAnchor(source.head, anchor);
2973
- if (!resolved) return void 0;
3617
+ const outcome = resolveAnchorSpan(source.head, anchor, resolvers);
3618
+ if (!outcome.ok) return void 0;
2974
3619
  return {
2975
- hash: hashAnchorText(resolved.text),
2976
- lines: resolved.endLine - resolved.startLine + 1
3620
+ hash: hashAnchorText(outcome.span.text),
3621
+ lines: outcome.span.endLine - outcome.span.startLine + 1
2977
3622
  };
2978
3623
  }
2979
3624
  async function readSources(anchors, root, offline) {
@@ -3023,13 +3668,13 @@ async function readSources(anchors, root, offline) {
3023
3668
  }
3024
3669
 
3025
3670
  // src/commands/answer.ts
3026
- import { z as z9 } from "zod";
3671
+ import { z as z10 } from "zod";
3027
3672
  var answerCommand = define({
3028
3673
  name: "answer",
3029
3674
  tool: "kb_answer",
3030
3675
  usage: "answer <concept-id> <answer...>",
3031
3676
  description: "Resolve an open question: set status, stamp who and when, append an Answer section. If the answer overturns a decision or assumption, supersede that record explicitly.",
3032
- input: z9.object({ bundlePath, conceptId, answer: z9.string().min(1) }),
3677
+ input: z10.object({ bundlePath, conceptId, answer: z10.string().min(1) }),
3033
3678
  fromArgv: (argv, path) => ({
3034
3679
  bundlePath: path,
3035
3680
  conceptId: argv[1],
@@ -3043,27 +3688,27 @@ var answerCommand = define({
3043
3688
  });
3044
3689
 
3045
3690
  // src/commands/backlinks.ts
3046
- import { z as z10 } from "zod";
3691
+ import { z as z11 } from "zod";
3047
3692
  var backlinksCommand = define({
3048
3693
  name: "backlinks",
3049
3694
  tool: "kb_backlinks",
3050
3695
  usage: "backlinks <concept-id>",
3051
3696
  description: "Who points at this record: every inbound typed causal link (`strauss_links`), one hop, every rel including `related_to`, each with its rel and the standing of the record that made it. Use it when you need the exact edges \u2014 reviewing or renaming a record.",
3052
- input: z10.object({ bundlePath, conceptId }),
3697
+ input: z11.object({ bundlePath, conceptId }),
3053
3698
  fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
3054
3699
  run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
3055
3700
  });
3056
3701
 
3057
3702
  // src/commands/catalog.ts
3058
- import { z as z11 } from "zod";
3703
+ import { z as z12 } from "zod";
3059
3704
  var catalogCommand = define({
3060
3705
  name: "catalog",
3061
3706
  tool: "kb_catalog",
3062
3707
  usage: "catalog [type]",
3063
3708
  description: "Lists every record as one line \u2014 concept id, type, title, standing, and a stale flag \u2014 at roughly thirty tokens each. Pick this over kb_load once kb_load refuses: kb_catalog never refuses. Superseded records show only their replacement; fetch bodies with kb_load, kb_pack, kb_query, or kb_trace.",
3064
- input: z11.object({
3709
+ input: z12.object({
3065
3710
  bundlePath,
3066
- type: z11.enum(KB_RECORD_TYPES).optional()
3711
+ type: z12.enum(KB_RECORD_TYPES).optional()
3067
3712
  }),
3068
3713
  fromArgv: (argv, path) => ({
3069
3714
  bundlePath: path,
@@ -3118,26 +3763,26 @@ function count(value, noun) {
3118
3763
  }
3119
3764
 
3120
3765
  // src/commands/context.ts
3121
- import { z as z12 } from "zod";
3766
+ import { z as z13 } from "zod";
3122
3767
  var contextCommand = define({
3123
3768
  name: "context",
3124
3769
  tool: "kb_context",
3125
3770
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
3126
3771
  description: "Index block of pinned bases (ids, titles, standing) for injection at context birth. Takes no bundlePath \u2014 reads the workspace pin manifests. Empty when nothing is pinned; refuses over budget rather than truncating. Budget precedence: flags, then the manifest `context[profile]` over `context.default`, then the built-in profile, then package defaults.",
3127
- input: z12.object({
3128
- budgetTokens: z12.number().int().positive().optional().describe(
3772
+ input: z13.object({
3773
+ budgetTokens: z13.number().int().positive().optional().describe(
3129
3774
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
3130
3775
  ),
3131
- fullUnderTokens: z12.number().int().positive().optional().describe(
3776
+ fullUnderTokens: z13.number().int().positive().optional().describe(
3132
3777
  "Per-base rendering threshold, applied before the budget: a base whose complete load fits under this arrives as full records instead of index lines, and the whole block still answers to budgetTokens. Off by default \u2014 index-only is the safe default at a context birth, because injected bodies outlive the qualifiers on them; the session-start profile opts tiny bases in at 1500."
3133
3778
  ),
3134
- profile: z12.string().optional().describe(
3779
+ profile: z13.string().optional().describe(
3135
3780
  "Named budget set: built-ins are session-start (full-under 1500), compact and turn (budget 2500); the manifests' `context` tables override per repo. Unknown names fall through to defaults rather than failing."
3136
3781
  ),
3137
- format: z12.enum(["markdown", "json"]).optional().describe(
3782
+ format: z13.enum(["markdown", "json"]).optional().describe(
3138
3783
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
3139
3784
  ),
3140
- event: z12.string().optional().describe(
3785
+ event: z13.string().optional().describe(
3141
3786
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
3142
3787
  )
3143
3788
  }),
@@ -3173,14 +3818,14 @@ var contextCommand = define({
3173
3818
  });
3174
3819
 
3175
3820
  // src/commands/doctor.ts
3176
- import { z as z13 } from "zod";
3177
- var days = (what, fallback) => z13.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3821
+ import { z as z14 } from "zod";
3822
+ var days = (what, fallback) => z14.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3178
3823
  var doctorCommand = define({
3179
3824
  name: "doctor",
3180
3825
  tool: "kb_doctor",
3181
3826
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3182
3827
  description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
3183
- input: z13.object({
3828
+ input: z14.object({
3184
3829
  bundlePath,
3185
3830
  repoRoot: REPO_ROOT,
3186
3831
  expiringDays: days(
@@ -3195,10 +3840,10 @@ var doctorCommand = define({
3195
3840
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3196
3841
  DEFAULT_AGING_DAYS
3197
3842
  ),
3198
- offline: z13.boolean().optional().describe(
3843
+ offline: z14.boolean().optional().describe(
3199
3844
  "Read foreign anchors from the local repo cache only, never fetching."
3200
3845
  ),
3201
- strict: z13.boolean().optional().describe(
3846
+ strict: z14.boolean().optional().describe(
3202
3847
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
3203
3848
  )
3204
3849
  }),
@@ -3241,7 +3886,13 @@ var doctorCommand = define({
3241
3886
  ...anchorDrift !== void 0 ? { anchorDrift } : {},
3242
3887
  now: new Date(checkedAt)
3243
3888
  });
3244
- return { bundlePath: path, checkedAt, ...report };
3889
+ const hints = grammarHints();
3890
+ return {
3891
+ bundlePath: path,
3892
+ checkedAt,
3893
+ ...report,
3894
+ ...hints.length ? { hints } : {}
3895
+ };
3245
3896
  },
3246
3897
  render: (result) => render2(result),
3247
3898
  // Only expiry, and only under --strict. The other seven checks report debt a
@@ -3259,12 +3910,15 @@ function render2(result) {
3259
3910
  `records: ${result.recordCount}`,
3260
3911
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
3261
3912
  `checked: ${result.checkedAt}`,
3913
+ ...result.anchorResolvers.total ? [
3914
+ `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
3915
+ ] : [],
3262
3916
  ""
3263
3917
  ];
3264
- const width = Math.max(...result.groups.map((group2) => group2.check.length));
3918
+ const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
3265
3919
  for (const group2 of result.groups) {
3266
3920
  lines.push(
3267
- ` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
3921
+ ` ${group2.check.padEnd(width2)} ${String(group2.count).padStart(3)} ${group2.headline}`
3268
3922
  );
3269
3923
  }
3270
3924
  for (const group2 of result.groups) {
@@ -3276,6 +3930,7 @@ function render2(result) {
3276
3930
  );
3277
3931
  }
3278
3932
  }
3933
+ for (const hint of result.hints ?? []) lines.push("", hint);
3279
3934
  lines.push(
3280
3935
  "",
3281
3936
  result.healthy ? "Nothing to repair." : `${result.findingCount} finding${result.findingCount === 1 ? "" : "s"} across ${result.groups.filter((group2) => group2.count).length} of ${result.groups.length} checks.`
@@ -3284,19 +3939,19 @@ function render2(result) {
3284
3939
  }
3285
3940
 
3286
3941
  // src/commands/impact.ts
3287
- import { z as z14 } from "zod";
3942
+ import { z as z15 } from "zod";
3288
3943
  var impactCommand = define({
3289
3944
  name: "impact",
3290
3945
  tool: "kb_impact",
3291
3946
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
3292
3947
  description: "What breaks if this record changes: its transitive set of dependants, each with its standing. Each rel declares which of its ends depends on the other, and the walk follows each rel in its own direction. Naming `related_to` or an unknown rel in `rels` is an error. kb_backlinks gives one flat hop.",
3293
- input: z14.object({
3948
+ input: z15.object({
3294
3949
  bundlePath,
3295
3950
  conceptId,
3296
- depth: z14.number().int().positive().optional().describe(
3951
+ depth: z15.number().int().positive().optional().describe(
3297
3952
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
3298
3953
  ),
3299
- rels: z14.array(z14.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3954
+ rels: z15.array(z15.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
3300
3955
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
3301
3956
  )
3302
3957
  }),
@@ -3317,13 +3972,13 @@ var impactCommand = define({
3317
3972
  });
3318
3973
 
3319
3974
  // src/commands/list.ts
3320
- import { z as z15 } from "zod";
3975
+ import { z as z16 } from "zod";
3321
3976
  var listCommand = define({
3322
3977
  name: "list",
3323
3978
  tool: "kb_list",
3324
3979
  usage: "list [type]",
3325
3980
  description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
3326
- input: z15.object({ bundlePath, type: z15.enum(KB_RECORD_TYPES).optional() }),
3981
+ input: z16.object({ bundlePath, type: z16.enum(KB_RECORD_TYPES).optional() }),
3327
3982
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
3328
3983
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
3329
3984
  conceptId: record.conceptId,
@@ -3335,17 +3990,17 @@ var listCommand = define({
3335
3990
  });
3336
3991
 
3337
3992
  // src/commands/load.ts
3338
- import { z as z16 } from "zod";
3993
+ import { z as z17 } from "zod";
3339
3994
  var loadCommand = define({
3340
3995
  name: "load",
3341
3996
  tool: "kb_load",
3342
3997
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
3343
3998
  description: "Load the whole base, each record with its standing \u2014 call it first, at the point of use, since compaction drops it. Superseded records arrive as stubs; kb_trace has the history. Over budget it refuses: kb_catalog, then kb_pack, or narrow with `type`; `all` bypasses. Never read record files directly \u2014 only kb_* tools resolve supersession. `digest` stamps the base's content, so hooks know when to reload.",
3344
- input: z16.object({
3999
+ input: z17.object({
3345
4000
  bundlePath,
3346
- type: z16.enum(KB_RECORD_TYPES).optional(),
3347
- budgetTokens: z16.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3348
- all: z16.boolean().optional().describe(
4001
+ type: z17.enum(KB_RECORD_TYPES).optional(),
4002
+ budgetTokens: z17.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
4003
+ all: z17.boolean().optional().describe(
3349
4004
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
3350
4005
  ),
3351
4006
  repoRoot: REPO_ROOT
@@ -3387,25 +4042,25 @@ var loadCommand = define({
3387
4042
  });
3388
4043
 
3389
4044
  // src/commands/log.ts
3390
- import { z as z17 } from "zod";
4045
+ import { z as z18 } from "zod";
3391
4046
  var logCommand = define({
3392
4047
  name: "log",
3393
4048
  tool: "kb_log",
3394
4049
  usage: "log",
3395
4050
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
3396
- input: z17.object({ bundlePath }),
4051
+ input: z18.object({ bundlePath }),
3397
4052
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3398
4053
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
3399
4054
  });
3400
4055
 
3401
4056
  // src/commands/no-decision.ts
3402
- import { z as z18 } from "zod";
4057
+ import { z as z19 } from "zod";
3403
4058
  var noDecisionCommand = define({
3404
4059
  name: "no-decision",
3405
4060
  tool: "kb_no_decision",
3406
4061
  usage: "no-decision <reason...>",
3407
4062
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
3408
- input: z18.object({ bundlePath, reason: z18.string().min(1) }),
4063
+ input: z19.object({ bundlePath, reason: z19.string().min(1) }),
3409
4064
  fromArgv: (argv, path) => ({
3410
4065
  bundlePath: path,
3411
4066
  reason: argv.slice(1).join(" ").trim()
@@ -3422,20 +4077,20 @@ var noDecisionCommand = define({
3422
4077
  });
3423
4078
 
3424
4079
  // src/commands/pack.ts
3425
- import { z as z19 } from "zod";
4080
+ import { z as z20 } from "zod";
3426
4081
  var packCommand = define({
3427
4082
  name: "pack",
3428
4083
  tool: "kb_pack",
3429
4084
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
3430
4085
  description: "Bounded neighbourhood around one record: within `hops`, ranked, cut to `maxNodes`, with every cut record named under Excluded. Use when the base is over kb_load's budget and the work centres on a record you can name. Refuses over budget rather than truncating. Everything below the header is byte-stable across runs. Resolves supersession like kb_load.",
3431
- input: z19.object({
4086
+ input: z20.object({
3432
4087
  bundlePath,
3433
4088
  conceptId,
3434
- hops: z19.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3435
- maxNodes: z19.number().int().positive().optional().describe(
4089
+ hops: z20.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
4090
+ maxNodes: z20.number().int().positive().optional().describe(
3436
4091
  "How many records the pack may hold, root included. Defaults to 20."
3437
4092
  ),
3438
- budgetTokens: z19.number().int().positive().optional().describe(
4093
+ budgetTokens: z20.number().int().positive().optional().describe(
3439
4094
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
3440
4095
  )
3441
4096
  }),
@@ -3522,22 +4177,22 @@ function warningLabel(warning) {
3522
4177
  }
3523
4178
 
3524
4179
  // src/commands/pin.ts
3525
- import { z as z20 } from "zod";
4180
+ import { z as z21 } from "zod";
3526
4181
  var pinCommand = define({
3527
4182
  name: "pin",
3528
4183
  tool: "kb_pin",
3529
4184
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
3530
4185
  description: "Pin a base into a workspace manifest so kb_context surfaces it. Layers, nearest wins: project `.strauss/kb-pins.json` (default), `--local` (personal, gitignored), `--user` (`~/.strauss`). Idempotent; `--mode full|index`, `--profiles`, `--frozen`/`--unfreeze` update only those fields. A path with no records pins with a warning. Never touches the base itself.",
3531
- input: z20.object({
4186
+ input: z21.object({
3532
4187
  bundlePath,
3533
- mode: z20.enum(["full", "index"]).optional().describe(
4188
+ mode: z21.enum(["full", "index"]).optional().describe(
3534
4189
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
3535
4190
  ),
3536
- profiles: z20.array(z20.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
3537
- layer: z20.enum(["project", "local", "user"]).optional().describe(
4191
+ profiles: z21.array(z21.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4192
+ layer: z21.enum(["project", "local", "user"]).optional().describe(
3538
4193
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
3539
4194
  ),
3540
- frozen: z20.boolean().optional().describe(
4195
+ frozen: z21.boolean().optional().describe(
3541
4196
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
3542
4197
  )
3543
4198
  }),
@@ -3566,29 +4221,29 @@ var pinCommand = define({
3566
4221
  });
3567
4222
 
3568
4223
  // src/commands/pins.ts
3569
- import { z as z21 } from "zod";
4224
+ import { z as z22 } from "zod";
3570
4225
  var pinsCommand = define({
3571
4226
  name: "pins",
3572
4227
  tool: "kb_pins",
3573
4228
  usage: "pins",
3574
4229
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
3575
- input: z21.object({}),
4230
+ input: z22.object({}),
3576
4231
  fromArgv: () => ({}),
3577
4232
  run: ({ store }) => listPins(store, process.cwd())
3578
4233
  });
3579
4234
 
3580
4235
  // src/commands/query.ts
3581
- import { z as z22 } from "zod";
4236
+ import { z as z23 } from "zod";
3582
4237
  var queryCommand = define({
3583
4238
  name: "query",
3584
4239
  tool: "kb_query",
3585
4240
  usage: "query <text...> [--repo-root PATH]",
3586
4241
  description: "Search; every hit carries its standing. Flagged, never filtered: a superseded hit returns with its replacement, a rejected one is marked. Prefer kb_load when the base fits its budget \u2014 a full read beats search. Results are volatile: place them at the tail, not the cached prefix. Never read record files directly.",
3587
- input: z22.object({
4242
+ input: z23.object({
3588
4243
  bundlePath,
3589
- text: z22.string().optional(),
3590
- type: z22.enum(KB_RECORD_TYPES).optional(),
3591
- includeNonCurrent: z22.boolean().optional(),
4244
+ text: z23.string().optional(),
4245
+ type: z23.enum(KB_RECORD_TYPES).optional(),
4246
+ includeNonCurrent: z23.boolean().optional(),
3592
4247
  repoRoot: REPO_ROOT
3593
4248
  }),
3594
4249
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -3620,43 +4275,43 @@ var queryCommand = define({
3620
4275
  });
3621
4276
 
3622
4277
  // src/commands/read-index.ts
3623
- import { z as z23 } from "zod";
4278
+ import { z as z24 } from "zod";
3624
4279
  var readIndexCommand = define({
3625
4280
  name: "index",
3626
4281
  tool: "kb_index",
3627
4282
  usage: "index",
3628
4283
  description: "The index \u2014 title, type, status, description per record \u2014 rebuilt if stale. Cheapest re-orientation after compaction: call it (or kb_context) first, then kb_load or fetch by id.",
3629
- input: z23.object({ bundlePath }),
4284
+ input: z24.object({ bundlePath }),
3630
4285
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3631
4286
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
3632
4287
  });
3633
4288
 
3634
4289
  // src/commands/schema.ts
3635
- import { z as z24 } from "zod";
4290
+ import { z as z25 } from "zod";
3636
4291
  var schemaCommand = define({
3637
4292
  name: "schema",
3638
4293
  tool: "kb_schema",
3639
4294
  usage: "schema",
3640
4295
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
3641
- input: z24.object({}),
4296
+ input: z25.object({}),
3642
4297
  fromArgv: () => ({}),
3643
4298
  run: () => Promise.resolve(kbJsonSchemas())
3644
4299
  });
3645
4300
 
3646
4301
  // src/commands/stamp.ts
3647
- import { readFile as readFile4 } from "fs/promises";
3648
- import { z as z25 } from "zod";
4302
+ import { readFile as readFile6 } from "fs/promises";
4303
+ import { z as z26 } from "zod";
3649
4304
  var DIGEST = /^[0-9a-f]{64}$/;
3650
4305
  var stampCommand = define({
3651
4306
  name: "stamp",
3652
4307
  tool: "kb_stamp",
3653
4308
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
3654
4309
  description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids when the baseline is a prior stamp; silent when nothing changed. Reads, never writes.",
3655
- input: z25.object({
3656
- bundlePath: z25.string().min(1).optional().describe(
4310
+ input: z26.object({
4311
+ bundlePath: z26.string().min(1).optional().describe(
3657
4312
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
3658
4313
  ),
3659
- since: z25.string().min(1).optional().describe(
4314
+ since: z26.string().min(1).optional().describe(
3660
4315
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
3661
4316
  )
3662
4317
  }),
@@ -3718,7 +4373,7 @@ async function readBaseline(since) {
3718
4373
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
3719
4374
  let parsed;
3720
4375
  try {
3721
- parsed = JSON.parse(await readFile4(since, "utf8"));
4376
+ parsed = JSON.parse(await readFile6(since, "utf8"));
3722
4377
  } catch {
3723
4378
  throw new KbStampBaselineError(since);
3724
4379
  }
@@ -3742,16 +4397,16 @@ async function readBaseline(since) {
3742
4397
  }
3743
4398
 
3744
4399
  // src/commands/status.ts
3745
- import { z as z26 } from "zod";
4400
+ import { z as z27 } from "zod";
3746
4401
  var statusCommand = define({
3747
4402
  name: "status",
3748
4403
  tool: "kb_status",
3749
4404
  usage: "status <concept-id> <status>",
3750
4405
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
3751
- input: z26.object({
4406
+ input: z27.object({
3752
4407
  bundlePath,
3753
4408
  conceptId,
3754
- status: z26.enum(KB_RECORD_STATUSES)
4409
+ status: z27.enum(KB_RECORD_STATUSES)
3755
4410
  }),
3756
4411
  fromArgv: (argv, path) => ({
3757
4412
  bundlePath: path,
@@ -3766,13 +4421,13 @@ var statusCommand = define({
3766
4421
  });
3767
4422
 
3768
4423
  // src/commands/supersede.ts
3769
- import { z as z27 } from "zod";
4424
+ import { z as z28 } from "zod";
3770
4425
  var supersedeCommand = define({
3771
4426
  name: "supersede",
3772
4427
  tool: "kb_supersede",
3773
4428
  usage: "supersede <concept-id> <replacement-id>",
3774
4429
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
3775
- input: z27.object({ bundlePath, conceptId, replacementId: conceptId }),
4430
+ input: z28.object({ bundlePath, conceptId, replacementId: conceptId }),
3776
4431
  fromArgv: (argv, path) => ({
3777
4432
  bundlePath: path,
3778
4433
  conceptId: argv[1],
@@ -3786,16 +4441,16 @@ var supersedeCommand = define({
3786
4441
  });
3787
4442
 
3788
4443
  // src/commands/sync-instructions.ts
3789
- import { z as z28 } from "zod";
4444
+ import { z as z29 } from "zod";
3790
4445
  var syncInstructionsCommand = define({
3791
4446
  name: "sync-instructions",
3792
4447
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
3793
4448
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
3794
- input: z28.object({
3795
- file: z28.string().min(1).describe("The instruction file to edit in place."),
3796
- budgetTokens: z28.number().int().positive().optional(),
3797
- fullUnderTokens: z28.number().int().positive().optional(),
3798
- profile: z28.string().optional()
4449
+ input: z29.object({
4450
+ file: z29.string().min(1).describe("The instruction file to edit in place."),
4451
+ budgetTokens: z29.number().int().positive().optional(),
4452
+ fullUnderTokens: z29.number().int().positive().optional(),
4453
+ profile: z29.string().optional()
3799
4454
  }),
3800
4455
  fromArgv: (argv) => {
3801
4456
  const budget = argvFlag(argv, "--budget");
@@ -3821,17 +4476,17 @@ var syncInstructionsCommand = define({
3821
4476
  });
3822
4477
 
3823
4478
  // src/commands/trace.ts
3824
- import { z as z29 } from "zod";
4479
+ import { z as z30 } from "zod";
3825
4480
  var traceCommand = define({
3826
4481
  name: "trace",
3827
4482
  tool: "kb_trace",
3828
4483
  usage: "trace <concept-id> [edges...]",
3829
4484
  description: 'Timeline of how a position was reached, ordered by write time, following supersession, shared anchors and shared sources. Includes rejected, draft and superseded records \u2014 in a history they are the content. For "why is it like this"; kb_load answers "what holds now".',
3830
- input: z29.object({
4485
+ input: z30.object({
3831
4486
  bundlePath,
3832
4487
  conceptId,
3833
- edges: z29.array(z29.enum(TRACE_EDGES)).optional(),
3834
- depth: z29.number().int().positive().optional()
4488
+ edges: z30.array(z30.enum(TRACE_EDGES)).optional(),
4489
+ depth: z30.number().int().positive().optional()
3835
4490
  }),
3836
4491
  fromArgv: (argv, path) => ({
3837
4492
  bundlePath: path,
@@ -3853,37 +4508,37 @@ var traceCommand = define({
3853
4508
  });
3854
4509
 
3855
4510
  // src/commands/types.ts
3856
- import { z as z30 } from "zod";
4511
+ import { z as z31 } from "zod";
3857
4512
  var typesCommand = define({
3858
4513
  name: "types",
3859
4514
  tool: "kb_types",
3860
4515
  usage: "types",
3861
4516
  description: "The twelve record types with their purpose, body sections, and starting status. Read this before writing rather than guessing headings \u2014 a section the type does not define is rejected.",
3862
- input: z30.object({}),
4517
+ input: z31.object({}),
3863
4518
  fromArgv: () => ({}),
3864
4519
  run: () => Promise.resolve(RECORD_TYPES)
3865
4520
  });
3866
4521
 
3867
4522
  // src/commands/unpin.ts
3868
- import { z as z31 } from "zod";
4523
+ import { z as z32 } from "zod";
3869
4524
  var unpinCommand = define({
3870
4525
  name: "unpin",
3871
4526
  tool: "kb_unpin",
3872
4527
  usage: "unpin [bundle-path]",
3873
4528
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
3874
- input: z31.object({ bundlePath }),
4529
+ input: z32.object({ bundlePath }),
3875
4530
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3876
4531
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3877
4532
  });
3878
4533
 
3879
4534
  // src/commands/validate.ts
3880
- import { z as z32 } from "zod";
4535
+ import { z as z33 } from "zod";
3881
4536
  var validateCommand = define({
3882
4537
  name: "validate",
3883
4538
  tool: "kb_validate",
3884
4539
  usage: "validate",
3885
4540
  description: "Check pointers no single record can see: supersession links that disagree between the two records, typed causal links, and assumptions that cite sources. Each finding carries a severity: errors fail the exit code, warnings do not.",
3886
- input: z32.object({ bundlePath }),
4541
+ input: z33.object({ bundlePath }),
3887
4542
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3888
4543
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3889
4544
  // Warnings never fail the exit code; every other severity does.
@@ -3893,16 +4548,16 @@ var validateCommand = define({
3893
4548
  });
3894
4549
 
3895
4550
  // src/commands/verify.ts
3896
- import { z as z33 } from "zod";
4551
+ import { z as z34 } from "zod";
3897
4552
  var verifyCommand = define({
3898
4553
  name: "verify",
3899
4554
  tool: "kb_verify",
3900
4555
  usage: "verify <concept-id> --note <text>",
3901
4556
  description: "Append a verified[] event: who checked, when, and what was found. Append-only. A record's own generator is refused unless the actor is `human:`-prefixed.",
3902
- input: z33.object({
4557
+ input: z34.object({
3903
4558
  bundlePath,
3904
4559
  conceptId,
3905
- note: z33.string().refine((s) => s.trim().length > 0, {
4560
+ note: z34.string().refine((s) => s.trim().length > 0, {
3906
4561
  message: "note must say what the check found"
3907
4562
  })
3908
4563
  }),
@@ -3922,15 +4577,15 @@ var verifyCommand = define({
3922
4577
  });
3923
4578
 
3924
4579
  // src/commands/write.ts
3925
- import { z as z34 } from "zod";
4580
+ import { z as z35 } from "zod";
3926
4581
  var writeCommand = define({
3927
4582
  name: "write",
3928
4583
  tool: "kb_write",
3929
4584
  usage: "write <type> < record.json",
3930
4585
  description: "Write one record. Search first \u2014 a duplicate concept id is rejected, not overwritten; kb_types lists each type's sections. An unsourced claim is an `assumption` with assumption: true, never a vague `fact`. Conflicting records get a `risk`, `open-question`, or superseding `decision`. Prefer a new short record over overloading one. Never delete; supersede.",
3931
- input: z34.object({
4586
+ input: z35.object({
3932
4587
  bundlePath,
3933
- type: z34.enum(KB_RECORD_TYPES),
4588
+ type: z35.enum(KB_RECORD_TYPES),
3934
4589
  input: composeInputSchema
3935
4590
  }),
3936
4591
  fromArgv: async (argv, path, stdin) => ({
@@ -3954,13 +4609,13 @@ var writeCommand = define({
3954
4609
  });
3955
4610
 
3956
4611
  // src/commands/write-decision.ts
3957
- import { z as z35 } from "zod";
4612
+ import { z as z36 } from "zod";
3958
4613
  var writeDecisionCommand = define({
3959
4614
  name: "write-decision",
3960
4615
  tool: "kb_write_decision",
3961
4616
  usage: "write-decision < decision.json",
3962
4617
  description: "Write a decision, with `alternative` (what was rejected and why) and `impact` as fields. Record one when a later reader would otherwise simplify the constraint away; skip when the diff already answers it. `sources` for material read, `anchors` for code, `relatedConceptIds` for records.",
3963
- input: z35.object({ bundlePath, input: decisionInputSchema }),
4618
+ input: z36.object({ bundlePath, input: decisionInputSchema }),
3964
4619
  fromArgv: async (_argv, path, stdin) => ({
3965
4620
  bundlePath: path,
3966
4621
  input: JSON.parse(await stdin())
@@ -4042,7 +4697,7 @@ function parseMarkdownWithFrontmatter(text, schema) {
4042
4697
 
4043
4698
  // src/search-index.ts
4044
4699
  import { stat as stat2 } from "fs/promises";
4045
- import { join as join4 } from "path";
4700
+ import { join as join6 } from "path";
4046
4701
  var SEARCH_INDEX_FILE = ".index.sqlite";
4047
4702
  var COLLECTION = "kb";
4048
4703
  async function searchBase(bundlePath2, query, options = {}) {
@@ -4051,7 +4706,7 @@ async function searchBase(bundlePath2, query, options = {}) {
4051
4706
  let store = null;
4052
4707
  try {
4053
4708
  store = await qmd.createStore({
4054
- dbPath: join4(bundlePath2, SEARCH_INDEX_FILE),
4709
+ dbPath: join6(bundlePath2, SEARCH_INDEX_FILE),
4055
4710
  config: {
4056
4711
  collections: {
4057
4712
  [COLLECTION]: {
@@ -4086,7 +4741,7 @@ async function searchBase(bundlePath2, query, options = {}) {
4086
4741
  }
4087
4742
  }
4088
4743
  async function isStale(bundlePath2) {
4089
- const indexAt = await stat2(join4(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
4744
+ const indexAt = await stat2(join6(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
4090
4745
  if (!indexAt) return true;
4091
4746
  const { readdir: readdir2 } = await import("fs/promises");
4092
4747
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -4095,7 +4750,7 @@ async function isStale(bundlePath2) {
4095
4750
  let stale = false;
4096
4751
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
4097
4752
  if (stale) return;
4098
- const at = await stat2(join4(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
4753
+ const at = await stat2(join6(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
4099
4754
  if (at > indexAt) stale = true;
4100
4755
  });
4101
4756
  return stale;
@@ -4133,25 +4788,25 @@ async function loadQmd(logger) {
4133
4788
  import {
4134
4789
  appendFile,
4135
4790
  link,
4136
- mkdir as mkdir3,
4791
+ mkdir as mkdir4,
4137
4792
  readdir,
4138
- readFile as readFile5,
4139
- rename,
4793
+ readFile as readFile7,
4794
+ rename as rename2,
4140
4795
  unlink,
4141
- writeFile as writeFile3
4796
+ writeFile as writeFile4
4142
4797
  } from "fs/promises";
4143
- import { join as join5, resolve as resolve5, sep as sep3 } from "path";
4798
+ import { join as join7, resolve as resolve5, sep as sep3 } from "path";
4144
4799
 
4145
4800
  // src/kb-stamp.ts
4146
- import { createHash as createHash2 } from "crypto";
4147
- function sha256(contents) {
4148
- return createHash2("sha256").update(contents).digest("hex");
4801
+ import { createHash as createHash4 } from "crypto";
4802
+ function sha2563(contents) {
4803
+ return createHash4("sha256").update(contents).digest("hex");
4149
4804
  }
4150
4805
  function bundleStamp(records, superseded) {
4151
4806
  const entries = [
4152
4807
  ...records.map((hit) => ({
4153
4808
  conceptId: hit.record.conceptId,
4154
- digest: `current:${sha256(
4809
+ digest: `current:${sha2563(
4155
4810
  stringifyMarkdownWithFrontmatter(
4156
4811
  hit.record.body,
4157
4812
  hit.record.frontmatter
@@ -4160,11 +4815,11 @@ function bundleStamp(records, superseded) {
4160
4815
  })),
4161
4816
  ...superseded.map((entry) => ({
4162
4817
  conceptId: entry.conceptId,
4163
- digest: `superseded:${sha256(JSON.stringify(entry))}`
4818
+ digest: `superseded:${sha2563(JSON.stringify(entry))}`
4164
4819
  }))
4165
4820
  ].sort((a, b) => a.conceptId < b.conceptId ? -1 : 1);
4166
4821
  return {
4167
- digest: sha256(
4822
+ digest: sha2563(
4168
4823
  entries.map((entry) => `${entry.conceptId}:${entry.digest}`).join("\n")
4169
4824
  ),
4170
4825
  records: entries
@@ -4334,7 +4989,7 @@ function appendUnionMergeLine(contents) {
4334
4989
  }
4335
4990
 
4336
4991
  // src/kb-store.ts
4337
- var KB_DIR = join5(".strauss", "kb");
4992
+ var KB_DIR = join7(".strauss", "kb");
4338
4993
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
4339
4994
  var DEFAULT_LOAD_BUDGET = 25e3;
4340
4995
  var KbStore = class {
@@ -4365,7 +5020,7 @@ var KbStore = class {
4365
5020
  const conceptId2 = `${input.type}.${input.slug}`;
4366
5021
  const root = this.root(bundlePath2);
4367
5022
  const target = this.recordPath(bundlePath2, conceptId2);
4368
- await mkdir3(root, { recursive: true });
5023
+ await mkdir4(root, { recursive: true });
4369
5024
  await this.publish(
4370
5025
  target,
4371
5026
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -4404,7 +5059,7 @@ var KbStore = class {
4404
5059
  const target = this.recordPath(bundlePath2, conceptId2);
4405
5060
  let raw;
4406
5061
  try {
4407
- raw = await readFile5(target, "utf8");
5062
+ raw = await readFile7(target, "utf8");
4408
5063
  } catch {
4409
5064
  return null;
4410
5065
  }
@@ -4429,7 +5084,7 @@ var KbStore = class {
4429
5084
  const records = await mapLimit(
4430
5085
  wanted,
4431
5086
  DEFAULT_IO_CONCURRENCY,
4432
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile5(join5(root, name), "utf8"))
5087
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile7(join7(root, name), "utf8"))
4433
5088
  );
4434
5089
  return records.filter((record) => record !== null);
4435
5090
  }
@@ -4586,7 +5241,7 @@ ${answer}
4586
5241
  if (found.length) return found;
4587
5242
  }
4588
5243
  const lowered = needle.toLowerCase();
4589
- return bundle.filter((record) => matches(record, lowered));
5244
+ return bundle.filter((record) => matches2(record, lowered));
4590
5245
  }
4591
5246
  /**
4592
5247
  * Anchor drift over the records about to be handed back. Like the search
@@ -4756,11 +5411,11 @@ ${answer}
4756
5411
  async readIndex(bundlePath2) {
4757
5412
  const root = this.root(bundlePath2);
4758
5413
  const expected = renderIndex(await this.list(bundlePath2));
4759
- const stored = await readFile5(join5(root, INDEX_FILE), "utf8").catch(
5414
+ const stored = await readFile7(join7(root, INDEX_FILE), "utf8").catch(
4760
5415
  () => null
4761
5416
  );
4762
5417
  if (indexIsStale(stored, expected)) {
4763
- await this.publish(join5(root, INDEX_FILE), expected, true, INDEX_FILE);
5418
+ await this.publish(join7(root, INDEX_FILE), expected, true, INDEX_FILE);
4764
5419
  this.logger.info?.({
4765
5420
  operation: "kb.index.repair",
4766
5421
  bundlePath: root,
@@ -4777,8 +5432,8 @@ ${answer}
4777
5432
  * knows which agent touched what. So a bad line is surfaced and left alone.
4778
5433
  */
4779
5434
  async readLog(bundlePath2) {
4780
- const raw = await readFile5(
4781
- join5(this.root(bundlePath2), LOG_FILE),
5435
+ const raw = await readFile7(
5436
+ join7(this.root(bundlePath2), LOG_FILE),
4782
5437
  "utf8"
4783
5438
  ).catch(() => "");
4784
5439
  const result = parseLog(raw);
@@ -4829,15 +5484,15 @@ ${answer}
4829
5484
  }
4830
5485
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
4831
5486
  const target = this.recordPath(bundlePath2, conceptId2);
4832
- const before = await readFile5(target, "utf8").catch(() => null);
5487
+ const before = await readFile7(target, "utf8").catch(() => null);
4833
5488
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
4834
5489
  const parsed = this.parse(conceptId2, before);
4835
5490
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
4836
5491
  const frontmatter = change(parsed.frontmatter);
4837
5492
  const body = changeBody(parsed.body);
4838
5493
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
4839
- const witness = await readFile5(target, "utf8").catch(() => null);
4840
- if (witness === null || sha256(witness) !== sha256(before)) {
5494
+ const witness = await readFile7(target, "utf8").catch(() => null);
5495
+ if (witness === null || sha2563(witness) !== sha2563(before)) {
4841
5496
  throw new KbWriteConflictError(conceptId2);
4842
5497
  }
4843
5498
  await this.publish(target, contents, true, conceptId2);
@@ -4862,10 +5517,10 @@ ${answer}
4862
5517
  */
4863
5518
  async publish(target, contents, overwrite, conceptId2) {
4864
5519
  const staging = `${target}.${process.pid}.tmp`;
4865
- await writeFile3(staging, contents, "utf8");
5520
+ await writeFile4(staging, contents, "utf8");
4866
5521
  try {
4867
5522
  if (overwrite) {
4868
- await rename(staging, target);
5523
+ await rename2(staging, target);
4869
5524
  return;
4870
5525
  }
4871
5526
  await link(staging, target);
@@ -4919,18 +5574,18 @@ ${answer}
4919
5574
  * file must not fail the mutation it guards.
4920
5575
  */
4921
5576
  async ensureGitattributes(root) {
4922
- const target = join5(root, GITATTRIBUTES_FILE);
5577
+ const target = join7(root, GITATTRIBUTES_FILE);
4923
5578
  try {
4924
5579
  let existing;
4925
5580
  try {
4926
- existing = await readFile5(target, "utf8");
5581
+ existing = await readFile7(target, "utf8");
4927
5582
  } catch (error) {
4928
5583
  if (error.code !== "ENOENT") throw error;
4929
5584
  existing = null;
4930
5585
  }
4931
5586
  if (existing === null) {
4932
5587
  try {
4933
- await writeFile3(target, appendUnionMergeLine(""), {
5588
+ await writeFile4(target, appendUnionMergeLine(""), {
4934
5589
  encoding: "utf8",
4935
5590
  flag: "wx"
4936
5591
  });
@@ -4970,7 +5625,7 @@ ${answer}
4970
5625
  async record(root, entry) {
4971
5626
  await this.ensureGitattributes(root);
4972
5627
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4973
- await appendFile(join5(root, LOG_FILE), line, "utf8").catch((error) => {
5628
+ await appendFile(join7(root, LOG_FILE), line, "utf8").catch((error) => {
4974
5629
  this.logger.warn?.({
4975
5630
  operation: "kb.log.append",
4976
5631
  outcome: "failed",
@@ -5007,7 +5662,7 @@ ${answer}
5007
5662
  { conceptId: conceptId2 }
5008
5663
  );
5009
5664
  }
5010
- return join5(this.root(bundlePath2), `${conceptId2}.md`);
5665
+ return join7(this.root(bundlePath2), `${conceptId2}.md`);
5011
5666
  }
5012
5667
  };
5013
5668
  function estimateTokens(record) {
@@ -5034,7 +5689,7 @@ function stub(hit) {
5034
5689
  at: hit.record.frontmatter.generated?.at ?? null
5035
5690
  };
5036
5691
  }
5037
- function matches(record, needle) {
5692
+ function matches2(record, needle) {
5038
5693
  const { title, description } = record.frontmatter;
5039
5694
  return [record.conceptId, title, description, record.body].some(
5040
5695
  (field) => field?.toLowerCase().includes(needle)
@@ -5126,12 +5781,12 @@ function byRank(left, right) {
5126
5781
  ) || left.record.conceptId.localeCompare(right.record.conceptId);
5127
5782
  }
5128
5783
  function typeRank(record) {
5129
- const index = TYPE_PRIORITY.indexOf(record.frontmatter.type);
5130
- return index === -1 ? TYPE_PRIORITY.length : index;
5784
+ const index2 = TYPE_PRIORITY.indexOf(record.frontmatter.type);
5785
+ return index2 === -1 ? TYPE_PRIORITY.length : index2;
5131
5786
  }
5132
5787
 
5133
5788
  // src/version.ts
5134
- var VERSION = true ? "0.1.16" : "0.0.0-dev";
5789
+ var VERSION = true ? "0.1.17" : "0.0.0-dev";
5135
5790
 
5136
5791
  export {
5137
5792
  kbSourceSchema,
@@ -5167,9 +5822,19 @@ export {
5167
5822
  repoCacheDir,
5168
5823
  readRemoteAnchors,
5169
5824
  anchorFilePath,
5825
+ grammarsCacheRoot,
5826
+ grammarManifest,
5827
+ ensureGrammar,
5828
+ grammarHints,
5829
+ languageForFile,
5830
+ treeSitterLanguages,
5831
+ TreeSitterResolver,
5170
5832
  regexResolver,
5171
5833
  hashAnchorText,
5172
5834
  resolveAnchor,
5835
+ resolveAnchorSpan,
5836
+ prepareResolvers,
5837
+ defaultAnchorResolvers,
5173
5838
  detectAnchorDrift,
5174
5839
  Fault,
5175
5840
  ErrorTypes,
@@ -5247,4 +5912,4 @@ export {
5247
5912
  KbStore,
5248
5913
  VERSION
5249
5914
  };
5250
- //# sourceMappingURL=chunk-H5W53NVU.js.map
5915
+ //# sourceMappingURL=chunk-ZKIQOBHT.js.map