@saasontools/strauss-kb 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -99,6 +99,7 @@ __export(index_exports, {
99
99
  impact: () => impact,
100
100
  inboundIndex: () => inboundIndex,
101
101
  indexIsStale: () => indexIsStale,
102
+ isCanonicalRepoUrl: () => isCanonicalRepoUrl,
102
103
  isKbLinkRel: () => isKbLinkRel,
103
104
  isKbRecordType: () => isKbRecordType,
104
105
  isNoDecisionRecord: () => isNoDecisionRecord,
@@ -122,11 +123,13 @@ __export(index_exports, {
122
123
  pinBase: () => pinBase,
123
124
  readMergedPins: () => readMergedPins,
124
125
  readPinsLayer: () => readPinsLayer,
126
+ readRemoteAnchors: () => readRemoteAnchors,
125
127
  regexResolver: () => regexResolver,
126
128
  renderCatalogLine: () => renderCatalogLine,
127
129
  renderIndex: () => renderIndex,
128
130
  renderIndexLine: () => renderIndexLine,
129
131
  renderLogEntry: () => renderLogEntry,
132
+ repoCacheDir: () => repoCacheDir,
130
133
  resolveAnchor: () => resolveAnchor,
131
134
  resolveHeads: () => resolveHeads,
132
135
  resolveHits: () => resolveHits,
@@ -147,8 +150,8 @@ module.exports = __toCommonJS(index_exports);
147
150
 
148
151
  // src/kb-store.ts
149
152
  var import_node_crypto2 = require("crypto");
150
- var import_promises3 = require("fs/promises");
151
- var import_node_path3 = require("path");
153
+ var import_promises4 = require("fs/promises");
154
+ var import_node_path4 = require("path");
152
155
 
153
156
  // src/concurrency.ts
154
157
  var DEFAULT_IO_CONCURRENCY = 16;
@@ -230,16 +233,17 @@ var kbAnchorSchema = import_zod.z.object({
230
233
  * (`https://github.com/org/name`) or a short name. Absent means the base's
231
234
  * own repository, which is what nearly every anchor means.
232
235
  *
233
- * Unvalidated beyond not-blank: one repository has many spellings.
234
- * Matched after normalisation; see ARCHITECTURE.
236
+ * Unvalidated beyond not-blank: one repository has many spellings, matched
237
+ * after normalisation. Only a full URL can be fetched from, so `validate`
238
+ * warns on a short one; see ARCHITECTURE.
235
239
  */
236
240
  repo: import_zod.z.string().trim().min(1).optional(),
237
241
  /**
238
242
  * The git rev the evidence was taken at. Prefer a commit SHA: a branch
239
243
  * name is a moving pointer, so an anchor pinned to one says the evidence
240
244
  * came from wherever that branch happens to be now, which is not a
241
- * baseline. Recorded and preserved in v1; ref-pinned reads land with
242
- * SAA-709.
245
+ * baseline. A foreign anchor is checked at this rev, and compared against
246
+ * the remote's default branch on top of it.
243
247
  */
244
248
  ref: import_zod.z.string().trim().min(1).optional(),
245
249
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
@@ -517,6 +521,436 @@ function indexIsStale(stored, expected) {
517
521
  return stored !== expected;
518
522
  }
519
523
 
524
+ // src/remote-repo/cache.ts
525
+ var import_node_os = require("os");
526
+ var import_node_path = require("path");
527
+
528
+ // src/anchor-resolver/repo-identity.ts
529
+ var import_node_child_process = require("child_process");
530
+ var import_node_util = require("util");
531
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
532
+ function normalizeRepoUrl(value) {
533
+ let url = value.trim().replace(/^git\+/, "");
534
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
535
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
536
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
537
+ url = trimTrailingSlashes(url);
538
+ if (url.endsWith(".git")) url = url.slice(0, -4);
539
+ return trimTrailingSlashes(url).toLowerCase();
540
+ }
541
+ function trimTrailingSlashes(value) {
542
+ let end = value.length;
543
+ while (end > 0 && value[end - 1] === "/") end -= 1;
544
+ return value.slice(0, end);
545
+ }
546
+ function isCanonicalRepoUrl(value) {
547
+ return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
548
+ }
549
+ function repoPath(normalized) {
550
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
551
+ const segments = withoutScheme.split("/").filter(Boolean);
552
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
553
+ }
554
+ function repoIdentifies(declared, originUrl) {
555
+ if (!originUrl) return false;
556
+ const origin = normalizeRepoUrl(originUrl);
557
+ const want = normalizeRepoUrl(declared);
558
+ if (!want || !origin) return false;
559
+ if (want === origin) return true;
560
+ const path = repoPath(origin);
561
+ if (!path) return false;
562
+ return want === path || want === (path.split("/").pop() ?? "");
563
+ }
564
+ async function repoOriginUrl(repoRoot) {
565
+ try {
566
+ const { stdout } = await execFileAsync(
567
+ "git",
568
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
569
+ { timeout: 5e3 }
570
+ );
571
+ return stdout.trim() || null;
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+ var LazyOrigin = class {
577
+ constructor(repoRoot) {
578
+ this.repoRoot = repoRoot;
579
+ }
580
+ repoRoot;
581
+ url = null;
582
+ asked = false;
583
+ /** Asks git once, so later `isForeign` calls need no await. */
584
+ async prime() {
585
+ if (this.asked) return;
586
+ this.url = await repoOriginUrl(this.repoRoot);
587
+ this.asked = true;
588
+ }
589
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
590
+ isForeign(anchor) {
591
+ if (!anchor.repo) return false;
592
+ return !repoIdentifies(anchor.repo, this.url);
593
+ }
594
+ async foreign(anchor) {
595
+ if (!anchor.repo) return false;
596
+ await this.prime();
597
+ return this.isForeign(anchor);
598
+ }
599
+ };
600
+
601
+ // src/remote-repo/cache.ts
602
+ function repoCacheDir(override) {
603
+ return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".strauss", "repo-cache");
604
+ }
605
+ var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
606
+ function fetchTimeoutMs(override) {
607
+ if (override !== void 0) return override;
608
+ const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
609
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
610
+ }
611
+ function cachePathFor(repo, cacheDir) {
612
+ const normalized = normalizeRepoUrl(repo);
613
+ const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
614
+ if (!scheme) return null;
615
+ const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
616
+ if (segments.length < 2 || segments.some((segment) => segment === null)) {
617
+ return null;
618
+ }
619
+ const path = segments;
620
+ return (0, import_node_path.join)(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
621
+ }
622
+ function safeSegment(value) {
623
+ return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
624
+ }
625
+ function revRef(rev) {
626
+ const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
627
+ let hash = 5381;
628
+ for (let at = 0; at < rev.length; at++) {
629
+ hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
630
+ }
631
+ return `refs/strauss/${safe}-${hash.toString(16)}`;
632
+ }
633
+
634
+ // src/remote-repo/model.ts
635
+ var UNCHECKED_REASONS = [
636
+ "remote-unreachable",
637
+ "repo-unauthorized",
638
+ "default-branch-unknown"
639
+ ];
640
+ function isUncheckedReason(reason) {
641
+ return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
642
+ }
643
+ function wantKey(repo, ref, file) {
644
+ return `${repo}\0${ref ?? ""}\0${file}`;
645
+ }
646
+
647
+ // src/remote-repo/read.ts
648
+ var import_promises = require("fs/promises");
649
+
650
+ // src/remote-repo/git.ts
651
+ var import_node_child_process2 = require("child_process");
652
+ var import_node_util2 = require("util");
653
+
654
+ // src/anchor-resolver/model.ts
655
+ var MAX_ANCHOR_FILE_BYTES = 1048576;
656
+
657
+ // src/remote-repo/git.ts
658
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
659
+ function childEnv() {
660
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
661
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
662
+ delete env[name];
663
+ }
664
+ return env;
665
+ }
666
+ async function git(args, options = {}) {
667
+ try {
668
+ const { stdout, stderr } = await execFileAsync2("git", args, {
669
+ ...options.cwd ? { cwd: options.cwd } : {},
670
+ timeout: options.timeoutMs ?? 3e4,
671
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
672
+ encoding: "utf8",
673
+ windowsHide: true,
674
+ env: childEnv()
675
+ });
676
+ return { ok: true, stdout, stderr, overflowed: false };
677
+ } catch (error) {
678
+ const failure = error;
679
+ return {
680
+ ok: false,
681
+ stdout: failure.stdout ?? "",
682
+ stderr: failure.stderr ?? "",
683
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
684
+ };
685
+ }
686
+ }
687
+ function transportReason(stderr) {
688
+ const text = stderr.toLowerCase();
689
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
690
+ return "repo-unauthorized";
691
+ }
692
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
693
+ return "ref-not-found";
694
+ }
695
+ return "remote-unreachable";
696
+ }
697
+
698
+ // src/remote-repo/validate.ts
699
+ var MAX_REF_LENGTH = 200;
700
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
701
+ function refShapeIsSafe(ref) {
702
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
703
+ if (ref.includes("..")) return false;
704
+ return REF_SHAPE.test(ref);
705
+ }
706
+ async function refIsWellFormed(ref) {
707
+ if (!refShapeIsSafe(ref)) return false;
708
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
709
+ return checked.ok;
710
+ }
711
+ function filePathIsSafe(file) {
712
+ const path = file.replace(/^\.\//, "");
713
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
714
+ return !path.split("/").includes("..");
715
+ }
716
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
717
+ function allowedProtocols() {
718
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
719
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
720
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
721
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
722
+ }
723
+ function isShortRepoName(repo) {
724
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
725
+ }
726
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
727
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
728
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
729
+ function repoUrlIsSafe(repo) {
730
+ const url = repo.trim();
731
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
732
+ const allowed = allowedProtocols();
733
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
734
+ const scheme = URL_SCHEME.exec(url);
735
+ if (!scheme?.[1]) return false;
736
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
737
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
738
+ const at = authority.lastIndexOf("@");
739
+ return at < 0 || !authority.slice(0, at).includes(":");
740
+ }
741
+ function protocolArgs() {
742
+ const allowed = allowedProtocols();
743
+ return [
744
+ "-c",
745
+ "protocol.ext.allow=never",
746
+ "-c",
747
+ `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
748
+ ];
749
+ }
750
+
751
+ // src/remote-repo/read.ts
752
+ var DEFAULT_REPO_CONCURRENCY = 4;
753
+ var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
754
+ async function readRemoteAnchors(wants, options = {}) {
755
+ const out = /* @__PURE__ */ new Map();
756
+ if (!wants.length) return out;
757
+ const cacheDir = repoCacheDir(options.cacheDir);
758
+ const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
759
+ const byRepo = /* @__PURE__ */ new Map();
760
+ for (const want of wants) {
761
+ const key2 = normalizeRepoUrl(want.repo);
762
+ const group2 = byRepo.get(key2) ?? { url: want.repo.trim(), wants: [] };
763
+ group2.wants.push(want);
764
+ byRepo.set(key2, group2);
765
+ }
766
+ const groups = [...byRepo.entries()];
767
+ const results = await mapLimit(
768
+ groups,
769
+ Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
770
+ ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
771
+ cacheDir,
772
+ timeoutMs,
773
+ offline: options.offline === true
774
+ })
775
+ );
776
+ for (const result of results) {
777
+ for (const [key2, read] of result) out.set(key2, read);
778
+ }
779
+ return out;
780
+ }
781
+ async function readOneRepo(repo, url, declared, context) {
782
+ let wants = declared;
783
+ const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
784
+ if (isShortRepoName(url)) {
785
+ return all({ ok: false, reason: "remote-unreachable" });
786
+ }
787
+ if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
788
+ const cache = cachePathFor(repo, context.cacheDir);
789
+ if (!cache) return all({ ok: false, reason: "remote-unreachable" });
790
+ const rejected = /* @__PURE__ */ new Map();
791
+ const usable = [];
792
+ for (const want of wants) {
793
+ const reason = wantReason(want);
794
+ if (reason)
795
+ rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
796
+ else usable.push(want);
797
+ }
798
+ if (!usable.length) return rejected;
799
+ wants = usable;
800
+ const opened = await openCache(cache, url, context);
801
+ if (opened) return new Map([...rejected, ...all(opened)]);
802
+ const wantsDefault = wants.some((want) => want.ref === void 0);
803
+ const branch = wantsDefault ? await defaultBranch(cache, context) : {};
804
+ const revs = /* @__PURE__ */ new Map();
805
+ for (const rev of distinctRevs(wants, branch.name)) {
806
+ revs.set(
807
+ rev,
808
+ await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
809
+ );
810
+ }
811
+ const reads = await mapLimit(
812
+ wants,
813
+ DEFAULT_IO_CONCURRENCY,
814
+ async (want) => {
815
+ const rev = want.ref ?? branch.name;
816
+ if (rev === void 0) {
817
+ return {
818
+ ok: false,
819
+ reason: branch.reason ?? "default-branch-unknown"
820
+ };
821
+ }
822
+ const failed = revs.get(rev);
823
+ if (failed) return failed;
824
+ return readBlob(cache, rev, want.file, context);
825
+ }
826
+ );
827
+ return new Map([
828
+ ...rejected,
829
+ ...wants.map(
830
+ (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
831
+ )
832
+ ]);
833
+ }
834
+ function wantReason(want) {
835
+ if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
836
+ return filePathIsSafe(want.file) ? void 0 : "outside-repo";
837
+ }
838
+ function distinctRevs(wants, branch) {
839
+ const revs = /* @__PURE__ */ new Set();
840
+ for (const want of wants) {
841
+ if (want.ref !== void 0) revs.add(want.ref);
842
+ else if (branch) revs.add(branch);
843
+ }
844
+ return [...revs];
845
+ }
846
+ async function openCache(cache, url, context) {
847
+ try {
848
+ await (0, import_promises.mkdir)(cache, { recursive: true });
849
+ } catch {
850
+ return { ok: false, reason: "remote-unreachable" };
851
+ }
852
+ const init = await git(["init", "--bare", "--quiet", cache], {
853
+ timeoutMs: context.timeoutMs
854
+ });
855
+ if (!init.ok) return { ok: false, reason: "remote-unreachable" };
856
+ const remote = await git(["config", "remote.origin.url", url], {
857
+ cwd: cache,
858
+ timeoutMs: context.timeoutMs
859
+ });
860
+ return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
861
+ }
862
+ async function defaultBranch(cache, context) {
863
+ if (!context.offline) {
864
+ const listed = await git(
865
+ [...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
866
+ {
867
+ cwd: cache,
868
+ timeoutMs: context.timeoutMs
869
+ }
870
+ );
871
+ const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
872
+ if (listed.ok && found?.[1]) {
873
+ const name = found[1];
874
+ await git(["config", "strauss.defaultBranch", name], { cwd: cache });
875
+ return { name };
876
+ }
877
+ if (!listed.ok) {
878
+ const reason = transportReason(listed.stderr);
879
+ if (reason !== "ref-not-found") {
880
+ const cached2 = await cachedBranch(cache);
881
+ return cached2 ? { name: cached2 } : { reason };
882
+ }
883
+ }
884
+ }
885
+ const cached = await cachedBranch(cache);
886
+ if (cached) return { name: cached };
887
+ return {
888
+ reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
889
+ };
890
+ }
891
+ async function cachedBranch(cache) {
892
+ const stored = await git(["config", "--get", "strauss.defaultBranch"], {
893
+ cwd: cache
894
+ });
895
+ if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
896
+ const head = await git(
897
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
898
+ {
899
+ cwd: cache
900
+ }
901
+ );
902
+ const name = head.stdout.trim().replace(/^origin\//, "");
903
+ return head.ok && name ? name : void 0;
904
+ }
905
+ async function ensureRev(cache, rev, context) {
906
+ const ref = revRef(rev);
907
+ const have = await git(
908
+ ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
909
+ {
910
+ cwd: cache
911
+ }
912
+ );
913
+ const cached = have.ok && have.stdout.trim().length > 0;
914
+ if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
915
+ if (context.offline) return { ok: false, reason: "remote-unreachable" };
916
+ const fetched = await git(
917
+ [
918
+ ...protocolArgs(),
919
+ "fetch",
920
+ "--depth",
921
+ "1",
922
+ "origin",
923
+ "--end-of-options",
924
+ rev
925
+ ],
926
+ { cwd: cache, timeoutMs: context.timeoutMs }
927
+ );
928
+ if (!fetched.ok) {
929
+ const reason = transportReason(fetched.stderr);
930
+ if (cached && reason !== "ref-not-found") return void 0;
931
+ return { ok: false, reason };
932
+ }
933
+ const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
934
+ const sha = head.stdout.trim();
935
+ if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
936
+ const updated = await git(["update-ref", ref, sha], { cwd: cache });
937
+ return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
938
+ }
939
+ async function readBlob(cache, rev, file, context) {
940
+ const path = file.replace(/^\.\//, "");
941
+ const blob = await git(
942
+ ["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
943
+ {
944
+ cwd: cache,
945
+ timeoutMs: context.timeoutMs
946
+ }
947
+ );
948
+ if (blob.ok) return { ok: true, source: blob.stdout };
949
+ if (blob.overflowed) return { ok: false, reason: "file-too-large" };
950
+ const text = blob.stderr.toLowerCase();
951
+ return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
952
+ }
953
+
520
954
  // src/adjudicate.ts
521
955
  var STANDING = {
522
956
  accepted: "current",
@@ -557,23 +991,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
557
991
  if (!record.frontmatter.verified?.length) {
558
992
  warnings.push({ kind: "unverified" });
559
993
  }
560
- const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
561
- (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
994
+ const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
995
+ (entry) => entry.state !== "match"
562
996
  );
997
+ const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
998
+ const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
563
999
  if (moved.length) {
1000
+ warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
1001
+ }
1002
+ if (unchecked2.length) {
564
1003
  warnings.push({
565
- kind: "drifted",
566
- anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
567
- file,
568
- ...symbol !== void 0 ? { symbol } : {},
569
- diffSize,
570
- ...reason !== void 0 ? { reason } : {}
571
- }))
1004
+ kind: "unchecked",
1005
+ anchors: unchecked2.map(warningAnchor)
572
1006
  });
573
1007
  }
574
1008
  return { record, standing: STANDING[status], heads, warnings };
575
1009
  });
576
1010
  }
1011
+ function warningAnchor(entry) {
1012
+ const { file, symbol, diffSize, reason, repo, remoteState } = entry;
1013
+ return {
1014
+ file,
1015
+ ...symbol !== void 0 ? { symbol } : {},
1016
+ diffSize,
1017
+ ...reason !== void 0 ? { reason } : {},
1018
+ ...repo !== void 0 ? { repo } : {},
1019
+ ...remoteState !== void 0 ? { remoteState } : {}
1020
+ };
1021
+ }
577
1022
  function resolveHeads(from, byId) {
578
1023
  const warnings = [];
579
1024
  const heads = /* @__PURE__ */ new Map();
@@ -623,14 +1068,98 @@ function successors(record, byId) {
623
1068
  return { records, missing };
624
1069
  }
625
1070
 
626
- // src/anchor-resolver.ts
627
- var import_node_child_process = require("child_process");
1071
+ // src/anchor-resolver/read.ts
1072
+ var import_promises2 = require("fs/promises");
1073
+ var import_node_path2 = require("path");
1074
+ function anchorFilePath(repoRoot, file) {
1075
+ const path = (0, import_node_path2.resolve)(repoRoot, file.replace(/^\.\//, ""));
1076
+ const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(repoRoot), path);
1077
+ if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path2.sep}`) || (0, import_node_path2.isAbsolute)(rel)) {
1078
+ return null;
1079
+ }
1080
+ return path;
1081
+ }
1082
+ function contains(root, path) {
1083
+ const rel = (0, import_node_path2.relative)(root, path);
1084
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`) && !(0, import_node_path2.isAbsolute)(rel);
1085
+ }
1086
+ function errorCode(error) {
1087
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
1088
+ }
1089
+ function anchorFileReader(repoRoot) {
1090
+ let rootOnce;
1091
+ const realRoot = () => {
1092
+ rootOnce ??= (0, import_promises2.realpath)((0, import_node_path2.resolve)(repoRoot)).catch((error) => {
1093
+ rootOnce = void 0;
1094
+ throw error;
1095
+ });
1096
+ return rootOnce;
1097
+ };
1098
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
1099
+ }
1100
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
1101
+ const lexical = anchorFilePath(repoRoot, file);
1102
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
1103
+ let root;
1104
+ let path;
1105
+ try {
1106
+ root = await realRoot();
1107
+ path = await (0, import_promises2.realpath)(lexical);
1108
+ } catch (error) {
1109
+ const code = errorCode(error);
1110
+ if (code === "ENOENT" || code === "ENOTDIR") {
1111
+ return { ok: false, reason: "file-missing" };
1112
+ }
1113
+ return { ok: false, reason: "file-unreadable" };
1114
+ }
1115
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
1116
+ try {
1117
+ const stats = await (0, import_promises2.stat)(path);
1118
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
1119
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
1120
+ return { ok: false, reason: "file-too-large" };
1121
+ }
1122
+ return { ok: true, source: await (0, import_promises2.readFile)(path, "utf8") };
1123
+ } catch (error) {
1124
+ const code = errorCode(error);
1125
+ if (code === "ENOENT" || code === "ENOTDIR") {
1126
+ return { ok: false, reason: "file-missing" };
1127
+ }
1128
+ return { ok: false, reason: "file-unreadable" };
1129
+ }
1130
+ }
1131
+ function looksLikeWrongRepoRoot(drift) {
1132
+ let checked = 0;
1133
+ for (const entries of drift.values()) {
1134
+ for (const entry of entries) {
1135
+ if (entry.repo !== void 0) continue;
1136
+ checked += 1;
1137
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
1138
+ return false;
1139
+ }
1140
+ }
1141
+ }
1142
+ return checked > 0;
1143
+ }
1144
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
1145
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
1146
+ throw new RangeError(
1147
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
1148
+ );
1149
+ }
1150
+ const wanted = [...new Set(files)];
1151
+ const results = await mapLimit(wanted, concurrency, async (file) => {
1152
+ try {
1153
+ return await read(file);
1154
+ } catch {
1155
+ return { ok: false, reason: "file-unreadable" };
1156
+ }
1157
+ });
1158
+ return new Map(wanted.map((file, at) => [file, results[at]]));
1159
+ }
1160
+
1161
+ // src/anchor-resolver/resolver.ts
628
1162
  var import_node_crypto = require("crypto");
629
- var import_promises = require("fs/promises");
630
- var import_node_path = require("path");
631
- var import_node_util = require("util");
632
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
633
- var MAX_ANCHOR_FILE_BYTES = 1048576;
634
1163
  var PARENT_SCOPE_LINES = 50;
635
1164
  var CLEAN_STATE = { blockComment: false, template: false };
636
1165
  function stripLine(line, state) {
@@ -806,157 +1335,8 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
806
1335
  }
807
1336
  return resolver.resolve(normalized, anchor.symbol);
808
1337
  }
809
- function anchorFilePath(repoRoot, file) {
810
- const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
811
- const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
812
- if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
813
- return null;
814
- }
815
- return path;
816
- }
817
- function contains(root, path) {
818
- const rel = (0, import_node_path.relative)(root, path);
819
- return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
820
- }
821
- function normalizeRepoUrl(value) {
822
- let url = value.trim().replace(/^git\+/, "");
823
- const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
824
- if (scp) url = `https://${scp[1]}/${scp[2]}`;
825
- url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
826
- url = trimTrailingSlashes(url);
827
- if (url.endsWith(".git")) url = url.slice(0, -4);
828
- return trimTrailingSlashes(url).toLowerCase();
829
- }
830
- function trimTrailingSlashes(value) {
831
- let end = value.length;
832
- while (end > 0 && value[end - 1] === "/") end -= 1;
833
- return value.slice(0, end);
834
- }
835
- function repoPath(normalized) {
836
- const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
837
- const segments = withoutScheme.split("/").filter(Boolean);
838
- return segments.length > 1 ? segments.slice(1).join("/") : "";
839
- }
840
- function repoIdentifies(declared, originUrl) {
841
- if (!originUrl) return false;
842
- const origin = normalizeRepoUrl(originUrl);
843
- const want = normalizeRepoUrl(declared);
844
- if (!want || !origin) return false;
845
- if (want === origin) return true;
846
- const path = repoPath(origin);
847
- if (!path) return false;
848
- return want === path || want === (path.split("/").pop() ?? "");
849
- }
850
- async function repoOriginUrl(repoRoot) {
851
- try {
852
- const { stdout } = await execFileAsync(
853
- "git",
854
- ["-C", repoRoot, "config", "--get", "remote.origin.url"],
855
- { timeout: 5e3 }
856
- );
857
- return stdout.trim() || null;
858
- } catch {
859
- return null;
860
- }
861
- }
862
- var LazyOrigin = class {
863
- constructor(repoRoot) {
864
- this.repoRoot = repoRoot;
865
- }
866
- repoRoot;
867
- url = null;
868
- asked = false;
869
- /** Asks git once, so later `isForeign` calls need no await. */
870
- async prime() {
871
- if (this.asked) return;
872
- this.url = await repoOriginUrl(this.repoRoot);
873
- this.asked = true;
874
- }
875
- /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
876
- isForeign(anchor) {
877
- if (!anchor.repo) return false;
878
- return !repoIdentifies(anchor.repo, this.url);
879
- }
880
- async foreign(anchor) {
881
- if (!anchor.repo) return false;
882
- await this.prime();
883
- return this.isForeign(anchor);
884
- }
885
- };
886
- function errorCode(error) {
887
- return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
888
- }
889
- function anchorFileReader(repoRoot) {
890
- let rootOnce;
891
- const realRoot = () => {
892
- rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
893
- rootOnce = void 0;
894
- throw error;
895
- });
896
- return rootOnce;
897
- };
898
- return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
899
- }
900
- async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
901
- const lexical = anchorFilePath(repoRoot, file);
902
- if (lexical === null) return { ok: false, reason: "outside-repo" };
903
- let root;
904
- let path;
905
- try {
906
- root = await realRoot();
907
- path = await (0, import_promises.realpath)(lexical);
908
- } catch (error) {
909
- const code = errorCode(error);
910
- if (code === "ENOENT" || code === "ENOTDIR") {
911
- return { ok: false, reason: "file-missing" };
912
- }
913
- return { ok: false, reason: "file-unreadable" };
914
- }
915
- if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
916
- try {
917
- const stats = await (0, import_promises.stat)(path);
918
- if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
919
- if (stats.size > MAX_ANCHOR_FILE_BYTES) {
920
- return { ok: false, reason: "file-too-large" };
921
- }
922
- return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
923
- } catch (error) {
924
- const code = errorCode(error);
925
- if (code === "ENOENT" || code === "ENOTDIR") {
926
- return { ok: false, reason: "file-missing" };
927
- }
928
- return { ok: false, reason: "file-unreadable" };
929
- }
930
- }
931
- function looksLikeWrongRepoRoot(drift) {
932
- let checked = 0;
933
- for (const entries of drift.values()) {
934
- for (const entry of entries) {
935
- if (entry.reason === "foreign-repo") continue;
936
- checked += 1;
937
- if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
938
- return false;
939
- }
940
- }
941
- }
942
- return checked > 0;
943
- }
944
- async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
945
- if (!Number.isInteger(concurrency) || concurrency < 1) {
946
- throw new RangeError(
947
- `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
948
- );
949
- }
950
- const wanted = [...new Set(files)];
951
- const results = await mapLimit(wanted, concurrency, async (file) => {
952
- try {
953
- return await read(file);
954
- } catch {
955
- return { ok: false, reason: "file-unreadable" };
956
- }
957
- });
958
- return new Map(wanted.map((file, at) => [file, results[at]]));
959
- }
1338
+
1339
+ // src/anchor-resolver/drift.ts
960
1340
  async function detectAnchorDrift(records, options = {}) {
961
1341
  const repoRoot = options.repoRoot ?? process.cwd();
962
1342
  const resolver = options.resolver ?? regexResolver;
@@ -982,71 +1362,101 @@ async function detectAnchorDrift(records, options = {}) {
982
1362
  }
983
1363
  }
984
1364
  const files = [];
1365
+ const wants = [];
985
1366
  for (const entries of planned.values()) {
986
- for (const entry of entries) {
987
- if (!entry.foreign) files.push(entry.anchor.file);
1367
+ for (const { anchor, foreign } of entries) {
1368
+ if (!foreign) files.push(anchor.file);
1369
+ else wants.push(...remoteWants(anchor));
988
1370
  }
989
1371
  }
990
- const reads = await readAnchorFiles(
991
- files,
992
- options.reader ?? anchorFileReader(repoRoot),
993
- options.concurrency ?? DEFAULT_IO_CONCURRENCY
994
- );
1372
+ const [reads, remote] = await Promise.all([
1373
+ readAnchorFiles(
1374
+ files,
1375
+ options.reader ?? anchorFileReader(repoRoot),
1376
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
1377
+ ),
1378
+ (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1379
+ ]);
995
1380
  const drift = /* @__PURE__ */ new Map();
996
1381
  for (const record of records) {
997
1382
  const entries = [];
998
1383
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
999
- const base = {
1000
- file: anchor.file,
1001
- ...anchor.symbol ? { symbol: anchor.symbol } : {},
1002
- storedHash: anchor.hash
1003
- };
1004
- if (foreign) {
1005
- entries.push({
1006
- ...base,
1007
- state: "unresolved",
1008
- diffSize: null,
1009
- reason: "foreign-repo"
1010
- });
1011
- continue;
1012
- }
1013
- const read = reads.get(anchor.file);
1014
- if (!read.ok) {
1015
- entries.push({
1016
- ...base,
1017
- state: "unresolved",
1018
- diffSize: null,
1019
- reason: read.reason
1020
- });
1021
- continue;
1022
- }
1023
- const resolved = resolveAnchor(read.source, anchor, resolver);
1024
- if (!resolved) {
1025
- entries.push({
1026
- ...base,
1027
- state: "unresolved",
1028
- diffSize: null,
1029
- reason: "symbol-not-found"
1030
- });
1031
- continue;
1032
- }
1033
- const currentHash = hashAnchorText(resolved.text);
1034
- const currentLines = resolved.endLine - resolved.startLine + 1;
1035
- entries.push({
1036
- ...base,
1037
- state: currentHash === anchor.hash ? "match" : "drifted",
1038
- currentHash,
1039
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
1040
- });
1384
+ entries.push(
1385
+ foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1386
+ );
1041
1387
  }
1042
1388
  if (entries.length) drift.set(record.conceptId, entries);
1043
1389
  }
1044
1390
  return drift;
1045
1391
  }
1392
+ function remoteWants(anchor) {
1393
+ const repo = anchor.repo;
1394
+ const wants = [{ repo, file: anchor.file }];
1395
+ if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
1396
+ return wants;
1397
+ }
1398
+ function base(anchor) {
1399
+ return {
1400
+ file: anchor.file,
1401
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1402
+ storedHash: anchor.hash
1403
+ };
1404
+ }
1405
+ function unresolved(anchor, reason, repo) {
1406
+ return {
1407
+ ...base(anchor),
1408
+ state: "unresolved",
1409
+ diffSize: null,
1410
+ ...reason ? { reason } : {},
1411
+ ...repo ? { repo } : {}
1412
+ };
1413
+ }
1414
+ function hashIn(source, anchor, resolver) {
1415
+ const resolved = resolveAnchor(source, anchor, resolver);
1416
+ if (!resolved) return null;
1417
+ return {
1418
+ hash: hashAnchorText(resolved.text),
1419
+ lines: resolved.endLine - resolved.startLine + 1
1420
+ };
1421
+ }
1422
+ function compared(anchor, current, extra = {}) {
1423
+ return {
1424
+ ...base(anchor),
1425
+ state: current.hash === anchor.hash ? "match" : "drifted",
1426
+ currentHash: current.hash,
1427
+ diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1428
+ ...extra
1429
+ };
1430
+ }
1431
+ function localEntry(anchor, read, resolver) {
1432
+ if (!read.ok) return unresolved(anchor, read.reason);
1433
+ const current = hashIn(read.source, anchor, resolver);
1434
+ return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1435
+ }
1436
+ function remoteEntry(anchor, remote, resolver) {
1437
+ const repo = anchor.repo;
1438
+ const key2 = normalizeRepoUrl(repo);
1439
+ const atDefault = remote.get(wantKey(key2, void 0, anchor.file));
1440
+ const primary = anchor.ref ? remote.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
1441
+ if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1442
+ if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1443
+ const current = hashIn(primary.source, anchor, resolver);
1444
+ if (!current) return unresolved(anchor, "symbol-not-found", repo);
1445
+ if (!anchor.ref) return compared(anchor, current, { repo });
1446
+ if (current.hash !== anchor.hash) {
1447
+ return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1448
+ }
1449
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1450
+ return head && head.hash !== anchor.hash ? {
1451
+ ...compared(anchor, head, { repo }),
1452
+ state: "drifted",
1453
+ remoteState: "drifted-on-default"
1454
+ } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1455
+ }
1046
1456
 
1047
1457
  // src/search-index.ts
1048
- var import_promises2 = require("fs/promises");
1049
- var import_node_path2 = require("path");
1458
+ var import_promises3 = require("fs/promises");
1459
+ var import_node_path3 = require("path");
1050
1460
 
1051
1461
  // src/kb-log.ts
1052
1462
  var import_zod2 = require("zod");
@@ -1109,7 +1519,7 @@ async function searchBase(bundlePath2, query, options = {}) {
1109
1519
  let store = null;
1110
1520
  try {
1111
1521
  store = await qmd.createStore({
1112
- dbPath: (0, import_node_path2.join)(bundlePath2, SEARCH_INDEX_FILE),
1522
+ dbPath: (0, import_node_path3.join)(bundlePath2, SEARCH_INDEX_FILE),
1113
1523
  config: {
1114
1524
  collections: {
1115
1525
  [COLLECTION]: {
@@ -1144,7 +1554,7 @@ async function searchBase(bundlePath2, query, options = {}) {
1144
1554
  }
1145
1555
  }
1146
1556
  async function isStale(bundlePath2) {
1147
- const indexAt = await (0, import_promises2.stat)((0, import_node_path2.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
1557
+ const indexAt = await (0, import_promises3.stat)((0, import_node_path3.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
1148
1558
  if (!indexAt) return true;
1149
1559
  const { readdir: readdir2 } = await import("fs/promises");
1150
1560
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -1153,7 +1563,7 @@ async function isStale(bundlePath2) {
1153
1563
  let stale = false;
1154
1564
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
1155
1565
  if (stale) return;
1156
- const at = await (0, import_promises2.stat)((0, import_node_path2.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
1566
+ const at = await (0, import_promises3.stat)((0, import_node_path3.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
1157
1567
  if (at > indexAt) stale = true;
1158
1568
  });
1159
1569
  return stale;
@@ -1743,7 +2153,7 @@ function appendUnionMergeLine(contents) {
1743
2153
  }
1744
2154
 
1745
2155
  // src/kb-store.ts
1746
- var KB_DIR = (0, import_node_path3.join)(".strauss", "kb");
2156
+ var KB_DIR = (0, import_node_path4.join)(".strauss", "kb");
1747
2157
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
1748
2158
  var DEFAULT_LOAD_BUDGET = 25e3;
1749
2159
  var KbStore = class {
@@ -1774,7 +2184,7 @@ var KbStore = class {
1774
2184
  const conceptId2 = `${input.type}.${input.slug}`;
1775
2185
  const root = this.root(bundlePath2);
1776
2186
  const target = this.recordPath(bundlePath2, conceptId2);
1777
- await (0, import_promises3.mkdir)(root, { recursive: true });
2187
+ await (0, import_promises4.mkdir)(root, { recursive: true });
1778
2188
  await this.publish(
1779
2189
  target,
1780
2190
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -1813,7 +2223,7 @@ var KbStore = class {
1813
2223
  const target = this.recordPath(bundlePath2, conceptId2);
1814
2224
  let raw;
1815
2225
  try {
1816
- raw = await (0, import_promises3.readFile)(target, "utf8");
2226
+ raw = await (0, import_promises4.readFile)(target, "utf8");
1817
2227
  } catch {
1818
2228
  return null;
1819
2229
  }
@@ -1830,7 +2240,7 @@ var KbStore = class {
1830
2240
  const root = this.root(bundlePath2);
1831
2241
  let names;
1832
2242
  try {
1833
- names = await (0, import_promises3.readdir)(root);
2243
+ names = await (0, import_promises4.readdir)(root);
1834
2244
  } catch {
1835
2245
  return [];
1836
2246
  }
@@ -1838,7 +2248,7 @@ var KbStore = class {
1838
2248
  const records = await mapLimit(
1839
2249
  wanted,
1840
2250
  DEFAULT_IO_CONCURRENCY,
1841
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises3.readFile)((0, import_node_path3.join)(root, name), "utf8"))
2251
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises4.readFile)((0, import_node_path4.join)(root, name), "utf8"))
1842
2252
  );
1843
2253
  return records.filter((record) => record !== null);
1844
2254
  }
@@ -2006,7 +2416,8 @@ ${answer}
2006
2416
  * at the repo root, and the MCP server's cwd is the workspace.
2007
2417
  *
2008
2418
  * Public because `doctor` needs the same map with the same degradation: a
2009
- * sweep that failed to read the tree should report no drift, not fail.
2419
+ * sweep that failed to read the tree should report no drift, not fail — and
2420
+ * `offline: false` there, because a sweep is worth a fetch.
2010
2421
  *
2011
2422
  * When no root was given and not one anchored file was found, the finding is
2012
2423
  * discarded. A base read from somewhere other than the tree it describes
@@ -2018,10 +2429,13 @@ ${answer}
2018
2429
  * plausible, and the misses become findings again; an explicit `repoRoot` is
2019
2430
  * taken at its word either way.
2020
2431
  */
2021
- async detectDrift(records, repoRoot) {
2432
+ async detectDrift(records, repoRoot, options = {}) {
2022
2433
  try {
2023
2434
  const drift = await detectAnchorDrift(records, {
2024
- repoRoot: repoRoot ?? process.cwd()
2435
+ repoRoot: repoRoot ?? process.cwd(),
2436
+ // Offline by default: a read path must never spend a network fetch per
2437
+ // call. `doctor` and `anchor-resolve` are the verbs that go get it.
2438
+ remote: { offline: options.offline !== false }
2025
2439
  });
2026
2440
  if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
2027
2441
  this.logger.warn?.({
@@ -2139,11 +2553,11 @@ ${answer}
2139
2553
  async readIndex(bundlePath2) {
2140
2554
  const root = this.root(bundlePath2);
2141
2555
  const expected = renderIndex(await this.list(bundlePath2));
2142
- const stored = await (0, import_promises3.readFile)((0, import_node_path3.join)(root, INDEX_FILE), "utf8").catch(
2556
+ const stored = await (0, import_promises4.readFile)((0, import_node_path4.join)(root, INDEX_FILE), "utf8").catch(
2143
2557
  () => null
2144
2558
  );
2145
2559
  if (indexIsStale(stored, expected)) {
2146
- await this.publish((0, import_node_path3.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
2560
+ await this.publish((0, import_node_path4.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
2147
2561
  this.logger.info?.({
2148
2562
  operation: "kb.index.repair",
2149
2563
  bundlePath: root,
@@ -2160,8 +2574,8 @@ ${answer}
2160
2574
  * knows which agent touched what. So a bad line is surfaced and left alone.
2161
2575
  */
2162
2576
  async readLog(bundlePath2) {
2163
- const raw = await (0, import_promises3.readFile)(
2164
- (0, import_node_path3.join)(this.root(bundlePath2), LOG_FILE),
2577
+ const raw = await (0, import_promises4.readFile)(
2578
+ (0, import_node_path4.join)(this.root(bundlePath2), LOG_FILE),
2165
2579
  "utf8"
2166
2580
  ).catch(() => "");
2167
2581
  const result = parseLog(raw);
@@ -2212,14 +2626,14 @@ ${answer}
2212
2626
  }
2213
2627
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
2214
2628
  const target = this.recordPath(bundlePath2, conceptId2);
2215
- const before = await (0, import_promises3.readFile)(target, "utf8").catch(() => null);
2629
+ const before = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
2216
2630
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
2217
2631
  const parsed = this.parse(conceptId2, before);
2218
2632
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
2219
2633
  const frontmatter = change(parsed.frontmatter);
2220
2634
  const body = changeBody(parsed.body);
2221
2635
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
2222
- const witness = await (0, import_promises3.readFile)(target, "utf8").catch(() => null);
2636
+ const witness = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
2223
2637
  if (witness === null || digest(witness) !== digest(before)) {
2224
2638
  throw new KbWriteConflictError(conceptId2);
2225
2639
  }
@@ -2245,20 +2659,20 @@ ${answer}
2245
2659
  */
2246
2660
  async publish(target, contents, overwrite, conceptId2) {
2247
2661
  const staging = `${target}.${process.pid}.tmp`;
2248
- await (0, import_promises3.writeFile)(staging, contents, "utf8");
2662
+ await (0, import_promises4.writeFile)(staging, contents, "utf8");
2249
2663
  try {
2250
2664
  if (overwrite) {
2251
- await (0, import_promises3.rename)(staging, target);
2665
+ await (0, import_promises4.rename)(staging, target);
2252
2666
  return;
2253
2667
  }
2254
- await (0, import_promises3.link)(staging, target);
2668
+ await (0, import_promises4.link)(staging, target);
2255
2669
  } catch (error) {
2256
2670
  if (error.code === "EEXIST") {
2257
2671
  throw new KbRecordAlreadyExistsError(conceptId2);
2258
2672
  }
2259
2673
  throw error;
2260
2674
  } finally {
2261
- await (0, import_promises3.unlink)(staging).catch(() => void 0);
2675
+ await (0, import_promises4.unlink)(staging).catch(() => void 0);
2262
2676
  }
2263
2677
  }
2264
2678
  /**
@@ -2302,18 +2716,18 @@ ${answer}
2302
2716
  * file must not fail the mutation it guards.
2303
2717
  */
2304
2718
  async ensureGitattributes(root) {
2305
- const target = (0, import_node_path3.join)(root, GITATTRIBUTES_FILE);
2719
+ const target = (0, import_node_path4.join)(root, GITATTRIBUTES_FILE);
2306
2720
  try {
2307
2721
  let existing;
2308
2722
  try {
2309
- existing = await (0, import_promises3.readFile)(target, "utf8");
2723
+ existing = await (0, import_promises4.readFile)(target, "utf8");
2310
2724
  } catch (error) {
2311
2725
  if (error.code !== "ENOENT") throw error;
2312
2726
  existing = null;
2313
2727
  }
2314
2728
  if (existing === null) {
2315
2729
  try {
2316
- await (0, import_promises3.writeFile)(target, appendUnionMergeLine(""), {
2730
+ await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
2317
2731
  encoding: "utf8",
2318
2732
  flag: "wx"
2319
2733
  });
@@ -2334,7 +2748,7 @@ ${answer}
2334
2748
  return;
2335
2749
  }
2336
2750
  if (!hasMergeDeclaration(existing)) {
2337
- await (0, import_promises3.appendFile)(target, appendUnionMergeLine(existing), "utf8");
2751
+ await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
2338
2752
  this.logger.info?.({
2339
2753
  operation: "kb.gitattributes.ensure",
2340
2754
  bundlePath: root,
@@ -2353,7 +2767,7 @@ ${answer}
2353
2767
  async record(root, entry) {
2354
2768
  await this.ensureGitattributes(root);
2355
2769
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
2356
- await (0, import_promises3.appendFile)((0, import_node_path3.join)(root, LOG_FILE), line, "utf8").catch((error) => {
2770
+ await (0, import_promises4.appendFile)((0, import_node_path4.join)(root, LOG_FILE), line, "utf8").catch((error) => {
2357
2771
  this.logger.warn?.({
2358
2772
  operation: "kb.log.append",
2359
2773
  outcome: "failed",
@@ -2379,18 +2793,18 @@ ${answer}
2379
2793
  };
2380
2794
  }
2381
2795
  root(bundlePath2) {
2382
- return (0, import_node_path3.resolve)(bundlePath2);
2796
+ return (0, import_node_path4.resolve)(bundlePath2);
2383
2797
  }
2384
2798
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
2385
2799
  // bundle root; anything carrying a separator would escape it.
2386
2800
  recordPath(bundlePath2, conceptId2) {
2387
- if (conceptId2.includes(import_node_path3.sep) || conceptId2.includes("/")) {
2801
+ if (conceptId2.includes(import_node_path4.sep) || conceptId2.includes("/")) {
2388
2802
  throw new KbInvalidConceptIdError(
2389
2803
  "concept id must not contain a path separator",
2390
2804
  { conceptId: conceptId2 }
2391
2805
  );
2392
2806
  }
2393
- return (0, import_node_path3.join)(this.root(bundlePath2), `${conceptId2}.md`);
2807
+ return (0, import_node_path4.join)(this.root(bundlePath2), `${conceptId2}.md`);
2394
2808
  }
2395
2809
  };
2396
2810
  function estimateTokens(record) {
@@ -2619,18 +3033,18 @@ var KbBaseFrozenError = class extends Error {
2619
3033
  };
2620
3034
 
2621
3035
  // src/kb-pins/frozen.ts
2622
- var import_node_path6 = require("path");
3036
+ var import_node_path7 = require("path");
2623
3037
 
2624
3038
  // src/kb-pins/layers.ts
2625
- var import_promises4 = require("fs/promises");
2626
- var import_node_os = require("os");
2627
- var import_node_path5 = require("path");
3039
+ var import_promises5 = require("fs/promises");
3040
+ var import_node_os2 = require("os");
3041
+ var import_node_path6 = require("path");
2628
3042
 
2629
3043
  // src/kb-pins/model.ts
2630
- var import_node_path4 = require("path");
3044
+ var import_node_path5 = require("path");
2631
3045
  var import_zod4 = require("zod");
2632
- var PINS_FILE = (0, import_node_path4.join)(".strauss", "kb-pins.json");
2633
- var PINS_LOCAL_FILE = (0, import_node_path4.join)(".strauss", "kb-pins.local.json");
3046
+ var PINS_FILE = (0, import_node_path5.join)(".strauss", "kb-pins.json");
3047
+ var PINS_LOCAL_FILE = (0, import_node_path5.join)(".strauss", "kb-pins.local.json");
2634
3048
  var PIN_LAYERS = ["project", "local", "user"];
2635
3049
  var pinSchema = import_zod4.z.object({
2636
3050
  /** Relative to the manifest's root, so the file is committable. */
@@ -2677,13 +3091,13 @@ var pinsManifestSchema = import_zod4.z.object({
2677
3091
 
2678
3092
  // src/kb-pins/layers.ts
2679
3093
  function userRoot() {
2680
- return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
3094
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
2681
3095
  }
2682
3096
  function layerRoot(workspaceDir, layer) {
2683
- return layer === "user" ? userRoot() : (0, import_node_path5.resolve)(workspaceDir);
3097
+ return layer === "user" ? userRoot() : (0, import_node_path6.resolve)(workspaceDir);
2684
3098
  }
2685
3099
  function layerFile(workspaceDir, layer) {
2686
- return (0, import_node_path5.join)(
3100
+ return (0, import_node_path6.join)(
2687
3101
  layerRoot(workspaceDir, layer),
2688
3102
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
2689
3103
  );
@@ -2692,7 +3106,7 @@ async function readPinsLayer(workspaceDir, layer) {
2692
3106
  const file = layerFile(workspaceDir, layer);
2693
3107
  let raw;
2694
3108
  try {
2695
- raw = await (0, import_promises4.readFile)(file, "utf8");
3109
+ raw = await (0, import_promises5.readFile)(file, "utf8");
2696
3110
  } catch {
2697
3111
  return { pins: [] };
2698
3112
  }
@@ -2716,16 +3130,16 @@ async function readPinsLayer(workspaceDir, layer) {
2716
3130
  }
2717
3131
  async function writePinsLayer(workspaceDir, layer, manifest) {
2718
3132
  const file = layerFile(workspaceDir, layer);
2719
- await (0, import_promises4.mkdir)((0, import_node_path5.dirname)(file), { recursive: true });
2720
- await (0, import_promises4.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
3133
+ await (0, import_promises5.mkdir)((0, import_node_path6.dirname)(file), { recursive: true });
3134
+ await (0, import_promises5.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
2721
3135
  `, "utf8");
2722
3136
  }
2723
3137
  function resolvePinPath(rootDir, path) {
2724
- return (0, import_node_path5.isAbsolute)(path) ? (0, import_node_path5.resolve)(path) : (0, import_node_path5.resolve)(rootDir, path.split("/").join(import_node_path5.sep));
3138
+ return (0, import_node_path6.isAbsolute)(path) ? (0, import_node_path6.resolve)(path) : (0, import_node_path6.resolve)(rootDir, path.split("/").join(import_node_path6.sep));
2725
3139
  }
2726
3140
  function storablePath(rootDir, bundlePath2) {
2727
- const rel = (0, import_node_path5.relative)((0, import_node_path5.resolve)(rootDir), (0, import_node_path5.resolve)(bundlePath2));
2728
- return (rel === "" ? "." : rel).split(import_node_path5.sep).join("/");
3141
+ const rel = (0, import_node_path6.relative)((0, import_node_path6.resolve)(rootDir), (0, import_node_path6.resolve)(bundlePath2));
3142
+ return (rel === "" ? "." : rel).split(import_node_path6.sep).join("/");
2729
3143
  }
2730
3144
  async function readMergedPins(workspaceDir) {
2731
3145
  const manifests = {};
@@ -2753,7 +3167,7 @@ async function readMergedPins(workspaceDir) {
2753
3167
  // src/kb-pins/frozen.ts
2754
3168
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
2755
3169
  const merged = await readMergedPins(workspaceDir);
2756
- const absolute = (0, import_node_path6.resolve)(bundlePath2);
3170
+ const absolute = (0, import_node_path7.resolve)(bundlePath2);
2757
3171
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
2758
3172
  if (pin?.frozen === true) {
2759
3173
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -2838,7 +3252,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2838
3252
  }
2839
3253
 
2840
3254
  // src/kb-pins/unpin.ts
2841
- var import_node_path7 = require("path");
3255
+ var import_node_path8 = require("path");
2842
3256
  async function unpinBase(workspaceDir, bundlePath2) {
2843
3257
  const layers = [];
2844
3258
  for (const layer of PIN_LAYERS) {
@@ -2859,14 +3273,14 @@ async function unpinBase(workspaceDir, bundlePath2) {
2859
3273
  }
2860
3274
  }
2861
3275
  return {
2862
- path: storablePath((0, import_node_path7.resolve)(workspaceDir), bundlePath2),
3276
+ path: storablePath((0, import_node_path8.resolve)(workspaceDir), bundlePath2),
2863
3277
  removed: layers.length > 0,
2864
3278
  layers
2865
3279
  };
2866
3280
  }
2867
3281
 
2868
3282
  // src/kb-context.ts
2869
- var import_promises5 = require("fs/promises");
3283
+ var import_promises6 = require("fs/promises");
2870
3284
  var HEADING2 = "## Knowledge bases (pinned)";
2871
3285
  var DEFAULT_CONTEXT_BUDGET = 4e3;
2872
3286
  var CONTEXT_PROFILES = {
@@ -3025,7 +3439,7 @@ async function buildContext(store, workspaceDir, options = {}) {
3025
3439
  operation: "kb.context.refused",
3026
3440
  approxTokens: total,
3027
3441
  budgetTokens,
3028
- bases: bases.map((base) => base.path)
3442
+ bases: bases.map((base2) => base2.path)
3029
3443
  });
3030
3444
  const refusal = [
3031
3445
  HEADING2,
@@ -3035,7 +3449,7 @@ async function buildContext(store, workspaceDir, options = {}) {
3035
3449
  "from a complete one. The pinned bases:",
3036
3450
  "",
3037
3451
  ...bases.map(
3038
- (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
3452
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
3039
3453
  ),
3040
3454
  "",
3041
3455
  "For the question at hand, read what you need now \u2014 `kb_load` a base",
@@ -3070,13 +3484,13 @@ function toHookJson(block, event) {
3070
3484
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
3071
3485
  var CONTEXT_END = "<!-- strauss-kb:end -->";
3072
3486
  async function syncInstructions(file, block) {
3073
- const existing = await (0, import_promises5.readFile)(file, "utf8").catch(() => null);
3487
+ const existing = await (0, import_promises6.readFile)(file, "utf8").catch(() => null);
3074
3488
  const region = block ? `${CONTEXT_BEGIN}
3075
3489
  ${block.trim()}
3076
3490
  ${CONTEXT_END}` : null;
3077
3491
  if (existing === null) {
3078
3492
  if (!region) return { file, action: "unchanged" };
3079
- await (0, import_promises5.writeFile)(file, `${region}
3493
+ await (0, import_promises6.writeFile)(file, `${region}
3080
3494
  `, "utf8");
3081
3495
  return { file, action: "created" };
3082
3496
  }
@@ -3087,11 +3501,11 @@ ${CONTEXT_END}` : null;
3087
3501
  const after = existing.slice(end + CONTEXT_END.length);
3088
3502
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
3089
3503
  if (next === existing) return { file, action: "unchanged" };
3090
- await (0, import_promises5.writeFile)(file, next, "utf8");
3504
+ await (0, import_promises6.writeFile)(file, next, "utf8");
3091
3505
  return { file, action: region ? "replaced" : "removed" };
3092
3506
  }
3093
3507
  if (!region) return { file, action: "unchanged" };
3094
- await (0, import_promises5.writeFile)(
3508
+ await (0, import_promises6.writeFile)(
3095
3509
  file,
3096
3510
  `${existing.replace(/\n*$/, "\n\n")}${region}
3097
3511
  `,
@@ -3250,6 +3664,16 @@ function validateBundle(records) {
3250
3664
  );
3251
3665
  }
3252
3666
  }
3667
+ for (const anchor of fm.strauss_anchors ?? []) {
3668
+ if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
3669
+ report(
3670
+ "anchor_repo",
3671
+ conceptId2,
3672
+ `anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
3673
+ "warning"
3674
+ );
3675
+ }
3676
+ }
3253
3677
  if (fm.strauss_assumption && fm.sources?.length) {
3254
3678
  report("assumption", conceptId2, "marked an assumption but cites sources");
3255
3679
  }
@@ -3269,7 +3693,8 @@ var KB_DOCTOR_CHECKS = [
3269
3693
  "orphaned",
3270
3694
  "broken-supersession",
3271
3695
  "superseded-but-cited",
3272
- "drifted"
3696
+ "drifted",
3697
+ "unchecked"
3273
3698
  ];
3274
3699
  var CHECK_HEADLINES = {
3275
3700
  expired: "past its stale_after date",
@@ -3279,7 +3704,8 @@ var CHECK_HEADLINES = {
3279
3704
  orphaned: "no other record links to it",
3280
3705
  "broken-supersession": "the supersession pointers do not resolve",
3281
3706
  "superseded-but-cited": "a live record's body links to one that no longer holds",
3282
- drifted: "the code an anchor points at moved out from under its hash"
3707
+ drifted: "the code an anchor points at moved out from under its hash",
3708
+ unchecked: "an anchor in another repository nothing could reach"
3283
3709
  };
3284
3710
  var DAY_MS = 864e5;
3285
3711
  function doctor(bundle, options = {}) {
@@ -3304,7 +3730,8 @@ function doctor(bundle, options = {}) {
3304
3730
  group("orphaned", orphaned(bundle)),
3305
3731
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
3306
3732
  group("superseded-but-cited", supersededButCited(bundle, standings)),
3307
- group("drifted", drifted(inForce))
3733
+ group("drifted", drifted(inForce)),
3734
+ group("unchecked", unchecked(inForce))
3308
3735
  ];
3309
3736
  const counts = Object.fromEntries(
3310
3737
  groups.map((entry) => [entry.check, entry.count])
@@ -3491,21 +3918,38 @@ function supersededButCited(bundle, standings) {
3491
3918
  return findings;
3492
3919
  }
3493
3920
  function drifted(hits) {
3921
+ return anchorFindings(
3922
+ hits,
3923
+ "drifted",
3924
+ (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
3925
+ );
3926
+ }
3927
+ function unchecked(hits) {
3928
+ return anchorFindings(
3929
+ hits,
3930
+ "unchecked",
3931
+ (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
3932
+ );
3933
+ }
3934
+ function anchorFindings(hits, kind, headline) {
3494
3935
  const findings = [];
3495
3936
  for (const hit of hits) {
3496
- const warning = hit.warnings.find((entry) => entry.kind === "drifted");
3937
+ const warning = hit.warnings.find(
3938
+ (entry) => entry.kind === kind
3939
+ );
3497
3940
  if (!warning) continue;
3941
+ const byRepo = /* @__PURE__ */ new Map();
3942
+ for (const anchor of warning.anchors) {
3943
+ const repo = anchor.repo ?? "";
3944
+ byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
3945
+ }
3946
+ const detail = [...byRepo.entries()].map(
3947
+ ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
3948
+ );
3498
3949
  findings.push(
3499
3950
  finding(
3500
3951
  hit.record,
3501
- `${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
3502
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3503
- if (anchor.reason) return `${at} (${anchor.reason})`;
3504
- if (anchor.diffSize === null) {
3505
- return `${at} (changed, size unrecorded)`;
3506
- }
3507
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3508
- }).join(", ")}`
3952
+ `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
3509
3953
  )
3510
3954
  );
3511
3955
  }
@@ -3513,6 +3957,15 @@ function drifted(hits) {
3513
3957
  (left, right) => left.conceptId.localeCompare(right.conceptId)
3514
3958
  );
3515
3959
  }
3960
+ function describeAnchor(anchor) {
3961
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3962
+ if (anchor.reason) return `${at} (${anchor.reason})`;
3963
+ if (anchor.remoteState === "drifted-on-default") {
3964
+ return `${at} (matches ref, moved on the default branch)`;
3965
+ }
3966
+ if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
3967
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3968
+ }
3516
3969
  function replaces(later, earlier) {
3517
3970
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
3518
3971
  }
@@ -3615,12 +4068,15 @@ function argvFlag(argv, name) {
3615
4068
  var anchorResolveCommand = define({
3616
4069
  name: "anchor-resolve",
3617
4070
  tool: "kb_anchor_resolve",
3618
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
3619
- description: "Resolve a record's anchors against the working tree: stamp a hash onto anchors that lack one, report drift where the code moved. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was, not whether the claim still holds. Anchors naming another repository are skipped. Exits non-zero on drift.",
4071
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
4072
+ description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
3620
4073
  input: import_zod8.z.object({
3621
4074
  bundlePath,
3622
4075
  conceptId,
3623
4076
  repoRoot: import_zod8.z.string().min(1).optional(),
4077
+ offline: import_zod8.z.boolean().optional().describe(
4078
+ "Resolve foreign anchors from the local repo cache only, never fetching."
4079
+ ),
3624
4080
  rebaseline: import_zod8.z.boolean().optional().describe(
3625
4081
  "Accept the current code as the new baseline for anchors that drifted."
3626
4082
  ),
@@ -3632,10 +4088,11 @@ var anchorResolveCommand = define({
3632
4088
  bundlePath: path,
3633
4089
  conceptId: argv[1],
3634
4090
  repoRoot: argvFlag(argv, "--repo-root"),
4091
+ offline: argv.includes("--offline"),
3635
4092
  rebaseline: argv.includes("--rebaseline"),
3636
4093
  restamp: argv.includes("--restamp")
3637
4094
  }),
3638
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
4095
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
3639
4096
  const root = repoRoot ?? process.cwd();
3640
4097
  const record = await store.read(path, id);
3641
4098
  if (!record) throw new KbRecordNotFoundError(id);
@@ -3650,18 +4107,10 @@ var anchorResolveCommand = define({
3650
4107
  }
3651
4108
  const results = [];
3652
4109
  const updated = [];
3653
- const origin = new LazyOrigin(root);
3654
4110
  let dirty = false;
3655
- if (anchors.some((anchor) => anchor.repo)) await origin.prime();
3656
- const foreign = new Map(
3657
- anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
3658
- );
3659
- const reads = await readAnchorFiles(
3660
- anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
3661
- anchorFileReader(root)
3662
- );
4111
+ const sources = await readSources(anchors, root, offline === true);
3663
4112
  for (const anchor of anchors) {
3664
- const base = {
4113
+ const base2 = {
3665
4114
  file: anchor.file,
3666
4115
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
3667
4116
  // Carried onto unresolved findings too: an anchor that once hashed
@@ -3669,21 +4118,17 @@ var anchorResolveCommand = define({
3669
4118
  // has to be able to tell it from one nobody ever stamped.
3670
4119
  ...anchor.hash ? { storedHash: anchor.hash } : {}
3671
4120
  };
3672
- if (foreign.get(anchor)) {
3673
- results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
3674
- updated.push(anchor);
3675
- continue;
3676
- }
3677
- const fileRead = reads.get(anchor.file);
3678
- if (!fileRead.ok) {
3679
- results.push({ ...base, state: "unresolved", reason: fileRead.reason });
4121
+ const source = sources.get(anchor);
4122
+ if (source.repo) base2.repo = source.repo;
4123
+ if (!source.ok) {
4124
+ results.push({ ...base2, state: "unresolved", reason: source.reason });
3680
4125
  updated.push(anchor);
3681
4126
  continue;
3682
4127
  }
3683
- const resolved = resolveAnchor(fileRead.source, anchor);
4128
+ const resolved = resolveAnchor(source.source, anchor);
3684
4129
  if (!resolved) {
3685
4130
  results.push({
3686
- ...base,
4131
+ ...base2,
3687
4132
  state: "unresolved",
3688
4133
  reason: "symbol-not-found"
3689
4134
  });
@@ -3698,30 +4143,47 @@ var anchorResolveCommand = define({
3698
4143
  lines: currentLines,
3699
4144
  resolved_at: now()
3700
4145
  };
4146
+ const pinned = anchor.ref !== void 0 && source.repo !== void 0;
3701
4147
  if (!anchor.hash) {
3702
- results.push({ ...base, state: "stamped", currentHash });
4148
+ results.push({ ...base2, state: "stamped", currentHash });
3703
4149
  updated.push(stamped);
3704
4150
  dirty = true;
3705
- } else if (anchor.hash === currentHash) {
3706
- results.push({
3707
- ...base,
3708
- state: "match",
3709
- currentHash
3710
- });
3711
- const refresh = restamp || anchor.resolved_at === void 0;
3712
- updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
3713
- if (refresh) dirty = true;
3714
- } else {
4151
+ continue;
4152
+ }
4153
+ if (anchor.hash !== currentHash) {
3715
4154
  results.push({
3716
- ...base,
4155
+ ...base2,
3717
4156
  state: "drifted",
3718
4157
  currentHash,
3719
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
4158
+ diffSize: lineDelta(anchor, currentLines),
4159
+ ...pinned ? { remoteState: "drifted-from-ref" } : {},
3720
4160
  ...rebaseline ? { rebaselined: true } : {}
3721
4161
  });
3722
4162
  updated.push(rebaseline ? stamped : anchor);
3723
4163
  if (rebaseline) dirty = true;
4164
+ continue;
3724
4165
  }
4166
+ const onDefault = pinned ? headHash(source, anchor) : void 0;
4167
+ if (onDefault && onDefault.hash !== anchor.hash) {
4168
+ results.push({
4169
+ ...base2,
4170
+ state: "drifted",
4171
+ currentHash: onDefault.hash,
4172
+ diffSize: lineDelta(anchor, onDefault.lines),
4173
+ remoteState: "drifted-on-default"
4174
+ });
4175
+ updated.push(anchor);
4176
+ continue;
4177
+ }
4178
+ results.push({
4179
+ ...base2,
4180
+ state: "match",
4181
+ currentHash,
4182
+ ...pinned ? { remoteState: "matches-ref" } : {}
4183
+ });
4184
+ const refresh = restamp || anchor.resolved_at === void 0;
4185
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
4186
+ if (refresh) dirty = true;
3725
4187
  }
3726
4188
  let frozen = false;
3727
4189
  if (dirty) {
@@ -3734,16 +4196,19 @@ var anchorResolveCommand = define({
3734
4196
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
3735
4197
  }
3736
4198
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
3737
- const checked = results.filter((entry) => entry.reason !== "foreign-repo");
3738
- const skipped = results.length - checked.length;
3739
- const matches2 = checked.filter((entry) => entry.state === "match").length;
3740
- const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
4199
+ const unreachable = results.filter(
4200
+ (entry) => isUncheckedReason(entry.reason)
4201
+ ).length;
4202
+ const checked = results.length - unreachable;
4203
+ const matches2 = results.filter((entry) => entry.state === "match").length;
4204
+ const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
4205
+ const clean = checked > 0 && matches2 === checked && unreachable === 0;
3741
4206
  if (clean) {
3742
4207
  try {
3743
4208
  await store.verify(
3744
4209
  path,
3745
4210
  id,
3746
- `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
4211
+ `anchor-resolve: ${note} (regex resolver)`,
3747
4212
  actor,
3748
4213
  now()
3749
4214
  );
@@ -3759,18 +4224,81 @@ var anchorResolveCommand = define({
3759
4224
  }
3760
4225
  return { conceptId: id, results, verified: true, ...frozenNote };
3761
4226
  }
3762
- return { conceptId: id, results, verified: false, ...frozenNote };
4227
+ return {
4228
+ conceptId: id,
4229
+ results,
4230
+ verified: false,
4231
+ ...unreachable ? { note } : {},
4232
+ ...frozenNote
4233
+ };
3763
4234
  },
3764
4235
  // A stored hash that no longer resolves is a broken anchor, not an absence:
3765
4236
  // the file was deleted or the symbol renamed, and exiting zero on it would
3766
4237
  // let the one edit that destroys an anchor pass the gate that exists to
3767
4238
  // catch it. An anchor nobody ever stamped is still just unstamped, and one
3768
- // belonging to another repository was never this run's to check — failing CI
3769
- // on either would gate on work this command did not do.
4239
+ // whose remote nothing could reach was never checked — failing CI on either
4240
+ // would gate on work this command did not do.
3770
4241
  failsWhen: (result) => result.results.some(
3771
- (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
4242
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
3772
4243
  )
3773
4244
  });
4245
+ function lineDelta(anchor, current) {
4246
+ return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
4247
+ }
4248
+ function headHash(source, anchor) {
4249
+ if (source.head === void 0) return void 0;
4250
+ const resolved = resolveAnchor(source.head, anchor);
4251
+ if (!resolved) return void 0;
4252
+ return {
4253
+ hash: hashAnchorText(resolved.text),
4254
+ lines: resolved.endLine - resolved.startLine + 1
4255
+ };
4256
+ }
4257
+ async function readSources(anchors, root, offline) {
4258
+ const origin = new LazyOrigin(root);
4259
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
4260
+ const foreign = new Map(
4261
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
4262
+ );
4263
+ const local = anchors.filter((anchor) => !foreign.get(anchor));
4264
+ const remote = anchors.filter((anchor) => foreign.get(anchor));
4265
+ const reads = await readAnchorFiles(
4266
+ local.map((anchor) => anchor.file),
4267
+ anchorFileReader(root)
4268
+ );
4269
+ const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
4270
+ offline
4271
+ });
4272
+ const sources = /* @__PURE__ */ new Map();
4273
+ for (const anchor of local) {
4274
+ const read = reads.get(anchor.file);
4275
+ sources.set(
4276
+ anchor,
4277
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
4278
+ );
4279
+ }
4280
+ for (const anchor of remote) {
4281
+ const repo = anchor.repo;
4282
+ const key2 = normalizeRepoUrl(repo);
4283
+ const atDefault = blobs.get(wantKey(key2, void 0, anchor.file));
4284
+ const primary = anchor.ref ? blobs.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
4285
+ if (!primary?.ok) {
4286
+ sources.set(anchor, {
4287
+ ok: false,
4288
+ reason: primary?.ok === false ? primary.reason : "remote-unreachable",
4289
+ repo
4290
+ });
4291
+ continue;
4292
+ }
4293
+ sources.set(anchor, {
4294
+ ok: true,
4295
+ source: primary.source,
4296
+ repo,
4297
+ ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
4298
+ });
4299
+ }
4300
+ return sources;
4301
+ }
3774
4302
 
3775
4303
  // src/commands/answer.ts
3776
4304
  var import_zod9 = require("zod");
@@ -3778,7 +4306,7 @@ var answerCommand = define({
3778
4306
  name: "answer",
3779
4307
  tool: "kb_answer",
3780
4308
  usage: "answer <concept-id> <answer...>",
3781
- description: "Resolve an open question: sets the status, stamps who answered and when, and appends an Answer section. If the answer overturns an assumption or a decision, that is a supersession \u2014 do it explicitly.",
4309
+ description: "Resolve an open question: set status, stamp who and when, append an Answer section. If the answer overturns a decision or assumption, supersede that record explicitly.",
3782
4310
  input: import_zod9.z.object({ bundlePath, conceptId, answer: import_zod9.z.string().min(1) }),
3783
4311
  fromArgv: (argv, path) => ({
3784
4312
  bundlePath: path,
@@ -3873,7 +4401,7 @@ var contextCommand = define({
3873
4401
  name: "context",
3874
4402
  tool: "kb_context",
3875
4403
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
3876
- description: "The pinned-base index block, for injection at every context birth \u2014 startup, clear, resume, and after compaction. An index, not the content: concept ids, titles and standing, with the bodies left behind kb_load at the point of use. Emits nothing when nothing is pinned. Refuses with the list of bases and their sizes rather than truncating past its budget. Budgets resolve most-specific-first: explicit flags, then the workspace manifests' `context` tables (per profile, over their `default`), then the built-in profile (session-start, compact, turn), then package defaults \u2014 so a repo tunes its own numbers in .strauss/kb-pins.json without touching hook commands. Like kb_schema and kb_types this takes no bundlePath \u2014 it reads the workspace pin manifests, because which bases a session should see is workspace state, not a property of one base.",
4404
+ description: "Index block of pinned bases (ids, titles, standing) for injection at context birth. Takes no bundlePath \u2014 reads the workspace pin manifests. Empty when nothing is pinned; refuses over budget rather than truncating. Budget precedence: flags, then the manifest `context[profile]` over `context.default`, then the built-in profile, then package defaults.",
3877
4405
  input: import_zod12.z.object({
3878
4406
  budgetTokens: import_zod12.z.number().int().positive().optional().describe(
3879
4407
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
@@ -3928,8 +4456,8 @@ var days = (what, fallback) => import_zod13.z.number().int().positive().optional
3928
4456
  var doctorCommand = define({
3929
4457
  name: "doctor",
3930
4458
  tool: "kb_doctor",
3931
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
3932
- description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
4459
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
4460
+ description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
3933
4461
  input: import_zod13.z.object({
3934
4462
  bundlePath,
3935
4463
  repoRoot: REPO_ROOT,
@@ -3945,6 +4473,9 @@ var doctorCommand = define({
3945
4473
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3946
4474
  DEFAULT_AGING_DAYS
3947
4475
  ),
4476
+ offline: import_zod13.z.boolean().optional().describe(
4477
+ "Read foreign anchors from the local repo cache only, never fetching."
4478
+ ),
3948
4479
  strict: import_zod13.z.boolean().optional().describe(
3949
4480
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
3950
4481
  )
@@ -3964,13 +4495,23 @@ var doctorCommand = define({
3964
4495
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
3965
4496
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
3966
4497
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
4498
+ ...argv.includes("--offline") ? { offline: true } : {},
3967
4499
  ...argv.includes("--strict") ? { strict: true } : {}
3968
4500
  };
3969
4501
  },
3970
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
4502
+ run: async ({ store, now }, {
4503
+ bundlePath: path,
4504
+ expiringDays,
4505
+ unverifiedDays,
4506
+ agingDays,
4507
+ repoRoot,
4508
+ offline
4509
+ }) => {
3971
4510
  const checkedAt = now();
3972
4511
  const records = await store.list(path);
3973
- const anchorDrift = await store.detectDrift(records, repoRoot);
4512
+ const anchorDrift = await store.detectDrift(records, repoRoot, {
4513
+ offline: offline === true
4514
+ });
3974
4515
  const report = doctor(records, {
3975
4516
  ...expiringDays !== void 0 ? { expiringDays } : {},
3976
4517
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
@@ -4059,7 +4600,7 @@ var listCommand = define({
4059
4600
  name: "list",
4060
4601
  tool: "kb_list",
4061
4602
  usage: "list [type]",
4062
- description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
4603
+ description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
4063
4604
  input: import_zod15.z.object({ bundlePath, type: import_zod15.z.enum(KB_RECORD_TYPES).optional() }),
4064
4605
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
4065
4606
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
@@ -4077,7 +4618,7 @@ var loadCommand = define({
4077
4618
  name: "load",
4078
4619
  tool: "kb_load",
4079
4620
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
4080
- description: "Loads the whole knowledge base at once, each record with its standing. Superseded records arrive as stubs; rejected and open records arrive whole. Refuses past the token budget \u2014 call kb_catalog, kb_pack on it; `all` bypasses the budget. Never read record files directly. Cache-stable; `digest` is the base's content stamp \u2014 hooks use it to tell you when to reload.",
4621
+ description: "Load the whole base, each record with its standing \u2014 call it first, at the point of use, since compaction drops it. Superseded records arrive as stubs; kb_trace has the history. Over budget it refuses: kb_catalog, then kb_pack, or narrow with `type`; `all` bypasses. Never read record files directly \u2014 only kb_* tools resolve supersession. `digest` stamps the base's content, so hooks know when to reload.",
4081
4622
  input: import_zod16.z.object({
4082
4623
  bundlePath,
4083
4624
  type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
@@ -4129,7 +4670,7 @@ var logCommand = define({
4129
4670
  name: "log",
4130
4671
  tool: "kb_log",
4131
4672
  usage: "log",
4132
- description: "What touched what, and when. The only artifact here that cannot be reconstructed from the records, so malformed lines are reported rather than repaired.",
4673
+ description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
4133
4674
  input: import_zod17.z.object({ bundlePath }),
4134
4675
  fromArgv: (_argv, path) => ({ bundlePath: path }),
4135
4676
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
@@ -4141,7 +4682,7 @@ var noDecisionCommand = define({
4141
4682
  name: "no-decision",
4142
4683
  tool: "kb_no_decision",
4143
4684
  usage: "no-decision <reason...>",
4144
- description: 'Claim in one sentence that there was nothing to decide. Gating on "did you write a decision?" rewards writing a junk one; gating on "did you answer?" does not, so silence has to be expressible. Idempotent \u2014 restating it is not a collision.',
4685
+ description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
4145
4686
  input: import_zod18.z.object({ bundlePath, reason: import_zod18.z.string().min(1) }),
4146
4687
  fromArgv: (argv, path) => ({
4147
4688
  bundlePath: path,
@@ -4164,7 +4705,7 @@ var packCommand = define({
4164
4705
  name: "pack",
4165
4706
  tool: "kb_pack",
4166
4707
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
4167
- description: "The bounded neighbourhood around one record: everything within `hops` of the root, ranked and cut to `maxNodes`, with every cut record named under Excluded \u2014 a named gap is knowable, a silent one is not. Prefer this over kb_load when the base is too large to hold whole and the work centres on one record; prefer it over kb_query when the question needs the governed neighbourhood \u2014 what was settled and what binds near this record \u2014 rather than a lookup by wording. Superseded records arrive as name, replacement and date stubs exactly as kb_load emits them: their bodies no longer hold, and kb_trace has the history. Refuses outright rather than truncating when the pack would exceed its token budget \u2014 a partial pack is indistinguishable from a complete one \u2014 reporting the record count and every already-cut id so the caller can lower hops or maxNodes, or raise the budget. The header carries the bundle, root, budget and a timestamp; everything below the header is byte-identical across runs over an unchanged base, so two packs can be diffed and a changed byte means changed knowledge. This tool (with kb_load, kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.",
4708
+ description: "Bounded neighbourhood around one record: within `hops`, ranked, cut to `maxNodes`, with every cut record named under Excluded. Use when the base is over kb_load's budget and the work centres on a record you can name. Refuses over budget rather than truncating. Everything below the header is byte-stable across runs. Resolves supersession like kb_load.",
4168
4709
  input: import_zod19.z.object({
4169
4710
  bundlePath,
4170
4711
  conceptId,
@@ -4264,7 +4805,7 @@ var pinCommand = define({
4264
4805
  name: "pin",
4265
4806
  tool: "kb_pin",
4266
4807
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
4267
- description: "Pin a base into a workspace pin manifest, so `context` surfaces it at every context birth. Three layers, nearest wins: the committed project manifest (.strauss/kb-pins.json, the default), `--local` (.strauss/kb-pins.local.json, personal and gitignored), and `--user` (~/.strauss/kb-pins.json, every workspace). Idempotent \u2014 re-pinning changes nothing unless --mode, --profiles, or --frozen/--unfreeze are given, which update just those fields. `--mode full` preloads the whole base into the block regardless of the full-under threshold; `--mode index` never upgrades. `--profiles` scopes the pin to named context profiles. `--frozen` marks the base concluded: write commands against it refuse and `context` labels it read-only. A path with no records yet succeeds with a warning; bases are routinely pinned before they are populated. Pins are workspace state: the pinned base itself is never touched.",
4808
+ description: "Pin a base into a workspace manifest so kb_context surfaces it. Layers, nearest wins: project `.strauss/kb-pins.json` (default), `--local` (personal, gitignored), `--user` (`~/.strauss`). Idempotent; `--mode full|index`, `--profiles`, `--frozen`/`--unfreeze` update only those fields. A path with no records pins with a warning. Never touches the base itself.",
4268
4809
  input: import_zod20.z.object({
4269
4810
  bundlePath,
4270
4811
  mode: import_zod20.z.enum(["full", "index"]).optional().describe(
@@ -4308,7 +4849,7 @@ var pinsCommand = define({
4308
4849
  name: "pins",
4309
4850
  tool: "kb_pins",
4310
4851
  usage: "pins",
4311
- description: "Every pinned base across the manifest layers, each with its layer and whether it currently resolves to readable records. Reads the workspace manifests rather than any one base, like kb_context.",
4852
+ description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
4312
4853
  input: import_zod21.z.object({}),
4313
4854
  fromArgv: () => ({}),
4314
4855
  run: ({ store }) => listPins(store, process.cwd())
@@ -4362,7 +4903,7 @@ var readIndexCommand = define({
4362
4903
  name: "index",
4363
4904
  tool: "kb_index",
4364
4905
  usage: "index",
4365
- description: "The index, rebuilt if it disagrees with the records. One call gives the whole shape of the base: title, type, status, and description per record. The cheap re-orientation call after compaction or deep in a long session \u2014 a few hundred tokens; call it (or kb_context, when bases are pinned) first, then kb_load or fetch by concept id.",
4906
+ description: "The index \u2014 title, type, status, description per record \u2014 rebuilt if stale. Cheapest re-orientation after compaction: call it (or kb_context) first, then kb_load or fetch by id.",
4366
4907
  input: import_zod23.z.object({ bundlePath }),
4367
4908
  fromArgv: (_argv, path) => ({ bundlePath: path }),
4368
4909
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
@@ -4374,7 +4915,7 @@ var schemaCommand = define({
4374
4915
  name: "schema",
4375
4916
  tool: "kb_schema",
4376
4917
  usage: "schema",
4377
- description: "JSON Schema for the frontmatter, the write input, and log entries \u2014 generated from the code that enforces them, so it cannot drift from what a write will accept.",
4918
+ description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
4378
4919
  input: import_zod24.z.object({}),
4379
4920
  fromArgv: () => ({}),
4380
4921
  run: () => Promise.resolve(kbJsonSchemas())
@@ -4386,7 +4927,7 @@ var statusCommand = define({
4386
4927
  name: "status",
4387
4928
  tool: "kb_status",
4388
4929
  usage: "status <concept-id> <status>",
4389
- description: "Move a record's status, leaving everything else alone. Uses a compare-and-swap, so a concurrent change fails loudly rather than being overwritten.",
4930
+ description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
4390
4931
  input: import_zod25.z.object({
4391
4932
  bundlePath,
4392
4933
  conceptId,
@@ -4410,7 +4951,7 @@ var supersedeCommand = define({
4410
4951
  name: "supersede",
4411
4952
  tool: "kb_supersede",
4412
4953
  usage: "supersede <concept-id> <replacement-id>",
4413
- description: "Mark a record superseded by another, linking both directions. Use this rather than editing a record whose meaning changed \u2014 a record that quietly becomes something else invalidates every reference to it, and the earlier understanding is what a later trace needs.",
4954
+ description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
4414
4955
  input: import_zod26.z.object({ bundlePath, conceptId, replacementId: conceptId }),
4415
4956
  fromArgv: (argv, path) => ({
4416
4957
  bundlePath: path,
@@ -4429,7 +4970,7 @@ var import_zod27 = require("zod");
4429
4970
  var syncInstructionsCommand = define({
4430
4971
  name: "sync-instructions",
4431
4972
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
4432
- description: "Idempotently plant the `context` block between sentinel comments in an instruction file (AGENTS.md, CLAUDE.md), creating the block when absent and leaving everything outside the sentinels alone. CLI-only: this is file plumbing for runtimes whose instruction files are re-read where their conversations are not, not an agent capability \u2014 the capability is kb_context.",
4973
+ description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
4433
4974
  input: import_zod27.z.object({
4434
4975
  file: import_zod27.z.string().min(1).describe("The instruction file to edit in place."),
4435
4976
  budgetTokens: import_zod27.z.number().int().positive().optional(),
@@ -4465,7 +5006,7 @@ var traceCommand = define({
4465
5006
  name: "trace",
4466
5007
  tool: "kb_trace",
4467
5008
  usage: "trace <concept-id> [edges...]",
4468
- description: 'How a position was arrived at, as a timeline ordered by when each record was written. Deliberately includes rejected, draft, and superseded records \u2014 in a history those are the content, not noise. Follows supersession, shared code anchors, and shared sources. Use when the question is "why is this the way it is" rather than "what do we hold now". This tool (with kb_load and kb_query) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.',
5009
+ description: 'Timeline of how a position was reached, ordered by write time, following supersession, shared anchors and shared sources. Includes rejected, draft and superseded records \u2014 in a history they are the content. For "why is it like this"; kb_load answers "what holds now".',
4469
5010
  input: import_zod28.z.object({
4470
5011
  bundlePath,
4471
5012
  conceptId,
@@ -4509,7 +5050,7 @@ var unpinCommand = define({
4509
5050
  name: "unpin",
4510
5051
  tool: "kb_unpin",
4511
5052
  usage: "unpin [bundle-path]",
4512
- description: "Remove a base from every pin manifest layer that holds it \u2014 project, local, and user \u2014 because unpinned means gone, not still injected from another file. Reports which layers were touched.",
5053
+ description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
4513
5054
  input: import_zod30.z.object({ bundlePath }),
4514
5055
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
4515
5056
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
@@ -4537,7 +5078,7 @@ var verifyCommand = define({
4537
5078
  name: "verify",
4538
5079
  tool: "kb_verify",
4539
5080
  usage: "verify <concept-id> --note <text>",
4540
- description: "Append one verified[] event \u2014 who checked the record, when, and what the check found. Appends only; prior events are never rewritten. A record's own generator is refused unless the actor is human: re-reading your own output is not an independent check.",
5081
+ description: "Append a verified[] event: who checked, when, and what was found. Append-only. A record's own generator is refused unless the actor is `human:`-prefixed.",
4541
5082
  input: import_zod32.z.object({
4542
5083
  bundlePath,
4543
5084
  conceptId,
@@ -4566,15 +5107,7 @@ var writeCommand = define({
4566
5107
  name: "write",
4567
5108
  tool: "kb_write",
4568
5109
  usage: "write <type> < record.json",
4569
- description: [
4570
- "Write one record. Search first \u2014 the same knowledge filed twice under different slugs is how a base rots, and a duplicate concept id is rejected rather than overwritten. Call kb_types for the sections each type accepts.",
4571
- "",
4572
- "Judgment the tool cannot enforce for you:",
4573
- "- An unsourced claim is an `assumption` record with assumption: true, never a `fact` with a vague source. The distinction is what lets a later reader separate what was established from what was guessed.",
4574
- "- When two records conflict, say so in a `risk`, an `open-question`, or a superseding `decision`. Quietly picking a winner destroys the disagreement, which is usually the useful part.",
4575
- "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
4576
- "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
4577
- ].join("\n"),
5110
+ description: "Write one record. Search first \u2014 a duplicate concept id is rejected, not overwritten; kb_types lists each type's sections. An unsourced claim is an `assumption` with assumption: true, never a vague `fact`. Conflicting records get a `risk`, `open-question`, or superseding `decision`. Prefer a new short record over overloading one. Never delete; supersede.",
4578
5111
  input: import_zod33.z.object({
4579
5112
  bundlePath,
4580
5113
  type: import_zod33.z.enum(KB_RECORD_TYPES),
@@ -4606,14 +5139,7 @@ var writeDecisionCommand = define({
4606
5139
  name: "write-decision",
4607
5140
  tool: "kb_write_decision",
4608
5141
  usage: "write-decision < decision.json",
4609
- description: [
4610
- "Write a decision. Takes `alternative` and `impact` as fields rather than free sections, because what was rejected is the part a later reader cannot reconstruct from the code \u2014 a heading is too easy to leave empty.",
4611
- "",
4612
- "What belongs in one:",
4613
- '- Record a decision when a later reader would otherwise "simplify" the constraint away. If the diff already answers the question, there is nothing here to write.',
4614
- "- `alternative` is what you turned down and why, not a list of everything considered.",
4615
- "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
4616
- ].join("\n"),
5142
+ description: "Write a decision, with `alternative` (what was rejected and why) and `impact` as fields. Record one when a later reader would otherwise simplify the constraint away; skip when the diff already answers it. `sources` for material read, `anchors` for code, `relatedConceptIds` for records.",
4617
5143
  input: import_zod34.z.object({ bundlePath, input: decisionInputSchema }),
4618
5144
  fromArgv: async (_argv, path, stdin) => ({
4619
5145
  bundlePath: path,
@@ -4673,7 +5199,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
4673
5199
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
4674
5200
 
4675
5201
  // src/version.ts
4676
- var VERSION = true ? "0.1.13" : "0.0.0-dev";
5202
+ var VERSION = true ? "0.1.15" : "0.0.0-dev";
4677
5203
 
4678
5204
  // src/mcp.ts
4679
5205
  function createKbMcpServer() {
@@ -4712,7 +5238,7 @@ async function runKbMcpServer() {
4712
5238
  }
4713
5239
 
4714
5240
  // src/cli.ts
4715
- var import_node_path8 = require("path");
5241
+ var import_node_path9 = require("path");
4716
5242
  async function runKbCli(argv) {
4717
5243
  const { flags, literal } = takeLiteral(argv);
4718
5244
  const { bundle, rest: withFlags } = takeBundle(flags);
@@ -4769,7 +5295,7 @@ function takeLiteral(argv) {
4769
5295
  function takeBundle(argv) {
4770
5296
  const at = argv.indexOf("--bundle");
4771
5297
  if (at === -1) {
4772
- return { bundle: (0, import_node_path8.join)(process.cwd(), KB_DIR), rest: argv };
5298
+ return { bundle: (0, import_node_path9.join)(process.cwd(), KB_DIR), rest: argv };
4773
5299
  }
4774
5300
  const bundle = argv[at + 1];
4775
5301
  if (!bundle) die("--bundle requires a path");
@@ -4884,6 +5410,7 @@ function usage() {
4884
5410
  impact,
4885
5411
  inboundIndex,
4886
5412
  indexIsStale,
5413
+ isCanonicalRepoUrl,
4887
5414
  isKbLinkRel,
4888
5415
  isKbRecordType,
4889
5416
  isNoDecisionRecord,
@@ -4907,11 +5434,13 @@ function usage() {
4907
5434
  pinBase,
4908
5435
  readMergedPins,
4909
5436
  readPinsLayer,
5437
+ readRemoteAnchors,
4910
5438
  regexResolver,
4911
5439
  renderCatalogLine,
4912
5440
  renderIndex,
4913
5441
  renderIndexLine,
4914
5442
  renderLogEntry,
5443
+ repoCacheDir,
4915
5444
  resolveAnchor,
4916
5445
  resolveHeads,
4917
5446
  resolveHits,