@saasontools/strauss-kb 0.1.14 → 0.1.15

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
@@ -99,6 +99,7 @@ __export(index_exports, {
99
99
  impact: () => impact,
100
100
  inboundIndex: () => inboundIndex,
101
101
  indexIsStale: () => indexIsStale,
102
+ isCanonicalRepoUrl: () => isCanonicalRepoUrl,
102
103
  isKbLinkRel: () => isKbLinkRel,
103
104
  isKbRecordType: () => isKbRecordType,
104
105
  isNoDecisionRecord: () => isNoDecisionRecord,
@@ -122,11 +123,13 @@ __export(index_exports, {
122
123
  pinBase: () => pinBase,
123
124
  readMergedPins: () => readMergedPins,
124
125
  readPinsLayer: () => readPinsLayer,
126
+ readRemoteAnchors: () => readRemoteAnchors,
125
127
  regexResolver: () => regexResolver,
126
128
  renderCatalogLine: () => renderCatalogLine,
127
129
  renderIndex: () => renderIndex,
128
130
  renderIndexLine: () => renderIndexLine,
129
131
  renderLogEntry: () => renderLogEntry,
132
+ repoCacheDir: () => repoCacheDir,
130
133
  resolveAnchor: () => resolveAnchor,
131
134
  resolveHeads: () => resolveHeads,
132
135
  resolveHits: () => resolveHits,
@@ -147,8 +150,8 @@ module.exports = __toCommonJS(index_exports);
147
150
 
148
151
  // src/kb-store.ts
149
152
  var import_node_crypto2 = require("crypto");
150
- var import_promises3 = require("fs/promises");
151
- var import_node_path3 = require("path");
153
+ var import_promises4 = require("fs/promises");
154
+ var import_node_path4 = require("path");
152
155
 
153
156
  // src/concurrency.ts
154
157
  var DEFAULT_IO_CONCURRENCY = 16;
@@ -230,16 +233,17 @@ var kbAnchorSchema = import_zod.z.object({
230
233
  * (`https://github.com/org/name`) or a short name. Absent means the base's
231
234
  * own repository, which is what nearly every anchor means.
232
235
  *
233
- * Unvalidated beyond not-blank: one repository has many spellings.
234
- * Matched after normalisation; see ARCHITECTURE.
236
+ * Unvalidated beyond not-blank: one repository has many spellings, matched
237
+ * after normalisation. Only a full URL can be fetched from, so `validate`
238
+ * warns on a short one; see ARCHITECTURE.
235
239
  */
236
240
  repo: import_zod.z.string().trim().min(1).optional(),
237
241
  /**
238
242
  * The git rev the evidence was taken at. Prefer a commit SHA: a branch
239
243
  * name is a moving pointer, so an anchor pinned to one says the evidence
240
244
  * came from wherever that branch happens to be now, which is not a
241
- * baseline. Recorded and preserved in v1; ref-pinned reads land with
242
- * SAA-709.
245
+ * baseline. A foreign anchor is checked at this rev, and compared against
246
+ * the remote's default branch on top of it.
243
247
  */
244
248
  ref: import_zod.z.string().trim().min(1).optional(),
245
249
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
@@ -517,6 +521,436 @@ function indexIsStale(stored, expected) {
517
521
  return stored !== expected;
518
522
  }
519
523
 
524
+ // src/remote-repo/cache.ts
525
+ var import_node_os = require("os");
526
+ var import_node_path = require("path");
527
+
528
+ // src/anchor-resolver/repo-identity.ts
529
+ var import_node_child_process = require("child_process");
530
+ var import_node_util = require("util");
531
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
532
+ function normalizeRepoUrl(value) {
533
+ let url = value.trim().replace(/^git\+/, "");
534
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
535
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
536
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
537
+ url = trimTrailingSlashes(url);
538
+ if (url.endsWith(".git")) url = url.slice(0, -4);
539
+ return trimTrailingSlashes(url).toLowerCase();
540
+ }
541
+ function trimTrailingSlashes(value) {
542
+ let end = value.length;
543
+ while (end > 0 && value[end - 1] === "/") end -= 1;
544
+ return value.slice(0, end);
545
+ }
546
+ function isCanonicalRepoUrl(value) {
547
+ return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
548
+ }
549
+ function repoPath(normalized) {
550
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
551
+ const segments = withoutScheme.split("/").filter(Boolean);
552
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
553
+ }
554
+ function repoIdentifies(declared, originUrl) {
555
+ if (!originUrl) return false;
556
+ const origin = normalizeRepoUrl(originUrl);
557
+ const want = normalizeRepoUrl(declared);
558
+ if (!want || !origin) return false;
559
+ if (want === origin) return true;
560
+ const path = repoPath(origin);
561
+ if (!path) return false;
562
+ return want === path || want === (path.split("/").pop() ?? "");
563
+ }
564
+ async function repoOriginUrl(repoRoot) {
565
+ try {
566
+ const { stdout } = await execFileAsync(
567
+ "git",
568
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
569
+ { timeout: 5e3 }
570
+ );
571
+ return stdout.trim() || null;
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+ var LazyOrigin = class {
577
+ constructor(repoRoot) {
578
+ this.repoRoot = repoRoot;
579
+ }
580
+ repoRoot;
581
+ url = null;
582
+ asked = false;
583
+ /** Asks git once, so later `isForeign` calls need no await. */
584
+ async prime() {
585
+ if (this.asked) return;
586
+ this.url = await repoOriginUrl(this.repoRoot);
587
+ this.asked = true;
588
+ }
589
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
590
+ isForeign(anchor) {
591
+ if (!anchor.repo) return false;
592
+ return !repoIdentifies(anchor.repo, this.url);
593
+ }
594
+ async foreign(anchor) {
595
+ if (!anchor.repo) return false;
596
+ await this.prime();
597
+ return this.isForeign(anchor);
598
+ }
599
+ };
600
+
601
+ // src/remote-repo/cache.ts
602
+ function repoCacheDir(override) {
603
+ return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".strauss", "repo-cache");
604
+ }
605
+ var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
606
+ function fetchTimeoutMs(override) {
607
+ if (override !== void 0) return override;
608
+ const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
609
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
610
+ }
611
+ function cachePathFor(repo, cacheDir) {
612
+ const normalized = normalizeRepoUrl(repo);
613
+ const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
614
+ if (!scheme) return null;
615
+ const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
616
+ if (segments.length < 2 || segments.some((segment) => segment === null)) {
617
+ return null;
618
+ }
619
+ const path = segments;
620
+ return (0, import_node_path.join)(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
621
+ }
622
+ function safeSegment(value) {
623
+ return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
624
+ }
625
+ function revRef(rev) {
626
+ const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
627
+ let hash = 5381;
628
+ for (let at = 0; at < rev.length; at++) {
629
+ hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
630
+ }
631
+ return `refs/strauss/${safe}-${hash.toString(16)}`;
632
+ }
633
+
634
+ // src/remote-repo/model.ts
635
+ var UNCHECKED_REASONS = [
636
+ "remote-unreachable",
637
+ "repo-unauthorized",
638
+ "default-branch-unknown"
639
+ ];
640
+ function isUncheckedReason(reason) {
641
+ return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
642
+ }
643
+ function wantKey(repo, ref, file) {
644
+ return `${repo}\0${ref ?? ""}\0${file}`;
645
+ }
646
+
647
+ // src/remote-repo/read.ts
648
+ var import_promises = require("fs/promises");
649
+
650
+ // src/remote-repo/git.ts
651
+ var import_node_child_process2 = require("child_process");
652
+ var import_node_util2 = require("util");
653
+
654
+ // src/anchor-resolver/model.ts
655
+ var MAX_ANCHOR_FILE_BYTES = 1048576;
656
+
657
+ // src/remote-repo/git.ts
658
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
659
+ function childEnv() {
660
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
661
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
662
+ delete env[name];
663
+ }
664
+ return env;
665
+ }
666
+ async function git(args, options = {}) {
667
+ try {
668
+ const { stdout, stderr } = await execFileAsync2("git", args, {
669
+ ...options.cwd ? { cwd: options.cwd } : {},
670
+ timeout: options.timeoutMs ?? 3e4,
671
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
672
+ encoding: "utf8",
673
+ windowsHide: true,
674
+ env: childEnv()
675
+ });
676
+ return { ok: true, stdout, stderr, overflowed: false };
677
+ } catch (error) {
678
+ const failure = error;
679
+ return {
680
+ ok: false,
681
+ stdout: failure.stdout ?? "",
682
+ stderr: failure.stderr ?? "",
683
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
684
+ };
685
+ }
686
+ }
687
+ function transportReason(stderr) {
688
+ const text = stderr.toLowerCase();
689
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
690
+ return "repo-unauthorized";
691
+ }
692
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
693
+ return "ref-not-found";
694
+ }
695
+ return "remote-unreachable";
696
+ }
697
+
698
+ // src/remote-repo/validate.ts
699
+ var MAX_REF_LENGTH = 200;
700
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
701
+ function refShapeIsSafe(ref) {
702
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
703
+ if (ref.includes("..")) return false;
704
+ return REF_SHAPE.test(ref);
705
+ }
706
+ async function refIsWellFormed(ref) {
707
+ if (!refShapeIsSafe(ref)) return false;
708
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
709
+ return checked.ok;
710
+ }
711
+ function filePathIsSafe(file) {
712
+ const path = file.replace(/^\.\//, "");
713
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
714
+ return !path.split("/").includes("..");
715
+ }
716
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
717
+ function allowedProtocols() {
718
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
719
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
720
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
721
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
722
+ }
723
+ function isShortRepoName(repo) {
724
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
725
+ }
726
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
727
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
728
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
729
+ function repoUrlIsSafe(repo) {
730
+ const url = repo.trim();
731
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
732
+ const allowed = allowedProtocols();
733
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
734
+ const scheme = URL_SCHEME.exec(url);
735
+ if (!scheme?.[1]) return false;
736
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
737
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
738
+ const at = authority.lastIndexOf("@");
739
+ return at < 0 || !authority.slice(0, at).includes(":");
740
+ }
741
+ function protocolArgs() {
742
+ const allowed = allowedProtocols();
743
+ return [
744
+ "-c",
745
+ "protocol.ext.allow=never",
746
+ "-c",
747
+ `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
748
+ ];
749
+ }
750
+
751
+ // src/remote-repo/read.ts
752
+ var DEFAULT_REPO_CONCURRENCY = 4;
753
+ var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
754
+ async function readRemoteAnchors(wants, options = {}) {
755
+ const out = /* @__PURE__ */ new Map();
756
+ if (!wants.length) return out;
757
+ const cacheDir = repoCacheDir(options.cacheDir);
758
+ const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
759
+ const byRepo = /* @__PURE__ */ new Map();
760
+ for (const want of wants) {
761
+ const key2 = normalizeRepoUrl(want.repo);
762
+ const group2 = byRepo.get(key2) ?? { url: want.repo.trim(), wants: [] };
763
+ group2.wants.push(want);
764
+ byRepo.set(key2, group2);
765
+ }
766
+ const groups = [...byRepo.entries()];
767
+ const results = await mapLimit(
768
+ groups,
769
+ Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
770
+ ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
771
+ cacheDir,
772
+ timeoutMs,
773
+ offline: options.offline === true
774
+ })
775
+ );
776
+ for (const result of results) {
777
+ for (const [key2, read] of result) out.set(key2, read);
778
+ }
779
+ return out;
780
+ }
781
+ async function readOneRepo(repo, url, declared, context) {
782
+ let wants = declared;
783
+ const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
784
+ if (isShortRepoName(url)) {
785
+ return all({ ok: false, reason: "remote-unreachable" });
786
+ }
787
+ if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
788
+ const cache = cachePathFor(repo, context.cacheDir);
789
+ if (!cache) return all({ ok: false, reason: "remote-unreachable" });
790
+ const rejected = /* @__PURE__ */ new Map();
791
+ const usable = [];
792
+ for (const want of wants) {
793
+ const reason = wantReason(want);
794
+ if (reason)
795
+ rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
796
+ else usable.push(want);
797
+ }
798
+ if (!usable.length) return rejected;
799
+ wants = usable;
800
+ const opened = await openCache(cache, url, context);
801
+ if (opened) return new Map([...rejected, ...all(opened)]);
802
+ const wantsDefault = wants.some((want) => want.ref === void 0);
803
+ const branch = wantsDefault ? await defaultBranch(cache, context) : {};
804
+ const revs = /* @__PURE__ */ new Map();
805
+ for (const rev of distinctRevs(wants, branch.name)) {
806
+ revs.set(
807
+ rev,
808
+ await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
809
+ );
810
+ }
811
+ const reads = await mapLimit(
812
+ wants,
813
+ DEFAULT_IO_CONCURRENCY,
814
+ async (want) => {
815
+ const rev = want.ref ?? branch.name;
816
+ if (rev === void 0) {
817
+ return {
818
+ ok: false,
819
+ reason: branch.reason ?? "default-branch-unknown"
820
+ };
821
+ }
822
+ const failed = revs.get(rev);
823
+ if (failed) return failed;
824
+ return readBlob(cache, rev, want.file, context);
825
+ }
826
+ );
827
+ return new Map([
828
+ ...rejected,
829
+ ...wants.map(
830
+ (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
831
+ )
832
+ ]);
833
+ }
834
+ function wantReason(want) {
835
+ if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
836
+ return filePathIsSafe(want.file) ? void 0 : "outside-repo";
837
+ }
838
+ function distinctRevs(wants, branch) {
839
+ const revs = /* @__PURE__ */ new Set();
840
+ for (const want of wants) {
841
+ if (want.ref !== void 0) revs.add(want.ref);
842
+ else if (branch) revs.add(branch);
843
+ }
844
+ return [...revs];
845
+ }
846
+ async function openCache(cache, url, context) {
847
+ try {
848
+ await (0, import_promises.mkdir)(cache, { recursive: true });
849
+ } catch {
850
+ return { ok: false, reason: "remote-unreachable" };
851
+ }
852
+ const init = await git(["init", "--bare", "--quiet", cache], {
853
+ timeoutMs: context.timeoutMs
854
+ });
855
+ if (!init.ok) return { ok: false, reason: "remote-unreachable" };
856
+ const remote = await git(["config", "remote.origin.url", url], {
857
+ cwd: cache,
858
+ timeoutMs: context.timeoutMs
859
+ });
860
+ return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
861
+ }
862
+ async function defaultBranch(cache, context) {
863
+ if (!context.offline) {
864
+ const listed = await git(
865
+ [...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
866
+ {
867
+ cwd: cache,
868
+ timeoutMs: context.timeoutMs
869
+ }
870
+ );
871
+ const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
872
+ if (listed.ok && found?.[1]) {
873
+ const name = found[1];
874
+ await git(["config", "strauss.defaultBranch", name], { cwd: cache });
875
+ return { name };
876
+ }
877
+ if (!listed.ok) {
878
+ const reason = transportReason(listed.stderr);
879
+ if (reason !== "ref-not-found") {
880
+ const cached2 = await cachedBranch(cache);
881
+ return cached2 ? { name: cached2 } : { reason };
882
+ }
883
+ }
884
+ }
885
+ const cached = await cachedBranch(cache);
886
+ if (cached) return { name: cached };
887
+ return {
888
+ reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
889
+ };
890
+ }
891
+ async function cachedBranch(cache) {
892
+ const stored = await git(["config", "--get", "strauss.defaultBranch"], {
893
+ cwd: cache
894
+ });
895
+ if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
896
+ const head = await git(
897
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
898
+ {
899
+ cwd: cache
900
+ }
901
+ );
902
+ const name = head.stdout.trim().replace(/^origin\//, "");
903
+ return head.ok && name ? name : void 0;
904
+ }
905
+ async function ensureRev(cache, rev, context) {
906
+ const ref = revRef(rev);
907
+ const have = await git(
908
+ ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
909
+ {
910
+ cwd: cache
911
+ }
912
+ );
913
+ const cached = have.ok && have.stdout.trim().length > 0;
914
+ if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
915
+ if (context.offline) return { ok: false, reason: "remote-unreachable" };
916
+ const fetched = await git(
917
+ [
918
+ ...protocolArgs(),
919
+ "fetch",
920
+ "--depth",
921
+ "1",
922
+ "origin",
923
+ "--end-of-options",
924
+ rev
925
+ ],
926
+ { cwd: cache, timeoutMs: context.timeoutMs }
927
+ );
928
+ if (!fetched.ok) {
929
+ const reason = transportReason(fetched.stderr);
930
+ if (cached && reason !== "ref-not-found") return void 0;
931
+ return { ok: false, reason };
932
+ }
933
+ const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
934
+ const sha = head.stdout.trim();
935
+ if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
936
+ const updated = await git(["update-ref", ref, sha], { cwd: cache });
937
+ return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
938
+ }
939
+ async function readBlob(cache, rev, file, context) {
940
+ const path = file.replace(/^\.\//, "");
941
+ const blob = await git(
942
+ ["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
943
+ {
944
+ cwd: cache,
945
+ timeoutMs: context.timeoutMs
946
+ }
947
+ );
948
+ if (blob.ok) return { ok: true, source: blob.stdout };
949
+ if (blob.overflowed) return { ok: false, reason: "file-too-large" };
950
+ const text = blob.stderr.toLowerCase();
951
+ return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
952
+ }
953
+
520
954
  // src/adjudicate.ts
521
955
  var STANDING = {
522
956
  accepted: "current",
@@ -557,23 +991,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
557
991
  if (!record.frontmatter.verified?.length) {
558
992
  warnings.push({ kind: "unverified" });
559
993
  }
560
- const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
561
- (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
994
+ const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
995
+ (entry) => entry.state !== "match"
562
996
  );
997
+ const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
998
+ const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
563
999
  if (moved.length) {
1000
+ warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
1001
+ }
1002
+ if (unchecked2.length) {
564
1003
  warnings.push({
565
- kind: "drifted",
566
- anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
567
- file,
568
- ...symbol !== void 0 ? { symbol } : {},
569
- diffSize,
570
- ...reason !== void 0 ? { reason } : {}
571
- }))
1004
+ kind: "unchecked",
1005
+ anchors: unchecked2.map(warningAnchor)
572
1006
  });
573
1007
  }
574
1008
  return { record, standing: STANDING[status], heads, warnings };
575
1009
  });
576
1010
  }
1011
+ function warningAnchor(entry) {
1012
+ const { file, symbol, diffSize, reason, repo, remoteState } = entry;
1013
+ return {
1014
+ file,
1015
+ ...symbol !== void 0 ? { symbol } : {},
1016
+ diffSize,
1017
+ ...reason !== void 0 ? { reason } : {},
1018
+ ...repo !== void 0 ? { repo } : {},
1019
+ ...remoteState !== void 0 ? { remoteState } : {}
1020
+ };
1021
+ }
577
1022
  function resolveHeads(from, byId) {
578
1023
  const warnings = [];
579
1024
  const heads = /* @__PURE__ */ new Map();
@@ -623,14 +1068,98 @@ function successors(record, byId) {
623
1068
  return { records, missing };
624
1069
  }
625
1070
 
626
- // src/anchor-resolver.ts
627
- var import_node_child_process = require("child_process");
1071
+ // src/anchor-resolver/read.ts
1072
+ var import_promises2 = require("fs/promises");
1073
+ var import_node_path2 = require("path");
1074
+ function anchorFilePath(repoRoot, file) {
1075
+ const path = (0, import_node_path2.resolve)(repoRoot, file.replace(/^\.\//, ""));
1076
+ const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(repoRoot), path);
1077
+ if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path2.sep}`) || (0, import_node_path2.isAbsolute)(rel)) {
1078
+ return null;
1079
+ }
1080
+ return path;
1081
+ }
1082
+ function contains(root, path) {
1083
+ const rel = (0, import_node_path2.relative)(root, path);
1084
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`) && !(0, import_node_path2.isAbsolute)(rel);
1085
+ }
1086
+ function errorCode(error) {
1087
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
1088
+ }
1089
+ function anchorFileReader(repoRoot) {
1090
+ let rootOnce;
1091
+ const realRoot = () => {
1092
+ rootOnce ??= (0, import_promises2.realpath)((0, import_node_path2.resolve)(repoRoot)).catch((error) => {
1093
+ rootOnce = void 0;
1094
+ throw error;
1095
+ });
1096
+ return rootOnce;
1097
+ };
1098
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
1099
+ }
1100
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
1101
+ const lexical = anchorFilePath(repoRoot, file);
1102
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
1103
+ let root;
1104
+ let path;
1105
+ try {
1106
+ root = await realRoot();
1107
+ path = await (0, import_promises2.realpath)(lexical);
1108
+ } catch (error) {
1109
+ const code = errorCode(error);
1110
+ if (code === "ENOENT" || code === "ENOTDIR") {
1111
+ return { ok: false, reason: "file-missing" };
1112
+ }
1113
+ return { ok: false, reason: "file-unreadable" };
1114
+ }
1115
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
1116
+ try {
1117
+ const stats = await (0, import_promises2.stat)(path);
1118
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
1119
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
1120
+ return { ok: false, reason: "file-too-large" };
1121
+ }
1122
+ return { ok: true, source: await (0, import_promises2.readFile)(path, "utf8") };
1123
+ } catch (error) {
1124
+ const code = errorCode(error);
1125
+ if (code === "ENOENT" || code === "ENOTDIR") {
1126
+ return { ok: false, reason: "file-missing" };
1127
+ }
1128
+ return { ok: false, reason: "file-unreadable" };
1129
+ }
1130
+ }
1131
+ function looksLikeWrongRepoRoot(drift) {
1132
+ let checked = 0;
1133
+ for (const entries of drift.values()) {
1134
+ for (const entry of entries) {
1135
+ if (entry.repo !== void 0) continue;
1136
+ checked += 1;
1137
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
1138
+ return false;
1139
+ }
1140
+ }
1141
+ }
1142
+ return checked > 0;
1143
+ }
1144
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
1145
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
1146
+ throw new RangeError(
1147
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
1148
+ );
1149
+ }
1150
+ const wanted = [...new Set(files)];
1151
+ const results = await mapLimit(wanted, concurrency, async (file) => {
1152
+ try {
1153
+ return await read(file);
1154
+ } catch {
1155
+ return { ok: false, reason: "file-unreadable" };
1156
+ }
1157
+ });
1158
+ return new Map(wanted.map((file, at) => [file, results[at]]));
1159
+ }
1160
+
1161
+ // src/anchor-resolver/resolver.ts
628
1162
  var import_node_crypto = require("crypto");
629
- var import_promises = require("fs/promises");
630
- var import_node_path = require("path");
631
- var import_node_util = require("util");
632
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
633
- var MAX_ANCHOR_FILE_BYTES = 1048576;
634
1163
  var PARENT_SCOPE_LINES = 50;
635
1164
  var CLEAN_STATE = { blockComment: false, template: false };
636
1165
  function stripLine(line, state) {
@@ -806,157 +1335,8 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
806
1335
  }
807
1336
  return resolver.resolve(normalized, anchor.symbol);
808
1337
  }
809
- function anchorFilePath(repoRoot, file) {
810
- const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
811
- const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
812
- if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
813
- return null;
814
- }
815
- return path;
816
- }
817
- function contains(root, path) {
818
- const rel = (0, import_node_path.relative)(root, path);
819
- return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
820
- }
821
- function normalizeRepoUrl(value) {
822
- let url = value.trim().replace(/^git\+/, "");
823
- const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
824
- if (scp) url = `https://${scp[1]}/${scp[2]}`;
825
- url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
826
- url = trimTrailingSlashes(url);
827
- if (url.endsWith(".git")) url = url.slice(0, -4);
828
- return trimTrailingSlashes(url).toLowerCase();
829
- }
830
- function trimTrailingSlashes(value) {
831
- let end = value.length;
832
- while (end > 0 && value[end - 1] === "/") end -= 1;
833
- return value.slice(0, end);
834
- }
835
- function repoPath(normalized) {
836
- const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
837
- const segments = withoutScheme.split("/").filter(Boolean);
838
- return segments.length > 1 ? segments.slice(1).join("/") : "";
839
- }
840
- function repoIdentifies(declared, originUrl) {
841
- if (!originUrl) return false;
842
- const origin = normalizeRepoUrl(originUrl);
843
- const want = normalizeRepoUrl(declared);
844
- if (!want || !origin) return false;
845
- if (want === origin) return true;
846
- const path = repoPath(origin);
847
- if (!path) return false;
848
- return want === path || want === (path.split("/").pop() ?? "");
849
- }
850
- async function repoOriginUrl(repoRoot) {
851
- try {
852
- const { stdout } = await execFileAsync(
853
- "git",
854
- ["-C", repoRoot, "config", "--get", "remote.origin.url"],
855
- { timeout: 5e3 }
856
- );
857
- return stdout.trim() || null;
858
- } catch {
859
- return null;
860
- }
861
- }
862
- var LazyOrigin = class {
863
- constructor(repoRoot) {
864
- this.repoRoot = repoRoot;
865
- }
866
- repoRoot;
867
- url = null;
868
- asked = false;
869
- /** Asks git once, so later `isForeign` calls need no await. */
870
- async prime() {
871
- if (this.asked) return;
872
- this.url = await repoOriginUrl(this.repoRoot);
873
- this.asked = true;
874
- }
875
- /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
876
- isForeign(anchor) {
877
- if (!anchor.repo) return false;
878
- return !repoIdentifies(anchor.repo, this.url);
879
- }
880
- async foreign(anchor) {
881
- if (!anchor.repo) return false;
882
- await this.prime();
883
- return this.isForeign(anchor);
884
- }
885
- };
886
- function errorCode(error) {
887
- return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
888
- }
889
- function anchorFileReader(repoRoot) {
890
- let rootOnce;
891
- const realRoot = () => {
892
- rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
893
- rootOnce = void 0;
894
- throw error;
895
- });
896
- return rootOnce;
897
- };
898
- return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
899
- }
900
- async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
901
- const lexical = anchorFilePath(repoRoot, file);
902
- if (lexical === null) return { ok: false, reason: "outside-repo" };
903
- let root;
904
- let path;
905
- try {
906
- root = await realRoot();
907
- path = await (0, import_promises.realpath)(lexical);
908
- } catch (error) {
909
- const code = errorCode(error);
910
- if (code === "ENOENT" || code === "ENOTDIR") {
911
- return { ok: false, reason: "file-missing" };
912
- }
913
- return { ok: false, reason: "file-unreadable" };
914
- }
915
- if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
916
- try {
917
- const stats = await (0, import_promises.stat)(path);
918
- if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
919
- if (stats.size > MAX_ANCHOR_FILE_BYTES) {
920
- return { ok: false, reason: "file-too-large" };
921
- }
922
- return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
923
- } catch (error) {
924
- const code = errorCode(error);
925
- if (code === "ENOENT" || code === "ENOTDIR") {
926
- return { ok: false, reason: "file-missing" };
927
- }
928
- return { ok: false, reason: "file-unreadable" };
929
- }
930
- }
931
- function looksLikeWrongRepoRoot(drift) {
932
- let checked = 0;
933
- for (const entries of drift.values()) {
934
- for (const entry of entries) {
935
- if (entry.reason === "foreign-repo") continue;
936
- checked += 1;
937
- if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
938
- return false;
939
- }
940
- }
941
- }
942
- return checked > 0;
943
- }
944
- async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
945
- if (!Number.isInteger(concurrency) || concurrency < 1) {
946
- throw new RangeError(
947
- `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
948
- );
949
- }
950
- const wanted = [...new Set(files)];
951
- const results = await mapLimit(wanted, concurrency, async (file) => {
952
- try {
953
- return await read(file);
954
- } catch {
955
- return { ok: false, reason: "file-unreadable" };
956
- }
957
- });
958
- return new Map(wanted.map((file, at) => [file, results[at]]));
959
- }
1338
+
1339
+ // src/anchor-resolver/drift.ts
960
1340
  async function detectAnchorDrift(records, options = {}) {
961
1341
  const repoRoot = options.repoRoot ?? process.cwd();
962
1342
  const resolver = options.resolver ?? regexResolver;
@@ -982,71 +1362,101 @@ async function detectAnchorDrift(records, options = {}) {
982
1362
  }
983
1363
  }
984
1364
  const files = [];
1365
+ const wants = [];
985
1366
  for (const entries of planned.values()) {
986
- for (const entry of entries) {
987
- if (!entry.foreign) files.push(entry.anchor.file);
1367
+ for (const { anchor, foreign } of entries) {
1368
+ if (!foreign) files.push(anchor.file);
1369
+ else wants.push(...remoteWants(anchor));
988
1370
  }
989
1371
  }
990
- const reads = await readAnchorFiles(
991
- files,
992
- options.reader ?? anchorFileReader(repoRoot),
993
- options.concurrency ?? DEFAULT_IO_CONCURRENCY
994
- );
1372
+ const [reads, remote] = await Promise.all([
1373
+ readAnchorFiles(
1374
+ files,
1375
+ options.reader ?? anchorFileReader(repoRoot),
1376
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
1377
+ ),
1378
+ (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1379
+ ]);
995
1380
  const drift = /* @__PURE__ */ new Map();
996
1381
  for (const record of records) {
997
1382
  const entries = [];
998
1383
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
999
- const base = {
1000
- file: anchor.file,
1001
- ...anchor.symbol ? { symbol: anchor.symbol } : {},
1002
- storedHash: anchor.hash
1003
- };
1004
- if (foreign) {
1005
- entries.push({
1006
- ...base,
1007
- state: "unresolved",
1008
- diffSize: null,
1009
- reason: "foreign-repo"
1010
- });
1011
- continue;
1012
- }
1013
- const read = reads.get(anchor.file);
1014
- if (!read.ok) {
1015
- entries.push({
1016
- ...base,
1017
- state: "unresolved",
1018
- diffSize: null,
1019
- reason: read.reason
1020
- });
1021
- continue;
1022
- }
1023
- const resolved = resolveAnchor(read.source, anchor, resolver);
1024
- if (!resolved) {
1025
- entries.push({
1026
- ...base,
1027
- state: "unresolved",
1028
- diffSize: null,
1029
- reason: "symbol-not-found"
1030
- });
1031
- continue;
1032
- }
1033
- const currentHash = hashAnchorText(resolved.text);
1034
- const currentLines = resolved.endLine - resolved.startLine + 1;
1035
- entries.push({
1036
- ...base,
1037
- state: currentHash === anchor.hash ? "match" : "drifted",
1038
- currentHash,
1039
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
1040
- });
1384
+ entries.push(
1385
+ foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1386
+ );
1041
1387
  }
1042
1388
  if (entries.length) drift.set(record.conceptId, entries);
1043
1389
  }
1044
1390
  return drift;
1045
1391
  }
1392
+ function remoteWants(anchor) {
1393
+ const repo = anchor.repo;
1394
+ const wants = [{ repo, file: anchor.file }];
1395
+ if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
1396
+ return wants;
1397
+ }
1398
+ function base(anchor) {
1399
+ return {
1400
+ file: anchor.file,
1401
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1402
+ storedHash: anchor.hash
1403
+ };
1404
+ }
1405
+ function unresolved(anchor, reason, repo) {
1406
+ return {
1407
+ ...base(anchor),
1408
+ state: "unresolved",
1409
+ diffSize: null,
1410
+ ...reason ? { reason } : {},
1411
+ ...repo ? { repo } : {}
1412
+ };
1413
+ }
1414
+ function hashIn(source, anchor, resolver) {
1415
+ const resolved = resolveAnchor(source, anchor, resolver);
1416
+ if (!resolved) return null;
1417
+ return {
1418
+ hash: hashAnchorText(resolved.text),
1419
+ lines: resolved.endLine - resolved.startLine + 1
1420
+ };
1421
+ }
1422
+ function compared(anchor, current, extra = {}) {
1423
+ return {
1424
+ ...base(anchor),
1425
+ state: current.hash === anchor.hash ? "match" : "drifted",
1426
+ currentHash: current.hash,
1427
+ diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1428
+ ...extra
1429
+ };
1430
+ }
1431
+ function localEntry(anchor, read, resolver) {
1432
+ if (!read.ok) return unresolved(anchor, read.reason);
1433
+ const current = hashIn(read.source, anchor, resolver);
1434
+ return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1435
+ }
1436
+ function remoteEntry(anchor, remote, resolver) {
1437
+ const repo = anchor.repo;
1438
+ const key2 = normalizeRepoUrl(repo);
1439
+ const atDefault = remote.get(wantKey(key2, void 0, anchor.file));
1440
+ const primary = anchor.ref ? remote.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
1441
+ if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1442
+ if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1443
+ const current = hashIn(primary.source, anchor, resolver);
1444
+ if (!current) return unresolved(anchor, "symbol-not-found", repo);
1445
+ if (!anchor.ref) return compared(anchor, current, { repo });
1446
+ if (current.hash !== anchor.hash) {
1447
+ return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1448
+ }
1449
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1450
+ return head && head.hash !== anchor.hash ? {
1451
+ ...compared(anchor, head, { repo }),
1452
+ state: "drifted",
1453
+ remoteState: "drifted-on-default"
1454
+ } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1455
+ }
1046
1456
 
1047
1457
  // src/search-index.ts
1048
- var import_promises2 = require("fs/promises");
1049
- var import_node_path2 = require("path");
1458
+ var import_promises3 = require("fs/promises");
1459
+ var import_node_path3 = require("path");
1050
1460
 
1051
1461
  // src/kb-log.ts
1052
1462
  var import_zod2 = require("zod");
@@ -1109,7 +1519,7 @@ async function searchBase(bundlePath2, query, options = {}) {
1109
1519
  let store = null;
1110
1520
  try {
1111
1521
  store = await qmd.createStore({
1112
- dbPath: (0, import_node_path2.join)(bundlePath2, SEARCH_INDEX_FILE),
1522
+ dbPath: (0, import_node_path3.join)(bundlePath2, SEARCH_INDEX_FILE),
1113
1523
  config: {
1114
1524
  collections: {
1115
1525
  [COLLECTION]: {
@@ -1144,7 +1554,7 @@ async function searchBase(bundlePath2, query, options = {}) {
1144
1554
  }
1145
1555
  }
1146
1556
  async function isStale(bundlePath2) {
1147
- const indexAt = await (0, import_promises2.stat)((0, import_node_path2.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
1557
+ const indexAt = await (0, import_promises3.stat)((0, import_node_path3.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
1148
1558
  if (!indexAt) return true;
1149
1559
  const { readdir: readdir2 } = await import("fs/promises");
1150
1560
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -1153,7 +1563,7 @@ async function isStale(bundlePath2) {
1153
1563
  let stale = false;
1154
1564
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
1155
1565
  if (stale) return;
1156
- const at = await (0, import_promises2.stat)((0, import_node_path2.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
1566
+ const at = await (0, import_promises3.stat)((0, import_node_path3.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
1157
1567
  if (at > indexAt) stale = true;
1158
1568
  });
1159
1569
  return stale;
@@ -1743,7 +2153,7 @@ function appendUnionMergeLine(contents) {
1743
2153
  }
1744
2154
 
1745
2155
  // src/kb-store.ts
1746
- var KB_DIR = (0, import_node_path3.join)(".strauss", "kb");
2156
+ var KB_DIR = (0, import_node_path4.join)(".strauss", "kb");
1747
2157
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
1748
2158
  var DEFAULT_LOAD_BUDGET = 25e3;
1749
2159
  var KbStore = class {
@@ -1774,7 +2184,7 @@ var KbStore = class {
1774
2184
  const conceptId2 = `${input.type}.${input.slug}`;
1775
2185
  const root = this.root(bundlePath2);
1776
2186
  const target = this.recordPath(bundlePath2, conceptId2);
1777
- await (0, import_promises3.mkdir)(root, { recursive: true });
2187
+ await (0, import_promises4.mkdir)(root, { recursive: true });
1778
2188
  await this.publish(
1779
2189
  target,
1780
2190
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -1813,7 +2223,7 @@ var KbStore = class {
1813
2223
  const target = this.recordPath(bundlePath2, conceptId2);
1814
2224
  let raw;
1815
2225
  try {
1816
- raw = await (0, import_promises3.readFile)(target, "utf8");
2226
+ raw = await (0, import_promises4.readFile)(target, "utf8");
1817
2227
  } catch {
1818
2228
  return null;
1819
2229
  }
@@ -1830,7 +2240,7 @@ var KbStore = class {
1830
2240
  const root = this.root(bundlePath2);
1831
2241
  let names;
1832
2242
  try {
1833
- names = await (0, import_promises3.readdir)(root);
2243
+ names = await (0, import_promises4.readdir)(root);
1834
2244
  } catch {
1835
2245
  return [];
1836
2246
  }
@@ -1838,7 +2248,7 @@ var KbStore = class {
1838
2248
  const records = await mapLimit(
1839
2249
  wanted,
1840
2250
  DEFAULT_IO_CONCURRENCY,
1841
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises3.readFile)((0, import_node_path3.join)(root, name), "utf8"))
2251
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises4.readFile)((0, import_node_path4.join)(root, name), "utf8"))
1842
2252
  );
1843
2253
  return records.filter((record) => record !== null);
1844
2254
  }
@@ -2006,7 +2416,8 @@ ${answer}
2006
2416
  * at the repo root, and the MCP server's cwd is the workspace.
2007
2417
  *
2008
2418
  * Public because `doctor` needs the same map with the same degradation: a
2009
- * sweep that failed to read the tree should report no drift, not fail.
2419
+ * sweep that failed to read the tree should report no drift, not fail — and
2420
+ * `offline: false` there, because a sweep is worth a fetch.
2010
2421
  *
2011
2422
  * When no root was given and not one anchored file was found, the finding is
2012
2423
  * discarded. A base read from somewhere other than the tree it describes
@@ -2018,10 +2429,13 @@ ${answer}
2018
2429
  * plausible, and the misses become findings again; an explicit `repoRoot` is
2019
2430
  * taken at its word either way.
2020
2431
  */
2021
- async detectDrift(records, repoRoot) {
2432
+ async detectDrift(records, repoRoot, options = {}) {
2022
2433
  try {
2023
2434
  const drift = await detectAnchorDrift(records, {
2024
- repoRoot: repoRoot ?? process.cwd()
2435
+ repoRoot: repoRoot ?? process.cwd(),
2436
+ // Offline by default: a read path must never spend a network fetch per
2437
+ // call. `doctor` and `anchor-resolve` are the verbs that go get it.
2438
+ remote: { offline: options.offline !== false }
2025
2439
  });
2026
2440
  if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
2027
2441
  this.logger.warn?.({
@@ -2139,11 +2553,11 @@ ${answer}
2139
2553
  async readIndex(bundlePath2) {
2140
2554
  const root = this.root(bundlePath2);
2141
2555
  const expected = renderIndex(await this.list(bundlePath2));
2142
- const stored = await (0, import_promises3.readFile)((0, import_node_path3.join)(root, INDEX_FILE), "utf8").catch(
2556
+ const stored = await (0, import_promises4.readFile)((0, import_node_path4.join)(root, INDEX_FILE), "utf8").catch(
2143
2557
  () => null
2144
2558
  );
2145
2559
  if (indexIsStale(stored, expected)) {
2146
- await this.publish((0, import_node_path3.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
2560
+ await this.publish((0, import_node_path4.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
2147
2561
  this.logger.info?.({
2148
2562
  operation: "kb.index.repair",
2149
2563
  bundlePath: root,
@@ -2160,8 +2574,8 @@ ${answer}
2160
2574
  * knows which agent touched what. So a bad line is surfaced and left alone.
2161
2575
  */
2162
2576
  async readLog(bundlePath2) {
2163
- const raw = await (0, import_promises3.readFile)(
2164
- (0, import_node_path3.join)(this.root(bundlePath2), LOG_FILE),
2577
+ const raw = await (0, import_promises4.readFile)(
2578
+ (0, import_node_path4.join)(this.root(bundlePath2), LOG_FILE),
2165
2579
  "utf8"
2166
2580
  ).catch(() => "");
2167
2581
  const result = parseLog(raw);
@@ -2212,14 +2626,14 @@ ${answer}
2212
2626
  }
2213
2627
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
2214
2628
  const target = this.recordPath(bundlePath2, conceptId2);
2215
- const before = await (0, import_promises3.readFile)(target, "utf8").catch(() => null);
2629
+ const before = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
2216
2630
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
2217
2631
  const parsed = this.parse(conceptId2, before);
2218
2632
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
2219
2633
  const frontmatter = change(parsed.frontmatter);
2220
2634
  const body = changeBody(parsed.body);
2221
2635
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
2222
- const witness = await (0, import_promises3.readFile)(target, "utf8").catch(() => null);
2636
+ const witness = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
2223
2637
  if (witness === null || digest(witness) !== digest(before)) {
2224
2638
  throw new KbWriteConflictError(conceptId2);
2225
2639
  }
@@ -2245,20 +2659,20 @@ ${answer}
2245
2659
  */
2246
2660
  async publish(target, contents, overwrite, conceptId2) {
2247
2661
  const staging = `${target}.${process.pid}.tmp`;
2248
- await (0, import_promises3.writeFile)(staging, contents, "utf8");
2662
+ await (0, import_promises4.writeFile)(staging, contents, "utf8");
2249
2663
  try {
2250
2664
  if (overwrite) {
2251
- await (0, import_promises3.rename)(staging, target);
2665
+ await (0, import_promises4.rename)(staging, target);
2252
2666
  return;
2253
2667
  }
2254
- await (0, import_promises3.link)(staging, target);
2668
+ await (0, import_promises4.link)(staging, target);
2255
2669
  } catch (error) {
2256
2670
  if (error.code === "EEXIST") {
2257
2671
  throw new KbRecordAlreadyExistsError(conceptId2);
2258
2672
  }
2259
2673
  throw error;
2260
2674
  } finally {
2261
- await (0, import_promises3.unlink)(staging).catch(() => void 0);
2675
+ await (0, import_promises4.unlink)(staging).catch(() => void 0);
2262
2676
  }
2263
2677
  }
2264
2678
  /**
@@ -2302,18 +2716,18 @@ ${answer}
2302
2716
  * file must not fail the mutation it guards.
2303
2717
  */
2304
2718
  async ensureGitattributes(root) {
2305
- const target = (0, import_node_path3.join)(root, GITATTRIBUTES_FILE);
2719
+ const target = (0, import_node_path4.join)(root, GITATTRIBUTES_FILE);
2306
2720
  try {
2307
2721
  let existing;
2308
2722
  try {
2309
- existing = await (0, import_promises3.readFile)(target, "utf8");
2723
+ existing = await (0, import_promises4.readFile)(target, "utf8");
2310
2724
  } catch (error) {
2311
2725
  if (error.code !== "ENOENT") throw error;
2312
2726
  existing = null;
2313
2727
  }
2314
2728
  if (existing === null) {
2315
2729
  try {
2316
- await (0, import_promises3.writeFile)(target, appendUnionMergeLine(""), {
2730
+ await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
2317
2731
  encoding: "utf8",
2318
2732
  flag: "wx"
2319
2733
  });
@@ -2334,7 +2748,7 @@ ${answer}
2334
2748
  return;
2335
2749
  }
2336
2750
  if (!hasMergeDeclaration(existing)) {
2337
- await (0, import_promises3.appendFile)(target, appendUnionMergeLine(existing), "utf8");
2751
+ await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
2338
2752
  this.logger.info?.({
2339
2753
  operation: "kb.gitattributes.ensure",
2340
2754
  bundlePath: root,
@@ -2353,7 +2767,7 @@ ${answer}
2353
2767
  async record(root, entry) {
2354
2768
  await this.ensureGitattributes(root);
2355
2769
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
2356
- await (0, import_promises3.appendFile)((0, import_node_path3.join)(root, LOG_FILE), line, "utf8").catch((error) => {
2770
+ await (0, import_promises4.appendFile)((0, import_node_path4.join)(root, LOG_FILE), line, "utf8").catch((error) => {
2357
2771
  this.logger.warn?.({
2358
2772
  operation: "kb.log.append",
2359
2773
  outcome: "failed",
@@ -2379,18 +2793,18 @@ ${answer}
2379
2793
  };
2380
2794
  }
2381
2795
  root(bundlePath2) {
2382
- return (0, import_node_path3.resolve)(bundlePath2);
2796
+ return (0, import_node_path4.resolve)(bundlePath2);
2383
2797
  }
2384
2798
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
2385
2799
  // bundle root; anything carrying a separator would escape it.
2386
2800
  recordPath(bundlePath2, conceptId2) {
2387
- if (conceptId2.includes(import_node_path3.sep) || conceptId2.includes("/")) {
2801
+ if (conceptId2.includes(import_node_path4.sep) || conceptId2.includes("/")) {
2388
2802
  throw new KbInvalidConceptIdError(
2389
2803
  "concept id must not contain a path separator",
2390
2804
  { conceptId: conceptId2 }
2391
2805
  );
2392
2806
  }
2393
- return (0, import_node_path3.join)(this.root(bundlePath2), `${conceptId2}.md`);
2807
+ return (0, import_node_path4.join)(this.root(bundlePath2), `${conceptId2}.md`);
2394
2808
  }
2395
2809
  };
2396
2810
  function estimateTokens(record) {
@@ -2619,18 +3033,18 @@ var KbBaseFrozenError = class extends Error {
2619
3033
  };
2620
3034
 
2621
3035
  // src/kb-pins/frozen.ts
2622
- var import_node_path6 = require("path");
3036
+ var import_node_path7 = require("path");
2623
3037
 
2624
3038
  // src/kb-pins/layers.ts
2625
- var import_promises4 = require("fs/promises");
2626
- var import_node_os = require("os");
2627
- var import_node_path5 = require("path");
3039
+ var import_promises5 = require("fs/promises");
3040
+ var import_node_os2 = require("os");
3041
+ var import_node_path6 = require("path");
2628
3042
 
2629
3043
  // src/kb-pins/model.ts
2630
- var import_node_path4 = require("path");
3044
+ var import_node_path5 = require("path");
2631
3045
  var import_zod4 = require("zod");
2632
- var PINS_FILE = (0, import_node_path4.join)(".strauss", "kb-pins.json");
2633
- var PINS_LOCAL_FILE = (0, import_node_path4.join)(".strauss", "kb-pins.local.json");
3046
+ var PINS_FILE = (0, import_node_path5.join)(".strauss", "kb-pins.json");
3047
+ var PINS_LOCAL_FILE = (0, import_node_path5.join)(".strauss", "kb-pins.local.json");
2634
3048
  var PIN_LAYERS = ["project", "local", "user"];
2635
3049
  var pinSchema = import_zod4.z.object({
2636
3050
  /** Relative to the manifest's root, so the file is committable. */
@@ -2677,13 +3091,13 @@ var pinsManifestSchema = import_zod4.z.object({
2677
3091
 
2678
3092
  // src/kb-pins/layers.ts
2679
3093
  function userRoot() {
2680
- return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
3094
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
2681
3095
  }
2682
3096
  function layerRoot(workspaceDir, layer) {
2683
- return layer === "user" ? userRoot() : (0, import_node_path5.resolve)(workspaceDir);
3097
+ return layer === "user" ? userRoot() : (0, import_node_path6.resolve)(workspaceDir);
2684
3098
  }
2685
3099
  function layerFile(workspaceDir, layer) {
2686
- return (0, import_node_path5.join)(
3100
+ return (0, import_node_path6.join)(
2687
3101
  layerRoot(workspaceDir, layer),
2688
3102
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
2689
3103
  );
@@ -2692,7 +3106,7 @@ async function readPinsLayer(workspaceDir, layer) {
2692
3106
  const file = layerFile(workspaceDir, layer);
2693
3107
  let raw;
2694
3108
  try {
2695
- raw = await (0, import_promises4.readFile)(file, "utf8");
3109
+ raw = await (0, import_promises5.readFile)(file, "utf8");
2696
3110
  } catch {
2697
3111
  return { pins: [] };
2698
3112
  }
@@ -2716,16 +3130,16 @@ async function readPinsLayer(workspaceDir, layer) {
2716
3130
  }
2717
3131
  async function writePinsLayer(workspaceDir, layer, manifest) {
2718
3132
  const file = layerFile(workspaceDir, layer);
2719
- await (0, import_promises4.mkdir)((0, import_node_path5.dirname)(file), { recursive: true });
2720
- await (0, import_promises4.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
3133
+ await (0, import_promises5.mkdir)((0, import_node_path6.dirname)(file), { recursive: true });
3134
+ await (0, import_promises5.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
2721
3135
  `, "utf8");
2722
3136
  }
2723
3137
  function resolvePinPath(rootDir, path) {
2724
- 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));
3138
+ return (0, import_node_path6.isAbsolute)(path) ? (0, import_node_path6.resolve)(path) : (0, import_node_path6.resolve)(rootDir, path.split("/").join(import_node_path6.sep));
2725
3139
  }
2726
3140
  function storablePath(rootDir, bundlePath2) {
2727
- const rel = (0, import_node_path5.relative)((0, import_node_path5.resolve)(rootDir), (0, import_node_path5.resolve)(bundlePath2));
2728
- return (rel === "" ? "." : rel).split(import_node_path5.sep).join("/");
3141
+ const rel = (0, import_node_path6.relative)((0, import_node_path6.resolve)(rootDir), (0, import_node_path6.resolve)(bundlePath2));
3142
+ return (rel === "" ? "." : rel).split(import_node_path6.sep).join("/");
2729
3143
  }
2730
3144
  async function readMergedPins(workspaceDir) {
2731
3145
  const manifests = {};
@@ -2753,7 +3167,7 @@ async function readMergedPins(workspaceDir) {
2753
3167
  // src/kb-pins/frozen.ts
2754
3168
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
2755
3169
  const merged = await readMergedPins(workspaceDir);
2756
- const absolute = (0, import_node_path6.resolve)(bundlePath2);
3170
+ const absolute = (0, import_node_path7.resolve)(bundlePath2);
2757
3171
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
2758
3172
  if (pin?.frozen === true) {
2759
3173
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -2838,7 +3252,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2838
3252
  }
2839
3253
 
2840
3254
  // src/kb-pins/unpin.ts
2841
- var import_node_path7 = require("path");
3255
+ var import_node_path8 = require("path");
2842
3256
  async function unpinBase(workspaceDir, bundlePath2) {
2843
3257
  const layers = [];
2844
3258
  for (const layer of PIN_LAYERS) {
@@ -2859,14 +3273,14 @@ async function unpinBase(workspaceDir, bundlePath2) {
2859
3273
  }
2860
3274
  }
2861
3275
  return {
2862
- path: storablePath((0, import_node_path7.resolve)(workspaceDir), bundlePath2),
3276
+ path: storablePath((0, import_node_path8.resolve)(workspaceDir), bundlePath2),
2863
3277
  removed: layers.length > 0,
2864
3278
  layers
2865
3279
  };
2866
3280
  }
2867
3281
 
2868
3282
  // src/kb-context.ts
2869
- var import_promises5 = require("fs/promises");
3283
+ var import_promises6 = require("fs/promises");
2870
3284
  var HEADING2 = "## Knowledge bases (pinned)";
2871
3285
  var DEFAULT_CONTEXT_BUDGET = 4e3;
2872
3286
  var CONTEXT_PROFILES = {
@@ -3025,7 +3439,7 @@ async function buildContext(store, workspaceDir, options = {}) {
3025
3439
  operation: "kb.context.refused",
3026
3440
  approxTokens: total,
3027
3441
  budgetTokens,
3028
- bases: bases.map((base) => base.path)
3442
+ bases: bases.map((base2) => base2.path)
3029
3443
  });
3030
3444
  const refusal = [
3031
3445
  HEADING2,
@@ -3035,7 +3449,7 @@ async function buildContext(store, workspaceDir, options = {}) {
3035
3449
  "from a complete one. The pinned bases:",
3036
3450
  "",
3037
3451
  ...bases.map(
3038
- (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
3452
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
3039
3453
  ),
3040
3454
  "",
3041
3455
  "For the question at hand, read what you need now \u2014 `kb_load` a base",
@@ -3070,13 +3484,13 @@ function toHookJson(block, event) {
3070
3484
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
3071
3485
  var CONTEXT_END = "<!-- strauss-kb:end -->";
3072
3486
  async function syncInstructions(file, block) {
3073
- const existing = await (0, import_promises5.readFile)(file, "utf8").catch(() => null);
3487
+ const existing = await (0, import_promises6.readFile)(file, "utf8").catch(() => null);
3074
3488
  const region = block ? `${CONTEXT_BEGIN}
3075
3489
  ${block.trim()}
3076
3490
  ${CONTEXT_END}` : null;
3077
3491
  if (existing === null) {
3078
3492
  if (!region) return { file, action: "unchanged" };
3079
- await (0, import_promises5.writeFile)(file, `${region}
3493
+ await (0, import_promises6.writeFile)(file, `${region}
3080
3494
  `, "utf8");
3081
3495
  return { file, action: "created" };
3082
3496
  }
@@ -3087,11 +3501,11 @@ ${CONTEXT_END}` : null;
3087
3501
  const after = existing.slice(end + CONTEXT_END.length);
3088
3502
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
3089
3503
  if (next === existing) return { file, action: "unchanged" };
3090
- await (0, import_promises5.writeFile)(file, next, "utf8");
3504
+ await (0, import_promises6.writeFile)(file, next, "utf8");
3091
3505
  return { file, action: region ? "replaced" : "removed" };
3092
3506
  }
3093
3507
  if (!region) return { file, action: "unchanged" };
3094
- await (0, import_promises5.writeFile)(
3508
+ await (0, import_promises6.writeFile)(
3095
3509
  file,
3096
3510
  `${existing.replace(/\n*$/, "\n\n")}${region}
3097
3511
  `,
@@ -3250,6 +3664,16 @@ function validateBundle(records) {
3250
3664
  );
3251
3665
  }
3252
3666
  }
3667
+ for (const anchor of fm.strauss_anchors ?? []) {
3668
+ if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
3669
+ report(
3670
+ "anchor_repo",
3671
+ conceptId2,
3672
+ `anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
3673
+ "warning"
3674
+ );
3675
+ }
3676
+ }
3253
3677
  if (fm.strauss_assumption && fm.sources?.length) {
3254
3678
  report("assumption", conceptId2, "marked an assumption but cites sources");
3255
3679
  }
@@ -3269,7 +3693,8 @@ var KB_DOCTOR_CHECKS = [
3269
3693
  "orphaned",
3270
3694
  "broken-supersession",
3271
3695
  "superseded-but-cited",
3272
- "drifted"
3696
+ "drifted",
3697
+ "unchecked"
3273
3698
  ];
3274
3699
  var CHECK_HEADLINES = {
3275
3700
  expired: "past its stale_after date",
@@ -3279,7 +3704,8 @@ var CHECK_HEADLINES = {
3279
3704
  orphaned: "no other record links to it",
3280
3705
  "broken-supersession": "the supersession pointers do not resolve",
3281
3706
  "superseded-but-cited": "a live record's body links to one that no longer holds",
3282
- drifted: "the code an anchor points at moved out from under its hash"
3707
+ drifted: "the code an anchor points at moved out from under its hash",
3708
+ unchecked: "an anchor in another repository nothing could reach"
3283
3709
  };
3284
3710
  var DAY_MS = 864e5;
3285
3711
  function doctor(bundle, options = {}) {
@@ -3304,7 +3730,8 @@ function doctor(bundle, options = {}) {
3304
3730
  group("orphaned", orphaned(bundle)),
3305
3731
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
3306
3732
  group("superseded-but-cited", supersededButCited(bundle, standings)),
3307
- group("drifted", drifted(inForce))
3733
+ group("drifted", drifted(inForce)),
3734
+ group("unchecked", unchecked(inForce))
3308
3735
  ];
3309
3736
  const counts = Object.fromEntries(
3310
3737
  groups.map((entry) => [entry.check, entry.count])
@@ -3491,21 +3918,38 @@ function supersededButCited(bundle, standings) {
3491
3918
  return findings;
3492
3919
  }
3493
3920
  function drifted(hits) {
3921
+ return anchorFindings(
3922
+ hits,
3923
+ "drifted",
3924
+ (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
3925
+ );
3926
+ }
3927
+ function unchecked(hits) {
3928
+ return anchorFindings(
3929
+ hits,
3930
+ "unchecked",
3931
+ (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
3932
+ );
3933
+ }
3934
+ function anchorFindings(hits, kind, headline) {
3494
3935
  const findings = [];
3495
3936
  for (const hit of hits) {
3496
- const warning = hit.warnings.find((entry) => entry.kind === "drifted");
3937
+ const warning = hit.warnings.find(
3938
+ (entry) => entry.kind === kind
3939
+ );
3497
3940
  if (!warning) continue;
3941
+ const byRepo = /* @__PURE__ */ new Map();
3942
+ for (const anchor of warning.anchors) {
3943
+ const repo = anchor.repo ?? "";
3944
+ byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
3945
+ }
3946
+ const detail = [...byRepo.entries()].map(
3947
+ ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
3948
+ );
3498
3949
  findings.push(
3499
3950
  finding(
3500
3951
  hit.record,
3501
- `${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
3502
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3503
- if (anchor.reason) return `${at} (${anchor.reason})`;
3504
- if (anchor.diffSize === null) {
3505
- return `${at} (changed, size unrecorded)`;
3506
- }
3507
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3508
- }).join(", ")}`
3952
+ `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
3509
3953
  )
3510
3954
  );
3511
3955
  }
@@ -3513,6 +3957,15 @@ function drifted(hits) {
3513
3957
  (left, right) => left.conceptId.localeCompare(right.conceptId)
3514
3958
  );
3515
3959
  }
3960
+ function describeAnchor(anchor) {
3961
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3962
+ if (anchor.reason) return `${at} (${anchor.reason})`;
3963
+ if (anchor.remoteState === "drifted-on-default") {
3964
+ return `${at} (matches ref, moved on the default branch)`;
3965
+ }
3966
+ if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
3967
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3968
+ }
3516
3969
  function replaces(later, earlier) {
3517
3970
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
3518
3971
  }
@@ -3615,12 +4068,15 @@ function argvFlag(argv, name) {
3615
4068
  var anchorResolveCommand = define({
3616
4069
  name: "anchor-resolve",
3617
4070
  tool: "kb_anchor_resolve",
3618
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
3619
- 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.",
4071
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
4072
+ description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
3620
4073
  input: import_zod8.z.object({
3621
4074
  bundlePath,
3622
4075
  conceptId,
3623
4076
  repoRoot: import_zod8.z.string().min(1).optional(),
4077
+ offline: import_zod8.z.boolean().optional().describe(
4078
+ "Resolve foreign anchors from the local repo cache only, never fetching."
4079
+ ),
3624
4080
  rebaseline: import_zod8.z.boolean().optional().describe(
3625
4081
  "Accept the current code as the new baseline for anchors that drifted."
3626
4082
  ),
@@ -3632,10 +4088,11 @@ var anchorResolveCommand = define({
3632
4088
  bundlePath: path,
3633
4089
  conceptId: argv[1],
3634
4090
  repoRoot: argvFlag(argv, "--repo-root"),
4091
+ offline: argv.includes("--offline"),
3635
4092
  rebaseline: argv.includes("--rebaseline"),
3636
4093
  restamp: argv.includes("--restamp")
3637
4094
  }),
3638
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
4095
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
3639
4096
  const root = repoRoot ?? process.cwd();
3640
4097
  const record = await store.read(path, id);
3641
4098
  if (!record) throw new KbRecordNotFoundError(id);
@@ -3650,18 +4107,10 @@ var anchorResolveCommand = define({
3650
4107
  }
3651
4108
  const results = [];
3652
4109
  const updated = [];
3653
- const origin = new LazyOrigin(root);
3654
4110
  let dirty = false;
3655
- if (anchors.some((anchor) => anchor.repo)) await origin.prime();
3656
- const foreign = new Map(
3657
- anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
3658
- );
3659
- const reads = await readAnchorFiles(
3660
- anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
3661
- anchorFileReader(root)
3662
- );
4111
+ const sources = await readSources(anchors, root, offline === true);
3663
4112
  for (const anchor of anchors) {
3664
- const base = {
4113
+ const base2 = {
3665
4114
  file: anchor.file,
3666
4115
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
3667
4116
  // Carried onto unresolved findings too: an anchor that once hashed
@@ -3669,21 +4118,17 @@ var anchorResolveCommand = define({
3669
4118
  // has to be able to tell it from one nobody ever stamped.
3670
4119
  ...anchor.hash ? { storedHash: anchor.hash } : {}
3671
4120
  };
3672
- if (foreign.get(anchor)) {
3673
- results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
4121
+ const source = sources.get(anchor);
4122
+ if (source.repo) base2.repo = source.repo;
4123
+ if (!source.ok) {
4124
+ results.push({ ...base2, state: "unresolved", reason: source.reason });
3674
4125
  updated.push(anchor);
3675
4126
  continue;
3676
4127
  }
3677
- const fileRead = reads.get(anchor.file);
3678
- if (!fileRead.ok) {
3679
- results.push({ ...base, state: "unresolved", reason: fileRead.reason });
3680
- updated.push(anchor);
3681
- continue;
3682
- }
3683
- const resolved = resolveAnchor(fileRead.source, anchor);
4128
+ const resolved = resolveAnchor(source.source, anchor);
3684
4129
  if (!resolved) {
3685
4130
  results.push({
3686
- ...base,
4131
+ ...base2,
3687
4132
  state: "unresolved",
3688
4133
  reason: "symbol-not-found"
3689
4134
  });
@@ -3698,30 +4143,47 @@ var anchorResolveCommand = define({
3698
4143
  lines: currentLines,
3699
4144
  resolved_at: now()
3700
4145
  };
4146
+ const pinned = anchor.ref !== void 0 && source.repo !== void 0;
3701
4147
  if (!anchor.hash) {
3702
- results.push({ ...base, state: "stamped", currentHash });
4148
+ results.push({ ...base2, state: "stamped", currentHash });
3703
4149
  updated.push(stamped);
3704
4150
  dirty = true;
3705
- } else if (anchor.hash === currentHash) {
3706
- results.push({
3707
- ...base,
3708
- state: "match",
3709
- currentHash
3710
- });
3711
- const refresh = restamp || anchor.resolved_at === void 0;
3712
- updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
3713
- if (refresh) dirty = true;
3714
- } else {
4151
+ continue;
4152
+ }
4153
+ if (anchor.hash !== currentHash) {
3715
4154
  results.push({
3716
- ...base,
4155
+ ...base2,
3717
4156
  state: "drifted",
3718
4157
  currentHash,
3719
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
4158
+ diffSize: lineDelta(anchor, currentLines),
4159
+ ...pinned ? { remoteState: "drifted-from-ref" } : {},
3720
4160
  ...rebaseline ? { rebaselined: true } : {}
3721
4161
  });
3722
4162
  updated.push(rebaseline ? stamped : anchor);
3723
4163
  if (rebaseline) dirty = true;
4164
+ continue;
4165
+ }
4166
+ const onDefault = pinned ? headHash(source, anchor) : void 0;
4167
+ if (onDefault && onDefault.hash !== anchor.hash) {
4168
+ results.push({
4169
+ ...base2,
4170
+ state: "drifted",
4171
+ currentHash: onDefault.hash,
4172
+ diffSize: lineDelta(anchor, onDefault.lines),
4173
+ remoteState: "drifted-on-default"
4174
+ });
4175
+ updated.push(anchor);
4176
+ continue;
3724
4177
  }
4178
+ results.push({
4179
+ ...base2,
4180
+ state: "match",
4181
+ currentHash,
4182
+ ...pinned ? { remoteState: "matches-ref" } : {}
4183
+ });
4184
+ const refresh = restamp || anchor.resolved_at === void 0;
4185
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
4186
+ if (refresh) dirty = true;
3725
4187
  }
3726
4188
  let frozen = false;
3727
4189
  if (dirty) {
@@ -3734,16 +4196,19 @@ var anchorResolveCommand = define({
3734
4196
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
3735
4197
  }
3736
4198
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
3737
- const checked = results.filter((entry) => entry.reason !== "foreign-repo");
3738
- const skipped = results.length - checked.length;
3739
- const matches2 = checked.filter((entry) => entry.state === "match").length;
3740
- const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
4199
+ const unreachable = results.filter(
4200
+ (entry) => isUncheckedReason(entry.reason)
4201
+ ).length;
4202
+ const checked = results.length - unreachable;
4203
+ const matches2 = results.filter((entry) => entry.state === "match").length;
4204
+ const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
4205
+ const clean = checked > 0 && matches2 === checked && unreachable === 0;
3741
4206
  if (clean) {
3742
4207
  try {
3743
4208
  await store.verify(
3744
4209
  path,
3745
4210
  id,
3746
- `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
4211
+ `anchor-resolve: ${note} (regex resolver)`,
3747
4212
  actor,
3748
4213
  now()
3749
4214
  );
@@ -3759,18 +4224,81 @@ var anchorResolveCommand = define({
3759
4224
  }
3760
4225
  return { conceptId: id, results, verified: true, ...frozenNote };
3761
4226
  }
3762
- return { conceptId: id, results, verified: false, ...frozenNote };
4227
+ return {
4228
+ conceptId: id,
4229
+ results,
4230
+ verified: false,
4231
+ ...unreachable ? { note } : {},
4232
+ ...frozenNote
4233
+ };
3763
4234
  },
3764
4235
  // A stored hash that no longer resolves is a broken anchor, not an absence:
3765
4236
  // the file was deleted or the symbol renamed, and exiting zero on it would
3766
4237
  // let the one edit that destroys an anchor pass the gate that exists to
3767
4238
  // catch it. An anchor nobody ever stamped is still just unstamped, and one
3768
- // belonging to another repository was never this run's to check — failing CI
3769
- // on either would gate on work this command did not do.
4239
+ // whose remote nothing could reach was never checked — failing CI on either
4240
+ // would gate on work this command did not do.
3770
4241
  failsWhen: (result) => result.results.some(
3771
- (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
4242
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
3772
4243
  )
3773
4244
  });
4245
+ function lineDelta(anchor, current) {
4246
+ return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
4247
+ }
4248
+ function headHash(source, anchor) {
4249
+ if (source.head === void 0) return void 0;
4250
+ const resolved = resolveAnchor(source.head, anchor);
4251
+ if (!resolved) return void 0;
4252
+ return {
4253
+ hash: hashAnchorText(resolved.text),
4254
+ lines: resolved.endLine - resolved.startLine + 1
4255
+ };
4256
+ }
4257
+ async function readSources(anchors, root, offline) {
4258
+ const origin = new LazyOrigin(root);
4259
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
4260
+ const foreign = new Map(
4261
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
4262
+ );
4263
+ const local = anchors.filter((anchor) => !foreign.get(anchor));
4264
+ const remote = anchors.filter((anchor) => foreign.get(anchor));
4265
+ const reads = await readAnchorFiles(
4266
+ local.map((anchor) => anchor.file),
4267
+ anchorFileReader(root)
4268
+ );
4269
+ const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
4270
+ offline
4271
+ });
4272
+ const sources = /* @__PURE__ */ new Map();
4273
+ for (const anchor of local) {
4274
+ const read = reads.get(anchor.file);
4275
+ sources.set(
4276
+ anchor,
4277
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
4278
+ );
4279
+ }
4280
+ for (const anchor of remote) {
4281
+ const repo = anchor.repo;
4282
+ const key2 = normalizeRepoUrl(repo);
4283
+ const atDefault = blobs.get(wantKey(key2, void 0, anchor.file));
4284
+ const primary = anchor.ref ? blobs.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
4285
+ if (!primary?.ok) {
4286
+ sources.set(anchor, {
4287
+ ok: false,
4288
+ reason: primary?.ok === false ? primary.reason : "remote-unreachable",
4289
+ repo
4290
+ });
4291
+ continue;
4292
+ }
4293
+ sources.set(anchor, {
4294
+ ok: true,
4295
+ source: primary.source,
4296
+ repo,
4297
+ ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
4298
+ });
4299
+ }
4300
+ return sources;
4301
+ }
3774
4302
 
3775
4303
  // src/commands/answer.ts
3776
4304
  var import_zod9 = require("zod");
@@ -3928,8 +4456,8 @@ var days = (what, fallback) => import_zod13.z.number().int().positive().optional
3928
4456
  var doctorCommand = define({
3929
4457
  name: "doctor",
3930
4458
  tool: "kb_doctor",
3931
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
3932
- 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.",
4459
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
4460
+ description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
3933
4461
  input: import_zod13.z.object({
3934
4462
  bundlePath,
3935
4463
  repoRoot: REPO_ROOT,
@@ -3945,6 +4473,9 @@ var doctorCommand = define({
3945
4473
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3946
4474
  DEFAULT_AGING_DAYS
3947
4475
  ),
4476
+ offline: import_zod13.z.boolean().optional().describe(
4477
+ "Read foreign anchors from the local repo cache only, never fetching."
4478
+ ),
3948
4479
  strict: import_zod13.z.boolean().optional().describe(
3949
4480
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
3950
4481
  )
@@ -3964,13 +4495,23 @@ var doctorCommand = define({
3964
4495
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
3965
4496
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
3966
4497
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
4498
+ ...argv.includes("--offline") ? { offline: true } : {},
3967
4499
  ...argv.includes("--strict") ? { strict: true } : {}
3968
4500
  };
3969
4501
  },
3970
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
4502
+ run: async ({ store, now }, {
4503
+ bundlePath: path,
4504
+ expiringDays,
4505
+ unverifiedDays,
4506
+ agingDays,
4507
+ repoRoot,
4508
+ offline
4509
+ }) => {
3971
4510
  const checkedAt = now();
3972
4511
  const records = await store.list(path);
3973
- const anchorDrift = await store.detectDrift(records, repoRoot);
4512
+ const anchorDrift = await store.detectDrift(records, repoRoot, {
4513
+ offline: offline === true
4514
+ });
3974
4515
  const report = doctor(records, {
3975
4516
  ...expiringDays !== void 0 ? { expiringDays } : {},
3976
4517
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
@@ -4658,7 +5199,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
4658
5199
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
4659
5200
 
4660
5201
  // src/version.ts
4661
- var VERSION = true ? "0.1.14" : "0.0.0-dev";
5202
+ var VERSION = true ? "0.1.15" : "0.0.0-dev";
4662
5203
 
4663
5204
  // src/mcp.ts
4664
5205
  function createKbMcpServer() {
@@ -4697,7 +5238,7 @@ async function runKbMcpServer() {
4697
5238
  }
4698
5239
 
4699
5240
  // src/cli.ts
4700
- var import_node_path8 = require("path");
5241
+ var import_node_path9 = require("path");
4701
5242
  async function runKbCli(argv) {
4702
5243
  const { flags, literal } = takeLiteral(argv);
4703
5244
  const { bundle, rest: withFlags } = takeBundle(flags);
@@ -4754,7 +5295,7 @@ function takeLiteral(argv) {
4754
5295
  function takeBundle(argv) {
4755
5296
  const at = argv.indexOf("--bundle");
4756
5297
  if (at === -1) {
4757
- return { bundle: (0, import_node_path8.join)(process.cwd(), KB_DIR), rest: argv };
5298
+ return { bundle: (0, import_node_path9.join)(process.cwd(), KB_DIR), rest: argv };
4758
5299
  }
4759
5300
  const bundle = argv[at + 1];
4760
5301
  if (!bundle) die("--bundle requires a path");
@@ -4869,6 +5410,7 @@ function usage() {
4869
5410
  impact,
4870
5411
  inboundIndex,
4871
5412
  indexIsStale,
5413
+ isCanonicalRepoUrl,
4872
5414
  isKbLinkRel,
4873
5415
  isKbRecordType,
4874
5416
  isNoDecisionRecord,
@@ -4892,11 +5434,13 @@ function usage() {
4892
5434
  pinBase,
4893
5435
  readMergedPins,
4894
5436
  readPinsLayer,
5437
+ readRemoteAnchors,
4895
5438
  regexResolver,
4896
5439
  renderCatalogLine,
4897
5440
  renderIndex,
4898
5441
  renderIndexLine,
4899
5442
  renderLogEntry,
5443
+ repoCacheDir,
4900
5444
  resolveAnchor,
4901
5445
  resolveHeads,
4902
5446
  resolveHits,