@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.
@@ -24,16 +24,17 @@ var kbAnchorSchema = z.object({
24
24
  * (`https://github.com/org/name`) or a short name. Absent means the base's
25
25
  * own repository, which is what nearly every anchor means.
26
26
  *
27
- * Unvalidated beyond not-blank: one repository has many spellings.
28
- * Matched after normalisation; see ARCHITECTURE.
27
+ * Unvalidated beyond not-blank: one repository has many spellings, matched
28
+ * after normalisation. Only a full URL can be fetched from, so `validate`
29
+ * warns on a short one; see ARCHITECTURE.
29
30
  */
30
31
  repo: z.string().trim().min(1).optional(),
31
32
  /**
32
33
  * The git rev the evidence was taken at. Prefer a commit SHA: a branch
33
34
  * name is a moving pointer, so an anchor pinned to one says the evidence
34
35
  * came from wherever that branch happens to be now, which is not a
35
- * baseline. Recorded and preserved in v1; ref-pinned reads land with
36
- * SAA-709.
36
+ * baseline. A foreign anchor is checked at this rev, and compared against
37
+ * the remote's default branch on top of it.
37
38
  */
38
39
  ref: z.string().trim().min(1).optional(),
39
40
  hash: z.string().regex(/^sha256:[0-9a-f]{64}$/, {
@@ -415,12 +416,116 @@ function selectDecisions(records) {
415
416
  );
416
417
  }
417
418
 
418
- // src/anchor-resolver.ts
419
+ // src/anchor-resolver/repo-identity.ts
419
420
  import { execFile } from "child_process";
420
- import { createHash } from "crypto";
421
- import { readFile, realpath, stat } from "fs/promises";
422
- import { isAbsolute, relative, resolve, sep } from "path";
423
421
  import { promisify } from "util";
422
+ var execFileAsync = promisify(execFile);
423
+ function normalizeRepoUrl(value) {
424
+ let url = value.trim().replace(/^git\+/, "");
425
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
426
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
427
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
428
+ url = trimTrailingSlashes(url);
429
+ if (url.endsWith(".git")) url = url.slice(0, -4);
430
+ return trimTrailingSlashes(url).toLowerCase();
431
+ }
432
+ function trimTrailingSlashes(value) {
433
+ let end = value.length;
434
+ while (end > 0 && value[end - 1] === "/") end -= 1;
435
+ return value.slice(0, end);
436
+ }
437
+ function isCanonicalRepoUrl(value) {
438
+ return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
439
+ }
440
+ function repoPath(normalized) {
441
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
442
+ const segments = withoutScheme.split("/").filter(Boolean);
443
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
444
+ }
445
+ function repoIdentifies(declared, originUrl) {
446
+ if (!originUrl) return false;
447
+ const origin = normalizeRepoUrl(originUrl);
448
+ const want = normalizeRepoUrl(declared);
449
+ if (!want || !origin) return false;
450
+ if (want === origin) return true;
451
+ const path = repoPath(origin);
452
+ if (!path) return false;
453
+ return want === path || want === (path.split("/").pop() ?? "");
454
+ }
455
+ async function repoOriginUrl(repoRoot) {
456
+ try {
457
+ const { stdout } = await execFileAsync(
458
+ "git",
459
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
460
+ { timeout: 5e3 }
461
+ );
462
+ return stdout.trim() || null;
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+ var LazyOrigin = class {
468
+ constructor(repoRoot) {
469
+ this.repoRoot = repoRoot;
470
+ }
471
+ repoRoot;
472
+ url = null;
473
+ asked = false;
474
+ /** Asks git once, so later `isForeign` calls need no await. */
475
+ async prime() {
476
+ if (this.asked) return;
477
+ this.url = await repoOriginUrl(this.repoRoot);
478
+ this.asked = true;
479
+ }
480
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
481
+ isForeign(anchor) {
482
+ if (!anchor.repo) return false;
483
+ return !repoIdentifies(anchor.repo, this.url);
484
+ }
485
+ async foreign(anchor) {
486
+ if (!anchor.repo) return false;
487
+ await this.prime();
488
+ return this.isForeign(anchor);
489
+ }
490
+ };
491
+
492
+ // src/remote-repo/cache.ts
493
+ import { homedir } from "os";
494
+ import { join } from "path";
495
+ function repoCacheDir(override) {
496
+ return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? join(homedir(), ".strauss", "repo-cache");
497
+ }
498
+ var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
499
+ function fetchTimeoutMs(override) {
500
+ if (override !== void 0) return override;
501
+ const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
502
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
503
+ }
504
+ function cachePathFor(repo, cacheDir) {
505
+ const normalized = normalizeRepoUrl(repo);
506
+ const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
507
+ if (!scheme) return null;
508
+ const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
509
+ if (segments.length < 2 || segments.some((segment) => segment === null)) {
510
+ return null;
511
+ }
512
+ const path = segments;
513
+ return join(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
514
+ }
515
+ function safeSegment(value) {
516
+ return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
517
+ }
518
+ function revRef(rev) {
519
+ const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
520
+ let hash = 5381;
521
+ for (let at = 0; at < rev.length; at++) {
522
+ hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
523
+ }
524
+ return `refs/strauss/${safe}-${hash.toString(16)}`;
525
+ }
526
+
527
+ // src/remote-repo/read.ts
528
+ import { mkdir } from "fs/promises";
424
529
 
425
530
  // src/concurrency.ts
426
531
  var DEFAULT_IO_CONCURRENCY = 16;
@@ -451,9 +556,415 @@ async function mapLimit(items, limit, fn) {
451
556
  return out;
452
557
  }
453
558
 
454
- // src/anchor-resolver.ts
455
- var execFileAsync = promisify(execFile);
559
+ // src/remote-repo/git.ts
560
+ import { execFile as execFile2 } from "child_process";
561
+ import { promisify as promisify2 } from "util";
562
+
563
+ // src/anchor-resolver/model.ts
456
564
  var MAX_ANCHOR_FILE_BYTES = 1048576;
565
+
566
+ // src/remote-repo/git.ts
567
+ var execFileAsync2 = promisify2(execFile2);
568
+ function childEnv() {
569
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
570
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
571
+ delete env[name];
572
+ }
573
+ return env;
574
+ }
575
+ async function git(args, options = {}) {
576
+ try {
577
+ const { stdout, stderr } = await execFileAsync2("git", args, {
578
+ ...options.cwd ? { cwd: options.cwd } : {},
579
+ timeout: options.timeoutMs ?? 3e4,
580
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
581
+ encoding: "utf8",
582
+ windowsHide: true,
583
+ env: childEnv()
584
+ });
585
+ return { ok: true, stdout, stderr, overflowed: false };
586
+ } catch (error) {
587
+ const failure = error;
588
+ return {
589
+ ok: false,
590
+ stdout: failure.stdout ?? "",
591
+ stderr: failure.stderr ?? "",
592
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
593
+ };
594
+ }
595
+ }
596
+ function transportReason(stderr) {
597
+ const text = stderr.toLowerCase();
598
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
599
+ return "repo-unauthorized";
600
+ }
601
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
602
+ return "ref-not-found";
603
+ }
604
+ return "remote-unreachable";
605
+ }
606
+
607
+ // src/remote-repo/model.ts
608
+ var UNCHECKED_REASONS = [
609
+ "remote-unreachable",
610
+ "repo-unauthorized",
611
+ "default-branch-unknown"
612
+ ];
613
+ function isUncheckedReason(reason) {
614
+ return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
615
+ }
616
+ function wantKey(repo, ref, file) {
617
+ return `${repo}\0${ref ?? ""}\0${file}`;
618
+ }
619
+
620
+ // src/remote-repo/validate.ts
621
+ var MAX_REF_LENGTH = 200;
622
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
623
+ function refShapeIsSafe(ref) {
624
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
625
+ if (ref.includes("..")) return false;
626
+ return REF_SHAPE.test(ref);
627
+ }
628
+ async function refIsWellFormed(ref) {
629
+ if (!refShapeIsSafe(ref)) return false;
630
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
631
+ return checked.ok;
632
+ }
633
+ function filePathIsSafe(file) {
634
+ const path = file.replace(/^\.\//, "");
635
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
636
+ return !path.split("/").includes("..");
637
+ }
638
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
639
+ function allowedProtocols() {
640
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
641
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
642
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
643
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
644
+ }
645
+ function isShortRepoName(repo) {
646
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
647
+ }
648
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
649
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
650
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
651
+ function repoUrlIsSafe(repo) {
652
+ const url = repo.trim();
653
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
654
+ const allowed = allowedProtocols();
655
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
656
+ const scheme = URL_SCHEME.exec(url);
657
+ if (!scheme?.[1]) return false;
658
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
659
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
660
+ const at = authority.lastIndexOf("@");
661
+ return at < 0 || !authority.slice(0, at).includes(":");
662
+ }
663
+ function protocolArgs() {
664
+ const allowed = allowedProtocols();
665
+ return [
666
+ "-c",
667
+ "protocol.ext.allow=never",
668
+ "-c",
669
+ `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
670
+ ];
671
+ }
672
+
673
+ // src/remote-repo/read.ts
674
+ var DEFAULT_REPO_CONCURRENCY = 4;
675
+ var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
676
+ async function readRemoteAnchors(wants, options = {}) {
677
+ const out = /* @__PURE__ */ new Map();
678
+ if (!wants.length) return out;
679
+ const cacheDir = repoCacheDir(options.cacheDir);
680
+ const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
681
+ const byRepo = /* @__PURE__ */ new Map();
682
+ for (const want of wants) {
683
+ const key = normalizeRepoUrl(want.repo);
684
+ const group2 = byRepo.get(key) ?? { url: want.repo.trim(), wants: [] };
685
+ group2.wants.push(want);
686
+ byRepo.set(key, group2);
687
+ }
688
+ const groups = [...byRepo.entries()];
689
+ const results = await mapLimit(
690
+ groups,
691
+ Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
692
+ ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
693
+ cacheDir,
694
+ timeoutMs,
695
+ offline: options.offline === true
696
+ })
697
+ );
698
+ for (const result of results) {
699
+ for (const [key, read] of result) out.set(key, read);
700
+ }
701
+ return out;
702
+ }
703
+ async function readOneRepo(repo, url, declared, context) {
704
+ let wants = declared;
705
+ const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
706
+ if (isShortRepoName(url)) {
707
+ return all({ ok: false, reason: "remote-unreachable" });
708
+ }
709
+ if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
710
+ const cache = cachePathFor(repo, context.cacheDir);
711
+ if (!cache) return all({ ok: false, reason: "remote-unreachable" });
712
+ const rejected = /* @__PURE__ */ new Map();
713
+ const usable = [];
714
+ for (const want of wants) {
715
+ const reason = wantReason(want);
716
+ if (reason)
717
+ rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
718
+ else usable.push(want);
719
+ }
720
+ if (!usable.length) return rejected;
721
+ wants = usable;
722
+ const opened = await openCache(cache, url, context);
723
+ if (opened) return new Map([...rejected, ...all(opened)]);
724
+ const wantsDefault = wants.some((want) => want.ref === void 0);
725
+ const branch = wantsDefault ? await defaultBranch(cache, context) : {};
726
+ const revs = /* @__PURE__ */ new Map();
727
+ for (const rev of distinctRevs(wants, branch.name)) {
728
+ revs.set(
729
+ rev,
730
+ await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
731
+ );
732
+ }
733
+ const reads = await mapLimit(
734
+ wants,
735
+ DEFAULT_IO_CONCURRENCY,
736
+ async (want) => {
737
+ const rev = want.ref ?? branch.name;
738
+ if (rev === void 0) {
739
+ return {
740
+ ok: false,
741
+ reason: branch.reason ?? "default-branch-unknown"
742
+ };
743
+ }
744
+ const failed = revs.get(rev);
745
+ if (failed) return failed;
746
+ return readBlob(cache, rev, want.file, context);
747
+ }
748
+ );
749
+ return new Map([
750
+ ...rejected,
751
+ ...wants.map(
752
+ (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
753
+ )
754
+ ]);
755
+ }
756
+ function wantReason(want) {
757
+ if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
758
+ return filePathIsSafe(want.file) ? void 0 : "outside-repo";
759
+ }
760
+ function distinctRevs(wants, branch) {
761
+ const revs = /* @__PURE__ */ new Set();
762
+ for (const want of wants) {
763
+ if (want.ref !== void 0) revs.add(want.ref);
764
+ else if (branch) revs.add(branch);
765
+ }
766
+ return [...revs];
767
+ }
768
+ async function openCache(cache, url, context) {
769
+ try {
770
+ await mkdir(cache, { recursive: true });
771
+ } catch {
772
+ return { ok: false, reason: "remote-unreachable" };
773
+ }
774
+ const init = await git(["init", "--bare", "--quiet", cache], {
775
+ timeoutMs: context.timeoutMs
776
+ });
777
+ if (!init.ok) return { ok: false, reason: "remote-unreachable" };
778
+ const remote = await git(["config", "remote.origin.url", url], {
779
+ cwd: cache,
780
+ timeoutMs: context.timeoutMs
781
+ });
782
+ return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
783
+ }
784
+ async function defaultBranch(cache, context) {
785
+ if (!context.offline) {
786
+ const listed = await git(
787
+ [...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
788
+ {
789
+ cwd: cache,
790
+ timeoutMs: context.timeoutMs
791
+ }
792
+ );
793
+ const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
794
+ if (listed.ok && found?.[1]) {
795
+ const name = found[1];
796
+ await git(["config", "strauss.defaultBranch", name], { cwd: cache });
797
+ return { name };
798
+ }
799
+ if (!listed.ok) {
800
+ const reason = transportReason(listed.stderr);
801
+ if (reason !== "ref-not-found") {
802
+ const cached2 = await cachedBranch(cache);
803
+ return cached2 ? { name: cached2 } : { reason };
804
+ }
805
+ }
806
+ }
807
+ const cached = await cachedBranch(cache);
808
+ if (cached) return { name: cached };
809
+ return {
810
+ reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
811
+ };
812
+ }
813
+ async function cachedBranch(cache) {
814
+ const stored = await git(["config", "--get", "strauss.defaultBranch"], {
815
+ cwd: cache
816
+ });
817
+ if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
818
+ const head = await git(
819
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
820
+ {
821
+ cwd: cache
822
+ }
823
+ );
824
+ const name = head.stdout.trim().replace(/^origin\//, "");
825
+ return head.ok && name ? name : void 0;
826
+ }
827
+ async function ensureRev(cache, rev, context) {
828
+ const ref = revRef(rev);
829
+ const have = await git(
830
+ ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
831
+ {
832
+ cwd: cache
833
+ }
834
+ );
835
+ const cached = have.ok && have.stdout.trim().length > 0;
836
+ if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
837
+ if (context.offline) return { ok: false, reason: "remote-unreachable" };
838
+ const fetched = await git(
839
+ [
840
+ ...protocolArgs(),
841
+ "fetch",
842
+ "--depth",
843
+ "1",
844
+ "origin",
845
+ "--end-of-options",
846
+ rev
847
+ ],
848
+ { cwd: cache, timeoutMs: context.timeoutMs }
849
+ );
850
+ if (!fetched.ok) {
851
+ const reason = transportReason(fetched.stderr);
852
+ if (cached && reason !== "ref-not-found") return void 0;
853
+ return { ok: false, reason };
854
+ }
855
+ const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
856
+ const sha = head.stdout.trim();
857
+ if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
858
+ const updated = await git(["update-ref", ref, sha], { cwd: cache });
859
+ return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
860
+ }
861
+ async function readBlob(cache, rev, file, context) {
862
+ const path = file.replace(/^\.\//, "");
863
+ const blob = await git(
864
+ ["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
865
+ {
866
+ cwd: cache,
867
+ timeoutMs: context.timeoutMs
868
+ }
869
+ );
870
+ if (blob.ok) return { ok: true, source: blob.stdout };
871
+ if (blob.overflowed) return { ok: false, reason: "file-too-large" };
872
+ const text = blob.stderr.toLowerCase();
873
+ return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
874
+ }
875
+
876
+ // src/anchor-resolver/read.ts
877
+ import { readFile, realpath, stat } from "fs/promises";
878
+ import { isAbsolute, relative, resolve, sep } from "path";
879
+ function anchorFilePath(repoRoot, file) {
880
+ const path = resolve(repoRoot, file.replace(/^\.\//, ""));
881
+ const rel = relative(resolve(repoRoot), path);
882
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
883
+ return null;
884
+ }
885
+ return path;
886
+ }
887
+ function contains(root, path) {
888
+ const rel = relative(root, path);
889
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
890
+ }
891
+ function errorCode(error) {
892
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
893
+ }
894
+ function anchorFileReader(repoRoot) {
895
+ let rootOnce;
896
+ const realRoot = () => {
897
+ rootOnce ??= realpath(resolve(repoRoot)).catch((error) => {
898
+ rootOnce = void 0;
899
+ throw error;
900
+ });
901
+ return rootOnce;
902
+ };
903
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
904
+ }
905
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
906
+ const lexical = anchorFilePath(repoRoot, file);
907
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
908
+ let root;
909
+ let path;
910
+ try {
911
+ root = await realRoot();
912
+ path = await realpath(lexical);
913
+ } catch (error) {
914
+ const code = errorCode(error);
915
+ if (code === "ENOENT" || code === "ENOTDIR") {
916
+ return { ok: false, reason: "file-missing" };
917
+ }
918
+ return { ok: false, reason: "file-unreadable" };
919
+ }
920
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
921
+ try {
922
+ const stats = await stat(path);
923
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
924
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
925
+ return { ok: false, reason: "file-too-large" };
926
+ }
927
+ return { ok: true, source: await readFile(path, "utf8") };
928
+ } catch (error) {
929
+ const code = errorCode(error);
930
+ if (code === "ENOENT" || code === "ENOTDIR") {
931
+ return { ok: false, reason: "file-missing" };
932
+ }
933
+ return { ok: false, reason: "file-unreadable" };
934
+ }
935
+ }
936
+ function looksLikeWrongRepoRoot(drift) {
937
+ let checked = 0;
938
+ for (const entries of drift.values()) {
939
+ for (const entry of entries) {
940
+ if (entry.repo !== void 0) continue;
941
+ checked += 1;
942
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
943
+ return false;
944
+ }
945
+ }
946
+ }
947
+ return checked > 0;
948
+ }
949
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
950
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
951
+ throw new RangeError(
952
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
953
+ );
954
+ }
955
+ const wanted = [...new Set(files)];
956
+ const results = await mapLimit(wanted, concurrency, async (file) => {
957
+ try {
958
+ return await read(file);
959
+ } catch {
960
+ return { ok: false, reason: "file-unreadable" };
961
+ }
962
+ });
963
+ return new Map(wanted.map((file, at) => [file, results[at]]));
964
+ }
965
+
966
+ // src/anchor-resolver/resolver.ts
967
+ import { createHash } from "crypto";
457
968
  var PARENT_SCOPE_LINES = 50;
458
969
  var CLEAN_STATE = { blockComment: false, template: false };
459
970
  function stripLine(line, state) {
@@ -629,157 +1140,8 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
629
1140
  }
630
1141
  return resolver.resolve(normalized, anchor.symbol);
631
1142
  }
632
- function anchorFilePath(repoRoot, file) {
633
- const path = resolve(repoRoot, file.replace(/^\.\//, ""));
634
- const rel = relative(resolve(repoRoot), path);
635
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
636
- return null;
637
- }
638
- return path;
639
- }
640
- function contains(root, path) {
641
- const rel = relative(root, path);
642
- return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
643
- }
644
- function normalizeRepoUrl(value) {
645
- let url = value.trim().replace(/^git\+/, "");
646
- const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
647
- if (scp) url = `https://${scp[1]}/${scp[2]}`;
648
- url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
649
- url = trimTrailingSlashes(url);
650
- if (url.endsWith(".git")) url = url.slice(0, -4);
651
- return trimTrailingSlashes(url).toLowerCase();
652
- }
653
- function trimTrailingSlashes(value) {
654
- let end = value.length;
655
- while (end > 0 && value[end - 1] === "/") end -= 1;
656
- return value.slice(0, end);
657
- }
658
- function repoPath(normalized) {
659
- const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
660
- const segments = withoutScheme.split("/").filter(Boolean);
661
- return segments.length > 1 ? segments.slice(1).join("/") : "";
662
- }
663
- function repoIdentifies(declared, originUrl) {
664
- if (!originUrl) return false;
665
- const origin = normalizeRepoUrl(originUrl);
666
- const want = normalizeRepoUrl(declared);
667
- if (!want || !origin) return false;
668
- if (want === origin) return true;
669
- const path = repoPath(origin);
670
- if (!path) return false;
671
- return want === path || want === (path.split("/").pop() ?? "");
672
- }
673
- async function repoOriginUrl(repoRoot) {
674
- try {
675
- const { stdout } = await execFileAsync(
676
- "git",
677
- ["-C", repoRoot, "config", "--get", "remote.origin.url"],
678
- { timeout: 5e3 }
679
- );
680
- return stdout.trim() || null;
681
- } catch {
682
- return null;
683
- }
684
- }
685
- var LazyOrigin = class {
686
- constructor(repoRoot) {
687
- this.repoRoot = repoRoot;
688
- }
689
- repoRoot;
690
- url = null;
691
- asked = false;
692
- /** Asks git once, so later `isForeign` calls need no await. */
693
- async prime() {
694
- if (this.asked) return;
695
- this.url = await repoOriginUrl(this.repoRoot);
696
- this.asked = true;
697
- }
698
- /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
699
- isForeign(anchor) {
700
- if (!anchor.repo) return false;
701
- return !repoIdentifies(anchor.repo, this.url);
702
- }
703
- async foreign(anchor) {
704
- if (!anchor.repo) return false;
705
- await this.prime();
706
- return this.isForeign(anchor);
707
- }
708
- };
709
- function errorCode(error) {
710
- return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
711
- }
712
- function anchorFileReader(repoRoot) {
713
- let rootOnce;
714
- const realRoot = () => {
715
- rootOnce ??= realpath(resolve(repoRoot)).catch((error) => {
716
- rootOnce = void 0;
717
- throw error;
718
- });
719
- return rootOnce;
720
- };
721
- return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
722
- }
723
- async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
724
- const lexical = anchorFilePath(repoRoot, file);
725
- if (lexical === null) return { ok: false, reason: "outside-repo" };
726
- let root;
727
- let path;
728
- try {
729
- root = await realRoot();
730
- path = await realpath(lexical);
731
- } catch (error) {
732
- const code = errorCode(error);
733
- if (code === "ENOENT" || code === "ENOTDIR") {
734
- return { ok: false, reason: "file-missing" };
735
- }
736
- return { ok: false, reason: "file-unreadable" };
737
- }
738
- if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
739
- try {
740
- const stats = await stat(path);
741
- if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
742
- if (stats.size > MAX_ANCHOR_FILE_BYTES) {
743
- return { ok: false, reason: "file-too-large" };
744
- }
745
- return { ok: true, source: await readFile(path, "utf8") };
746
- } catch (error) {
747
- const code = errorCode(error);
748
- if (code === "ENOENT" || code === "ENOTDIR") {
749
- return { ok: false, reason: "file-missing" };
750
- }
751
- return { ok: false, reason: "file-unreadable" };
752
- }
753
- }
754
- function looksLikeWrongRepoRoot(drift) {
755
- let checked = 0;
756
- for (const entries of drift.values()) {
757
- for (const entry of entries) {
758
- if (entry.reason === "foreign-repo") continue;
759
- checked += 1;
760
- if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
761
- return false;
762
- }
763
- }
764
- }
765
- return checked > 0;
766
- }
767
- async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
768
- if (!Number.isInteger(concurrency) || concurrency < 1) {
769
- throw new RangeError(
770
- `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
771
- );
772
- }
773
- const wanted = [...new Set(files)];
774
- const results = await mapLimit(wanted, concurrency, async (file) => {
775
- try {
776
- return await read(file);
777
- } catch {
778
- return { ok: false, reason: "file-unreadable" };
779
- }
780
- });
781
- return new Map(wanted.map((file, at) => [file, results[at]]));
782
- }
1143
+
1144
+ // src/anchor-resolver/drift.ts
783
1145
  async function detectAnchorDrift(records, options = {}) {
784
1146
  const repoRoot = options.repoRoot ?? process.cwd();
785
1147
  const resolver = options.resolver ?? regexResolver;
@@ -805,67 +1167,97 @@ async function detectAnchorDrift(records, options = {}) {
805
1167
  }
806
1168
  }
807
1169
  const files = [];
1170
+ const wants = [];
808
1171
  for (const entries of planned.values()) {
809
- for (const entry of entries) {
810
- if (!entry.foreign) files.push(entry.anchor.file);
1172
+ for (const { anchor, foreign } of entries) {
1173
+ if (!foreign) files.push(anchor.file);
1174
+ else wants.push(...remoteWants(anchor));
811
1175
  }
812
1176
  }
813
- const reads = await readAnchorFiles(
814
- files,
815
- options.reader ?? anchorFileReader(repoRoot),
816
- options.concurrency ?? DEFAULT_IO_CONCURRENCY
817
- );
1177
+ const [reads, remote] = await Promise.all([
1178
+ readAnchorFiles(
1179
+ files,
1180
+ options.reader ?? anchorFileReader(repoRoot),
1181
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
1182
+ ),
1183
+ (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1184
+ ]);
818
1185
  const drift = /* @__PURE__ */ new Map();
819
1186
  for (const record of records) {
820
1187
  const entries = [];
821
1188
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
822
- const base = {
823
- file: anchor.file,
824
- ...anchor.symbol ? { symbol: anchor.symbol } : {},
825
- storedHash: anchor.hash
826
- };
827
- if (foreign) {
828
- entries.push({
829
- ...base,
830
- state: "unresolved",
831
- diffSize: null,
832
- reason: "foreign-repo"
833
- });
834
- continue;
835
- }
836
- const read = reads.get(anchor.file);
837
- if (!read.ok) {
838
- entries.push({
839
- ...base,
840
- state: "unresolved",
841
- diffSize: null,
842
- reason: read.reason
843
- });
844
- continue;
845
- }
846
- const resolved = resolveAnchor(read.source, anchor, resolver);
847
- if (!resolved) {
848
- entries.push({
849
- ...base,
850
- state: "unresolved",
851
- diffSize: null,
852
- reason: "symbol-not-found"
853
- });
854
- continue;
855
- }
856
- const currentHash = hashAnchorText(resolved.text);
857
- const currentLines = resolved.endLine - resolved.startLine + 1;
858
- entries.push({
859
- ...base,
860
- state: currentHash === anchor.hash ? "match" : "drifted",
861
- currentHash,
862
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
863
- });
1189
+ entries.push(
1190
+ foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1191
+ );
864
1192
  }
865
1193
  if (entries.length) drift.set(record.conceptId, entries);
866
1194
  }
867
1195
  return drift;
868
1196
  }
1197
+ function remoteWants(anchor) {
1198
+ const repo = anchor.repo;
1199
+ const wants = [{ repo, file: anchor.file }];
1200
+ if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
1201
+ return wants;
1202
+ }
1203
+ function base(anchor) {
1204
+ return {
1205
+ file: anchor.file,
1206
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1207
+ storedHash: anchor.hash
1208
+ };
1209
+ }
1210
+ function unresolved(anchor, reason, repo) {
1211
+ return {
1212
+ ...base(anchor),
1213
+ state: "unresolved",
1214
+ diffSize: null,
1215
+ ...reason ? { reason } : {},
1216
+ ...repo ? { repo } : {}
1217
+ };
1218
+ }
1219
+ function hashIn(source, anchor, resolver) {
1220
+ const resolved = resolveAnchor(source, anchor, resolver);
1221
+ if (!resolved) return null;
1222
+ return {
1223
+ hash: hashAnchorText(resolved.text),
1224
+ lines: resolved.endLine - resolved.startLine + 1
1225
+ };
1226
+ }
1227
+ function compared(anchor, current, extra = {}) {
1228
+ return {
1229
+ ...base(anchor),
1230
+ state: current.hash === anchor.hash ? "match" : "drifted",
1231
+ currentHash: current.hash,
1232
+ diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1233
+ ...extra
1234
+ };
1235
+ }
1236
+ function localEntry(anchor, read, resolver) {
1237
+ if (!read.ok) return unresolved(anchor, read.reason);
1238
+ const current = hashIn(read.source, anchor, resolver);
1239
+ return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1240
+ }
1241
+ function remoteEntry(anchor, remote, resolver) {
1242
+ const repo = anchor.repo;
1243
+ const key = normalizeRepoUrl(repo);
1244
+ const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1245
+ const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1246
+ if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1247
+ if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1248
+ const current = hashIn(primary.source, anchor, resolver);
1249
+ if (!current) return unresolved(anchor, "symbol-not-found", repo);
1250
+ if (!anchor.ref) return compared(anchor, current, { repo });
1251
+ if (current.hash !== anchor.hash) {
1252
+ return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1253
+ }
1254
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1255
+ return head && head.hash !== anchor.hash ? {
1256
+ ...compared(anchor, head, { repo }),
1257
+ state: "drifted",
1258
+ remoteState: "drifted-on-default"
1259
+ } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1260
+ }
869
1261
 
870
1262
  // src/errors.ts
871
1263
  var Fault = /* @__PURE__ */ ((Fault2) => {
@@ -1085,10 +1477,10 @@ var KbBaseFrozenError = class extends Error {
1085
1477
  };
1086
1478
 
1087
1479
  // src/kb-pins/model.ts
1088
- import { join } from "path";
1480
+ import { join as join2 } from "path";
1089
1481
  import { z as z4 } from "zod";
1090
- var PINS_FILE = join(".strauss", "kb-pins.json");
1091
- var PINS_LOCAL_FILE = join(".strauss", "kb-pins.local.json");
1482
+ var PINS_FILE = join2(".strauss", "kb-pins.json");
1483
+ var PINS_LOCAL_FILE = join2(".strauss", "kb-pins.local.json");
1092
1484
  var PIN_LAYERS = ["project", "local", "user"];
1093
1485
  var pinSchema = z4.object({
1094
1486
  /** Relative to the manifest's root, so the file is committable. */
@@ -1134,17 +1526,17 @@ var pinsManifestSchema = z4.object({
1134
1526
  }).passthrough();
1135
1527
 
1136
1528
  // src/kb-pins/layers.ts
1137
- import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
1138
- import { homedir } from "os";
1139
- import { dirname, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
1529
+ import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
1530
+ import { homedir as homedir2 } from "os";
1531
+ import { dirname, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
1140
1532
  function userRoot() {
1141
- return process.env.STRAUSS_KB_USER_ROOT || homedir();
1533
+ return process.env.STRAUSS_KB_USER_ROOT || homedir2();
1142
1534
  }
1143
1535
  function layerRoot(workspaceDir, layer) {
1144
1536
  return layer === "user" ? userRoot() : resolve2(workspaceDir);
1145
1537
  }
1146
1538
  function layerFile(workspaceDir, layer) {
1147
- return join2(
1539
+ return join3(
1148
1540
  layerRoot(workspaceDir, layer),
1149
1541
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1150
1542
  );
@@ -1177,7 +1569,7 @@ async function readPinsLayer(workspaceDir, layer) {
1177
1569
  }
1178
1570
  async function writePinsLayer(workspaceDir, layer, manifest) {
1179
1571
  const file = layerFile(workspaceDir, layer);
1180
- await mkdir(dirname(file), { recursive: true });
1572
+ await mkdir2(dirname(file), { recursive: true });
1181
1573
  await writeFile(file, `${JSON.stringify(manifest, null, 2)}
1182
1574
  `, "utf8");
1183
1575
  }
@@ -1367,23 +1759,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
1367
1759
  if (!record.frontmatter.verified?.length) {
1368
1760
  warnings.push({ kind: "unverified" });
1369
1761
  }
1370
- const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
1371
- (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
1762
+ const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
1763
+ (entry) => entry.state !== "match"
1372
1764
  );
1765
+ const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
1766
+ const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
1373
1767
  if (moved.length) {
1768
+ warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
1769
+ }
1770
+ if (unchecked2.length) {
1374
1771
  warnings.push({
1375
- kind: "drifted",
1376
- anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
1377
- file,
1378
- ...symbol !== void 0 ? { symbol } : {},
1379
- diffSize,
1380
- ...reason !== void 0 ? { reason } : {}
1381
- }))
1772
+ kind: "unchecked",
1773
+ anchors: unchecked2.map(warningAnchor)
1382
1774
  });
1383
1775
  }
1384
1776
  return { record, standing: STANDING[status], heads, warnings };
1385
1777
  });
1386
1778
  }
1779
+ function warningAnchor(entry) {
1780
+ const { file, symbol, diffSize, reason, repo, remoteState } = entry;
1781
+ return {
1782
+ file,
1783
+ ...symbol !== void 0 ? { symbol } : {},
1784
+ diffSize,
1785
+ ...reason !== void 0 ? { reason } : {},
1786
+ ...repo !== void 0 ? { repo } : {},
1787
+ ...remoteState !== void 0 ? { remoteState } : {}
1788
+ };
1789
+ }
1387
1790
  function resolveHeads(from, byId) {
1388
1791
  const warnings = [];
1389
1792
  const heads = /* @__PURE__ */ new Map();
@@ -1660,7 +2063,7 @@ async function buildContext(store, workspaceDir, options = {}) {
1660
2063
  operation: "kb.context.refused",
1661
2064
  approxTokens: total,
1662
2065
  budgetTokens,
1663
- bases: bases.map((base) => base.path)
2066
+ bases: bases.map((base2) => base2.path)
1664
2067
  });
1665
2068
  const refusal = [
1666
2069
  HEADING2,
@@ -1670,7 +2073,7 @@ async function buildContext(store, workspaceDir, options = {}) {
1670
2073
  "from a complete one. The pinned bases:",
1671
2074
  "",
1672
2075
  ...bases.map(
1673
- (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
2076
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
1674
2077
  ),
1675
2078
  "",
1676
2079
  "For the question at hand, read what you need now \u2014 `kb_load` a base",
@@ -1886,6 +2289,16 @@ function validateBundle(records) {
1886
2289
  );
1887
2290
  }
1888
2291
  }
2292
+ for (const anchor of fm.strauss_anchors ?? []) {
2293
+ if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
2294
+ report(
2295
+ "anchor_repo",
2296
+ conceptId2,
2297
+ `anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
2298
+ "warning"
2299
+ );
2300
+ }
2301
+ }
1889
2302
  if (fm.strauss_assumption && fm.sources?.length) {
1890
2303
  report("assumption", conceptId2, "marked an assumption but cites sources");
1891
2304
  }
@@ -1905,7 +2318,8 @@ var KB_DOCTOR_CHECKS = [
1905
2318
  "orphaned",
1906
2319
  "broken-supersession",
1907
2320
  "superseded-but-cited",
1908
- "drifted"
2321
+ "drifted",
2322
+ "unchecked"
1909
2323
  ];
1910
2324
  var CHECK_HEADLINES = {
1911
2325
  expired: "past its stale_after date",
@@ -1915,7 +2329,8 @@ var CHECK_HEADLINES = {
1915
2329
  orphaned: "no other record links to it",
1916
2330
  "broken-supersession": "the supersession pointers do not resolve",
1917
2331
  "superseded-but-cited": "a live record's body links to one that no longer holds",
1918
- drifted: "the code an anchor points at moved out from under its hash"
2332
+ drifted: "the code an anchor points at moved out from under its hash",
2333
+ unchecked: "an anchor in another repository nothing could reach"
1919
2334
  };
1920
2335
  var DAY_MS = 864e5;
1921
2336
  function doctor(bundle, options = {}) {
@@ -1940,7 +2355,8 @@ function doctor(bundle, options = {}) {
1940
2355
  group("orphaned", orphaned(bundle)),
1941
2356
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
1942
2357
  group("superseded-but-cited", supersededButCited(bundle, standings)),
1943
- group("drifted", drifted(inForce))
2358
+ group("drifted", drifted(inForce)),
2359
+ group("unchecked", unchecked(inForce))
1944
2360
  ];
1945
2361
  const counts = Object.fromEntries(
1946
2362
  groups.map((entry) => [entry.check, entry.count])
@@ -2127,21 +2543,38 @@ function supersededButCited(bundle, standings) {
2127
2543
  return findings;
2128
2544
  }
2129
2545
  function drifted(hits) {
2546
+ return anchorFindings(
2547
+ hits,
2548
+ "drifted",
2549
+ (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
2550
+ );
2551
+ }
2552
+ function unchecked(hits) {
2553
+ return anchorFindings(
2554
+ hits,
2555
+ "unchecked",
2556
+ (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
2557
+ );
2558
+ }
2559
+ function anchorFindings(hits, kind, headline) {
2130
2560
  const findings = [];
2131
2561
  for (const hit of hits) {
2132
- const warning = hit.warnings.find((entry) => entry.kind === "drifted");
2562
+ const warning = hit.warnings.find(
2563
+ (entry) => entry.kind === kind
2564
+ );
2133
2565
  if (!warning) continue;
2566
+ const byRepo = /* @__PURE__ */ new Map();
2567
+ for (const anchor of warning.anchors) {
2568
+ const repo = anchor.repo ?? "";
2569
+ byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
2570
+ }
2571
+ const detail = [...byRepo.entries()].map(
2572
+ ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
2573
+ );
2134
2574
  findings.push(
2135
2575
  finding(
2136
2576
  hit.record,
2137
- `${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
2138
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
2139
- if (anchor.reason) return `${at} (${anchor.reason})`;
2140
- if (anchor.diffSize === null) {
2141
- return `${at} (changed, size unrecorded)`;
2142
- }
2143
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
2144
- }).join(", ")}`
2577
+ `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
2145
2578
  )
2146
2579
  );
2147
2580
  }
@@ -2149,6 +2582,15 @@ function drifted(hits) {
2149
2582
  (left, right) => left.conceptId.localeCompare(right.conceptId)
2150
2583
  );
2151
2584
  }
2585
+ function describeAnchor(anchor) {
2586
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
2587
+ if (anchor.reason) return `${at} (${anchor.reason})`;
2588
+ if (anchor.remoteState === "drifted-on-default") {
2589
+ return `${at} (matches ref, moved on the default branch)`;
2590
+ }
2591
+ if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
2592
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
2593
+ }
2152
2594
  function replaces(later, earlier) {
2153
2595
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
2154
2596
  }
@@ -2316,12 +2758,15 @@ function argvFlag(argv, name) {
2316
2758
  var anchorResolveCommand = define({
2317
2759
  name: "anchor-resolve",
2318
2760
  tool: "kb_anchor_resolve",
2319
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
2320
- 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.",
2761
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
2762
+ 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.",
2321
2763
  input: z8.object({
2322
2764
  bundlePath,
2323
2765
  conceptId,
2324
2766
  repoRoot: z8.string().min(1).optional(),
2767
+ offline: z8.boolean().optional().describe(
2768
+ "Resolve foreign anchors from the local repo cache only, never fetching."
2769
+ ),
2325
2770
  rebaseline: z8.boolean().optional().describe(
2326
2771
  "Accept the current code as the new baseline for anchors that drifted."
2327
2772
  ),
@@ -2333,10 +2778,11 @@ var anchorResolveCommand = define({
2333
2778
  bundlePath: path,
2334
2779
  conceptId: argv[1],
2335
2780
  repoRoot: argvFlag(argv, "--repo-root"),
2781
+ offline: argv.includes("--offline"),
2336
2782
  rebaseline: argv.includes("--rebaseline"),
2337
2783
  restamp: argv.includes("--restamp")
2338
2784
  }),
2339
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
2785
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
2340
2786
  const root = repoRoot ?? process.cwd();
2341
2787
  const record = await store.read(path, id);
2342
2788
  if (!record) throw new KbRecordNotFoundError(id);
@@ -2351,18 +2797,10 @@ var anchorResolveCommand = define({
2351
2797
  }
2352
2798
  const results = [];
2353
2799
  const updated = [];
2354
- const origin = new LazyOrigin(root);
2355
2800
  let dirty = false;
2356
- if (anchors.some((anchor) => anchor.repo)) await origin.prime();
2357
- const foreign = new Map(
2358
- anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
2359
- );
2360
- const reads = await readAnchorFiles(
2361
- anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
2362
- anchorFileReader(root)
2363
- );
2801
+ const sources = await readSources(anchors, root, offline === true);
2364
2802
  for (const anchor of anchors) {
2365
- const base = {
2803
+ const base2 = {
2366
2804
  file: anchor.file,
2367
2805
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
2368
2806
  // Carried onto unresolved findings too: an anchor that once hashed
@@ -2370,21 +2808,17 @@ var anchorResolveCommand = define({
2370
2808
  // has to be able to tell it from one nobody ever stamped.
2371
2809
  ...anchor.hash ? { storedHash: anchor.hash } : {}
2372
2810
  };
2373
- if (foreign.get(anchor)) {
2374
- results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
2811
+ const source = sources.get(anchor);
2812
+ if (source.repo) base2.repo = source.repo;
2813
+ if (!source.ok) {
2814
+ results.push({ ...base2, state: "unresolved", reason: source.reason });
2375
2815
  updated.push(anchor);
2376
2816
  continue;
2377
2817
  }
2378
- const fileRead = reads.get(anchor.file);
2379
- if (!fileRead.ok) {
2380
- results.push({ ...base, state: "unresolved", reason: fileRead.reason });
2381
- updated.push(anchor);
2382
- continue;
2383
- }
2384
- const resolved = resolveAnchor(fileRead.source, anchor);
2818
+ const resolved = resolveAnchor(source.source, anchor);
2385
2819
  if (!resolved) {
2386
2820
  results.push({
2387
- ...base,
2821
+ ...base2,
2388
2822
  state: "unresolved",
2389
2823
  reason: "symbol-not-found"
2390
2824
  });
@@ -2399,30 +2833,47 @@ var anchorResolveCommand = define({
2399
2833
  lines: currentLines,
2400
2834
  resolved_at: now()
2401
2835
  };
2836
+ const pinned = anchor.ref !== void 0 && source.repo !== void 0;
2402
2837
  if (!anchor.hash) {
2403
- results.push({ ...base, state: "stamped", currentHash });
2838
+ results.push({ ...base2, state: "stamped", currentHash });
2404
2839
  updated.push(stamped);
2405
2840
  dirty = true;
2406
- } else if (anchor.hash === currentHash) {
2407
- results.push({
2408
- ...base,
2409
- state: "match",
2410
- currentHash
2411
- });
2412
- const refresh = restamp || anchor.resolved_at === void 0;
2413
- updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
2414
- if (refresh) dirty = true;
2415
- } else {
2841
+ continue;
2842
+ }
2843
+ if (anchor.hash !== currentHash) {
2416
2844
  results.push({
2417
- ...base,
2845
+ ...base2,
2418
2846
  state: "drifted",
2419
2847
  currentHash,
2420
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
2848
+ diffSize: lineDelta(anchor, currentLines),
2849
+ ...pinned ? { remoteState: "drifted-from-ref" } : {},
2421
2850
  ...rebaseline ? { rebaselined: true } : {}
2422
2851
  });
2423
2852
  updated.push(rebaseline ? stamped : anchor);
2424
2853
  if (rebaseline) dirty = true;
2854
+ continue;
2425
2855
  }
2856
+ const onDefault = pinned ? headHash(source, anchor) : void 0;
2857
+ if (onDefault && onDefault.hash !== anchor.hash) {
2858
+ results.push({
2859
+ ...base2,
2860
+ state: "drifted",
2861
+ currentHash: onDefault.hash,
2862
+ diffSize: lineDelta(anchor, onDefault.lines),
2863
+ remoteState: "drifted-on-default"
2864
+ });
2865
+ updated.push(anchor);
2866
+ continue;
2867
+ }
2868
+ results.push({
2869
+ ...base2,
2870
+ state: "match",
2871
+ currentHash,
2872
+ ...pinned ? { remoteState: "matches-ref" } : {}
2873
+ });
2874
+ const refresh = restamp || anchor.resolved_at === void 0;
2875
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
2876
+ if (refresh) dirty = true;
2426
2877
  }
2427
2878
  let frozen = false;
2428
2879
  if (dirty) {
@@ -2435,16 +2886,19 @@ var anchorResolveCommand = define({
2435
2886
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
2436
2887
  }
2437
2888
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
2438
- const checked = results.filter((entry) => entry.reason !== "foreign-repo");
2439
- const skipped = results.length - checked.length;
2440
- const matches2 = checked.filter((entry) => entry.state === "match").length;
2441
- const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
2889
+ const unreachable = results.filter(
2890
+ (entry) => isUncheckedReason(entry.reason)
2891
+ ).length;
2892
+ const checked = results.length - unreachable;
2893
+ const matches2 = results.filter((entry) => entry.state === "match").length;
2894
+ const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
2895
+ const clean = checked > 0 && matches2 === checked && unreachable === 0;
2442
2896
  if (clean) {
2443
2897
  try {
2444
2898
  await store.verify(
2445
2899
  path,
2446
2900
  id,
2447
- `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
2901
+ `anchor-resolve: ${note} (regex resolver)`,
2448
2902
  actor,
2449
2903
  now()
2450
2904
  );
@@ -2460,18 +2914,81 @@ var anchorResolveCommand = define({
2460
2914
  }
2461
2915
  return { conceptId: id, results, verified: true, ...frozenNote };
2462
2916
  }
2463
- return { conceptId: id, results, verified: false, ...frozenNote };
2917
+ return {
2918
+ conceptId: id,
2919
+ results,
2920
+ verified: false,
2921
+ ...unreachable ? { note } : {},
2922
+ ...frozenNote
2923
+ };
2464
2924
  },
2465
2925
  // A stored hash that no longer resolves is a broken anchor, not an absence:
2466
2926
  // the file was deleted or the symbol renamed, and exiting zero on it would
2467
2927
  // let the one edit that destroys an anchor pass the gate that exists to
2468
2928
  // catch it. An anchor nobody ever stamped is still just unstamped, and one
2469
- // belonging to another repository was never this run's to check — failing CI
2470
- // on either would gate on work this command did not do.
2929
+ // whose remote nothing could reach was never checked — failing CI on either
2930
+ // would gate on work this command did not do.
2471
2931
  failsWhen: (result) => result.results.some(
2472
- (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
2932
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
2473
2933
  )
2474
2934
  });
2935
+ function lineDelta(anchor, current) {
2936
+ return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
2937
+ }
2938
+ function headHash(source, anchor) {
2939
+ if (source.head === void 0) return void 0;
2940
+ const resolved = resolveAnchor(source.head, anchor);
2941
+ if (!resolved) return void 0;
2942
+ return {
2943
+ hash: hashAnchorText(resolved.text),
2944
+ lines: resolved.endLine - resolved.startLine + 1
2945
+ };
2946
+ }
2947
+ async function readSources(anchors, root, offline) {
2948
+ const origin = new LazyOrigin(root);
2949
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
2950
+ const foreign = new Map(
2951
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
2952
+ );
2953
+ const local = anchors.filter((anchor) => !foreign.get(anchor));
2954
+ const remote = anchors.filter((anchor) => foreign.get(anchor));
2955
+ const reads = await readAnchorFiles(
2956
+ local.map((anchor) => anchor.file),
2957
+ anchorFileReader(root)
2958
+ );
2959
+ const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
2960
+ offline
2961
+ });
2962
+ const sources = /* @__PURE__ */ new Map();
2963
+ for (const anchor of local) {
2964
+ const read = reads.get(anchor.file);
2965
+ sources.set(
2966
+ anchor,
2967
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
2968
+ );
2969
+ }
2970
+ for (const anchor of remote) {
2971
+ const repo = anchor.repo;
2972
+ const key = normalizeRepoUrl(repo);
2973
+ const atDefault = blobs.get(wantKey(key, void 0, anchor.file));
2974
+ const primary = anchor.ref ? blobs.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
2975
+ if (!primary?.ok) {
2976
+ sources.set(anchor, {
2977
+ ok: false,
2978
+ reason: primary?.ok === false ? primary.reason : "remote-unreachable",
2979
+ repo
2980
+ });
2981
+ continue;
2982
+ }
2983
+ sources.set(anchor, {
2984
+ ok: true,
2985
+ source: primary.source,
2986
+ repo,
2987
+ ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
2988
+ });
2989
+ }
2990
+ return sources;
2991
+ }
2475
2992
 
2476
2993
  // src/commands/answer.ts
2477
2994
  import { z as z9 } from "zod";
@@ -2629,8 +3146,8 @@ var days = (what, fallback) => z13.number().int().positive().optional().describe
2629
3146
  var doctorCommand = define({
2630
3147
  name: "doctor",
2631
3148
  tool: "kb_doctor",
2632
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
2633
- 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.",
3149
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3150
+ 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.",
2634
3151
  input: z13.object({
2635
3152
  bundlePath,
2636
3153
  repoRoot: REPO_ROOT,
@@ -2646,6 +3163,9 @@ var doctorCommand = define({
2646
3163
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
2647
3164
  DEFAULT_AGING_DAYS
2648
3165
  ),
3166
+ offline: z13.boolean().optional().describe(
3167
+ "Read foreign anchors from the local repo cache only, never fetching."
3168
+ ),
2649
3169
  strict: z13.boolean().optional().describe(
2650
3170
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
2651
3171
  )
@@ -2665,13 +3185,23 @@ var doctorCommand = define({
2665
3185
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
2666
3186
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
2667
3187
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
3188
+ ...argv.includes("--offline") ? { offline: true } : {},
2668
3189
  ...argv.includes("--strict") ? { strict: true } : {}
2669
3190
  };
2670
3191
  },
2671
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
3192
+ run: async ({ store, now }, {
3193
+ bundlePath: path,
3194
+ expiringDays,
3195
+ unverifiedDays,
3196
+ agingDays,
3197
+ repoRoot,
3198
+ offline
3199
+ }) => {
2672
3200
  const checkedAt = now();
2673
3201
  const records = await store.list(path);
2674
- const anchorDrift = await store.detectDrift(records, repoRoot);
3202
+ const anchorDrift = await store.detectDrift(records, repoRoot, {
3203
+ offline: offline === true
3204
+ });
2675
3205
  const report = doctor(records, {
2676
3206
  ...expiringDays !== void 0 ? { expiringDays } : {},
2677
3207
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
@@ -3381,7 +3911,7 @@ function parseMarkdownWithFrontmatter(text, schema) {
3381
3911
 
3382
3912
  // src/search-index.ts
3383
3913
  import { stat as stat2 } from "fs/promises";
3384
- import { join as join3 } from "path";
3914
+ import { join as join4 } from "path";
3385
3915
  var SEARCH_INDEX_FILE = ".index.sqlite";
3386
3916
  var COLLECTION = "kb";
3387
3917
  async function searchBase(bundlePath2, query, options = {}) {
@@ -3390,7 +3920,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3390
3920
  let store = null;
3391
3921
  try {
3392
3922
  store = await qmd.createStore({
3393
- dbPath: join3(bundlePath2, SEARCH_INDEX_FILE),
3923
+ dbPath: join4(bundlePath2, SEARCH_INDEX_FILE),
3394
3924
  config: {
3395
3925
  collections: {
3396
3926
  [COLLECTION]: {
@@ -3425,7 +3955,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3425
3955
  }
3426
3956
  }
3427
3957
  async function isStale(bundlePath2) {
3428
- const indexAt = await stat2(join3(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3958
+ const indexAt = await stat2(join4(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3429
3959
  if (!indexAt) return true;
3430
3960
  const { readdir: readdir2 } = await import("fs/promises");
3431
3961
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -3434,7 +3964,7 @@ async function isStale(bundlePath2) {
3434
3964
  let stale = false;
3435
3965
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
3436
3966
  if (stale) return;
3437
- const at = await stat2(join3(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3967
+ const at = await stat2(join4(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3438
3968
  if (at > indexAt) stale = true;
3439
3969
  });
3440
3970
  return stale;
@@ -3473,14 +4003,14 @@ import { createHash as createHash2 } from "crypto";
3473
4003
  import {
3474
4004
  appendFile,
3475
4005
  link,
3476
- mkdir as mkdir2,
4006
+ mkdir as mkdir3,
3477
4007
  readdir,
3478
4008
  readFile as readFile4,
3479
4009
  rename,
3480
4010
  unlink,
3481
4011
  writeFile as writeFile3
3482
4012
  } from "fs/promises";
3483
- import { join as join4, resolve as resolve5, sep as sep3 } from "path";
4013
+ import { join as join5, resolve as resolve5, sep as sep3 } from "path";
3484
4014
 
3485
4015
  // src/kb-links/inbound.ts
3486
4016
  function inboundIndex(bundle) {
@@ -3642,7 +4172,7 @@ function appendUnionMergeLine(contents) {
3642
4172
  }
3643
4173
 
3644
4174
  // src/kb-store.ts
3645
- var KB_DIR = join4(".strauss", "kb");
4175
+ var KB_DIR = join5(".strauss", "kb");
3646
4176
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
3647
4177
  var DEFAULT_LOAD_BUDGET = 25e3;
3648
4178
  var KbStore = class {
@@ -3673,7 +4203,7 @@ var KbStore = class {
3673
4203
  const conceptId2 = `${input.type}.${input.slug}`;
3674
4204
  const root = this.root(bundlePath2);
3675
4205
  const target = this.recordPath(bundlePath2, conceptId2);
3676
- await mkdir2(root, { recursive: true });
4206
+ await mkdir3(root, { recursive: true });
3677
4207
  await this.publish(
3678
4208
  target,
3679
4209
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -3737,7 +4267,7 @@ var KbStore = class {
3737
4267
  const records = await mapLimit(
3738
4268
  wanted,
3739
4269
  DEFAULT_IO_CONCURRENCY,
3740
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile4(join4(root, name), "utf8"))
4270
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile4(join5(root, name), "utf8"))
3741
4271
  );
3742
4272
  return records.filter((record) => record !== null);
3743
4273
  }
@@ -3905,7 +4435,8 @@ ${answer}
3905
4435
  * at the repo root, and the MCP server's cwd is the workspace.
3906
4436
  *
3907
4437
  * Public because `doctor` needs the same map with the same degradation: a
3908
- * sweep that failed to read the tree should report no drift, not fail.
4438
+ * sweep that failed to read the tree should report no drift, not fail — and
4439
+ * `offline: false` there, because a sweep is worth a fetch.
3909
4440
  *
3910
4441
  * When no root was given and not one anchored file was found, the finding is
3911
4442
  * discarded. A base read from somewhere other than the tree it describes
@@ -3917,10 +4448,13 @@ ${answer}
3917
4448
  * plausible, and the misses become findings again; an explicit `repoRoot` is
3918
4449
  * taken at its word either way.
3919
4450
  */
3920
- async detectDrift(records, repoRoot) {
4451
+ async detectDrift(records, repoRoot, options = {}) {
3921
4452
  try {
3922
4453
  const drift = await detectAnchorDrift(records, {
3923
- repoRoot: repoRoot ?? process.cwd()
4454
+ repoRoot: repoRoot ?? process.cwd(),
4455
+ // Offline by default: a read path must never spend a network fetch per
4456
+ // call. `doctor` and `anchor-resolve` are the verbs that go get it.
4457
+ remote: { offline: options.offline !== false }
3924
4458
  });
3925
4459
  if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
3926
4460
  this.logger.warn?.({
@@ -4038,11 +4572,11 @@ ${answer}
4038
4572
  async readIndex(bundlePath2) {
4039
4573
  const root = this.root(bundlePath2);
4040
4574
  const expected = renderIndex(await this.list(bundlePath2));
4041
- const stored = await readFile4(join4(root, INDEX_FILE), "utf8").catch(
4575
+ const stored = await readFile4(join5(root, INDEX_FILE), "utf8").catch(
4042
4576
  () => null
4043
4577
  );
4044
4578
  if (indexIsStale(stored, expected)) {
4045
- await this.publish(join4(root, INDEX_FILE), expected, true, INDEX_FILE);
4579
+ await this.publish(join5(root, INDEX_FILE), expected, true, INDEX_FILE);
4046
4580
  this.logger.info?.({
4047
4581
  operation: "kb.index.repair",
4048
4582
  bundlePath: root,
@@ -4060,7 +4594,7 @@ ${answer}
4060
4594
  */
4061
4595
  async readLog(bundlePath2) {
4062
4596
  const raw = await readFile4(
4063
- join4(this.root(bundlePath2), LOG_FILE),
4597
+ join5(this.root(bundlePath2), LOG_FILE),
4064
4598
  "utf8"
4065
4599
  ).catch(() => "");
4066
4600
  const result = parseLog(raw);
@@ -4201,7 +4735,7 @@ ${answer}
4201
4735
  * file must not fail the mutation it guards.
4202
4736
  */
4203
4737
  async ensureGitattributes(root) {
4204
- const target = join4(root, GITATTRIBUTES_FILE);
4738
+ const target = join5(root, GITATTRIBUTES_FILE);
4205
4739
  try {
4206
4740
  let existing;
4207
4741
  try {
@@ -4252,7 +4786,7 @@ ${answer}
4252
4786
  async record(root, entry) {
4253
4787
  await this.ensureGitattributes(root);
4254
4788
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4255
- await appendFile(join4(root, LOG_FILE), line, "utf8").catch((error) => {
4789
+ await appendFile(join5(root, LOG_FILE), line, "utf8").catch((error) => {
4256
4790
  this.logger.warn?.({
4257
4791
  operation: "kb.log.append",
4258
4792
  outcome: "failed",
@@ -4289,7 +4823,7 @@ ${answer}
4289
4823
  { conceptId: conceptId2 }
4290
4824
  );
4291
4825
  }
4292
- return join4(this.root(bundlePath2), `${conceptId2}.md`);
4826
+ return join5(this.root(bundlePath2), `${conceptId2}.md`);
4293
4827
  }
4294
4828
  };
4295
4829
  function estimateTokens(record) {
@@ -4432,7 +4966,7 @@ function typeRank(record) {
4432
4966
  }
4433
4967
 
4434
4968
  // src/version.ts
4435
- var VERSION = true ? "0.1.14" : "0.0.0-dev";
4969
+ var VERSION = true ? "0.1.15" : "0.0.0-dev";
4436
4970
 
4437
4971
  export {
4438
4972
  kbSourceSchema,
@@ -4464,10 +4998,13 @@ export {
4464
4998
  composeNoDecisionRecord,
4465
4999
  isNoDecisionRecord,
4466
5000
  selectDecisions,
5001
+ isCanonicalRepoUrl,
5002
+ repoCacheDir,
5003
+ readRemoteAnchors,
5004
+ anchorFilePath,
4467
5005
  regexResolver,
4468
5006
  hashAnchorText,
4469
5007
  resolveAnchor,
4470
- anchorFilePath,
4471
5008
  detectAnchorDrift,
4472
5009
  Fault,
4473
5010
  ErrorTypes,
@@ -4545,4 +5082,4 @@ export {
4545
5082
  KbStore,
4546
5083
  VERSION
4547
5084
  };
4548
- //# sourceMappingURL=chunk-43KALLFU.js.map
5085
+ //# sourceMappingURL=chunk-KNIUBCZY.js.map