@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/cli-main.cjs CHANGED
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli.ts
27
- var import_node_path8 = require("path");
27
+ var import_node_path9 = require("path");
28
28
 
29
29
  // src/decision-record.ts
30
30
  var import_zod3 = require("zod");
@@ -58,16 +58,17 @@ var kbAnchorSchema = import_zod.z.object({
58
58
  * (`https://github.com/org/name`) or a short name. Absent means the base's
59
59
  * own repository, which is what nearly every anchor means.
60
60
  *
61
- * Unvalidated beyond not-blank: one repository has many spellings.
62
- * Matched after normalisation; see ARCHITECTURE.
61
+ * Unvalidated beyond not-blank: one repository has many spellings, matched
62
+ * after normalisation. Only a full URL can be fetched from, so `validate`
63
+ * warns on a short one; see ARCHITECTURE.
63
64
  */
64
65
  repo: import_zod.z.string().trim().min(1).optional(),
65
66
  /**
66
67
  * The git rev the evidence was taken at. Prefer a commit SHA: a branch
67
68
  * name is a moving pointer, so an anchor pinned to one says the evidence
68
69
  * came from wherever that branch happens to be now, which is not a
69
- * baseline. Recorded and preserved in v1; ref-pinned reads land with
70
- * SAA-709.
70
+ * baseline. A foreign anchor is checked at this rev, and compared against
71
+ * the remote's default branch on top of it.
71
72
  */
72
73
  ref: import_zod.z.string().trim().min(1).optional(),
73
74
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
@@ -442,13 +443,6 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
442
443
  // src/commands/anchor-resolve.ts
443
444
  var import_zod6 = require("zod");
444
445
 
445
- // src/anchor-resolver.ts
446
- var import_node_child_process = require("child_process");
447
- var import_node_crypto = require("crypto");
448
- var import_promises = require("fs/promises");
449
- var import_node_path = require("path");
450
- var import_node_util = require("util");
451
-
452
446
  // src/concurrency.ts
453
447
  var DEFAULT_IO_CONCURRENCY = 16;
454
448
  async function mapLimit(items, limit, fn) {
@@ -478,9 +472,528 @@ async function mapLimit(items, limit, fn) {
478
472
  return out;
479
473
  }
480
474
 
481
- // src/anchor-resolver.ts
475
+ // src/remote-repo/cache.ts
476
+ var import_node_os = require("os");
477
+ var import_node_path = require("path");
478
+
479
+ // src/anchor-resolver/repo-identity.ts
480
+ var import_node_child_process = require("child_process");
481
+ var import_node_util = require("util");
482
482
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
483
+ function normalizeRepoUrl(value) {
484
+ let url = value.trim().replace(/^git\+/, "");
485
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
486
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
487
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
488
+ url = trimTrailingSlashes(url);
489
+ if (url.endsWith(".git")) url = url.slice(0, -4);
490
+ return trimTrailingSlashes(url).toLowerCase();
491
+ }
492
+ function trimTrailingSlashes(value) {
493
+ let end = value.length;
494
+ while (end > 0 && value[end - 1] === "/") end -= 1;
495
+ return value.slice(0, end);
496
+ }
497
+ function isCanonicalRepoUrl(value) {
498
+ return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
499
+ }
500
+ function repoPath(normalized) {
501
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
502
+ const segments = withoutScheme.split("/").filter(Boolean);
503
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
504
+ }
505
+ function repoIdentifies(declared, originUrl) {
506
+ if (!originUrl) return false;
507
+ const origin = normalizeRepoUrl(originUrl);
508
+ const want = normalizeRepoUrl(declared);
509
+ if (!want || !origin) return false;
510
+ if (want === origin) return true;
511
+ const path = repoPath(origin);
512
+ if (!path) return false;
513
+ return want === path || want === (path.split("/").pop() ?? "");
514
+ }
515
+ async function repoOriginUrl(repoRoot) {
516
+ try {
517
+ const { stdout } = await execFileAsync(
518
+ "git",
519
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
520
+ { timeout: 5e3 }
521
+ );
522
+ return stdout.trim() || null;
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
527
+ var LazyOrigin = class {
528
+ constructor(repoRoot) {
529
+ this.repoRoot = repoRoot;
530
+ }
531
+ repoRoot;
532
+ url = null;
533
+ asked = false;
534
+ /** Asks git once, so later `isForeign` calls need no await. */
535
+ async prime() {
536
+ if (this.asked) return;
537
+ this.url = await repoOriginUrl(this.repoRoot);
538
+ this.asked = true;
539
+ }
540
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
541
+ isForeign(anchor) {
542
+ if (!anchor.repo) return false;
543
+ return !repoIdentifies(anchor.repo, this.url);
544
+ }
545
+ async foreign(anchor) {
546
+ if (!anchor.repo) return false;
547
+ await this.prime();
548
+ return this.isForeign(anchor);
549
+ }
550
+ };
551
+
552
+ // src/remote-repo/cache.ts
553
+ function repoCacheDir(override) {
554
+ return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".strauss", "repo-cache");
555
+ }
556
+ var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
557
+ function fetchTimeoutMs(override) {
558
+ if (override !== void 0) return override;
559
+ const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
560
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
561
+ }
562
+ function cachePathFor(repo, cacheDir) {
563
+ const normalized = normalizeRepoUrl(repo);
564
+ const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
565
+ if (!scheme) return null;
566
+ const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
567
+ if (segments.length < 2 || segments.some((segment) => segment === null)) {
568
+ return null;
569
+ }
570
+ const path = segments;
571
+ return (0, import_node_path.join)(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
572
+ }
573
+ function safeSegment(value) {
574
+ return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
575
+ }
576
+ function revRef(rev) {
577
+ const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
578
+ let hash = 5381;
579
+ for (let at = 0; at < rev.length; at++) {
580
+ hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
581
+ }
582
+ return `refs/strauss/${safe}-${hash.toString(16)}`;
583
+ }
584
+
585
+ // src/remote-repo/model.ts
586
+ var UNCHECKED_REASONS = [
587
+ "remote-unreachable",
588
+ "repo-unauthorized",
589
+ "default-branch-unknown"
590
+ ];
591
+ function isUncheckedReason(reason) {
592
+ return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
593
+ }
594
+ function wantKey(repo, ref, file) {
595
+ return `${repo}\0${ref ?? ""}\0${file}`;
596
+ }
597
+
598
+ // src/remote-repo/read.ts
599
+ var import_promises = require("fs/promises");
600
+
601
+ // src/remote-repo/git.ts
602
+ var import_node_child_process2 = require("child_process");
603
+ var import_node_util2 = require("util");
604
+
605
+ // src/anchor-resolver/model.ts
483
606
  var MAX_ANCHOR_FILE_BYTES = 1048576;
607
+
608
+ // src/remote-repo/git.ts
609
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
610
+ function childEnv() {
611
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
612
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
613
+ delete env[name];
614
+ }
615
+ return env;
616
+ }
617
+ async function git(args, options = {}) {
618
+ try {
619
+ const { stdout, stderr } = await execFileAsync2("git", args, {
620
+ ...options.cwd ? { cwd: options.cwd } : {},
621
+ timeout: options.timeoutMs ?? 3e4,
622
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
623
+ encoding: "utf8",
624
+ windowsHide: true,
625
+ env: childEnv()
626
+ });
627
+ return { ok: true, stdout, stderr, overflowed: false };
628
+ } catch (error) {
629
+ const failure = error;
630
+ return {
631
+ ok: false,
632
+ stdout: failure.stdout ?? "",
633
+ stderr: failure.stderr ?? "",
634
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
635
+ };
636
+ }
637
+ }
638
+ function transportReason(stderr) {
639
+ const text = stderr.toLowerCase();
640
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
641
+ return "repo-unauthorized";
642
+ }
643
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
644
+ return "ref-not-found";
645
+ }
646
+ return "remote-unreachable";
647
+ }
648
+
649
+ // src/remote-repo/validate.ts
650
+ var MAX_REF_LENGTH = 200;
651
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
652
+ function refShapeIsSafe(ref) {
653
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
654
+ if (ref.includes("..")) return false;
655
+ return REF_SHAPE.test(ref);
656
+ }
657
+ async function refIsWellFormed(ref) {
658
+ if (!refShapeIsSafe(ref)) return false;
659
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
660
+ return checked.ok;
661
+ }
662
+ function filePathIsSafe(file) {
663
+ const path = file.replace(/^\.\//, "");
664
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
665
+ return !path.split("/").includes("..");
666
+ }
667
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
668
+ function allowedProtocols() {
669
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
670
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
671
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
672
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
673
+ }
674
+ function isShortRepoName(repo) {
675
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
676
+ }
677
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
678
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
679
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
680
+ function repoUrlIsSafe(repo) {
681
+ const url = repo.trim();
682
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
683
+ const allowed = allowedProtocols();
684
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
685
+ const scheme = URL_SCHEME.exec(url);
686
+ if (!scheme?.[1]) return false;
687
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
688
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
689
+ const at = authority.lastIndexOf("@");
690
+ return at < 0 || !authority.slice(0, at).includes(":");
691
+ }
692
+ function protocolArgs() {
693
+ const allowed = allowedProtocols();
694
+ return [
695
+ "-c",
696
+ "protocol.ext.allow=never",
697
+ "-c",
698
+ `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
699
+ ];
700
+ }
701
+
702
+ // src/remote-repo/read.ts
703
+ var DEFAULT_REPO_CONCURRENCY = 4;
704
+ var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
705
+ async function readRemoteAnchors(wants, options = {}) {
706
+ const out = /* @__PURE__ */ new Map();
707
+ if (!wants.length) return out;
708
+ const cacheDir = repoCacheDir(options.cacheDir);
709
+ const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
710
+ const byRepo = /* @__PURE__ */ new Map();
711
+ for (const want of wants) {
712
+ const key = normalizeRepoUrl(want.repo);
713
+ const group2 = byRepo.get(key) ?? { url: want.repo.trim(), wants: [] };
714
+ group2.wants.push(want);
715
+ byRepo.set(key, group2);
716
+ }
717
+ const groups = [...byRepo.entries()];
718
+ const results = await mapLimit(
719
+ groups,
720
+ Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
721
+ ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
722
+ cacheDir,
723
+ timeoutMs,
724
+ offline: options.offline === true
725
+ })
726
+ );
727
+ for (const result of results) {
728
+ for (const [key, read] of result) out.set(key, read);
729
+ }
730
+ return out;
731
+ }
732
+ async function readOneRepo(repo, url, declared, context) {
733
+ let wants = declared;
734
+ const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
735
+ if (isShortRepoName(url)) {
736
+ return all({ ok: false, reason: "remote-unreachable" });
737
+ }
738
+ if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
739
+ const cache = cachePathFor(repo, context.cacheDir);
740
+ if (!cache) return all({ ok: false, reason: "remote-unreachable" });
741
+ const rejected = /* @__PURE__ */ new Map();
742
+ const usable = [];
743
+ for (const want of wants) {
744
+ const reason = wantReason(want);
745
+ if (reason)
746
+ rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
747
+ else usable.push(want);
748
+ }
749
+ if (!usable.length) return rejected;
750
+ wants = usable;
751
+ const opened = await openCache(cache, url, context);
752
+ if (opened) return new Map([...rejected, ...all(opened)]);
753
+ const wantsDefault = wants.some((want) => want.ref === void 0);
754
+ const branch = wantsDefault ? await defaultBranch(cache, context) : {};
755
+ const revs = /* @__PURE__ */ new Map();
756
+ for (const rev of distinctRevs(wants, branch.name)) {
757
+ revs.set(
758
+ rev,
759
+ await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
760
+ );
761
+ }
762
+ const reads = await mapLimit(
763
+ wants,
764
+ DEFAULT_IO_CONCURRENCY,
765
+ async (want) => {
766
+ const rev = want.ref ?? branch.name;
767
+ if (rev === void 0) {
768
+ return {
769
+ ok: false,
770
+ reason: branch.reason ?? "default-branch-unknown"
771
+ };
772
+ }
773
+ const failed = revs.get(rev);
774
+ if (failed) return failed;
775
+ return readBlob(cache, rev, want.file, context);
776
+ }
777
+ );
778
+ return new Map([
779
+ ...rejected,
780
+ ...wants.map(
781
+ (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
782
+ )
783
+ ]);
784
+ }
785
+ function wantReason(want) {
786
+ if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
787
+ return filePathIsSafe(want.file) ? void 0 : "outside-repo";
788
+ }
789
+ function distinctRevs(wants, branch) {
790
+ const revs = /* @__PURE__ */ new Set();
791
+ for (const want of wants) {
792
+ if (want.ref !== void 0) revs.add(want.ref);
793
+ else if (branch) revs.add(branch);
794
+ }
795
+ return [...revs];
796
+ }
797
+ async function openCache(cache, url, context) {
798
+ try {
799
+ await (0, import_promises.mkdir)(cache, { recursive: true });
800
+ } catch {
801
+ return { ok: false, reason: "remote-unreachable" };
802
+ }
803
+ const init = await git(["init", "--bare", "--quiet", cache], {
804
+ timeoutMs: context.timeoutMs
805
+ });
806
+ if (!init.ok) return { ok: false, reason: "remote-unreachable" };
807
+ const remote = await git(["config", "remote.origin.url", url], {
808
+ cwd: cache,
809
+ timeoutMs: context.timeoutMs
810
+ });
811
+ return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
812
+ }
813
+ async function defaultBranch(cache, context) {
814
+ if (!context.offline) {
815
+ const listed = await git(
816
+ [...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
817
+ {
818
+ cwd: cache,
819
+ timeoutMs: context.timeoutMs
820
+ }
821
+ );
822
+ const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
823
+ if (listed.ok && found?.[1]) {
824
+ const name = found[1];
825
+ await git(["config", "strauss.defaultBranch", name], { cwd: cache });
826
+ return { name };
827
+ }
828
+ if (!listed.ok) {
829
+ const reason = transportReason(listed.stderr);
830
+ if (reason !== "ref-not-found") {
831
+ const cached2 = await cachedBranch(cache);
832
+ return cached2 ? { name: cached2 } : { reason };
833
+ }
834
+ }
835
+ }
836
+ const cached = await cachedBranch(cache);
837
+ if (cached) return { name: cached };
838
+ return {
839
+ reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
840
+ };
841
+ }
842
+ async function cachedBranch(cache) {
843
+ const stored = await git(["config", "--get", "strauss.defaultBranch"], {
844
+ cwd: cache
845
+ });
846
+ if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
847
+ const head = await git(
848
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
849
+ {
850
+ cwd: cache
851
+ }
852
+ );
853
+ const name = head.stdout.trim().replace(/^origin\//, "");
854
+ return head.ok && name ? name : void 0;
855
+ }
856
+ async function ensureRev(cache, rev, context) {
857
+ const ref = revRef(rev);
858
+ const have = await git(
859
+ ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
860
+ {
861
+ cwd: cache
862
+ }
863
+ );
864
+ const cached = have.ok && have.stdout.trim().length > 0;
865
+ if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
866
+ if (context.offline) return { ok: false, reason: "remote-unreachable" };
867
+ const fetched = await git(
868
+ [
869
+ ...protocolArgs(),
870
+ "fetch",
871
+ "--depth",
872
+ "1",
873
+ "origin",
874
+ "--end-of-options",
875
+ rev
876
+ ],
877
+ { cwd: cache, timeoutMs: context.timeoutMs }
878
+ );
879
+ if (!fetched.ok) {
880
+ const reason = transportReason(fetched.stderr);
881
+ if (cached && reason !== "ref-not-found") return void 0;
882
+ return { ok: false, reason };
883
+ }
884
+ const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
885
+ const sha = head.stdout.trim();
886
+ if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
887
+ const updated = await git(["update-ref", ref, sha], { cwd: cache });
888
+ return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
889
+ }
890
+ async function readBlob(cache, rev, file, context) {
891
+ const path = file.replace(/^\.\//, "");
892
+ const blob = await git(
893
+ ["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
894
+ {
895
+ cwd: cache,
896
+ timeoutMs: context.timeoutMs
897
+ }
898
+ );
899
+ if (blob.ok) return { ok: true, source: blob.stdout };
900
+ if (blob.overflowed) return { ok: false, reason: "file-too-large" };
901
+ const text = blob.stderr.toLowerCase();
902
+ return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
903
+ }
904
+
905
+ // src/anchor-resolver/read.ts
906
+ var import_promises2 = require("fs/promises");
907
+ var import_node_path2 = require("path");
908
+ function anchorFilePath(repoRoot, file) {
909
+ const path = (0, import_node_path2.resolve)(repoRoot, file.replace(/^\.\//, ""));
910
+ const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(repoRoot), path);
911
+ if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path2.sep}`) || (0, import_node_path2.isAbsolute)(rel)) {
912
+ return null;
913
+ }
914
+ return path;
915
+ }
916
+ function contains(root, path) {
917
+ const rel = (0, import_node_path2.relative)(root, path);
918
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`) && !(0, import_node_path2.isAbsolute)(rel);
919
+ }
920
+ function errorCode(error) {
921
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
922
+ }
923
+ function anchorFileReader(repoRoot) {
924
+ let rootOnce;
925
+ const realRoot = () => {
926
+ rootOnce ??= (0, import_promises2.realpath)((0, import_node_path2.resolve)(repoRoot)).catch((error) => {
927
+ rootOnce = void 0;
928
+ throw error;
929
+ });
930
+ return rootOnce;
931
+ };
932
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
933
+ }
934
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
935
+ const lexical = anchorFilePath(repoRoot, file);
936
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
937
+ let root;
938
+ let path;
939
+ try {
940
+ root = await realRoot();
941
+ path = await (0, import_promises2.realpath)(lexical);
942
+ } catch (error) {
943
+ const code = errorCode(error);
944
+ if (code === "ENOENT" || code === "ENOTDIR") {
945
+ return { ok: false, reason: "file-missing" };
946
+ }
947
+ return { ok: false, reason: "file-unreadable" };
948
+ }
949
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
950
+ try {
951
+ const stats = await (0, import_promises2.stat)(path);
952
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
953
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
954
+ return { ok: false, reason: "file-too-large" };
955
+ }
956
+ return { ok: true, source: await (0, import_promises2.readFile)(path, "utf8") };
957
+ } catch (error) {
958
+ const code = errorCode(error);
959
+ if (code === "ENOENT" || code === "ENOTDIR") {
960
+ return { ok: false, reason: "file-missing" };
961
+ }
962
+ return { ok: false, reason: "file-unreadable" };
963
+ }
964
+ }
965
+ function looksLikeWrongRepoRoot(drift) {
966
+ let checked = 0;
967
+ for (const entries of drift.values()) {
968
+ for (const entry of entries) {
969
+ if (entry.repo !== void 0) continue;
970
+ checked += 1;
971
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
972
+ return false;
973
+ }
974
+ }
975
+ }
976
+ return checked > 0;
977
+ }
978
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
979
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
980
+ throw new RangeError(
981
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
982
+ );
983
+ }
984
+ const wanted = [...new Set(files)];
985
+ const results = await mapLimit(wanted, concurrency, async (file) => {
986
+ try {
987
+ return await read(file);
988
+ } catch {
989
+ return { ok: false, reason: "file-unreadable" };
990
+ }
991
+ });
992
+ return new Map(wanted.map((file, at) => [file, results[at]]));
993
+ }
994
+
995
+ // src/anchor-resolver/resolver.ts
996
+ var import_node_crypto = require("crypto");
484
997
  var PARENT_SCOPE_LINES = 50;
485
998
  var CLEAN_STATE = { blockComment: false, template: false };
486
999
  function stripLine(line, state) {
@@ -656,157 +1169,8 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
656
1169
  }
657
1170
  return resolver.resolve(normalized, anchor.symbol);
658
1171
  }
659
- function anchorFilePath(repoRoot, file) {
660
- const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
661
- const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
662
- if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
663
- return null;
664
- }
665
- return path;
666
- }
667
- function contains(root, path) {
668
- const rel = (0, import_node_path.relative)(root, path);
669
- return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
670
- }
671
- function normalizeRepoUrl(value) {
672
- let url = value.trim().replace(/^git\+/, "");
673
- const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
674
- if (scp) url = `https://${scp[1]}/${scp[2]}`;
675
- url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
676
- url = trimTrailingSlashes(url);
677
- if (url.endsWith(".git")) url = url.slice(0, -4);
678
- return trimTrailingSlashes(url).toLowerCase();
679
- }
680
- function trimTrailingSlashes(value) {
681
- let end = value.length;
682
- while (end > 0 && value[end - 1] === "/") end -= 1;
683
- return value.slice(0, end);
684
- }
685
- function repoPath(normalized) {
686
- const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
687
- const segments = withoutScheme.split("/").filter(Boolean);
688
- return segments.length > 1 ? segments.slice(1).join("/") : "";
689
- }
690
- function repoIdentifies(declared, originUrl) {
691
- if (!originUrl) return false;
692
- const origin = normalizeRepoUrl(originUrl);
693
- const want = normalizeRepoUrl(declared);
694
- if (!want || !origin) return false;
695
- if (want === origin) return true;
696
- const path = repoPath(origin);
697
- if (!path) return false;
698
- return want === path || want === (path.split("/").pop() ?? "");
699
- }
700
- async function repoOriginUrl(repoRoot) {
701
- try {
702
- const { stdout } = await execFileAsync(
703
- "git",
704
- ["-C", repoRoot, "config", "--get", "remote.origin.url"],
705
- { timeout: 5e3 }
706
- );
707
- return stdout.trim() || null;
708
- } catch {
709
- return null;
710
- }
711
- }
712
- var LazyOrigin = class {
713
- constructor(repoRoot) {
714
- this.repoRoot = repoRoot;
715
- }
716
- repoRoot;
717
- url = null;
718
- asked = false;
719
- /** Asks git once, so later `isForeign` calls need no await. */
720
- async prime() {
721
- if (this.asked) return;
722
- this.url = await repoOriginUrl(this.repoRoot);
723
- this.asked = true;
724
- }
725
- /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
726
- isForeign(anchor) {
727
- if (!anchor.repo) return false;
728
- return !repoIdentifies(anchor.repo, this.url);
729
- }
730
- async foreign(anchor) {
731
- if (!anchor.repo) return false;
732
- await this.prime();
733
- return this.isForeign(anchor);
734
- }
735
- };
736
- function errorCode(error) {
737
- return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
738
- }
739
- function anchorFileReader(repoRoot) {
740
- let rootOnce;
741
- const realRoot = () => {
742
- rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
743
- rootOnce = void 0;
744
- throw error;
745
- });
746
- return rootOnce;
747
- };
748
- return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
749
- }
750
- async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
751
- const lexical = anchorFilePath(repoRoot, file);
752
- if (lexical === null) return { ok: false, reason: "outside-repo" };
753
- let root;
754
- let path;
755
- try {
756
- root = await realRoot();
757
- path = await (0, import_promises.realpath)(lexical);
758
- } catch (error) {
759
- const code = errorCode(error);
760
- if (code === "ENOENT" || code === "ENOTDIR") {
761
- return { ok: false, reason: "file-missing" };
762
- }
763
- return { ok: false, reason: "file-unreadable" };
764
- }
765
- if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
766
- try {
767
- const stats = await (0, import_promises.stat)(path);
768
- if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
769
- if (stats.size > MAX_ANCHOR_FILE_BYTES) {
770
- return { ok: false, reason: "file-too-large" };
771
- }
772
- return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
773
- } catch (error) {
774
- const code = errorCode(error);
775
- if (code === "ENOENT" || code === "ENOTDIR") {
776
- return { ok: false, reason: "file-missing" };
777
- }
778
- return { ok: false, reason: "file-unreadable" };
779
- }
780
- }
781
- function looksLikeWrongRepoRoot(drift) {
782
- let checked = 0;
783
- for (const entries of drift.values()) {
784
- for (const entry of entries) {
785
- if (entry.reason === "foreign-repo") continue;
786
- checked += 1;
787
- if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
788
- return false;
789
- }
790
- }
791
- }
792
- return checked > 0;
793
- }
794
- async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
795
- if (!Number.isInteger(concurrency) || concurrency < 1) {
796
- throw new RangeError(
797
- `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
798
- );
799
- }
800
- const wanted = [...new Set(files)];
801
- const results = await mapLimit(wanted, concurrency, async (file) => {
802
- try {
803
- return await read(file);
804
- } catch {
805
- return { ok: false, reason: "file-unreadable" };
806
- }
807
- });
808
- return new Map(wanted.map((file, at) => [file, results[at]]));
809
- }
1172
+
1173
+ // src/anchor-resolver/drift.ts
810
1174
  async function detectAnchorDrift(records, options = {}) {
811
1175
  const repoRoot = options.repoRoot ?? process.cwd();
812
1176
  const resolver = options.resolver ?? regexResolver;
@@ -832,67 +1196,97 @@ async function detectAnchorDrift(records, options = {}) {
832
1196
  }
833
1197
  }
834
1198
  const files = [];
1199
+ const wants = [];
835
1200
  for (const entries of planned.values()) {
836
- for (const entry of entries) {
837
- if (!entry.foreign) files.push(entry.anchor.file);
1201
+ for (const { anchor, foreign } of entries) {
1202
+ if (!foreign) files.push(anchor.file);
1203
+ else wants.push(...remoteWants(anchor));
838
1204
  }
839
1205
  }
840
- const reads = await readAnchorFiles(
841
- files,
842
- options.reader ?? anchorFileReader(repoRoot),
843
- options.concurrency ?? DEFAULT_IO_CONCURRENCY
844
- );
1206
+ const [reads, remote] = await Promise.all([
1207
+ readAnchorFiles(
1208
+ files,
1209
+ options.reader ?? anchorFileReader(repoRoot),
1210
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
1211
+ ),
1212
+ (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1213
+ ]);
845
1214
  const drift = /* @__PURE__ */ new Map();
846
1215
  for (const record of records) {
847
1216
  const entries = [];
848
1217
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
849
- const base = {
850
- file: anchor.file,
851
- ...anchor.symbol ? { symbol: anchor.symbol } : {},
852
- storedHash: anchor.hash
853
- };
854
- if (foreign) {
855
- entries.push({
856
- ...base,
857
- state: "unresolved",
858
- diffSize: null,
859
- reason: "foreign-repo"
860
- });
861
- continue;
862
- }
863
- const read = reads.get(anchor.file);
864
- if (!read.ok) {
865
- entries.push({
866
- ...base,
867
- state: "unresolved",
868
- diffSize: null,
869
- reason: read.reason
870
- });
871
- continue;
872
- }
873
- const resolved = resolveAnchor(read.source, anchor, resolver);
874
- if (!resolved) {
875
- entries.push({
876
- ...base,
877
- state: "unresolved",
878
- diffSize: null,
879
- reason: "symbol-not-found"
880
- });
881
- continue;
882
- }
883
- const currentHash = hashAnchorText(resolved.text);
884
- const currentLines = resolved.endLine - resolved.startLine + 1;
885
- entries.push({
886
- ...base,
887
- state: currentHash === anchor.hash ? "match" : "drifted",
888
- currentHash,
889
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
890
- });
1218
+ entries.push(
1219
+ foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1220
+ );
891
1221
  }
892
1222
  if (entries.length) drift.set(record.conceptId, entries);
893
1223
  }
894
1224
  return drift;
895
1225
  }
1226
+ function remoteWants(anchor) {
1227
+ const repo = anchor.repo;
1228
+ const wants = [{ repo, file: anchor.file }];
1229
+ if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
1230
+ return wants;
1231
+ }
1232
+ function base(anchor) {
1233
+ return {
1234
+ file: anchor.file,
1235
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1236
+ storedHash: anchor.hash
1237
+ };
1238
+ }
1239
+ function unresolved(anchor, reason, repo) {
1240
+ return {
1241
+ ...base(anchor),
1242
+ state: "unresolved",
1243
+ diffSize: null,
1244
+ ...reason ? { reason } : {},
1245
+ ...repo ? { repo } : {}
1246
+ };
1247
+ }
1248
+ function hashIn(source, anchor, resolver) {
1249
+ const resolved = resolveAnchor(source, anchor, resolver);
1250
+ if (!resolved) return null;
1251
+ return {
1252
+ hash: hashAnchorText(resolved.text),
1253
+ lines: resolved.endLine - resolved.startLine + 1
1254
+ };
1255
+ }
1256
+ function compared(anchor, current, extra = {}) {
1257
+ return {
1258
+ ...base(anchor),
1259
+ state: current.hash === anchor.hash ? "match" : "drifted",
1260
+ currentHash: current.hash,
1261
+ diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1262
+ ...extra
1263
+ };
1264
+ }
1265
+ function localEntry(anchor, read, resolver) {
1266
+ if (!read.ok) return unresolved(anchor, read.reason);
1267
+ const current = hashIn(read.source, anchor, resolver);
1268
+ return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1269
+ }
1270
+ function remoteEntry(anchor, remote, resolver) {
1271
+ const repo = anchor.repo;
1272
+ const key = normalizeRepoUrl(repo);
1273
+ const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1274
+ const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1275
+ if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1276
+ if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1277
+ const current = hashIn(primary.source, anchor, resolver);
1278
+ if (!current) return unresolved(anchor, "symbol-not-found", repo);
1279
+ if (!anchor.ref) return compared(anchor, current, { repo });
1280
+ if (current.hash !== anchor.hash) {
1281
+ return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1282
+ }
1283
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1284
+ return head && head.hash !== anchor.hash ? {
1285
+ ...compared(anchor, head, { repo }),
1286
+ state: "drifted",
1287
+ remoteState: "drifted-on-default"
1288
+ } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1289
+ }
896
1290
 
897
1291
  // src/errors.ts
898
1292
  var BaseError = class extends Error {
@@ -1095,18 +1489,18 @@ var KbBaseFrozenError = class extends Error {
1095
1489
  };
1096
1490
 
1097
1491
  // src/kb-pins/frozen.ts
1098
- var import_node_path4 = require("path");
1492
+ var import_node_path5 = require("path");
1099
1493
 
1100
1494
  // src/kb-pins/layers.ts
1101
- var import_promises2 = require("fs/promises");
1102
- var import_node_os = require("os");
1103
- var import_node_path3 = require("path");
1495
+ var import_promises3 = require("fs/promises");
1496
+ var import_node_os2 = require("os");
1497
+ var import_node_path4 = require("path");
1104
1498
 
1105
1499
  // src/kb-pins/model.ts
1106
- var import_node_path2 = require("path");
1500
+ var import_node_path3 = require("path");
1107
1501
  var import_zod4 = require("zod");
1108
- var PINS_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.json");
1109
- var PINS_LOCAL_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.local.json");
1502
+ var PINS_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.json");
1503
+ var PINS_LOCAL_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.local.json");
1110
1504
  var PIN_LAYERS = ["project", "local", "user"];
1111
1505
  var pinSchema = import_zod4.z.object({
1112
1506
  /** Relative to the manifest's root, so the file is committable. */
@@ -1153,13 +1547,13 @@ var pinsManifestSchema = import_zod4.z.object({
1153
1547
 
1154
1548
  // src/kb-pins/layers.ts
1155
1549
  function userRoot() {
1156
- return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
1550
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
1157
1551
  }
1158
1552
  function layerRoot(workspaceDir, layer) {
1159
- return layer === "user" ? userRoot() : (0, import_node_path3.resolve)(workspaceDir);
1553
+ return layer === "user" ? userRoot() : (0, import_node_path4.resolve)(workspaceDir);
1160
1554
  }
1161
1555
  function layerFile(workspaceDir, layer) {
1162
- return (0, import_node_path3.join)(
1556
+ return (0, import_node_path4.join)(
1163
1557
  layerRoot(workspaceDir, layer),
1164
1558
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1165
1559
  );
@@ -1168,7 +1562,7 @@ async function readPinsLayer(workspaceDir, layer) {
1168
1562
  const file = layerFile(workspaceDir, layer);
1169
1563
  let raw;
1170
1564
  try {
1171
- raw = await (0, import_promises2.readFile)(file, "utf8");
1565
+ raw = await (0, import_promises3.readFile)(file, "utf8");
1172
1566
  } catch {
1173
1567
  return { pins: [] };
1174
1568
  }
@@ -1192,16 +1586,16 @@ async function readPinsLayer(workspaceDir, layer) {
1192
1586
  }
1193
1587
  async function writePinsLayer(workspaceDir, layer, manifest) {
1194
1588
  const file = layerFile(workspaceDir, layer);
1195
- await (0, import_promises2.mkdir)((0, import_node_path3.dirname)(file), { recursive: true });
1196
- await (0, import_promises2.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1589
+ await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(file), { recursive: true });
1590
+ await (0, import_promises3.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1197
1591
  `, "utf8");
1198
1592
  }
1199
1593
  function resolvePinPath(rootDir, path) {
1200
- return (0, import_node_path3.isAbsolute)(path) ? (0, import_node_path3.resolve)(path) : (0, import_node_path3.resolve)(rootDir, path.split("/").join(import_node_path3.sep));
1594
+ return (0, import_node_path4.isAbsolute)(path) ? (0, import_node_path4.resolve)(path) : (0, import_node_path4.resolve)(rootDir, path.split("/").join(import_node_path4.sep));
1201
1595
  }
1202
1596
  function storablePath(rootDir, bundlePath2) {
1203
- const rel = (0, import_node_path3.relative)((0, import_node_path3.resolve)(rootDir), (0, import_node_path3.resolve)(bundlePath2));
1204
- return (rel === "" ? "." : rel).split(import_node_path3.sep).join("/");
1597
+ const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(rootDir), (0, import_node_path4.resolve)(bundlePath2));
1598
+ return (rel === "" ? "." : rel).split(import_node_path4.sep).join("/");
1205
1599
  }
1206
1600
  async function readMergedPins(workspaceDir) {
1207
1601
  const manifests = {};
@@ -1229,7 +1623,7 @@ async function readMergedPins(workspaceDir) {
1229
1623
  // src/kb-pins/frozen.ts
1230
1624
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
1231
1625
  const merged = await readMergedPins(workspaceDir);
1232
- const absolute = (0, import_node_path4.resolve)(bundlePath2);
1626
+ const absolute = (0, import_node_path5.resolve)(bundlePath2);
1233
1627
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
1234
1628
  if (pin?.frozen === true) {
1235
1629
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -1314,7 +1708,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1314
1708
  }
1315
1709
 
1316
1710
  // src/kb-pins/unpin.ts
1317
- var import_node_path5 = require("path");
1711
+ var import_node_path6 = require("path");
1318
1712
  async function unpinBase(workspaceDir, bundlePath2) {
1319
1713
  const layers = [];
1320
1714
  for (const layer of PIN_LAYERS) {
@@ -1335,7 +1729,7 @@ async function unpinBase(workspaceDir, bundlePath2) {
1335
1729
  }
1336
1730
  }
1337
1731
  return {
1338
- path: storablePath((0, import_node_path5.resolve)(workspaceDir), bundlePath2),
1732
+ path: storablePath((0, import_node_path6.resolve)(workspaceDir), bundlePath2),
1339
1733
  removed: layers.length > 0,
1340
1734
  layers
1341
1735
  };
@@ -1371,12 +1765,15 @@ function argvFlag(argv, name) {
1371
1765
  var anchorResolveCommand = define({
1372
1766
  name: "anchor-resolve",
1373
1767
  tool: "kb_anchor_resolve",
1374
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
1375
- 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.",
1768
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
1769
+ 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.",
1376
1770
  input: import_zod6.z.object({
1377
1771
  bundlePath,
1378
1772
  conceptId,
1379
1773
  repoRoot: import_zod6.z.string().min(1).optional(),
1774
+ offline: import_zod6.z.boolean().optional().describe(
1775
+ "Resolve foreign anchors from the local repo cache only, never fetching."
1776
+ ),
1380
1777
  rebaseline: import_zod6.z.boolean().optional().describe(
1381
1778
  "Accept the current code as the new baseline for anchors that drifted."
1382
1779
  ),
@@ -1388,10 +1785,11 @@ var anchorResolveCommand = define({
1388
1785
  bundlePath: path,
1389
1786
  conceptId: argv[1],
1390
1787
  repoRoot: argvFlag(argv, "--repo-root"),
1788
+ offline: argv.includes("--offline"),
1391
1789
  rebaseline: argv.includes("--rebaseline"),
1392
1790
  restamp: argv.includes("--restamp")
1393
1791
  }),
1394
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
1792
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
1395
1793
  const root = repoRoot ?? process.cwd();
1396
1794
  const record = await store.read(path, id);
1397
1795
  if (!record) throw new KbRecordNotFoundError(id);
@@ -1406,18 +1804,10 @@ var anchorResolveCommand = define({
1406
1804
  }
1407
1805
  const results = [];
1408
1806
  const updated = [];
1409
- const origin = new LazyOrigin(root);
1410
1807
  let dirty = false;
1411
- if (anchors.some((anchor) => anchor.repo)) await origin.prime();
1412
- const foreign = new Map(
1413
- anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
1414
- );
1415
- const reads = await readAnchorFiles(
1416
- anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
1417
- anchorFileReader(root)
1418
- );
1808
+ const sources = await readSources(anchors, root, offline === true);
1419
1809
  for (const anchor of anchors) {
1420
- const base = {
1810
+ const base2 = {
1421
1811
  file: anchor.file,
1422
1812
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
1423
1813
  // Carried onto unresolved findings too: an anchor that once hashed
@@ -1425,21 +1815,17 @@ var anchorResolveCommand = define({
1425
1815
  // has to be able to tell it from one nobody ever stamped.
1426
1816
  ...anchor.hash ? { storedHash: anchor.hash } : {}
1427
1817
  };
1428
- if (foreign.get(anchor)) {
1429
- results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
1430
- updated.push(anchor);
1431
- continue;
1432
- }
1433
- const fileRead = reads.get(anchor.file);
1434
- if (!fileRead.ok) {
1435
- results.push({ ...base, state: "unresolved", reason: fileRead.reason });
1818
+ const source = sources.get(anchor);
1819
+ if (source.repo) base2.repo = source.repo;
1820
+ if (!source.ok) {
1821
+ results.push({ ...base2, state: "unresolved", reason: source.reason });
1436
1822
  updated.push(anchor);
1437
1823
  continue;
1438
1824
  }
1439
- const resolved = resolveAnchor(fileRead.source, anchor);
1825
+ const resolved = resolveAnchor(source.source, anchor);
1440
1826
  if (!resolved) {
1441
1827
  results.push({
1442
- ...base,
1828
+ ...base2,
1443
1829
  state: "unresolved",
1444
1830
  reason: "symbol-not-found"
1445
1831
  });
@@ -1454,30 +1840,47 @@ var anchorResolveCommand = define({
1454
1840
  lines: currentLines,
1455
1841
  resolved_at: now()
1456
1842
  };
1843
+ const pinned = anchor.ref !== void 0 && source.repo !== void 0;
1457
1844
  if (!anchor.hash) {
1458
- results.push({ ...base, state: "stamped", currentHash });
1845
+ results.push({ ...base2, state: "stamped", currentHash });
1459
1846
  updated.push(stamped);
1460
1847
  dirty = true;
1461
- } else if (anchor.hash === currentHash) {
1462
- results.push({
1463
- ...base,
1464
- state: "match",
1465
- currentHash
1466
- });
1467
- const refresh = restamp || anchor.resolved_at === void 0;
1468
- updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
1469
- if (refresh) dirty = true;
1470
- } else {
1848
+ continue;
1849
+ }
1850
+ if (anchor.hash !== currentHash) {
1471
1851
  results.push({
1472
- ...base,
1852
+ ...base2,
1473
1853
  state: "drifted",
1474
1854
  currentHash,
1475
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
1855
+ diffSize: lineDelta(anchor, currentLines),
1856
+ ...pinned ? { remoteState: "drifted-from-ref" } : {},
1476
1857
  ...rebaseline ? { rebaselined: true } : {}
1477
1858
  });
1478
1859
  updated.push(rebaseline ? stamped : anchor);
1479
1860
  if (rebaseline) dirty = true;
1861
+ continue;
1862
+ }
1863
+ const onDefault = pinned ? headHash(source, anchor) : void 0;
1864
+ if (onDefault && onDefault.hash !== anchor.hash) {
1865
+ results.push({
1866
+ ...base2,
1867
+ state: "drifted",
1868
+ currentHash: onDefault.hash,
1869
+ diffSize: lineDelta(anchor, onDefault.lines),
1870
+ remoteState: "drifted-on-default"
1871
+ });
1872
+ updated.push(anchor);
1873
+ continue;
1480
1874
  }
1875
+ results.push({
1876
+ ...base2,
1877
+ state: "match",
1878
+ currentHash,
1879
+ ...pinned ? { remoteState: "matches-ref" } : {}
1880
+ });
1881
+ const refresh = restamp || anchor.resolved_at === void 0;
1882
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
1883
+ if (refresh) dirty = true;
1481
1884
  }
1482
1885
  let frozen = false;
1483
1886
  if (dirty) {
@@ -1490,16 +1893,19 @@ var anchorResolveCommand = define({
1490
1893
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
1491
1894
  }
1492
1895
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
1493
- const checked = results.filter((entry) => entry.reason !== "foreign-repo");
1494
- const skipped = results.length - checked.length;
1495
- const matches2 = checked.filter((entry) => entry.state === "match").length;
1496
- const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
1896
+ const unreachable = results.filter(
1897
+ (entry) => isUncheckedReason(entry.reason)
1898
+ ).length;
1899
+ const checked = results.length - unreachable;
1900
+ const matches2 = results.filter((entry) => entry.state === "match").length;
1901
+ const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
1902
+ const clean = checked > 0 && matches2 === checked && unreachable === 0;
1497
1903
  if (clean) {
1498
1904
  try {
1499
1905
  await store.verify(
1500
1906
  path,
1501
1907
  id,
1502
- `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
1908
+ `anchor-resolve: ${note} (regex resolver)`,
1503
1909
  actor,
1504
1910
  now()
1505
1911
  );
@@ -1515,18 +1921,81 @@ var anchorResolveCommand = define({
1515
1921
  }
1516
1922
  return { conceptId: id, results, verified: true, ...frozenNote };
1517
1923
  }
1518
- return { conceptId: id, results, verified: false, ...frozenNote };
1924
+ return {
1925
+ conceptId: id,
1926
+ results,
1927
+ verified: false,
1928
+ ...unreachable ? { note } : {},
1929
+ ...frozenNote
1930
+ };
1519
1931
  },
1520
1932
  // A stored hash that no longer resolves is a broken anchor, not an absence:
1521
1933
  // the file was deleted or the symbol renamed, and exiting zero on it would
1522
1934
  // let the one edit that destroys an anchor pass the gate that exists to
1523
1935
  // catch it. An anchor nobody ever stamped is still just unstamped, and one
1524
- // belonging to another repository was never this run's to check — failing CI
1525
- // on either would gate on work this command did not do.
1936
+ // whose remote nothing could reach was never checked — failing CI on either
1937
+ // would gate on work this command did not do.
1526
1938
  failsWhen: (result) => result.results.some(
1527
- (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
1939
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
1528
1940
  )
1529
1941
  });
1942
+ function lineDelta(anchor, current) {
1943
+ return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
1944
+ }
1945
+ function headHash(source, anchor) {
1946
+ if (source.head === void 0) return void 0;
1947
+ const resolved = resolveAnchor(source.head, anchor);
1948
+ if (!resolved) return void 0;
1949
+ return {
1950
+ hash: hashAnchorText(resolved.text),
1951
+ lines: resolved.endLine - resolved.startLine + 1
1952
+ };
1953
+ }
1954
+ async function readSources(anchors, root, offline) {
1955
+ const origin = new LazyOrigin(root);
1956
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
1957
+ const foreign = new Map(
1958
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
1959
+ );
1960
+ const local = anchors.filter((anchor) => !foreign.get(anchor));
1961
+ const remote = anchors.filter((anchor) => foreign.get(anchor));
1962
+ const reads = await readAnchorFiles(
1963
+ local.map((anchor) => anchor.file),
1964
+ anchorFileReader(root)
1965
+ );
1966
+ const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
1967
+ offline
1968
+ });
1969
+ const sources = /* @__PURE__ */ new Map();
1970
+ for (const anchor of local) {
1971
+ const read = reads.get(anchor.file);
1972
+ sources.set(
1973
+ anchor,
1974
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
1975
+ );
1976
+ }
1977
+ for (const anchor of remote) {
1978
+ const repo = anchor.repo;
1979
+ const key = normalizeRepoUrl(repo);
1980
+ const atDefault = blobs.get(wantKey(key, void 0, anchor.file));
1981
+ const primary = anchor.ref ? blobs.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1982
+ if (!primary?.ok) {
1983
+ sources.set(anchor, {
1984
+ ok: false,
1985
+ reason: primary?.ok === false ? primary.reason : "remote-unreachable",
1986
+ repo
1987
+ });
1988
+ continue;
1989
+ }
1990
+ sources.set(anchor, {
1991
+ ok: true,
1992
+ source: primary.source,
1993
+ repo,
1994
+ ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
1995
+ });
1996
+ }
1997
+ return sources;
1998
+ }
1530
1999
 
1531
2000
  // src/commands/answer.ts
1532
2001
  var import_zod7 = require("zod");
@@ -1603,23 +2072,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
1603
2072
  if (!record.frontmatter.verified?.length) {
1604
2073
  warnings.push({ kind: "unverified" });
1605
2074
  }
1606
- const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
1607
- (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
2075
+ const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
2076
+ (entry) => entry.state !== "match"
1608
2077
  );
2078
+ const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
2079
+ const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
1609
2080
  if (moved.length) {
2081
+ warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
2082
+ }
2083
+ if (unchecked2.length) {
1610
2084
  warnings.push({
1611
- kind: "drifted",
1612
- anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
1613
- file,
1614
- ...symbol !== void 0 ? { symbol } : {},
1615
- diffSize,
1616
- ...reason !== void 0 ? { reason } : {}
1617
- }))
2085
+ kind: "unchecked",
2086
+ anchors: unchecked2.map(warningAnchor)
1618
2087
  });
1619
2088
  }
1620
2089
  return { record, standing: STANDING[status], heads, warnings };
1621
2090
  });
1622
2091
  }
2092
+ function warningAnchor(entry) {
2093
+ const { file, symbol, diffSize, reason, repo, remoteState } = entry;
2094
+ return {
2095
+ file,
2096
+ ...symbol !== void 0 ? { symbol } : {},
2097
+ diffSize,
2098
+ ...reason !== void 0 ? { reason } : {},
2099
+ ...repo !== void 0 ? { repo } : {},
2100
+ ...remoteState !== void 0 ? { remoteState } : {}
2101
+ };
2102
+ }
1623
2103
  function resolveHeads(from, byId) {
1624
2104
  const warnings = [];
1625
2105
  const heads = /* @__PURE__ */ new Map();
@@ -1781,7 +2261,7 @@ function count(value, noun) {
1781
2261
  var import_zod10 = require("zod");
1782
2262
 
1783
2263
  // src/kb-context.ts
1784
- var import_promises3 = require("fs/promises");
2264
+ var import_promises4 = require("fs/promises");
1785
2265
 
1786
2266
  // src/kb-index.ts
1787
2267
  var INDEX_FILE = "INDEX.md";
@@ -1963,7 +2443,7 @@ async function buildContext(store, workspaceDir, options = {}) {
1963
2443
  operation: "kb.context.refused",
1964
2444
  approxTokens: total,
1965
2445
  budgetTokens,
1966
- bases: bases.map((base) => base.path)
2446
+ bases: bases.map((base2) => base2.path)
1967
2447
  });
1968
2448
  const refusal = [
1969
2449
  HEADING2,
@@ -1973,7 +2453,7 @@ async function buildContext(store, workspaceDir, options = {}) {
1973
2453
  "from a complete one. The pinned bases:",
1974
2454
  "",
1975
2455
  ...bases.map(
1976
- (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
2456
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
1977
2457
  ),
1978
2458
  "",
1979
2459
  "For the question at hand, read what you need now \u2014 `kb_load` a base",
@@ -2008,13 +2488,13 @@ function toHookJson(block, event) {
2008
2488
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2009
2489
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2010
2490
  async function syncInstructions(file, block) {
2011
- const existing = await (0, import_promises3.readFile)(file, "utf8").catch(() => null);
2491
+ const existing = await (0, import_promises4.readFile)(file, "utf8").catch(() => null);
2012
2492
  const region = block ? `${CONTEXT_BEGIN}
2013
2493
  ${block.trim()}
2014
2494
  ${CONTEXT_END}` : null;
2015
2495
  if (existing === null) {
2016
2496
  if (!region) return { file, action: "unchanged" };
2017
- await (0, import_promises3.writeFile)(file, `${region}
2497
+ await (0, import_promises4.writeFile)(file, `${region}
2018
2498
  `, "utf8");
2019
2499
  return { file, action: "created" };
2020
2500
  }
@@ -2025,11 +2505,11 @@ ${CONTEXT_END}` : null;
2025
2505
  const after = existing.slice(end + CONTEXT_END.length);
2026
2506
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2027
2507
  if (next === existing) return { file, action: "unchanged" };
2028
- await (0, import_promises3.writeFile)(file, next, "utf8");
2508
+ await (0, import_promises4.writeFile)(file, next, "utf8");
2029
2509
  return { file, action: region ? "replaced" : "removed" };
2030
2510
  }
2031
2511
  if (!region) return { file, action: "unchanged" };
2032
- await (0, import_promises3.writeFile)(
2512
+ await (0, import_promises4.writeFile)(
2033
2513
  file,
2034
2514
  `${existing.replace(/\n*$/, "\n\n")}${region}
2035
2515
  `,
@@ -2246,6 +2726,16 @@ function validateBundle(records) {
2246
2726
  );
2247
2727
  }
2248
2728
  }
2729
+ for (const anchor of fm.strauss_anchors ?? []) {
2730
+ if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
2731
+ report(
2732
+ "anchor_repo",
2733
+ conceptId2,
2734
+ `anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
2735
+ "warning"
2736
+ );
2737
+ }
2738
+ }
2249
2739
  if (fm.strauss_assumption && fm.sources?.length) {
2250
2740
  report("assumption", conceptId2, "marked an assumption but cites sources");
2251
2741
  }
@@ -2265,7 +2755,8 @@ var CHECK_HEADLINES = {
2265
2755
  orphaned: "no other record links to it",
2266
2756
  "broken-supersession": "the supersession pointers do not resolve",
2267
2757
  "superseded-but-cited": "a live record's body links to one that no longer holds",
2268
- drifted: "the code an anchor points at moved out from under its hash"
2758
+ drifted: "the code an anchor points at moved out from under its hash",
2759
+ unchecked: "an anchor in another repository nothing could reach"
2269
2760
  };
2270
2761
  var DAY_MS = 864e5;
2271
2762
  function doctor(bundle, options = {}) {
@@ -2290,7 +2781,8 @@ function doctor(bundle, options = {}) {
2290
2781
  group("orphaned", orphaned(bundle)),
2291
2782
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
2292
2783
  group("superseded-but-cited", supersededButCited(bundle, standings)),
2293
- group("drifted", drifted(inForce))
2784
+ group("drifted", drifted(inForce)),
2785
+ group("unchecked", unchecked(inForce))
2294
2786
  ];
2295
2787
  const counts = Object.fromEntries(
2296
2788
  groups.map((entry) => [entry.check, entry.count])
@@ -2477,21 +2969,38 @@ function supersededButCited(bundle, standings) {
2477
2969
  return findings;
2478
2970
  }
2479
2971
  function drifted(hits) {
2972
+ return anchorFindings(
2973
+ hits,
2974
+ "drifted",
2975
+ (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
2976
+ );
2977
+ }
2978
+ function unchecked(hits) {
2979
+ return anchorFindings(
2980
+ hits,
2981
+ "unchecked",
2982
+ (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
2983
+ );
2984
+ }
2985
+ function anchorFindings(hits, kind, headline) {
2480
2986
  const findings = [];
2481
2987
  for (const hit of hits) {
2482
- const warning = hit.warnings.find((entry) => entry.kind === "drifted");
2988
+ const warning = hit.warnings.find(
2989
+ (entry) => entry.kind === kind
2990
+ );
2483
2991
  if (!warning) continue;
2992
+ const byRepo = /* @__PURE__ */ new Map();
2993
+ for (const anchor of warning.anchors) {
2994
+ const repo = anchor.repo ?? "";
2995
+ byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
2996
+ }
2997
+ const detail = [...byRepo.entries()].map(
2998
+ ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
2999
+ );
2484
3000
  findings.push(
2485
3001
  finding(
2486
3002
  hit.record,
2487
- `${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
2488
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
2489
- if (anchor.reason) return `${at} (${anchor.reason})`;
2490
- if (anchor.diffSize === null) {
2491
- return `${at} (changed, size unrecorded)`;
2492
- }
2493
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
2494
- }).join(", ")}`
3003
+ `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
2495
3004
  )
2496
3005
  );
2497
3006
  }
@@ -2499,6 +3008,15 @@ function drifted(hits) {
2499
3008
  (left, right) => left.conceptId.localeCompare(right.conceptId)
2500
3009
  );
2501
3010
  }
3011
+ function describeAnchor(anchor) {
3012
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3013
+ if (anchor.reason) return `${at} (${anchor.reason})`;
3014
+ if (anchor.remoteState === "drifted-on-default") {
3015
+ return `${at} (matches ref, moved on the default branch)`;
3016
+ }
3017
+ if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
3018
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3019
+ }
2502
3020
  function replaces(later, earlier) {
2503
3021
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
2504
3022
  }
@@ -2526,8 +3044,8 @@ var days = (what, fallback) => import_zod11.z.number().int().positive().optional
2526
3044
  var doctorCommand = define({
2527
3045
  name: "doctor",
2528
3046
  tool: "kb_doctor",
2529
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
2530
- 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.",
3047
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3048
+ 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.",
2531
3049
  input: import_zod11.z.object({
2532
3050
  bundlePath,
2533
3051
  repoRoot: REPO_ROOT,
@@ -2543,6 +3061,9 @@ var doctorCommand = define({
2543
3061
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
2544
3062
  DEFAULT_AGING_DAYS
2545
3063
  ),
3064
+ offline: import_zod11.z.boolean().optional().describe(
3065
+ "Read foreign anchors from the local repo cache only, never fetching."
3066
+ ),
2546
3067
  strict: import_zod11.z.boolean().optional().describe(
2547
3068
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
2548
3069
  )
@@ -2562,13 +3083,23 @@ var doctorCommand = define({
2562
3083
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
2563
3084
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
2564
3085
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
3086
+ ...argv.includes("--offline") ? { offline: true } : {},
2565
3087
  ...argv.includes("--strict") ? { strict: true } : {}
2566
3088
  };
2567
3089
  },
2568
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
3090
+ run: async ({ store, now }, {
3091
+ bundlePath: path,
3092
+ expiringDays,
3093
+ unverifiedDays,
3094
+ agingDays,
3095
+ repoRoot,
3096
+ offline
3097
+ }) => {
2569
3098
  const checkedAt = now();
2570
3099
  const records = await store.list(path);
2571
- const anchorDrift = await store.detectDrift(records, repoRoot);
3100
+ const anchorDrift = await store.detectDrift(records, repoRoot, {
3101
+ offline: offline === true
3102
+ });
2572
3103
  const report = doctor(records, {
2573
3104
  ...expiringDays !== void 0 ? { expiringDays } : {},
2574
3105
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
@@ -3371,8 +3902,8 @@ var KB_COMMANDS_BY_NAME = new Map(
3371
3902
 
3372
3903
  // src/kb-store.ts
3373
3904
  var import_node_crypto2 = require("crypto");
3374
- var import_promises5 = require("fs/promises");
3375
- var import_node_path7 = require("path");
3905
+ var import_promises6 = require("fs/promises");
3906
+ var import_node_path8 = require("path");
3376
3907
 
3377
3908
  // src/markdown.ts
3378
3909
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -3400,8 +3931,8 @@ function parseMarkdownWithFrontmatter(text, schema) {
3400
3931
  }
3401
3932
 
3402
3933
  // src/search-index.ts
3403
- var import_promises4 = require("fs/promises");
3404
- var import_node_path6 = require("path");
3934
+ var import_promises5 = require("fs/promises");
3935
+ var import_node_path7 = require("path");
3405
3936
  var SEARCH_INDEX_FILE = ".index.sqlite";
3406
3937
  var COLLECTION = "kb";
3407
3938
  async function searchBase(bundlePath2, query, options = {}) {
@@ -3410,7 +3941,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3410
3941
  let store = null;
3411
3942
  try {
3412
3943
  store = await qmd.createStore({
3413
- dbPath: (0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE),
3944
+ dbPath: (0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE),
3414
3945
  config: {
3415
3946
  collections: {
3416
3947
  [COLLECTION]: {
@@ -3445,7 +3976,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3445
3976
  }
3446
3977
  }
3447
3978
  async function isStale(bundlePath2) {
3448
- const indexAt = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3979
+ const indexAt = await (0, import_promises5.stat)((0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3449
3980
  if (!indexAt) return true;
3450
3981
  const { readdir: readdir2 } = await import("fs/promises");
3451
3982
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -3454,7 +3985,7 @@ async function isStale(bundlePath2) {
3454
3985
  let stale = false;
3455
3986
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
3456
3987
  if (stale) return;
3457
- const at = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3988
+ const at = await (0, import_promises5.stat)((0, import_node_path7.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3458
3989
  if (at > indexAt) stale = true;
3459
3990
  });
3460
3991
  return stale;
@@ -3732,7 +4263,7 @@ function appendUnionMergeLine(contents) {
3732
4263
  }
3733
4264
 
3734
4265
  // src/kb-store.ts
3735
- var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
4266
+ var KB_DIR = (0, import_node_path8.join)(".strauss", "kb");
3736
4267
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
3737
4268
  var DEFAULT_LOAD_BUDGET = 25e3;
3738
4269
  var KbStore = class {
@@ -3763,7 +4294,7 @@ var KbStore = class {
3763
4294
  const conceptId2 = `${input.type}.${input.slug}`;
3764
4295
  const root = this.root(bundlePath2);
3765
4296
  const target = this.recordPath(bundlePath2, conceptId2);
3766
- await (0, import_promises5.mkdir)(root, { recursive: true });
4297
+ await (0, import_promises6.mkdir)(root, { recursive: true });
3767
4298
  await this.publish(
3768
4299
  target,
3769
4300
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -3802,7 +4333,7 @@ var KbStore = class {
3802
4333
  const target = this.recordPath(bundlePath2, conceptId2);
3803
4334
  let raw;
3804
4335
  try {
3805
- raw = await (0, import_promises5.readFile)(target, "utf8");
4336
+ raw = await (0, import_promises6.readFile)(target, "utf8");
3806
4337
  } catch {
3807
4338
  return null;
3808
4339
  }
@@ -3819,7 +4350,7 @@ var KbStore = class {
3819
4350
  const root = this.root(bundlePath2);
3820
4351
  let names;
3821
4352
  try {
3822
- names = await (0, import_promises5.readdir)(root);
4353
+ names = await (0, import_promises6.readdir)(root);
3823
4354
  } catch {
3824
4355
  return [];
3825
4356
  }
@@ -3827,7 +4358,7 @@ var KbStore = class {
3827
4358
  const records = await mapLimit(
3828
4359
  wanted,
3829
4360
  DEFAULT_IO_CONCURRENCY,
3830
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises5.readFile)((0, import_node_path7.join)(root, name), "utf8"))
4361
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises6.readFile)((0, import_node_path8.join)(root, name), "utf8"))
3831
4362
  );
3832
4363
  return records.filter((record) => record !== null);
3833
4364
  }
@@ -3995,7 +4526,8 @@ ${answer}
3995
4526
  * at the repo root, and the MCP server's cwd is the workspace.
3996
4527
  *
3997
4528
  * Public because `doctor` needs the same map with the same degradation: a
3998
- * sweep that failed to read the tree should report no drift, not fail.
4529
+ * sweep that failed to read the tree should report no drift, not fail — and
4530
+ * `offline: false` there, because a sweep is worth a fetch.
3999
4531
  *
4000
4532
  * When no root was given and not one anchored file was found, the finding is
4001
4533
  * discarded. A base read from somewhere other than the tree it describes
@@ -4007,10 +4539,13 @@ ${answer}
4007
4539
  * plausible, and the misses become findings again; an explicit `repoRoot` is
4008
4540
  * taken at its word either way.
4009
4541
  */
4010
- async detectDrift(records, repoRoot) {
4542
+ async detectDrift(records, repoRoot, options = {}) {
4011
4543
  try {
4012
4544
  const drift = await detectAnchorDrift(records, {
4013
- repoRoot: repoRoot ?? process.cwd()
4545
+ repoRoot: repoRoot ?? process.cwd(),
4546
+ // Offline by default: a read path must never spend a network fetch per
4547
+ // call. `doctor` and `anchor-resolve` are the verbs that go get it.
4548
+ remote: { offline: options.offline !== false }
4014
4549
  });
4015
4550
  if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
4016
4551
  this.logger.warn?.({
@@ -4128,11 +4663,11 @@ ${answer}
4128
4663
  async readIndex(bundlePath2) {
4129
4664
  const root = this.root(bundlePath2);
4130
4665
  const expected = renderIndex(await this.list(bundlePath2));
4131
- const stored = await (0, import_promises5.readFile)((0, import_node_path7.join)(root, INDEX_FILE), "utf8").catch(
4666
+ const stored = await (0, import_promises6.readFile)((0, import_node_path8.join)(root, INDEX_FILE), "utf8").catch(
4132
4667
  () => null
4133
4668
  );
4134
4669
  if (indexIsStale(stored, expected)) {
4135
- await this.publish((0, import_node_path7.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
4670
+ await this.publish((0, import_node_path8.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
4136
4671
  this.logger.info?.({
4137
4672
  operation: "kb.index.repair",
4138
4673
  bundlePath: root,
@@ -4149,8 +4684,8 @@ ${answer}
4149
4684
  * knows which agent touched what. So a bad line is surfaced and left alone.
4150
4685
  */
4151
4686
  async readLog(bundlePath2) {
4152
- const raw = await (0, import_promises5.readFile)(
4153
- (0, import_node_path7.join)(this.root(bundlePath2), LOG_FILE),
4687
+ const raw = await (0, import_promises6.readFile)(
4688
+ (0, import_node_path8.join)(this.root(bundlePath2), LOG_FILE),
4154
4689
  "utf8"
4155
4690
  ).catch(() => "");
4156
4691
  const result = parseLog(raw);
@@ -4201,14 +4736,14 @@ ${answer}
4201
4736
  }
4202
4737
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
4203
4738
  const target = this.recordPath(bundlePath2, conceptId2);
4204
- const before = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
4739
+ const before = await (0, import_promises6.readFile)(target, "utf8").catch(() => null);
4205
4740
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
4206
4741
  const parsed = this.parse(conceptId2, before);
4207
4742
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
4208
4743
  const frontmatter = change(parsed.frontmatter);
4209
4744
  const body = changeBody(parsed.body);
4210
4745
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
4211
- const witness = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
4746
+ const witness = await (0, import_promises6.readFile)(target, "utf8").catch(() => null);
4212
4747
  if (witness === null || digest(witness) !== digest(before)) {
4213
4748
  throw new KbWriteConflictError(conceptId2);
4214
4749
  }
@@ -4234,20 +4769,20 @@ ${answer}
4234
4769
  */
4235
4770
  async publish(target, contents, overwrite, conceptId2) {
4236
4771
  const staging = `${target}.${process.pid}.tmp`;
4237
- await (0, import_promises5.writeFile)(staging, contents, "utf8");
4772
+ await (0, import_promises6.writeFile)(staging, contents, "utf8");
4238
4773
  try {
4239
4774
  if (overwrite) {
4240
- await (0, import_promises5.rename)(staging, target);
4775
+ await (0, import_promises6.rename)(staging, target);
4241
4776
  return;
4242
4777
  }
4243
- await (0, import_promises5.link)(staging, target);
4778
+ await (0, import_promises6.link)(staging, target);
4244
4779
  } catch (error) {
4245
4780
  if (error.code === "EEXIST") {
4246
4781
  throw new KbRecordAlreadyExistsError(conceptId2);
4247
4782
  }
4248
4783
  throw error;
4249
4784
  } finally {
4250
- await (0, import_promises5.unlink)(staging).catch(() => void 0);
4785
+ await (0, import_promises6.unlink)(staging).catch(() => void 0);
4251
4786
  }
4252
4787
  }
4253
4788
  /**
@@ -4291,18 +4826,18 @@ ${answer}
4291
4826
  * file must not fail the mutation it guards.
4292
4827
  */
4293
4828
  async ensureGitattributes(root) {
4294
- const target = (0, import_node_path7.join)(root, GITATTRIBUTES_FILE);
4829
+ const target = (0, import_node_path8.join)(root, GITATTRIBUTES_FILE);
4295
4830
  try {
4296
4831
  let existing;
4297
4832
  try {
4298
- existing = await (0, import_promises5.readFile)(target, "utf8");
4833
+ existing = await (0, import_promises6.readFile)(target, "utf8");
4299
4834
  } catch (error) {
4300
4835
  if (error.code !== "ENOENT") throw error;
4301
4836
  existing = null;
4302
4837
  }
4303
4838
  if (existing === null) {
4304
4839
  try {
4305
- await (0, import_promises5.writeFile)(target, appendUnionMergeLine(""), {
4840
+ await (0, import_promises6.writeFile)(target, appendUnionMergeLine(""), {
4306
4841
  encoding: "utf8",
4307
4842
  flag: "wx"
4308
4843
  });
@@ -4323,7 +4858,7 @@ ${answer}
4323
4858
  return;
4324
4859
  }
4325
4860
  if (!hasMergeDeclaration(existing)) {
4326
- await (0, import_promises5.appendFile)(target, appendUnionMergeLine(existing), "utf8");
4861
+ await (0, import_promises6.appendFile)(target, appendUnionMergeLine(existing), "utf8");
4327
4862
  this.logger.info?.({
4328
4863
  operation: "kb.gitattributes.ensure",
4329
4864
  bundlePath: root,
@@ -4342,7 +4877,7 @@ ${answer}
4342
4877
  async record(root, entry) {
4343
4878
  await this.ensureGitattributes(root);
4344
4879
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4345
- await (0, import_promises5.appendFile)((0, import_node_path7.join)(root, LOG_FILE), line, "utf8").catch((error) => {
4880
+ await (0, import_promises6.appendFile)((0, import_node_path8.join)(root, LOG_FILE), line, "utf8").catch((error) => {
4346
4881
  this.logger.warn?.({
4347
4882
  operation: "kb.log.append",
4348
4883
  outcome: "failed",
@@ -4368,18 +4903,18 @@ ${answer}
4368
4903
  };
4369
4904
  }
4370
4905
  root(bundlePath2) {
4371
- return (0, import_node_path7.resolve)(bundlePath2);
4906
+ return (0, import_node_path8.resolve)(bundlePath2);
4372
4907
  }
4373
4908
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
4374
4909
  // bundle root; anything carrying a separator would escape it.
4375
4910
  recordPath(bundlePath2, conceptId2) {
4376
- if (conceptId2.includes(import_node_path7.sep) || conceptId2.includes("/")) {
4911
+ if (conceptId2.includes(import_node_path8.sep) || conceptId2.includes("/")) {
4377
4912
  throw new KbInvalidConceptIdError(
4378
4913
  "concept id must not contain a path separator",
4379
4914
  { conceptId: conceptId2 }
4380
4915
  );
4381
4916
  }
4382
- return (0, import_node_path7.join)(this.root(bundlePath2), `${conceptId2}.md`);
4917
+ return (0, import_node_path8.join)(this.root(bundlePath2), `${conceptId2}.md`);
4383
4918
  }
4384
4919
  };
4385
4920
  function estimateTokens(record) {
@@ -4438,7 +4973,7 @@ function bundleDigest(records, superseded) {
4438
4973
  }
4439
4974
 
4440
4975
  // src/version.ts
4441
- var VERSION = true ? "0.1.14" : "0.0.0-dev";
4976
+ var VERSION = true ? "0.1.15" : "0.0.0-dev";
4442
4977
 
4443
4978
  // src/cli.ts
4444
4979
  async function runKbCli(argv) {
@@ -4497,7 +5032,7 @@ function takeLiteral(argv) {
4497
5032
  function takeBundle(argv) {
4498
5033
  const at = argv.indexOf("--bundle");
4499
5034
  if (at === -1) {
4500
- return { bundle: (0, import_node_path8.join)(process.cwd(), KB_DIR), rest: argv };
5035
+ return { bundle: (0, import_node_path9.join)(process.cwd(), KB_DIR), rest: argv };
4501
5036
  }
4502
5037
  const bundle = argv[at + 1];
4503
5038
  if (!bundle) die("--bundle requires a path");