@saasontools/strauss-kb 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -74,6 +74,7 @@ __export(index_exports, {
74
74
  SEARCH_INDEX_FILE: () => SEARCH_INDEX_FILE,
75
75
  TRACE_EDGES: () => TRACE_EDGES,
76
76
  adjudicate: () => adjudicate,
77
+ anchorFilePath: () => anchorFilePath,
77
78
  assertBaseNotFrozen: () => assertBaseNotFrozen,
78
79
  buildContext: () => buildContext,
79
80
  catalog: () => catalog,
@@ -84,8 +85,10 @@ __export(index_exports, {
84
85
  contextProfileBudgets: () => contextProfileBudgets,
85
86
  createKbMcpServer: () => createKbMcpServer,
86
87
  decisionInputSchema: () => decisionInputSchema,
88
+ detectAnchorDrift: () => detectAnchorDrift,
87
89
  doctor: () => doctor,
88
90
  edgeNeighbours: () => edgeNeighbours,
91
+ hashAnchorText: () => hashAnchorText,
89
92
  indexIsStale: () => indexIsStale,
90
93
  isKbRecordType: () => isKbRecordType,
91
94
  isNoDecisionRecord: () => isNoDecisionRecord,
@@ -108,10 +111,12 @@ __export(index_exports, {
108
111
  pinBase: () => pinBase,
109
112
  readMergedPins: () => readMergedPins,
110
113
  readPinsLayer: () => readPinsLayer,
114
+ regexResolver: () => regexResolver,
111
115
  renderCatalogLine: () => renderCatalogLine,
112
116
  renderIndex: () => renderIndex,
113
117
  renderIndexLine: () => renderIndexLine,
114
118
  renderLogEntry: () => renderLogEntry,
119
+ resolveAnchor: () => resolveAnchor,
115
120
  resolveHeads: () => resolveHeads,
116
121
  resolveHits: () => resolveHits,
117
122
  resolvePinPath: () => resolvePinPath,
@@ -130,9 +135,38 @@ __export(index_exports, {
130
135
  module.exports = __toCommonJS(index_exports);
131
136
 
132
137
  // src/kb-store.ts
133
- var import_node_crypto = require("crypto");
134
- var import_promises2 = require("fs/promises");
135
- var import_node_path2 = require("path");
138
+ var import_node_crypto2 = require("crypto");
139
+ var import_promises3 = require("fs/promises");
140
+ var import_node_path3 = require("path");
141
+
142
+ // src/concurrency.ts
143
+ var DEFAULT_IO_CONCURRENCY = 16;
144
+ async function mapLimit(items, limit, fn) {
145
+ if (!Number.isInteger(limit) || limit < 1) {
146
+ throw new RangeError(
147
+ `mapLimit: "limit" must be a positive integer, got ${limit}`
148
+ );
149
+ }
150
+ const out = new Array(items.length);
151
+ let next = 0;
152
+ let failed = false;
153
+ const runners = Array.from(
154
+ { length: Math.min(limit, items.length) },
155
+ async () => {
156
+ while (!failed && next < items.length) {
157
+ const at = next++;
158
+ try {
159
+ out[at] = await fn(items[at], at);
160
+ } catch (error) {
161
+ failed = true;
162
+ throw error;
163
+ }
164
+ }
165
+ }
166
+ );
167
+ await Promise.all(runners);
168
+ return out;
169
+ }
136
170
 
137
171
  // src/markdown.ts
138
172
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -179,7 +213,31 @@ var kbVerifiedEventSchema = kbActorStampSchema.extend({
179
213
  });
180
214
  var kbAnchorSchema = import_zod.z.object({
181
215
  file: import_zod.z.string().min(1),
182
- symbol: import_zod.z.string().min(1).optional()
216
+ symbol: import_zod.z.string().min(1).optional(),
217
+ /**
218
+ * Which repository the file lives in — a remote URL
219
+ * (`https://github.com/org/name`) or a short name. Absent means the base's
220
+ * own repository, which is what nearly every anchor means.
221
+ *
222
+ * Unvalidated beyond not-blank: one repository has many spellings.
223
+ * Matched after normalisation; see ARCHITECTURE.
224
+ */
225
+ repo: import_zod.z.string().trim().min(1).optional(),
226
+ /**
227
+ * The git rev the evidence was taken at. Prefer a commit SHA: a branch
228
+ * name is a moving pointer, so an anchor pinned to one says the evidence
229
+ * came from wherever that branch happens to be now, which is not a
230
+ * baseline. Recorded and preserved in v1; ref-pinned reads land with
231
+ * SAA-709.
232
+ */
233
+ ref: import_zod.z.string().trim().min(1).optional(),
234
+ hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
235
+ message: "hash must be sha256:<64 hex chars>"
236
+ }).optional(),
237
+ /** ISO 8601 timestamp of the last successful resolution. */
238
+ resolved_at: import_zod.z.string().min(1).optional(),
239
+ /** Line count of the text the hash was taken over. */
240
+ lines: import_zod.z.number().int().positive().optional()
183
241
  }).strict();
184
242
  var KB_RECORD_TYPES = [
185
243
  "fact",
@@ -432,7 +490,7 @@ var STANDING = {
432
490
  rejected: "rejected",
433
491
  superseded: "superseded"
434
492
  };
435
- function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
493
+ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift) {
436
494
  const byId = new Map(bundle.map((record) => [record.conceptId, record]));
437
495
  return hits.map((record) => {
438
496
  const status = record.frontmatter.strauss_status;
@@ -462,6 +520,20 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
462
520
  if (!record.frontmatter.verified?.length) {
463
521
  warnings.push({ kind: "unverified" });
464
522
  }
523
+ const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
524
+ (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
525
+ );
526
+ if (moved.length) {
527
+ warnings.push({
528
+ kind: "drifted",
529
+ anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
530
+ file,
531
+ ...symbol !== void 0 ? { symbol } : {},
532
+ diffSize,
533
+ ...reason !== void 0 ? { reason } : {}
534
+ }))
535
+ });
536
+ }
465
537
  return { record, standing: STANDING[status], heads, warnings };
466
538
  });
467
539
  }
@@ -514,9 +586,430 @@ function successors(record, byId) {
514
586
  return { records, missing };
515
587
  }
516
588
 
517
- // src/search-index.ts
589
+ // src/anchor-resolver.ts
590
+ var import_node_child_process = require("child_process");
591
+ var import_node_crypto = require("crypto");
518
592
  var import_promises = require("fs/promises");
519
593
  var import_node_path = require("path");
594
+ var import_node_util = require("util");
595
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
596
+ var MAX_ANCHOR_FILE_BYTES = 1048576;
597
+ var PARENT_SCOPE_LINES = 50;
598
+ var CLEAN_STATE = { blockComment: false, template: false };
599
+ function stripLine(line, state) {
600
+ let out = "";
601
+ let index = 0;
602
+ let { blockComment, template } = state;
603
+ while (index < line.length) {
604
+ const char = line[index];
605
+ const next = line[index + 1];
606
+ if (blockComment) {
607
+ if (char === "*" && next === "/") {
608
+ blockComment = false;
609
+ index += 2;
610
+ continue;
611
+ }
612
+ index += 1;
613
+ continue;
614
+ }
615
+ if (template) {
616
+ if (char === "\\") {
617
+ index += 2;
618
+ continue;
619
+ }
620
+ if (char === "`") template = false;
621
+ index += 1;
622
+ continue;
623
+ }
624
+ if (char === "/" && next === "*") {
625
+ blockComment = true;
626
+ index += 2;
627
+ continue;
628
+ }
629
+ if (char === "/" && next === "/") break;
630
+ if (char === "`") {
631
+ template = true;
632
+ index += 1;
633
+ continue;
634
+ }
635
+ if (char === "'" || char === '"') {
636
+ const quote = char;
637
+ index += 1;
638
+ while (index < line.length) {
639
+ if (line[index] === "\\") {
640
+ index += 2;
641
+ continue;
642
+ }
643
+ if (line[index] === quote) {
644
+ index += 1;
645
+ break;
646
+ }
647
+ index += 1;
648
+ }
649
+ continue;
650
+ }
651
+ out += char;
652
+ index += 1;
653
+ }
654
+ return { code: out, state: { blockComment, template } };
655
+ }
656
+ function span(lines, from, to) {
657
+ return {
658
+ text: lines.slice(from, to + 1).join("\n"),
659
+ startLine: from + 1,
660
+ endLine: to + 1
661
+ };
662
+ }
663
+ function captureBraceBlock(lines, matchLine) {
664
+ let depth = 0;
665
+ let opened = false;
666
+ let state = CLEAN_STATE;
667
+ for (let index = matchLine; index < lines.length; index++) {
668
+ const stripped = stripLine(lines[index] ?? "", state);
669
+ state = stripped.state;
670
+ for (const char of stripped.code) {
671
+ if (char === "{") {
672
+ depth += 1;
673
+ opened = true;
674
+ } else if (char === "}") {
675
+ depth = Math.max(0, depth - 1);
676
+ } else if (char === ";" && !opened) {
677
+ return span(lines, matchLine, index);
678
+ }
679
+ }
680
+ if (opened && depth === 0) return span(lines, matchLine, index);
681
+ }
682
+ return null;
683
+ }
684
+ var PYTHON_HEADER = /^\s*(?:async\s+)?(?:def|class)\s+[A-Za-z_]\w*\s*[(:]/;
685
+ function captureIndentedBlock(lines, matchLine) {
686
+ const header = lines[matchLine] ?? "";
687
+ const indent = header.length - header.trimStart().length;
688
+ let headerEnd = -1;
689
+ for (let index = matchLine; index < lines.length && index <= matchLine + 20; index++) {
690
+ const code = stripLine(lines[index] ?? "", CLEAN_STATE).code.trimEnd();
691
+ if (code.endsWith(":")) {
692
+ headerEnd = index;
693
+ break;
694
+ }
695
+ if (code.includes(":")) return span(lines, matchLine, index);
696
+ }
697
+ if (headerEnd === -1) return null;
698
+ let end = headerEnd;
699
+ for (let index = headerEnd + 1; index < lines.length; index++) {
700
+ const line = lines[index] ?? "";
701
+ if (line.trim() === "") continue;
702
+ const lineIndent = line.length - line.trimStart().length;
703
+ if (lineIndent <= indent) break;
704
+ end = index;
705
+ }
706
+ return end === headerEnd ? null : span(lines, matchLine, end);
707
+ }
708
+ var TIERS = [
709
+ (name) => new RegExp(
710
+ `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
711
+ ),
712
+ (name) => new RegExp(`\\b${name}\\s*[:=]`),
713
+ (name) => new RegExp(`\\b${name}\\s*\\(`),
714
+ (name) => new RegExp(`\\b${name}\\b`)
715
+ ];
716
+ var regexResolver = {
717
+ name: "regex",
718
+ resolve(source, symbol) {
719
+ const segments = symbol.split(".");
720
+ const name = segments[segments.length - 1];
721
+ if (!name) return null;
722
+ const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
723
+ const escaped = escapeRegExp(name);
724
+ const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
725
+ const lines = source.split("\n");
726
+ for (const tier of TIERS) {
727
+ const pattern = tier(escaped);
728
+ let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
729
+ if (!candidates.length) continue;
730
+ if (parentPattern && candidates.length > 1) {
731
+ const distances = candidates.map(
732
+ (index) => distanceToParent(lines, index, parentPattern)
733
+ );
734
+ const nearest = Math.min(...distances);
735
+ if (Number.isFinite(nearest)) {
736
+ candidates = candidates.filter((_, at) => distances[at] === nearest);
737
+ }
738
+ }
739
+ if (candidates.length !== 1) return null;
740
+ const matchLine = candidates[0];
741
+ return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
742
+ }
743
+ return null;
744
+ }
745
+ };
746
+ function escapeRegExp(value) {
747
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
748
+ }
749
+ function distanceToParent(lines, index, parent) {
750
+ const floor = Math.max(0, index - PARENT_SCOPE_LINES);
751
+ for (let at = index; at >= floor; at--) {
752
+ if (parent.test(lines[at] ?? "")) return index - at;
753
+ }
754
+ return Number.POSITIVE_INFINITY;
755
+ }
756
+ function hashAnchorText(text) {
757
+ return `sha256:${(0, import_node_crypto.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
758
+ }
759
+ function resolveAnchor(source, anchor, resolver = regexResolver) {
760
+ const normalized = source.replace(/\r\n/g, "\n");
761
+ if (!anchor.symbol) {
762
+ const lines = normalized.split("\n");
763
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
764
+ return {
765
+ text: normalized,
766
+ startLine: 1,
767
+ endLine: Math.max(1, lines.length)
768
+ };
769
+ }
770
+ return resolver.resolve(normalized, anchor.symbol);
771
+ }
772
+ function anchorFilePath(repoRoot, file) {
773
+ const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
774
+ const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
775
+ if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
776
+ return null;
777
+ }
778
+ return path;
779
+ }
780
+ function contains(root, path) {
781
+ const rel = (0, import_node_path.relative)(root, path);
782
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
783
+ }
784
+ function normalizeRepoUrl(value) {
785
+ let url = value.trim().replace(/^git\+/, "");
786
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
787
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
788
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
789
+ url = trimTrailingSlashes(url);
790
+ if (url.endsWith(".git")) url = url.slice(0, -4);
791
+ return trimTrailingSlashes(url).toLowerCase();
792
+ }
793
+ function trimTrailingSlashes(value) {
794
+ let end = value.length;
795
+ while (end > 0 && value[end - 1] === "/") end -= 1;
796
+ return value.slice(0, end);
797
+ }
798
+ function repoPath(normalized) {
799
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
800
+ const segments = withoutScheme.split("/").filter(Boolean);
801
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
802
+ }
803
+ function repoIdentifies(declared, originUrl) {
804
+ if (!originUrl) return false;
805
+ const origin = normalizeRepoUrl(originUrl);
806
+ const want = normalizeRepoUrl(declared);
807
+ if (!want || !origin) return false;
808
+ if (want === origin) return true;
809
+ const path = repoPath(origin);
810
+ if (!path) return false;
811
+ return want === path || want === (path.split("/").pop() ?? "");
812
+ }
813
+ async function repoOriginUrl(repoRoot) {
814
+ try {
815
+ const { stdout } = await execFileAsync(
816
+ "git",
817
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
818
+ { timeout: 5e3 }
819
+ );
820
+ return stdout.trim() || null;
821
+ } catch {
822
+ return null;
823
+ }
824
+ }
825
+ var LazyOrigin = class {
826
+ constructor(repoRoot) {
827
+ this.repoRoot = repoRoot;
828
+ }
829
+ repoRoot;
830
+ url = null;
831
+ asked = false;
832
+ /** Asks git once, so later `isForeign` calls need no await. */
833
+ async prime() {
834
+ if (this.asked) return;
835
+ this.url = await repoOriginUrl(this.repoRoot);
836
+ this.asked = true;
837
+ }
838
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
839
+ isForeign(anchor) {
840
+ if (!anchor.repo) return false;
841
+ return !repoIdentifies(anchor.repo, this.url);
842
+ }
843
+ async foreign(anchor) {
844
+ if (!anchor.repo) return false;
845
+ await this.prime();
846
+ return this.isForeign(anchor);
847
+ }
848
+ };
849
+ function errorCode(error) {
850
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
851
+ }
852
+ function anchorFileReader(repoRoot) {
853
+ let rootOnce;
854
+ const realRoot = () => {
855
+ rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
856
+ rootOnce = void 0;
857
+ throw error;
858
+ });
859
+ return rootOnce;
860
+ };
861
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
862
+ }
863
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
864
+ const lexical = anchorFilePath(repoRoot, file);
865
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
866
+ let root;
867
+ let path;
868
+ try {
869
+ root = await realRoot();
870
+ path = await (0, import_promises.realpath)(lexical);
871
+ } catch (error) {
872
+ const code = errorCode(error);
873
+ if (code === "ENOENT" || code === "ENOTDIR") {
874
+ return { ok: false, reason: "file-missing" };
875
+ }
876
+ return { ok: false, reason: "file-unreadable" };
877
+ }
878
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
879
+ try {
880
+ const stats = await (0, import_promises.stat)(path);
881
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
882
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
883
+ return { ok: false, reason: "file-too-large" };
884
+ }
885
+ return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
886
+ } catch (error) {
887
+ const code = errorCode(error);
888
+ if (code === "ENOENT" || code === "ENOTDIR") {
889
+ return { ok: false, reason: "file-missing" };
890
+ }
891
+ return { ok: false, reason: "file-unreadable" };
892
+ }
893
+ }
894
+ function looksLikeWrongRepoRoot(drift) {
895
+ let checked = 0;
896
+ for (const entries of drift.values()) {
897
+ for (const entry of entries) {
898
+ if (entry.reason === "foreign-repo") continue;
899
+ checked += 1;
900
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
901
+ return false;
902
+ }
903
+ }
904
+ }
905
+ return checked > 0;
906
+ }
907
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
908
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
909
+ throw new RangeError(
910
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
911
+ );
912
+ }
913
+ const wanted = [...new Set(files)];
914
+ const results = await mapLimit(wanted, concurrency, async (file) => {
915
+ try {
916
+ return await read(file);
917
+ } catch {
918
+ return { ok: false, reason: "file-unreadable" };
919
+ }
920
+ });
921
+ return new Map(wanted.map((file, at) => [file, results[at]]));
922
+ }
923
+ async function detectAnchorDrift(records, options = {}) {
924
+ const repoRoot = options.repoRoot ?? process.cwd();
925
+ const resolver = options.resolver ?? regexResolver;
926
+ const origin = new LazyOrigin(repoRoot);
927
+ const planned = /* @__PURE__ */ new Map();
928
+ let declaresRepo = false;
929
+ for (const record of records) {
930
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
931
+ (anchor) => anchor.hash
932
+ );
933
+ if (!anchors.length) continue;
934
+ if (anchors.some((anchor) => anchor.repo)) declaresRepo = true;
935
+ planned.set(
936
+ record.conceptId,
937
+ anchors.map((anchor) => ({ anchor, foreign: false }))
938
+ );
939
+ }
940
+ if (declaresRepo) {
941
+ await origin.prime();
942
+ for (const entries of planned.values()) {
943
+ for (const entry of entries)
944
+ entry.foreign = origin.isForeign(entry.anchor);
945
+ }
946
+ }
947
+ const files = [];
948
+ for (const entries of planned.values()) {
949
+ for (const entry of entries) {
950
+ if (!entry.foreign) files.push(entry.anchor.file);
951
+ }
952
+ }
953
+ const reads = await readAnchorFiles(
954
+ files,
955
+ options.reader ?? anchorFileReader(repoRoot),
956
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
957
+ );
958
+ const drift = /* @__PURE__ */ new Map();
959
+ for (const record of records) {
960
+ const entries = [];
961
+ for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
962
+ const base = {
963
+ file: anchor.file,
964
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
965
+ storedHash: anchor.hash
966
+ };
967
+ if (foreign) {
968
+ entries.push({
969
+ ...base,
970
+ state: "unresolved",
971
+ diffSize: null,
972
+ reason: "foreign-repo"
973
+ });
974
+ continue;
975
+ }
976
+ const read = reads.get(anchor.file);
977
+ if (!read.ok) {
978
+ entries.push({
979
+ ...base,
980
+ state: "unresolved",
981
+ diffSize: null,
982
+ reason: read.reason
983
+ });
984
+ continue;
985
+ }
986
+ const resolved = resolveAnchor(read.source, anchor, resolver);
987
+ if (!resolved) {
988
+ entries.push({
989
+ ...base,
990
+ state: "unresolved",
991
+ diffSize: null,
992
+ reason: "symbol-not-found"
993
+ });
994
+ continue;
995
+ }
996
+ const currentHash = hashAnchorText(resolved.text);
997
+ const currentLines = resolved.endLine - resolved.startLine + 1;
998
+ entries.push({
999
+ ...base,
1000
+ state: currentHash === anchor.hash ? "match" : "drifted",
1001
+ currentHash,
1002
+ diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
1003
+ });
1004
+ }
1005
+ if (entries.length) drift.set(record.conceptId, entries);
1006
+ }
1007
+ return drift;
1008
+ }
1009
+
1010
+ // src/search-index.ts
1011
+ var import_promises2 = require("fs/promises");
1012
+ var import_node_path2 = require("path");
520
1013
 
521
1014
  // src/kb-log.ts
522
1015
  var import_zod2 = require("zod");
@@ -579,7 +1072,7 @@ async function searchBase(bundlePath2, query, options = {}) {
579
1072
  let store = null;
580
1073
  try {
581
1074
  store = await qmd.createStore({
582
- dbPath: (0, import_node_path.join)(bundlePath2, SEARCH_INDEX_FILE),
1075
+ dbPath: (0, import_node_path2.join)(bundlePath2, SEARCH_INDEX_FILE),
583
1076
  config: {
584
1077
  collections: {
585
1078
  [COLLECTION]: {
@@ -614,16 +1107,19 @@ async function searchBase(bundlePath2, query, options = {}) {
614
1107
  }
615
1108
  }
616
1109
  async function isStale(bundlePath2) {
617
- const indexAt = await (0, import_promises.stat)((0, import_node_path.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
1110
+ const indexAt = await (0, import_promises2.stat)((0, import_node_path2.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
618
1111
  if (!indexAt) return true;
619
1112
  const { readdir: readdir2 } = await import("fs/promises");
620
- const names = await readdir2(bundlePath2).catch(() => []);
621
- for (const name of names) {
622
- if (!name.endsWith(".md") || name === INDEX_FILE) continue;
623
- const at = await (0, import_promises.stat)((0, import_node_path.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
624
- if (at > indexAt) return true;
625
- }
626
- return false;
1113
+ const names = (await readdir2(bundlePath2).catch(() => [])).filter(
1114
+ (name) => name.endsWith(".md") && name !== INDEX_FILE
1115
+ );
1116
+ let stale = false;
1117
+ await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
1118
+ if (stale) return;
1119
+ const at = await (0, import_promises2.stat)((0, import_node_path2.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
1120
+ if (at > indexAt) stale = true;
1121
+ });
1122
+ return stale;
627
1123
  }
628
1124
  function resolveHits(hits, records) {
629
1125
  const byName = /* @__PURE__ */ new Map();
@@ -923,7 +1419,7 @@ function appendUnionMergeLine(contents) {
923
1419
  }
924
1420
 
925
1421
  // src/kb-store.ts
926
- var KB_DIR = (0, import_node_path2.join)(".strauss", "kb");
1422
+ var KB_DIR = (0, import_node_path3.join)(".strauss", "kb");
927
1423
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
928
1424
  var DEFAULT_LOAD_BUDGET = 25e3;
929
1425
  var KbStore = class {
@@ -954,7 +1450,7 @@ var KbStore = class {
954
1450
  const conceptId2 = `${input.type}.${input.slug}`;
955
1451
  const root = this.root(bundlePath2);
956
1452
  const target = this.recordPath(bundlePath2, conceptId2);
957
- await (0, import_promises2.mkdir)(root, { recursive: true });
1453
+ await (0, import_promises3.mkdir)(root, { recursive: true });
958
1454
  await this.publish(
959
1455
  target,
960
1456
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -993,7 +1489,7 @@ var KbStore = class {
993
1489
  const target = this.recordPath(bundlePath2, conceptId2);
994
1490
  let raw;
995
1491
  try {
996
- raw = await (0, import_promises2.readFile)(target, "utf8");
1492
+ raw = await (0, import_promises3.readFile)(target, "utf8");
997
1493
  } catch {
998
1494
  return null;
999
1495
  }
@@ -1010,15 +1506,15 @@ var KbStore = class {
1010
1506
  const root = this.root(bundlePath2);
1011
1507
  let names;
1012
1508
  try {
1013
- names = await (0, import_promises2.readdir)(root);
1509
+ names = await (0, import_promises3.readdir)(root);
1014
1510
  } catch {
1015
1511
  return [];
1016
1512
  }
1017
1513
  const wanted = names.sort().filter((name) => name.endsWith(".md") && !STORE_OWNED.has(name)).map((name) => ({ name, conceptId: name.slice(0, -".md".length) })).filter(({ conceptId: conceptId2 }) => !type || conceptId2.startsWith(`${type}.`));
1018
- const records = await Promise.all(
1019
- wanted.map(
1020
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises2.readFile)((0, import_node_path2.join)(root, name), "utf8"))
1021
- )
1514
+ const records = await mapLimit(
1515
+ wanted,
1516
+ DEFAULT_IO_CONCURRENCY,
1517
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises3.readFile)((0, import_node_path3.join)(root, name), "utf8"))
1022
1518
  );
1023
1519
  return records.filter((record) => record !== null);
1024
1520
  }
@@ -1040,6 +1536,21 @@ var KbStore = class {
1040
1536
  { operation: `status:${status}`, by: actor }
1041
1537
  );
1042
1538
  }
1539
+ /**
1540
+ * Replaces a record's anchors wholesale, preserving everything else.
1541
+ *
1542
+ * Wholesale rather than merged: the caller just resolved the anchors it is
1543
+ * writing, so it holds the complete current set, and a merge would keep
1544
+ * stale entries the resolution pass deliberately dropped.
1545
+ */
1546
+ async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
1547
+ return this.mutate(
1548
+ bundlePath2,
1549
+ conceptId2,
1550
+ (frontmatter) => ({ ...frontmatter, strauss_anchors: anchors }),
1551
+ { operation: "anchor-resolve", by: actor }
1552
+ );
1553
+ }
1043
1554
  /**
1044
1555
  * Appends one `verified[]` event: who checked the record, when, and what the
1045
1556
  * check found. Append-only — prior events are history, and are spread into
@@ -1138,9 +1649,12 @@ ${answer}
1138
1649
  const bundle = await this.list(bundlePath2);
1139
1650
  const needle = text.trim();
1140
1651
  const hits = needle ? await this.rank(bundlePath2, needle, bundle) : bundle;
1652
+ const narrowed = options.type ? hits.filter((r) => r.frontmatter.type === options.type) : hits;
1141
1653
  const adjudicated = adjudicate(
1142
- options.type ? hits.filter((r) => r.frontmatter.type === options.type) : hits,
1143
- bundle
1654
+ narrowed,
1655
+ bundle,
1656
+ /* @__PURE__ */ new Date(),
1657
+ await this.detectDrift(narrowed, options.repoRoot)
1144
1658
  );
1145
1659
  if (options.includeNonCurrent) return adjudicated;
1146
1660
  const present = new Set(adjudicated.map((hit) => hit.record.conceptId));
@@ -1159,6 +1673,50 @@ ${answer}
1159
1673
  const lowered = needle.toLowerCase();
1160
1674
  return bundle.filter((record) => matches(record, lowered));
1161
1675
  }
1676
+ /**
1677
+ * Anchor drift over the records about to be handed back. Like the search
1678
+ * index, this is an enrichment: a filesystem failure degrades to "no drift
1679
+ * reported" rather than failing the read. Anchors without a stored hash are
1680
+ * skipped inside `detectAnchorDrift`, so a base nobody has stamped pays no
1681
+ * fs cost here. `repoRoot` defaults to the working directory — the CLI runs
1682
+ * at the repo root, and the MCP server's cwd is the workspace.
1683
+ *
1684
+ * Public because `doctor` needs the same map with the same degradation: a
1685
+ * sweep that failed to read the tree should report no drift, not fail.
1686
+ *
1687
+ * When no root was given and not one anchored file was found, the finding is
1688
+ * discarded. A base read from somewhere other than the tree it describes
1689
+ * misses every file at once, and that shape is far likelier to be a wrong
1690
+ * default root than a repository where every anchored file was deleted on
1691
+ * the same day. Reporting it would put a drift warning on every record in
1692
+ * the base, which teaches a reader to ignore the warning — the one outcome
1693
+ * worse than not having it. One file found anywhere makes the root
1694
+ * plausible, and the misses become findings again; an explicit `repoRoot` is
1695
+ * taken at its word either way.
1696
+ */
1697
+ async detectDrift(records, repoRoot) {
1698
+ try {
1699
+ const drift = await detectAnchorDrift(records, {
1700
+ repoRoot: repoRoot ?? process.cwd()
1701
+ });
1702
+ if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
1703
+ this.logger.warn?.({
1704
+ operation: "kb.anchor-drift",
1705
+ outcome: "skipped",
1706
+ reason: "no anchored file found under the default repo root"
1707
+ });
1708
+ return void 0;
1709
+ }
1710
+ return drift;
1711
+ } catch (error) {
1712
+ this.logger.warn?.({
1713
+ operation: "kb.anchor-drift",
1714
+ outcome: "skipped",
1715
+ error: error instanceof Error ? error.message : "unknown"
1716
+ });
1717
+ return void 0;
1718
+ }
1719
+ }
1162
1720
  /**
1163
1721
  * The whole base, adjudicated, when it is small enough to hand over.
1164
1722
  *
@@ -1193,7 +1751,12 @@ ${answer}
1193
1751
  const budgetTokens = options.budgetTokens ?? DEFAULT_LOAD_BUDGET;
1194
1752
  const bundle = await this.list(bundlePath2);
1195
1753
  const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
1196
- const adjudicated = adjudicate(wanted, bundle);
1754
+ const adjudicated = adjudicate(
1755
+ wanted,
1756
+ bundle,
1757
+ /* @__PURE__ */ new Date(),
1758
+ await this.detectDrift(wanted, options.repoRoot)
1759
+ );
1197
1760
  const records = adjudicated.filter((hit) => hit.standing !== "superseded");
1198
1761
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
1199
1762
  const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
@@ -1241,11 +1804,11 @@ ${answer}
1241
1804
  async readIndex(bundlePath2) {
1242
1805
  const root = this.root(bundlePath2);
1243
1806
  const expected = renderIndex(await this.list(bundlePath2));
1244
- const stored = await (0, import_promises2.readFile)((0, import_node_path2.join)(root, INDEX_FILE), "utf8").catch(
1807
+ const stored = await (0, import_promises3.readFile)((0, import_node_path3.join)(root, INDEX_FILE), "utf8").catch(
1245
1808
  () => null
1246
1809
  );
1247
1810
  if (indexIsStale(stored, expected)) {
1248
- await this.publish((0, import_node_path2.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
1811
+ await this.publish((0, import_node_path3.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
1249
1812
  this.logger.info?.({
1250
1813
  operation: "kb.index.repair",
1251
1814
  bundlePath: root,
@@ -1262,8 +1825,8 @@ ${answer}
1262
1825
  * knows which agent touched what. So a bad line is surfaced and left alone.
1263
1826
  */
1264
1827
  async readLog(bundlePath2) {
1265
- const raw = await (0, import_promises2.readFile)(
1266
- (0, import_node_path2.join)(this.root(bundlePath2), LOG_FILE),
1828
+ const raw = await (0, import_promises3.readFile)(
1829
+ (0, import_node_path3.join)(this.root(bundlePath2), LOG_FILE),
1267
1830
  "utf8"
1268
1831
  ).catch(() => "");
1269
1832
  const result = parseLog(raw);
@@ -1314,14 +1877,14 @@ ${answer}
1314
1877
  }
1315
1878
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
1316
1879
  const target = this.recordPath(bundlePath2, conceptId2);
1317
- const before = await (0, import_promises2.readFile)(target, "utf8").catch(() => null);
1880
+ const before = await (0, import_promises3.readFile)(target, "utf8").catch(() => null);
1318
1881
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
1319
1882
  const parsed = this.parse(conceptId2, before);
1320
1883
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
1321
1884
  const frontmatter = change(parsed.frontmatter);
1322
1885
  const body = changeBody(parsed.body);
1323
1886
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
1324
- const witness = await (0, import_promises2.readFile)(target, "utf8").catch(() => null);
1887
+ const witness = await (0, import_promises3.readFile)(target, "utf8").catch(() => null);
1325
1888
  if (witness === null || digest(witness) !== digest(before)) {
1326
1889
  throw new KbWriteConflictError(conceptId2);
1327
1890
  }
@@ -1347,20 +1910,20 @@ ${answer}
1347
1910
  */
1348
1911
  async publish(target, contents, overwrite, conceptId2) {
1349
1912
  const staging = `${target}.${process.pid}.tmp`;
1350
- await (0, import_promises2.writeFile)(staging, contents, "utf8");
1913
+ await (0, import_promises3.writeFile)(staging, contents, "utf8");
1351
1914
  try {
1352
1915
  if (overwrite) {
1353
- await (0, import_promises2.rename)(staging, target);
1916
+ await (0, import_promises3.rename)(staging, target);
1354
1917
  return;
1355
1918
  }
1356
- await (0, import_promises2.link)(staging, target);
1919
+ await (0, import_promises3.link)(staging, target);
1357
1920
  } catch (error) {
1358
1921
  if (error.code === "EEXIST") {
1359
1922
  throw new KbRecordAlreadyExistsError(conceptId2);
1360
1923
  }
1361
1924
  throw error;
1362
1925
  } finally {
1363
- await (0, import_promises2.unlink)(staging).catch(() => void 0);
1926
+ await (0, import_promises3.unlink)(staging).catch(() => void 0);
1364
1927
  }
1365
1928
  }
1366
1929
  /**
@@ -1404,20 +1967,30 @@ ${answer}
1404
1967
  * file must not fail the mutation it guards.
1405
1968
  */
1406
1969
  async ensureGitattributes(root) {
1407
- const target = (0, import_node_path2.join)(root, GITATTRIBUTES_FILE);
1970
+ const target = (0, import_node_path3.join)(root, GITATTRIBUTES_FILE);
1408
1971
  try {
1409
1972
  let existing;
1410
1973
  try {
1411
- existing = await (0, import_promises2.readFile)(target, "utf8");
1974
+ existing = await (0, import_promises3.readFile)(target, "utf8");
1412
1975
  } catch (error) {
1413
1976
  if (error.code !== "ENOENT") throw error;
1414
1977
  existing = null;
1415
1978
  }
1416
1979
  if (existing === null) {
1417
- await (0, import_promises2.writeFile)(target, appendUnionMergeLine(""), {
1418
- encoding: "utf8",
1419
- flag: "wx"
1420
- });
1980
+ try {
1981
+ await (0, import_promises3.writeFile)(target, appendUnionMergeLine(""), {
1982
+ encoding: "utf8",
1983
+ flag: "wx"
1984
+ });
1985
+ } catch (error) {
1986
+ if (error.code !== "EEXIST") throw error;
1987
+ this.logger.info?.({
1988
+ operation: "kb.gitattributes.ensure",
1989
+ bundlePath: root,
1990
+ outcome: "exists"
1991
+ });
1992
+ return;
1993
+ }
1421
1994
  this.logger.info?.({
1422
1995
  operation: "kb.gitattributes.ensure",
1423
1996
  bundlePath: root,
@@ -1426,7 +1999,7 @@ ${answer}
1426
1999
  return;
1427
2000
  }
1428
2001
  if (!hasMergeDeclaration(existing)) {
1429
- await (0, import_promises2.appendFile)(target, appendUnionMergeLine(existing), "utf8");
2002
+ await (0, import_promises3.appendFile)(target, appendUnionMergeLine(existing), "utf8");
1430
2003
  this.logger.info?.({
1431
2004
  operation: "kb.gitattributes.ensure",
1432
2005
  bundlePath: root,
@@ -1445,7 +2018,7 @@ ${answer}
1445
2018
  async record(root, entry) {
1446
2019
  await this.ensureGitattributes(root);
1447
2020
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
1448
- await (0, import_promises2.appendFile)((0, import_node_path2.join)(root, LOG_FILE), line, "utf8").catch((error) => {
2021
+ await (0, import_promises3.appendFile)((0, import_node_path3.join)(root, LOG_FILE), line, "utf8").catch((error) => {
1449
2022
  this.logger.warn?.({
1450
2023
  operation: "kb.log.append",
1451
2024
  outcome: "failed",
@@ -1471,18 +2044,18 @@ ${answer}
1471
2044
  };
1472
2045
  }
1473
2046
  root(bundlePath2) {
1474
- return (0, import_node_path2.resolve)(bundlePath2);
2047
+ return (0, import_node_path3.resolve)(bundlePath2);
1475
2048
  }
1476
2049
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
1477
2050
  // bundle root; anything carrying a separator would escape it.
1478
2051
  recordPath(bundlePath2, conceptId2) {
1479
- if (conceptId2.includes(import_node_path2.sep) || conceptId2.includes("/")) {
2052
+ if (conceptId2.includes(import_node_path3.sep) || conceptId2.includes("/")) {
1480
2053
  throw new KbInvalidConceptIdError(
1481
2054
  "concept id must not contain a path separator",
1482
2055
  { conceptId: conceptId2 }
1483
2056
  );
1484
2057
  }
1485
- return (0, import_node_path2.join)(this.root(bundlePath2), `${conceptId2}.md`);
2058
+ return (0, import_node_path3.join)(this.root(bundlePath2), `${conceptId2}.md`);
1486
2059
  }
1487
2060
  };
1488
2061
  function estimateTokens(record) {
@@ -1521,7 +2094,7 @@ function normalizeActor(id) {
1521
2094
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
1522
2095
  }
1523
2096
  function digest(contents) {
1524
- return (0, import_node_crypto.createHash)("sha256").update(contents).digest("hex");
2097
+ return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
1525
2098
  }
1526
2099
 
1527
2100
  // src/record-types.ts
@@ -1732,18 +2305,18 @@ var KbBaseFrozenError = class extends Error {
1732
2305
  };
1733
2306
 
1734
2307
  // src/kb-pins/frozen.ts
1735
- var import_node_path5 = require("path");
2308
+ var import_node_path6 = require("path");
1736
2309
 
1737
2310
  // src/kb-pins/layers.ts
1738
- var import_promises3 = require("fs/promises");
2311
+ var import_promises4 = require("fs/promises");
1739
2312
  var import_node_os = require("os");
1740
- var import_node_path4 = require("path");
2313
+ var import_node_path5 = require("path");
1741
2314
 
1742
2315
  // src/kb-pins/model.ts
1743
- var import_node_path3 = require("path");
2316
+ var import_node_path4 = require("path");
1744
2317
  var import_zod4 = require("zod");
1745
- var PINS_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.json");
1746
- var PINS_LOCAL_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.local.json");
2318
+ var PINS_FILE = (0, import_node_path4.join)(".strauss", "kb-pins.json");
2319
+ var PINS_LOCAL_FILE = (0, import_node_path4.join)(".strauss", "kb-pins.local.json");
1747
2320
  var PIN_LAYERS = ["project", "local", "user"];
1748
2321
  var pinSchema = import_zod4.z.object({
1749
2322
  /** Relative to the manifest's root, so the file is committable. */
@@ -1793,10 +2366,10 @@ function userRoot() {
1793
2366
  return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
1794
2367
  }
1795
2368
  function layerRoot(workspaceDir, layer) {
1796
- return layer === "user" ? userRoot() : (0, import_node_path4.resolve)(workspaceDir);
2369
+ return layer === "user" ? userRoot() : (0, import_node_path5.resolve)(workspaceDir);
1797
2370
  }
1798
2371
  function layerFile(workspaceDir, layer) {
1799
- return (0, import_node_path4.join)(
2372
+ return (0, import_node_path5.join)(
1800
2373
  layerRoot(workspaceDir, layer),
1801
2374
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1802
2375
  );
@@ -1805,7 +2378,7 @@ async function readPinsLayer(workspaceDir, layer) {
1805
2378
  const file = layerFile(workspaceDir, layer);
1806
2379
  let raw;
1807
2380
  try {
1808
- raw = await (0, import_promises3.readFile)(file, "utf8");
2381
+ raw = await (0, import_promises4.readFile)(file, "utf8");
1809
2382
  } catch {
1810
2383
  return { pins: [] };
1811
2384
  }
@@ -1829,16 +2402,16 @@ async function readPinsLayer(workspaceDir, layer) {
1829
2402
  }
1830
2403
  async function writePinsLayer(workspaceDir, layer, manifest) {
1831
2404
  const file = layerFile(workspaceDir, layer);
1832
- await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(file), { recursive: true });
1833
- await (0, import_promises3.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
2405
+ await (0, import_promises4.mkdir)((0, import_node_path5.dirname)(file), { recursive: true });
2406
+ await (0, import_promises4.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1834
2407
  `, "utf8");
1835
2408
  }
1836
2409
  function resolvePinPath(rootDir, path) {
1837
- return (0, import_node_path4.isAbsolute)(path) ? (0, import_node_path4.resolve)(path) : (0, import_node_path4.resolve)(rootDir, path.split("/").join(import_node_path4.sep));
2410
+ return (0, import_node_path5.isAbsolute)(path) ? (0, import_node_path5.resolve)(path) : (0, import_node_path5.resolve)(rootDir, path.split("/").join(import_node_path5.sep));
1838
2411
  }
1839
2412
  function storablePath(rootDir, bundlePath2) {
1840
- const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(rootDir), (0, import_node_path4.resolve)(bundlePath2));
1841
- return (rel === "" ? "." : rel).split(import_node_path4.sep).join("/");
2413
+ const rel = (0, import_node_path5.relative)((0, import_node_path5.resolve)(rootDir), (0, import_node_path5.resolve)(bundlePath2));
2414
+ return (rel === "" ? "." : rel).split(import_node_path5.sep).join("/");
1842
2415
  }
1843
2416
  async function readMergedPins(workspaceDir) {
1844
2417
  const manifests = {};
@@ -1866,7 +2439,7 @@ async function readMergedPins(workspaceDir) {
1866
2439
  // src/kb-pins/frozen.ts
1867
2440
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
1868
2441
  const merged = await readMergedPins(workspaceDir);
1869
- const absolute = (0, import_node_path5.resolve)(bundlePath2);
2442
+ const absolute = (0, import_node_path6.resolve)(bundlePath2);
1870
2443
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
1871
2444
  if (pin?.frozen === true) {
1872
2445
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -1951,7 +2524,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1951
2524
  }
1952
2525
 
1953
2526
  // src/kb-pins/unpin.ts
1954
- var import_node_path6 = require("path");
2527
+ var import_node_path7 = require("path");
1955
2528
  async function unpinBase(workspaceDir, bundlePath2) {
1956
2529
  const layers = [];
1957
2530
  for (const layer of PIN_LAYERS) {
@@ -1972,14 +2545,14 @@ async function unpinBase(workspaceDir, bundlePath2) {
1972
2545
  }
1973
2546
  }
1974
2547
  return {
1975
- path: storablePath((0, import_node_path6.resolve)(workspaceDir), bundlePath2),
2548
+ path: storablePath((0, import_node_path7.resolve)(workspaceDir), bundlePath2),
1976
2549
  removed: layers.length > 0,
1977
2550
  layers
1978
2551
  };
1979
2552
  }
1980
2553
 
1981
2554
  // src/kb-context.ts
1982
- var import_promises4 = require("fs/promises");
2555
+ var import_promises5 = require("fs/promises");
1983
2556
  var HEADING2 = "## Knowledge bases (pinned)";
1984
2557
  var DEFAULT_CONTEXT_BUDGET = 4e3;
1985
2558
  var CONTEXT_PROFILES = {
@@ -2183,13 +2756,13 @@ function toHookJson(block, event) {
2183
2756
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2184
2757
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2185
2758
  async function syncInstructions(file, block) {
2186
- const existing = await (0, import_promises4.readFile)(file, "utf8").catch(() => null);
2759
+ const existing = await (0, import_promises5.readFile)(file, "utf8").catch(() => null);
2187
2760
  const region = block ? `${CONTEXT_BEGIN}
2188
2761
  ${block.trim()}
2189
2762
  ${CONTEXT_END}` : null;
2190
2763
  if (existing === null) {
2191
2764
  if (!region) return { file, action: "unchanged" };
2192
- await (0, import_promises4.writeFile)(file, `${region}
2765
+ await (0, import_promises5.writeFile)(file, `${region}
2193
2766
  `, "utf8");
2194
2767
  return { file, action: "created" };
2195
2768
  }
@@ -2200,11 +2773,11 @@ ${CONTEXT_END}` : null;
2200
2773
  const after = existing.slice(end + CONTEXT_END.length);
2201
2774
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2202
2775
  if (next === existing) return { file, action: "unchanged" };
2203
- await (0, import_promises4.writeFile)(file, next, "utf8");
2776
+ await (0, import_promises5.writeFile)(file, next, "utf8");
2204
2777
  return { file, action: region ? "replaced" : "removed" };
2205
2778
  }
2206
2779
  if (!region) return { file, action: "unchanged" };
2207
- await (0, import_promises4.writeFile)(
2780
+ await (0, import_promises5.writeFile)(
2208
2781
  file,
2209
2782
  `${existing.replace(/\n*$/, "\n\n")}${region}
2210
2783
  `,
@@ -2351,7 +2924,8 @@ var KB_DOCTOR_CHECKS = [
2351
2924
  "aging",
2352
2925
  "orphaned",
2353
2926
  "broken-supersession",
2354
- "superseded-but-cited"
2927
+ "superseded-but-cited",
2928
+ "drifted"
2355
2929
  ];
2356
2930
  var CHECK_HEADLINES = {
2357
2931
  expired: "past its stale_after date",
@@ -2360,7 +2934,8 @@ var CHECK_HEADLINES = {
2360
2934
  aging: "still open or still proposed long after it was written",
2361
2935
  orphaned: "no other record links to it",
2362
2936
  "broken-supersession": "the supersession pointers do not resolve",
2363
- "superseded-but-cited": "a live record's body links to one that no longer holds"
2937
+ "superseded-but-cited": "a live record's body links to one that no longer holds",
2938
+ drifted: "the code an anchor points at moved out from under its hash"
2364
2939
  };
2365
2940
  var DAY_MS = 864e5;
2366
2941
  function doctor(bundle, options = {}) {
@@ -2370,7 +2945,7 @@ function doctor(bundle, options = {}) {
2370
2945
  agingDays: options.agingDays ?? DEFAULT_AGING_DAYS
2371
2946
  };
2372
2947
  const now = options.now ?? /* @__PURE__ */ new Date();
2373
- const adjudicated = adjudicate(bundle, bundle, now);
2948
+ const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
2374
2949
  const standings = new Map(
2375
2950
  adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
2376
2951
  );
@@ -2384,7 +2959,8 @@ function doctor(bundle, options = {}) {
2384
2959
  group("aging", aging(inForce, now, thresholds.agingDays)),
2385
2960
  group("orphaned", orphaned(bundle)),
2386
2961
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
2387
- group("superseded-but-cited", supersededButCited(bundle, standings))
2962
+ group("superseded-but-cited", supersededButCited(bundle, standings)),
2963
+ group("drifted", drifted(inForce))
2388
2964
  ];
2389
2965
  const counts = Object.fromEntries(
2390
2966
  groups.map((entry) => [entry.check, entry.count])
@@ -2570,6 +3146,29 @@ function supersededButCited(bundle, standings) {
2570
3146
  }
2571
3147
  return findings;
2572
3148
  }
3149
+ function drifted(hits) {
3150
+ const findings = [];
3151
+ for (const hit of hits) {
3152
+ const warning = hit.warnings.find((entry) => entry.kind === "drifted");
3153
+ if (!warning) continue;
3154
+ findings.push(
3155
+ finding(
3156
+ hit.record,
3157
+ `${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
3158
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3159
+ if (anchor.reason) return `${at} (${anchor.reason})`;
3160
+ if (anchor.diffSize === null) {
3161
+ return `${at} (changed, size unrecorded)`;
3162
+ }
3163
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3164
+ }).join(", ")}`
3165
+ )
3166
+ );
3167
+ }
3168
+ return findings.sort(
3169
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
3170
+ );
3171
+ }
2573
3172
  function replaces(later, earlier) {
2574
3173
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
2575
3174
  }
@@ -2639,13 +3238,16 @@ function selectDecisions(records) {
2639
3238
  );
2640
3239
  }
2641
3240
 
2642
- // src/commands/answer.ts
3241
+ // src/commands/anchor-resolve.ts
2643
3242
  var import_zod8 = require("zod");
2644
3243
 
2645
3244
  // src/commands/model.ts
2646
3245
  var import_zod7 = require("zod");
2647
3246
  var bundlePath = import_zod7.z.string().min(1).describe("Absolute path to the knowledge base directory.");
2648
3247
  var conceptId = import_zod7.z.string().min(1).describe("e.g. decision.cursor-v2");
3248
+ var REPO_ROOT = import_zod7.z.string().min(1).optional().describe(
3249
+ "Where the anchored source lives, for the drift check. Defaults to the working directory."
3250
+ );
2649
3251
  function define(command) {
2650
3252
  return command;
2651
3253
  }
@@ -2665,13 +3267,175 @@ function argvFlag(argv, name) {
2665
3267
  return value;
2666
3268
  }
2667
3269
 
3270
+ // src/commands/anchor-resolve.ts
3271
+ var anchorResolveCommand = define({
3272
+ name: "anchor-resolve",
3273
+ tool: "kb_anchor_resolve",
3274
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
3275
+ description: "Resolve a record's anchors against the working tree: stamp a hash onto anchors that lack one, report drift where the code moved. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was, not whether the claim still holds. Anchors naming another repository are skipped. Exits non-zero on drift.",
3276
+ input: import_zod8.z.object({
3277
+ bundlePath,
3278
+ conceptId,
3279
+ repoRoot: import_zod8.z.string().min(1).optional(),
3280
+ rebaseline: import_zod8.z.boolean().optional().describe(
3281
+ "Accept the current code as the new baseline for anchors that drifted."
3282
+ ),
3283
+ restamp: import_zod8.z.boolean().optional().describe(
3284
+ "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
3285
+ )
3286
+ }),
3287
+ fromArgv: (argv, path) => ({
3288
+ bundlePath: path,
3289
+ conceptId: argv[1],
3290
+ repoRoot: argvFlag(argv, "--repo-root"),
3291
+ rebaseline: argv.includes("--rebaseline"),
3292
+ restamp: argv.includes("--restamp")
3293
+ }),
3294
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
3295
+ const root = repoRoot ?? process.cwd();
3296
+ const record = await store.read(path, id);
3297
+ if (!record) throw new KbRecordNotFoundError(id);
3298
+ const anchors = record.frontmatter.strauss_anchors ?? [];
3299
+ if (!anchors.length) {
3300
+ return {
3301
+ conceptId: id,
3302
+ results: [],
3303
+ verified: false,
3304
+ note: "record has no anchors"
3305
+ };
3306
+ }
3307
+ const results = [];
3308
+ const updated = [];
3309
+ const origin = new LazyOrigin(root);
3310
+ let dirty = false;
3311
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
3312
+ const foreign = new Map(
3313
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
3314
+ );
3315
+ const reads = await readAnchorFiles(
3316
+ anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
3317
+ anchorFileReader(root)
3318
+ );
3319
+ for (const anchor of anchors) {
3320
+ const base = {
3321
+ file: anchor.file,
3322
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
3323
+ // Carried onto unresolved findings too: an anchor that once hashed
3324
+ // and now resolves to nothing is a broken anchor, and the exit code
3325
+ // has to be able to tell it from one nobody ever stamped.
3326
+ ...anchor.hash ? { storedHash: anchor.hash } : {}
3327
+ };
3328
+ if (foreign.get(anchor)) {
3329
+ results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
3330
+ updated.push(anchor);
3331
+ continue;
3332
+ }
3333
+ const fileRead = reads.get(anchor.file);
3334
+ if (!fileRead.ok) {
3335
+ results.push({ ...base, state: "unresolved", reason: fileRead.reason });
3336
+ updated.push(anchor);
3337
+ continue;
3338
+ }
3339
+ const resolved = resolveAnchor(fileRead.source, anchor);
3340
+ if (!resolved) {
3341
+ results.push({
3342
+ ...base,
3343
+ state: "unresolved",
3344
+ reason: "symbol-not-found"
3345
+ });
3346
+ updated.push(anchor);
3347
+ continue;
3348
+ }
3349
+ const currentHash = hashAnchorText(resolved.text);
3350
+ const currentLines = resolved.endLine - resolved.startLine + 1;
3351
+ const stamped = {
3352
+ ...anchor,
3353
+ hash: currentHash,
3354
+ lines: currentLines,
3355
+ resolved_at: now()
3356
+ };
3357
+ if (!anchor.hash) {
3358
+ results.push({ ...base, state: "stamped", currentHash });
3359
+ updated.push(stamped);
3360
+ dirty = true;
3361
+ } else if (anchor.hash === currentHash) {
3362
+ results.push({
3363
+ ...base,
3364
+ state: "match",
3365
+ currentHash
3366
+ });
3367
+ const refresh = restamp || anchor.resolved_at === void 0;
3368
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
3369
+ if (refresh) dirty = true;
3370
+ } else {
3371
+ results.push({
3372
+ ...base,
3373
+ state: "drifted",
3374
+ currentHash,
3375
+ diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
3376
+ ...rebaseline ? { rebaselined: true } : {}
3377
+ });
3378
+ updated.push(rebaseline ? stamped : anchor);
3379
+ if (rebaseline) dirty = true;
3380
+ }
3381
+ }
3382
+ let frozen = false;
3383
+ if (dirty) {
3384
+ try {
3385
+ await assertBaseNotFrozen(process.cwd(), path);
3386
+ } catch (error) {
3387
+ if (!(error instanceof KbBaseFrozenError)) throw error;
3388
+ frozen = true;
3389
+ }
3390
+ if (!frozen) await store.updateAnchors(path, id, updated, actor);
3391
+ }
3392
+ const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
3393
+ const checked = results.filter((entry) => entry.reason !== "foreign-repo");
3394
+ const skipped = results.length - checked.length;
3395
+ const matches2 = checked.filter((entry) => entry.state === "match").length;
3396
+ const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
3397
+ if (clean) {
3398
+ try {
3399
+ await store.verify(
3400
+ path,
3401
+ id,
3402
+ `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
3403
+ actor,
3404
+ now()
3405
+ );
3406
+ } catch (error) {
3407
+ if (!(error instanceof KbSelfVerificationError)) throw error;
3408
+ return {
3409
+ conceptId: id,
3410
+ results,
3411
+ verified: false,
3412
+ verifyRefused: "self-verification",
3413
+ ...frozenNote
3414
+ };
3415
+ }
3416
+ return { conceptId: id, results, verified: true, ...frozenNote };
3417
+ }
3418
+ return { conceptId: id, results, verified: false, ...frozenNote };
3419
+ },
3420
+ // A stored hash that no longer resolves is a broken anchor, not an absence:
3421
+ // the file was deleted or the symbol renamed, and exiting zero on it would
3422
+ // let the one edit that destroys an anchor pass the gate that exists to
3423
+ // catch it. An anchor nobody ever stamped is still just unstamped, and one
3424
+ // belonging to another repository was never this run's to check — failing CI
3425
+ // on either would gate on work this command did not do.
3426
+ failsWhen: (result) => result.results.some(
3427
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
3428
+ )
3429
+ });
3430
+
2668
3431
  // src/commands/answer.ts
3432
+ var import_zod9 = require("zod");
2669
3433
  var answerCommand = define({
2670
3434
  name: "answer",
2671
3435
  tool: "kb_answer",
2672
3436
  usage: "answer <concept-id> <answer...>",
2673
3437
  description: "Resolve an open question: sets the status, stamps who answered and when, and appends an Answer section. If the answer overturns an assumption or a decision, that is a supersession \u2014 do it explicitly.",
2674
- input: import_zod8.z.object({ bundlePath, conceptId, answer: import_zod8.z.string().min(1) }),
3438
+ input: import_zod9.z.object({ bundlePath, conceptId, answer: import_zod9.z.string().min(1) }),
2675
3439
  fromArgv: (argv, path) => ({
2676
3440
  bundlePath: path,
2677
3441
  conceptId: argv[1],
@@ -2685,15 +3449,15 @@ var answerCommand = define({
2685
3449
  });
2686
3450
 
2687
3451
  // src/commands/catalog.ts
2688
- var import_zod9 = require("zod");
3452
+ var import_zod10 = require("zod");
2689
3453
  var catalogCommand = define({
2690
3454
  name: "catalog",
2691
3455
  tool: "kb_catalog",
2692
3456
  usage: "catalog [type]",
2693
3457
  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.",
2694
- input: import_zod9.z.object({
3458
+ input: import_zod10.z.object({
2695
3459
  bundlePath,
2696
- type: import_zod9.z.enum(KB_RECORD_TYPES).optional()
3460
+ type: import_zod10.z.enum(KB_RECORD_TYPES).optional()
2697
3461
  }),
2698
3462
  fromArgv: (argv, path) => ({
2699
3463
  bundlePath: path,
@@ -2748,26 +3512,26 @@ function count(value, noun) {
2748
3512
  }
2749
3513
 
2750
3514
  // src/commands/context.ts
2751
- var import_zod10 = require("zod");
3515
+ var import_zod11 = require("zod");
2752
3516
  var contextCommand = define({
2753
3517
  name: "context",
2754
3518
  tool: "kb_context",
2755
3519
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
2756
3520
  description: "The pinned-base index block, for injection at every context birth \u2014 startup, clear, resume, and after compaction. An index, not the content: concept ids, titles and standing, with the bodies left behind kb_load at the point of use. Emits nothing when nothing is pinned. Refuses with the list of bases and their sizes rather than truncating past its budget. Budgets resolve most-specific-first: explicit flags, then the workspace manifests' `context` tables (per profile, over their `default`), then the built-in profile (session-start, compact, turn), then package defaults \u2014 so a repo tunes its own numbers in .strauss/kb-pins.json without touching hook commands. Like kb_schema and kb_types this takes no bundlePath \u2014 it reads the workspace pin manifests, because which bases a session should see is workspace state, not a property of one base.",
2757
- input: import_zod10.z.object({
2758
- budgetTokens: import_zod10.z.number().int().positive().optional().describe(
3521
+ input: import_zod11.z.object({
3522
+ budgetTokens: import_zod11.z.number().int().positive().optional().describe(
2759
3523
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
2760
3524
  ),
2761
- fullUnderTokens: import_zod10.z.number().int().positive().optional().describe(
3525
+ fullUnderTokens: import_zod11.z.number().int().positive().optional().describe(
2762
3526
  "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."
2763
3527
  ),
2764
- profile: import_zod10.z.string().optional().describe(
3528
+ profile: import_zod11.z.string().optional().describe(
2765
3529
  "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."
2766
3530
  ),
2767
- format: import_zod10.z.enum(["markdown", "json"]).optional().describe(
3531
+ format: import_zod11.z.enum(["markdown", "json"]).optional().describe(
2768
3532
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
2769
3533
  ),
2770
- event: import_zod10.z.string().optional().describe(
3534
+ event: import_zod11.z.string().optional().describe(
2771
3535
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
2772
3536
  )
2773
3537
  }),
@@ -2803,15 +3567,16 @@ var contextCommand = define({
2803
3567
  });
2804
3568
 
2805
3569
  // src/commands/doctor.ts
2806
- var import_zod11 = require("zod");
2807
- var days = (what, fallback) => import_zod11.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
3570
+ var import_zod12 = require("zod");
3571
+ var days = (what, fallback) => import_zod12.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
2808
3572
  var doctorCommand = define({
2809
3573
  name: "doctor",
2810
3574
  tool: "kb_doctor",
2811
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
2812
- description: "A health sweep over a whole base: what the calendar has already retired, what nobody ever confirmed, what has been open or proposed long enough that the status is now the answer, and what the graph has dropped on the floor. Read-only \u2014 it never writes, never supersedes, and never re-dates anything; every finding names a record for a person to repair. Seven checks, grouped and counted: expired (past `stale_after`), expiring (inside the window), unverified (an empty `verified[]` on a record old enough to matter), aging (still `open` or `proposed`), orphaned (no other record links to it), broken supersession (a chain that does not resolve), and superseded-but-cited (a live record whose body links to a record that no longer holds). Every group is reported even when empty, because a check that found nothing and a check that never ran look identical in a report that only lists findings.\n\nThis is the question no reader thinks to ask, which is why it needs a command: decay is invisible from inside a single record \u2014 a stale one reads exactly like a live one, and a question nobody answered reads exactly like one nobody asked. Reach for it when picking up a base someone else kept, before trusting a base you have not touched in months, or on a schedule; kb_validate is the narrower neighbour, checking only whether pointers between records agree.",
2813
- input: import_zod11.z.object({
3575
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
3576
+ description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted 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.",
3577
+ input: import_zod12.z.object({
2814
3578
  bundlePath,
3579
+ repoRoot: REPO_ROOT,
2815
3580
  expiringDays: days(
2816
3581
  "How far ahead `expiring` looks, in days.",
2817
3582
  DEFAULT_EXPIRING_DAYS
@@ -2824,7 +3589,7 @@ var doctorCommand = define({
2824
3589
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
2825
3590
  DEFAULT_AGING_DAYS
2826
3591
  ),
2827
- strict: import_zod11.z.boolean().optional().describe(
3592
+ strict: import_zod12.z.boolean().optional().describe(
2828
3593
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
2829
3594
  )
2830
3595
  }),
@@ -2836,29 +3601,36 @@ var doctorCommand = define({
2836
3601
  const expiring2 = argvFlag(argv, "--expiring-days");
2837
3602
  const unverified2 = argvFlag(argv, "--unverified-days");
2838
3603
  const agingDays = argvFlag(argv, "--aging-days");
3604
+ const repoRoot = argvFlag(argv, "--repo-root");
2839
3605
  return {
2840
3606
  bundlePath: path,
3607
+ ...repoRoot !== void 0 ? { repoRoot } : {},
2841
3608
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
2842
3609
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
2843
3610
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
2844
3611
  ...argv.includes("--strict") ? { strict: true } : {}
2845
3612
  };
2846
3613
  },
2847
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays }) => {
3614
+ run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
2848
3615
  const checkedAt = now();
2849
- const report = doctor(await store.list(path), {
3616
+ const records = await store.list(path);
3617
+ const anchorDrift = await store.detectDrift(records, repoRoot);
3618
+ const report = doctor(records, {
2850
3619
  ...expiringDays !== void 0 ? { expiringDays } : {},
2851
3620
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
2852
3621
  ...agingDays !== void 0 ? { agingDays } : {},
3622
+ ...anchorDrift !== void 0 ? { anchorDrift } : {},
2853
3623
  now: new Date(checkedAt)
2854
3624
  });
2855
3625
  return { bundlePath: path, checkedAt, ...report };
2856
3626
  },
2857
3627
  render: (result) => render2(result),
2858
- // Only expiry, and only under --strict. The other six checks report debt a
3628
+ // Only expiry, and only under --strict. The other seven checks report debt a
2859
3629
  // reader decides about; an expired record is the base asserting something it
2860
3630
  // already said it would stop standing behind, which is the one finding a
2861
- // pipeline can act on without a judgment call.
3631
+ // pipeline can act on without a judgment call. Drift has its own gate —
3632
+ // `anchor-resolve` exits non-zero on it, against a repo root the caller
3633
+ // named, which is the run a CI pipeline should be making anyway.
2862
3634
  failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
2863
3635
  });
2864
3636
  function render2(result) {
@@ -2893,13 +3665,13 @@ function render2(result) {
2893
3665
  }
2894
3666
 
2895
3667
  // src/commands/list.ts
2896
- var import_zod12 = require("zod");
3668
+ var import_zod13 = require("zod");
2897
3669
  var listCommand = define({
2898
3670
  name: "list",
2899
3671
  tool: "kb_list",
2900
3672
  usage: "list [type]",
2901
3673
  description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
2902
- input: import_zod12.z.object({ bundlePath, type: import_zod12.z.enum(KB_RECORD_TYPES).optional() }),
3674
+ input: import_zod13.z.object({ bundlePath, type: import_zod13.z.enum(KB_RECORD_TYPES).optional() }),
2903
3675
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
2904
3676
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
2905
3677
  conceptId: record.conceptId,
@@ -2911,36 +3683,40 @@ var listCommand = define({
2911
3683
  });
2912
3684
 
2913
3685
  // src/commands/load.ts
2914
- var import_zod13 = require("zod");
3686
+ var import_zod14 = require("zod");
2915
3687
  var loadCommand = define({
2916
3688
  name: "load",
2917
3689
  tool: "kb_load",
2918
- usage: "load [type] [--budget N] [--all]",
3690
+ usage: "load [type] [--budget N | --all] [--repo-root PATH]",
2919
3691
  description: "Loads the whole knowledge base at once, each record with its standing. Superseded records arrive as stubs (name, replacement, date); rejected and open records arrive whole. Refuses past the token budget rather than truncating \u2014 call kb_catalog, then kb_pack on the record that matters, or narrow with `type`; kb_query for a lookup by wording. `all` bypasses the budget.",
2920
- input: import_zod13.z.object({
3692
+ input: import_zod14.z.object({
2921
3693
  bundlePath,
2922
- type: import_zod13.z.enum(KB_RECORD_TYPES).optional(),
2923
- budgetTokens: import_zod13.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
2924
- all: import_zod13.z.boolean().optional().describe(
3694
+ type: import_zod14.z.enum(KB_RECORD_TYPES).optional(),
3695
+ budgetTokens: import_zod14.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
3696
+ all: import_zod14.z.boolean().optional().describe(
2925
3697
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
2926
- )
3698
+ ),
3699
+ repoRoot: REPO_ROOT
2927
3700
  }).refine((value) => !(value.all && value.budgetTokens !== void 0), {
2928
3701
  message: "all is mutually exclusive with budgetTokens: pass a ceiling or none, not both."
2929
3702
  }),
2930
3703
  fromArgv: (argv, path) => {
2931
3704
  const budget = argvFlag(argv, "--budget");
3705
+ const repoRoot = argvFlag(argv, "--repo-root");
2932
3706
  return {
2933
3707
  bundlePath: path,
2934
3708
  ...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {},
2935
3709
  ...budget ? { budgetTokens: Number(budget) } : {},
2936
- ...argv.includes("--all") ? { all: true } : {}
3710
+ ...argv.includes("--all") ? { all: true } : {},
3711
+ ...repoRoot !== void 0 ? { repoRoot } : {}
2937
3712
  };
2938
3713
  },
2939
- run: async ({ store }, { bundlePath: path, type, budgetTokens, all }) => {
3714
+ run: async ({ store }, { bundlePath: path, type, budgetTokens, all, repoRoot }) => {
2940
3715
  const result = await store.load(path, {
2941
3716
  ...type ? { type } : {},
2942
3717
  ...budgetTokens ? { budgetTokens } : {},
2943
- ...all ? { all } : {}
3718
+ ...all ? { all } : {},
3719
+ ...repoRoot !== void 0 ? { repoRoot } : {}
2944
3720
  });
2945
3721
  if (!result.loaded) return result;
2946
3722
  return {
@@ -2959,25 +3735,25 @@ var loadCommand = define({
2959
3735
  });
2960
3736
 
2961
3737
  // src/commands/log.ts
2962
- var import_zod14 = require("zod");
3738
+ var import_zod15 = require("zod");
2963
3739
  var logCommand = define({
2964
3740
  name: "log",
2965
3741
  tool: "kb_log",
2966
3742
  usage: "log",
2967
3743
  description: "What touched what, and when. The only artifact here that cannot be reconstructed from the records, so malformed lines are reported rather than repaired.",
2968
- input: import_zod14.z.object({ bundlePath }),
3744
+ input: import_zod15.z.object({ bundlePath }),
2969
3745
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2970
3746
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
2971
3747
  });
2972
3748
 
2973
3749
  // src/commands/no-decision.ts
2974
- var import_zod15 = require("zod");
3750
+ var import_zod16 = require("zod");
2975
3751
  var noDecisionCommand = define({
2976
3752
  name: "no-decision",
2977
3753
  tool: "kb_no_decision",
2978
3754
  usage: "no-decision <reason...>",
2979
3755
  description: 'Claim in one sentence that there was nothing to decide. Gating on "did you write a decision?" rewards writing a junk one; gating on "did you answer?" does not, so silence has to be expressible. Idempotent \u2014 restating it is not a collision.',
2980
- input: import_zod15.z.object({ bundlePath, reason: import_zod15.z.string().min(1) }),
3756
+ input: import_zod16.z.object({ bundlePath, reason: import_zod16.z.string().min(1) }),
2981
3757
  fromArgv: (argv, path) => ({
2982
3758
  bundlePath: path,
2983
3759
  reason: argv.slice(1).join(" ").trim()
@@ -2994,20 +3770,20 @@ var noDecisionCommand = define({
2994
3770
  });
2995
3771
 
2996
3772
  // src/commands/pack.ts
2997
- var import_zod16 = require("zod");
3773
+ var import_zod17 = require("zod");
2998
3774
  var packCommand = define({
2999
3775
  name: "pack",
3000
3776
  tool: "kb_pack",
3001
3777
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
3002
3778
  description: "The bounded neighbourhood around one record: everything within `hops` of the root, ranked and cut to `maxNodes`, with every cut record named under Excluded \u2014 a named gap is knowable, a silent one is not. Prefer this over kb_load when the base is too large to hold whole and the work centres on one record; prefer it over kb_query when the question needs the governed neighbourhood \u2014 what was settled and what binds near this record \u2014 rather than a lookup by wording. Superseded records arrive as name, replacement and date stubs exactly as kb_load emits them: their bodies no longer hold, and kb_trace has the history. Refuses outright rather than truncating when the pack would exceed its token budget \u2014 a partial pack is indistinguishable from a complete one \u2014 reporting the record count and every already-cut id so the caller can lower hops or maxNodes, or raise the budget. The header carries the bundle, root, budget and a timestamp; everything below the header is byte-identical across runs over an unchanged base, so two packs can be diffed and a changed byte means changed knowledge. This tool (with kb_load, kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.",
3003
- input: import_zod16.z.object({
3779
+ input: import_zod17.z.object({
3004
3780
  bundlePath,
3005
3781
  conceptId,
3006
- hops: import_zod16.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3007
- maxNodes: import_zod16.z.number().int().positive().optional().describe(
3782
+ hops: import_zod17.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
3783
+ maxNodes: import_zod17.z.number().int().positive().optional().describe(
3008
3784
  "How many records the pack may hold, root included. Defaults to 20."
3009
3785
  ),
3010
- budgetTokens: import_zod16.z.number().int().positive().optional().describe(
3786
+ budgetTokens: import_zod17.z.number().int().positive().optional().describe(
3011
3787
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
3012
3788
  )
3013
3789
  }),
@@ -3094,22 +3870,22 @@ function warningLabel(warning) {
3094
3870
  }
3095
3871
 
3096
3872
  // src/commands/pin.ts
3097
- var import_zod17 = require("zod");
3873
+ var import_zod18 = require("zod");
3098
3874
  var pinCommand = define({
3099
3875
  name: "pin",
3100
3876
  tool: "kb_pin",
3101
3877
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
3102
3878
  description: "Pin a base into a workspace pin manifest, so `context` surfaces it at every context birth. Three layers, nearest wins: the committed project manifest (.strauss/kb-pins.json, the default), `--local` (.strauss/kb-pins.local.json, personal and gitignored), and `--user` (~/.strauss/kb-pins.json, every workspace). Idempotent \u2014 re-pinning changes nothing unless --mode, --profiles, or --frozen/--unfreeze are given, which update just those fields. `--mode full` preloads the whole base into the block regardless of the full-under threshold; `--mode index` never upgrades. `--profiles` scopes the pin to named context profiles. `--frozen` marks the base concluded: write commands against it refuse and `context` labels it read-only. A path with no records yet succeeds with a warning; bases are routinely pinned before they are populated. Pins are workspace state: the pinned base itself is never touched.",
3103
- input: import_zod17.z.object({
3879
+ input: import_zod18.z.object({
3104
3880
  bundlePath,
3105
- mode: import_zod17.z.enum(["full", "index"]).optional().describe(
3881
+ mode: import_zod18.z.enum(["full", "index"]).optional().describe(
3106
3882
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
3107
3883
  ),
3108
- profiles: import_zod17.z.array(import_zod17.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
3109
- layer: import_zod17.z.enum(["project", "local", "user"]).optional().describe(
3884
+ profiles: import_zod18.z.array(import_zod18.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
3885
+ layer: import_zod18.z.enum(["project", "local", "user"]).optional().describe(
3110
3886
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
3111
3887
  ),
3112
- frozen: import_zod17.z.boolean().optional().describe(
3888
+ frozen: import_zod18.z.boolean().optional().describe(
3113
3889
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
3114
3890
  )
3115
3891
  }),
@@ -3138,38 +3914,48 @@ var pinCommand = define({
3138
3914
  });
3139
3915
 
3140
3916
  // src/commands/pins.ts
3141
- var import_zod18 = require("zod");
3917
+ var import_zod19 = require("zod");
3142
3918
  var pinsCommand = define({
3143
3919
  name: "pins",
3144
3920
  tool: "kb_pins",
3145
3921
  usage: "pins",
3146
3922
  description: "Every pinned base across the manifest layers, each with its layer and whether it currently resolves to readable records. Reads the workspace manifests rather than any one base, like kb_context.",
3147
- input: import_zod18.z.object({}),
3923
+ input: import_zod19.z.object({}),
3148
3924
  fromArgv: () => ({}),
3149
3925
  run: ({ store }) => listPins(store, process.cwd())
3150
3926
  });
3151
3927
 
3152
3928
  // src/commands/query.ts
3153
- var import_zod19 = require("zod");
3929
+ var import_zod20 = require("zod");
3154
3930
  var queryCommand = define({
3155
3931
  name: "query",
3156
3932
  tool: "kb_query",
3157
- usage: "query <text...>",
3933
+ usage: "query <text...> [--repo-root PATH]",
3158
3934
  description: "Search and return each match with its standing. Results are flagged, never filtered: a superseded record comes back alongside whatever replaced it, and a rejected one is marked as something explicitly not adopted. This is the lookup-by-wording rung, and the narrowest of the three: use it when you know roughly what the record says. The decision rule around it \u2014 while the base fits kb_load's token budget, kb_load it whole, because on this package's measurements a reader holding the whole base answered eight of nine questions whose wording appears in no record where embedding search answered four; once kb_load refuses, kb_catalog for one line per record and then kb_pack on the record the work centres on; and kb_query when the question is a point lookup rather than a neighbourhood. A query cannot tell you that nothing was decided \u2014 it returns its nearest hit whatever the distance \u2014 so reach for kb_catalog when the question is what exists. Never read record files directly: this tool (with kb_load, kb_catalog, kb_pack and kb_trace) is the only supported way to read a base; a file read bypasses supersession resolution and returns replaced records as if current.",
3159
- input: import_zod19.z.object({
3935
+ input: import_zod20.z.object({
3160
3936
  bundlePath,
3161
- text: import_zod19.z.string().optional(),
3162
- type: import_zod19.z.enum(KB_RECORD_TYPES).optional(),
3163
- includeNonCurrent: import_zod19.z.boolean().optional()
3937
+ text: import_zod20.z.string().optional(),
3938
+ type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
3939
+ includeNonCurrent: import_zod20.z.boolean().optional(),
3940
+ repoRoot: REPO_ROOT
3164
3941
  }),
3165
- fromArgv: (argv, path) => ({
3166
- bundlePath: path,
3167
- text: argv.slice(1).join(" ").trim(),
3168
- includeNonCurrent: true
3169
- }),
3170
- run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
3942
+ // `--repo-root` is a flag, so its value must not fall into the search text.
3943
+ fromArgv: (argv, path) => {
3944
+ const repoRoot = argvFlag(argv, "--repo-root");
3945
+ const words = argv.slice(1);
3946
+ const flag = words.indexOf("--repo-root");
3947
+ if (flag !== -1) words.splice(flag, 2);
3948
+ return {
3949
+ bundlePath: path,
3950
+ text: words.join(" ").trim(),
3951
+ includeNonCurrent: true,
3952
+ ...repoRoot !== void 0 ? { repoRoot } : {}
3953
+ };
3954
+ },
3955
+ run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent, repoRoot }) => (await store.query(path, text ?? "", {
3171
3956
  ...type ? { type } : {},
3172
- includeNonCurrent: includeNonCurrent === true
3957
+ includeNonCurrent: includeNonCurrent === true,
3958
+ ...repoRoot !== void 0 ? { repoRoot } : {}
3173
3959
  })).map((hit) => ({
3174
3960
  conceptId: hit.record.conceptId,
3175
3961
  title: hit.record.frontmatter.title ?? null,
@@ -3182,40 +3968,40 @@ var queryCommand = define({
3182
3968
  });
3183
3969
 
3184
3970
  // src/commands/read-index.ts
3185
- var import_zod20 = require("zod");
3971
+ var import_zod21 = require("zod");
3186
3972
  var readIndexCommand = define({
3187
3973
  name: "index",
3188
3974
  tool: "kb_index",
3189
3975
  usage: "index",
3190
3976
  description: "The index, rebuilt if it disagrees with the records. One call gives the whole shape of the base: title, type, status, and description per record. The cheap re-orientation call after compaction or deep in a long session \u2014 a few hundred tokens; call it (or kb_context, when bases are pinned) first, then kb_load or fetch by concept id.",
3191
- input: import_zod20.z.object({ bundlePath }),
3977
+ input: import_zod21.z.object({ bundlePath }),
3192
3978
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3193
3979
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
3194
3980
  });
3195
3981
 
3196
3982
  // src/commands/schema.ts
3197
- var import_zod21 = require("zod");
3983
+ var import_zod22 = require("zod");
3198
3984
  var schemaCommand = define({
3199
3985
  name: "schema",
3200
3986
  tool: "kb_schema",
3201
3987
  usage: "schema",
3202
3988
  description: "JSON Schema for the frontmatter, the write input, and log entries \u2014 generated from the code that enforces them, so it cannot drift from what a write will accept.",
3203
- input: import_zod21.z.object({}),
3989
+ input: import_zod22.z.object({}),
3204
3990
  fromArgv: () => ({}),
3205
3991
  run: () => Promise.resolve(kbJsonSchemas())
3206
3992
  });
3207
3993
 
3208
3994
  // src/commands/status.ts
3209
- var import_zod22 = require("zod");
3995
+ var import_zod23 = require("zod");
3210
3996
  var statusCommand = define({
3211
3997
  name: "status",
3212
3998
  tool: "kb_status",
3213
3999
  usage: "status <concept-id> <status>",
3214
4000
  description: "Move a record's status, leaving everything else alone. Uses a compare-and-swap, so a concurrent change fails loudly rather than being overwritten.",
3215
- input: import_zod22.z.object({
4001
+ input: import_zod23.z.object({
3216
4002
  bundlePath,
3217
4003
  conceptId,
3218
- status: import_zod22.z.enum(KB_RECORD_STATUSES)
4004
+ status: import_zod23.z.enum(KB_RECORD_STATUSES)
3219
4005
  }),
3220
4006
  fromArgv: (argv, path) => ({
3221
4007
  bundlePath: path,
@@ -3230,13 +4016,13 @@ var statusCommand = define({
3230
4016
  });
3231
4017
 
3232
4018
  // src/commands/supersede.ts
3233
- var import_zod23 = require("zod");
4019
+ var import_zod24 = require("zod");
3234
4020
  var supersedeCommand = define({
3235
4021
  name: "supersede",
3236
4022
  tool: "kb_supersede",
3237
4023
  usage: "supersede <concept-id> <replacement-id>",
3238
4024
  description: "Mark a record superseded by another, linking both directions. Use this rather than editing a record whose meaning changed \u2014 a record that quietly becomes something else invalidates every reference to it, and the earlier understanding is what a later trace needs.",
3239
- input: import_zod23.z.object({ bundlePath, conceptId, replacementId: conceptId }),
4025
+ input: import_zod24.z.object({ bundlePath, conceptId, replacementId: conceptId }),
3240
4026
  fromArgv: (argv, path) => ({
3241
4027
  bundlePath: path,
3242
4028
  conceptId: argv[1],
@@ -3250,16 +4036,16 @@ var supersedeCommand = define({
3250
4036
  });
3251
4037
 
3252
4038
  // src/commands/sync-instructions.ts
3253
- var import_zod24 = require("zod");
4039
+ var import_zod25 = require("zod");
3254
4040
  var syncInstructionsCommand = define({
3255
4041
  name: "sync-instructions",
3256
4042
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
3257
4043
  description: "Idempotently plant the `context` block between sentinel comments in an instruction file (AGENTS.md, CLAUDE.md), creating the block when absent and leaving everything outside the sentinels alone. CLI-only: this is file plumbing for runtimes whose instruction files are re-read where their conversations are not, not an agent capability \u2014 the capability is kb_context.",
3258
- input: import_zod24.z.object({
3259
- file: import_zod24.z.string().min(1).describe("The instruction file to edit in place."),
3260
- budgetTokens: import_zod24.z.number().int().positive().optional(),
3261
- fullUnderTokens: import_zod24.z.number().int().positive().optional(),
3262
- profile: import_zod24.z.string().optional()
4044
+ input: import_zod25.z.object({
4045
+ file: import_zod25.z.string().min(1).describe("The instruction file to edit in place."),
4046
+ budgetTokens: import_zod25.z.number().int().positive().optional(),
4047
+ fullUnderTokens: import_zod25.z.number().int().positive().optional(),
4048
+ profile: import_zod25.z.string().optional()
3263
4049
  }),
3264
4050
  fromArgv: (argv) => {
3265
4051
  const budget = argvFlag(argv, "--budget");
@@ -3285,17 +4071,17 @@ var syncInstructionsCommand = define({
3285
4071
  });
3286
4072
 
3287
4073
  // src/commands/trace.ts
3288
- var import_zod25 = require("zod");
4074
+ var import_zod26 = require("zod");
3289
4075
  var traceCommand = define({
3290
4076
  name: "trace",
3291
4077
  tool: "kb_trace",
3292
4078
  usage: "trace <concept-id> [edges...]",
3293
4079
  description: 'How a position was arrived at, as a timeline ordered by when each record was written. Deliberately includes rejected, draft, and superseded records \u2014 in a history those are the content, not noise. Follows supersession, shared code anchors, and shared sources. Use when the question is "why is this the way it is" rather than "what do we hold now". This tool (with kb_load and kb_query) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.',
3294
- input: import_zod25.z.object({
4080
+ input: import_zod26.z.object({
3295
4081
  bundlePath,
3296
4082
  conceptId,
3297
- edges: import_zod25.z.array(import_zod25.z.enum(TRACE_EDGES)).optional(),
3298
- depth: import_zod25.z.number().int().positive().optional()
4083
+ edges: import_zod26.z.array(import_zod26.z.enum(TRACE_EDGES)).optional(),
4084
+ depth: import_zod26.z.number().int().positive().optional()
3299
4085
  }),
3300
4086
  fromArgv: (argv, path) => ({
3301
4087
  bundlePath: path,
@@ -3317,53 +4103,53 @@ var traceCommand = define({
3317
4103
  });
3318
4104
 
3319
4105
  // src/commands/types.ts
3320
- var import_zod26 = require("zod");
4106
+ var import_zod27 = require("zod");
3321
4107
  var typesCommand = define({
3322
4108
  name: "types",
3323
4109
  tool: "kb_types",
3324
4110
  usage: "types",
3325
4111
  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.",
3326
- input: import_zod26.z.object({}),
4112
+ input: import_zod27.z.object({}),
3327
4113
  fromArgv: () => ({}),
3328
4114
  run: () => Promise.resolve(RECORD_TYPES)
3329
4115
  });
3330
4116
 
3331
4117
  // src/commands/unpin.ts
3332
- var import_zod27 = require("zod");
4118
+ var import_zod28 = require("zod");
3333
4119
  var unpinCommand = define({
3334
4120
  name: "unpin",
3335
4121
  tool: "kb_unpin",
3336
4122
  usage: "unpin [bundle-path]",
3337
4123
  description: "Remove a base from every pin manifest layer that holds it \u2014 project, local, and user \u2014 because unpinned means gone, not still injected from another file. Reports which layers were touched.",
3338
- input: import_zod27.z.object({ bundlePath }),
4124
+ input: import_zod28.z.object({ bundlePath }),
3339
4125
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3340
4126
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3341
4127
  });
3342
4128
 
3343
4129
  // src/commands/validate.ts
3344
- var import_zod28 = require("zod");
4130
+ var import_zod29 = require("zod");
3345
4131
  var validateCommand = define({
3346
4132
  name: "validate",
3347
4133
  tool: "kb_validate",
3348
4134
  usage: "validate",
3349
4135
  description: "Check pointers no single record can see: supersession links that disagree between the two records, and assumptions that cite sources. Per-record shape is enforced on every read, so a problem here means someone edited a file by hand.",
3350
- input: import_zod28.z.object({ bundlePath }),
4136
+ input: import_zod29.z.object({ bundlePath }),
3351
4137
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3352
4138
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3353
4139
  failsWhen: (result) => Array.isArray(result) && result.length > 0
3354
4140
  });
3355
4141
 
3356
4142
  // src/commands/verify.ts
3357
- var import_zod29 = require("zod");
4143
+ var import_zod30 = require("zod");
3358
4144
  var verifyCommand = define({
3359
4145
  name: "verify",
3360
4146
  tool: "kb_verify",
3361
4147
  usage: "verify <concept-id> --note <text>",
3362
4148
  description: "Append one verified[] event \u2014 who checked the record, when, and what the check found. Appends only; prior events are never rewritten. A record's own generator is refused unless the actor is human: re-reading your own output is not an independent check.",
3363
- input: import_zod29.z.object({
4149
+ input: import_zod30.z.object({
3364
4150
  bundlePath,
3365
4151
  conceptId,
3366
- note: import_zod29.z.string().refine((s) => s.trim().length > 0, {
4152
+ note: import_zod30.z.string().refine((s) => s.trim().length > 0, {
3367
4153
  message: "note must say what the check found"
3368
4154
  })
3369
4155
  }),
@@ -3383,7 +4169,7 @@ var verifyCommand = define({
3383
4169
  });
3384
4170
 
3385
4171
  // src/commands/write.ts
3386
- var import_zod30 = require("zod");
4172
+ var import_zod31 = require("zod");
3387
4173
  var writeCommand = define({
3388
4174
  name: "write",
3389
4175
  tool: "kb_write",
@@ -3397,9 +4183,9 @@ var writeCommand = define({
3397
4183
  "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
3398
4184
  "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
3399
4185
  ].join("\n"),
3400
- input: import_zod30.z.object({
4186
+ input: import_zod31.z.object({
3401
4187
  bundlePath,
3402
- type: import_zod30.z.enum(KB_RECORD_TYPES),
4188
+ type: import_zod31.z.enum(KB_RECORD_TYPES),
3403
4189
  input: composeInputSchema
3404
4190
  }),
3405
4191
  fromArgv: async (argv, path, stdin) => ({
@@ -3423,7 +4209,7 @@ var writeCommand = define({
3423
4209
  });
3424
4210
 
3425
4211
  // src/commands/write-decision.ts
3426
- var import_zod31 = require("zod");
4212
+ var import_zod32 = require("zod");
3427
4213
  var writeDecisionCommand = define({
3428
4214
  name: "write-decision",
3429
4215
  tool: "kb_write_decision",
@@ -3436,7 +4222,7 @@ var writeDecisionCommand = define({
3436
4222
  "- `alternative` is what you turned down and why, not a list of everything considered.",
3437
4223
  "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
3438
4224
  ].join("\n"),
3439
- input: import_zod31.z.object({ bundlePath, input: decisionInputSchema }),
4225
+ input: import_zod32.z.object({ bundlePath, input: decisionInputSchema }),
3440
4226
  fromArgv: async (_argv, path, stdin) => ({
3441
4227
  bundlePath: path,
3442
4228
  input: JSON.parse(await stdin())
@@ -3465,6 +4251,7 @@ var KB_COMMANDS = [
3465
4251
  supersedeCommand,
3466
4252
  answerCommand,
3467
4253
  verifyCommand,
4254
+ anchorResolveCommand,
3468
4255
  loadCommand,
3469
4256
  catalogCommand,
3470
4257
  packCommand,
@@ -3492,7 +4279,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
3492
4279
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
3493
4280
 
3494
4281
  // src/version.ts
3495
- var VERSION = true ? "0.1.10" : "0.0.0-dev";
4282
+ var VERSION = true ? "0.1.11" : "0.0.0-dev";
3496
4283
 
3497
4284
  // src/mcp.ts
3498
4285
  function createKbMcpServer() {
@@ -3531,7 +4318,7 @@ async function runKbMcpServer() {
3531
4318
  }
3532
4319
 
3533
4320
  // src/cli.ts
3534
- var import_node_path7 = require("path");
4321
+ var import_node_path8 = require("path");
3535
4322
  async function runKbCli(argv) {
3536
4323
  const { flags, literal } = takeLiteral(argv);
3537
4324
  const { bundle, rest: withFlags } = takeBundle(flags);
@@ -3588,18 +4375,18 @@ function takeLiteral(argv) {
3588
4375
  function takeBundle(argv) {
3589
4376
  const at = argv.indexOf("--bundle");
3590
4377
  if (at === -1) {
3591
- return { bundle: (0, import_node_path7.join)(process.cwd(), KB_DIR), rest: argv };
4378
+ return { bundle: (0, import_node_path8.join)(process.cwd(), KB_DIR), rest: argv };
3592
4379
  }
3593
4380
  const bundle = argv[at + 1];
3594
4381
  if (!bundle) die("--bundle requires a path");
3595
4382
  return { bundle, rest: [...argv.slice(0, at), ...argv.slice(at + 2)] };
3596
4383
  }
3597
4384
  function readStdin() {
3598
- return new Promise((resolve5, reject) => {
4385
+ return new Promise((resolve6, reject) => {
3599
4386
  let text = "";
3600
4387
  process.stdin.setEncoding("utf8");
3601
4388
  process.stdin.on("data", (chunk) => text += chunk);
3602
- process.stdin.on("end", () => resolve5(text));
4389
+ process.stdin.on("end", () => resolve6(text));
3603
4390
  process.stdin.on("error", reject);
3604
4391
  });
3605
4392
  }
@@ -3678,6 +4465,7 @@ function usage() {
3678
4465
  SEARCH_INDEX_FILE,
3679
4466
  TRACE_EDGES,
3680
4467
  adjudicate,
4468
+ anchorFilePath,
3681
4469
  assertBaseNotFrozen,
3682
4470
  buildContext,
3683
4471
  catalog,
@@ -3688,8 +4476,10 @@ function usage() {
3688
4476
  contextProfileBudgets,
3689
4477
  createKbMcpServer,
3690
4478
  decisionInputSchema,
4479
+ detectAnchorDrift,
3691
4480
  doctor,
3692
4481
  edgeNeighbours,
4482
+ hashAnchorText,
3693
4483
  indexIsStale,
3694
4484
  isKbRecordType,
3695
4485
  isNoDecisionRecord,
@@ -3712,10 +4502,12 @@ function usage() {
3712
4502
  pinBase,
3713
4503
  readMergedPins,
3714
4504
  readPinsLayer,
4505
+ regexResolver,
3715
4506
  renderCatalogLine,
3716
4507
  renderIndex,
3717
4508
  renderIndexLine,
3718
4509
  renderLogEntry,
4510
+ resolveAnchor,
3719
4511
  resolveHeads,
3720
4512
  resolveHits,
3721
4513
  resolvePinPath,