@saasontools/strauss-kb 0.1.14 → 0.1.16

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,
@@ -146,9 +149,8 @@ __export(index_exports, {
146
149
  module.exports = __toCommonJS(index_exports);
147
150
 
148
151
  // src/kb-store.ts
149
- var import_node_crypto2 = require("crypto");
150
- var import_promises3 = require("fs/promises");
151
- var import_node_path3 = require("path");
152
+ var import_promises4 = require("fs/promises");
153
+ var import_node_path4 = require("path");
152
154
 
153
155
  // src/concurrency.ts
154
156
  var DEFAULT_IO_CONCURRENCY = 16;
@@ -230,16 +232,17 @@ var kbAnchorSchema = import_zod.z.object({
230
232
  * (`https://github.com/org/name`) or a short name. Absent means the base's
231
233
  * own repository, which is what nearly every anchor means.
232
234
  *
233
- * Unvalidated beyond not-blank: one repository has many spellings.
234
- * Matched after normalisation; see ARCHITECTURE.
235
+ * Unvalidated beyond not-blank: one repository has many spellings, matched
236
+ * after normalisation. Only a full URL can be fetched from, so `validate`
237
+ * warns on a short one; see ARCHITECTURE.
235
238
  */
236
239
  repo: import_zod.z.string().trim().min(1).optional(),
237
240
  /**
238
241
  * The git rev the evidence was taken at. Prefer a commit SHA: a branch
239
242
  * name is a moving pointer, so an anchor pinned to one says the evidence
240
243
  * 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.
244
+ * baseline. A foreign anchor is checked at this rev, and compared against
245
+ * the remote's default branch on top of it.
243
246
  */
244
247
  ref: import_zod.z.string().trim().min(1).optional(),
245
248
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
@@ -341,6 +344,8 @@ var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
341
344
  ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
342
345
  ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
343
346
  ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
347
+ ErrorTypes2["KbStampBaselineUnreadable"] = "KbStampBaselineUnreadable";
348
+ ErrorTypes2["KbStampDigestBaselineAmbiguous"] = "KbStampDigestBaselineAmbiguous";
344
349
  ErrorTypes2["KbUnknownLinkRel"] = "KbUnknownLinkRel";
345
350
  ErrorTypes2["KbWriteConflict"] = "KbWriteConflict";
346
351
  return ErrorTypes2;
@@ -495,6 +500,36 @@ var KbInvalidConceptIdError = class extends BaseError {
495
500
  });
496
501
  }
497
502
  };
503
+ var KbStampBaselineError = class extends BaseError {
504
+ constructor(since) {
505
+ super({
506
+ message: `kb: --since ${since} is neither a 64-character digest nor a readable stamp file`,
507
+ errorType: "KbStampBaselineUnreadable" /* KbStampBaselineUnreadable */,
508
+ code: 400,
509
+ fault: "User" /* User */,
510
+ retriable: false,
511
+ reportToUser: true,
512
+ details: { since }
513
+ });
514
+ this.since = since;
515
+ }
516
+ since;
517
+ };
518
+ var KbStampDigestBaselineError = class extends BaseError {
519
+ constructor(since) {
520
+ super({
521
+ message: `kb: --since ${since} is a digest, which needs --bundle (one base) \u2014 a file baseline works for many`,
522
+ errorType: "KbStampDigestBaselineAmbiguous" /* KbStampDigestBaselineAmbiguous */,
523
+ code: 400,
524
+ fault: "User" /* User */,
525
+ retriable: false,
526
+ reportToUser: true,
527
+ details: { since }
528
+ });
529
+ this.since = since;
530
+ }
531
+ since;
532
+ };
498
533
 
499
534
  // src/kb-index.ts
500
535
  var INDEX_FILE = "INDEX.md";
@@ -517,6 +552,436 @@ function indexIsStale(stored, expected) {
517
552
  return stored !== expected;
518
553
  }
519
554
 
555
+ // src/remote-repo/cache.ts
556
+ var import_node_os = require("os");
557
+ var import_node_path = require("path");
558
+
559
+ // src/anchor-resolver/repo-identity.ts
560
+ var import_node_child_process = require("child_process");
561
+ var import_node_util = require("util");
562
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
563
+ function normalizeRepoUrl(value) {
564
+ let url = value.trim().replace(/^git\+/, "");
565
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
566
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
567
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
568
+ url = trimTrailingSlashes(url);
569
+ if (url.endsWith(".git")) url = url.slice(0, -4);
570
+ return trimTrailingSlashes(url).toLowerCase();
571
+ }
572
+ function trimTrailingSlashes(value) {
573
+ let end = value.length;
574
+ while (end > 0 && value[end - 1] === "/") end -= 1;
575
+ return value.slice(0, end);
576
+ }
577
+ function isCanonicalRepoUrl(value) {
578
+ return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
579
+ }
580
+ function repoPath(normalized) {
581
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
582
+ const segments = withoutScheme.split("/").filter(Boolean);
583
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
584
+ }
585
+ function repoIdentifies(declared, originUrl) {
586
+ if (!originUrl) return false;
587
+ const origin = normalizeRepoUrl(originUrl);
588
+ const want = normalizeRepoUrl(declared);
589
+ if (!want || !origin) return false;
590
+ if (want === origin) return true;
591
+ const path = repoPath(origin);
592
+ if (!path) return false;
593
+ return want === path || want === (path.split("/").pop() ?? "");
594
+ }
595
+ async function repoOriginUrl(repoRoot) {
596
+ try {
597
+ const { stdout } = await execFileAsync(
598
+ "git",
599
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
600
+ { timeout: 5e3 }
601
+ );
602
+ return stdout.trim() || null;
603
+ } catch {
604
+ return null;
605
+ }
606
+ }
607
+ var LazyOrigin = class {
608
+ constructor(repoRoot) {
609
+ this.repoRoot = repoRoot;
610
+ }
611
+ repoRoot;
612
+ url = null;
613
+ asked = false;
614
+ /** Asks git once, so later `isForeign` calls need no await. */
615
+ async prime() {
616
+ if (this.asked) return;
617
+ this.url = await repoOriginUrl(this.repoRoot);
618
+ this.asked = true;
619
+ }
620
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
621
+ isForeign(anchor) {
622
+ if (!anchor.repo) return false;
623
+ return !repoIdentifies(anchor.repo, this.url);
624
+ }
625
+ async foreign(anchor) {
626
+ if (!anchor.repo) return false;
627
+ await this.prime();
628
+ return this.isForeign(anchor);
629
+ }
630
+ };
631
+
632
+ // src/remote-repo/cache.ts
633
+ function repoCacheDir(override) {
634
+ return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".strauss", "repo-cache");
635
+ }
636
+ var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
637
+ function fetchTimeoutMs(override) {
638
+ if (override !== void 0) return override;
639
+ const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
640
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
641
+ }
642
+ function cachePathFor(repo, cacheDir) {
643
+ const normalized = normalizeRepoUrl(repo);
644
+ const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
645
+ if (!scheme) return null;
646
+ const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
647
+ if (segments.length < 2 || segments.some((segment) => segment === null)) {
648
+ return null;
649
+ }
650
+ const path = segments;
651
+ return (0, import_node_path.join)(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
652
+ }
653
+ function safeSegment(value) {
654
+ return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
655
+ }
656
+ function revRef(rev) {
657
+ const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
658
+ let hash = 5381;
659
+ for (let at = 0; at < rev.length; at++) {
660
+ hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
661
+ }
662
+ return `refs/strauss/${safe}-${hash.toString(16)}`;
663
+ }
664
+
665
+ // src/remote-repo/model.ts
666
+ var UNCHECKED_REASONS = [
667
+ "remote-unreachable",
668
+ "repo-unauthorized",
669
+ "default-branch-unknown"
670
+ ];
671
+ function isUncheckedReason(reason) {
672
+ return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
673
+ }
674
+ function wantKey(repo, ref, file) {
675
+ return `${repo}\0${ref ?? ""}\0${file}`;
676
+ }
677
+
678
+ // src/remote-repo/read.ts
679
+ var import_promises = require("fs/promises");
680
+
681
+ // src/remote-repo/git.ts
682
+ var import_node_child_process2 = require("child_process");
683
+ var import_node_util2 = require("util");
684
+
685
+ // src/anchor-resolver/model.ts
686
+ var MAX_ANCHOR_FILE_BYTES = 1048576;
687
+
688
+ // src/remote-repo/git.ts
689
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
690
+ function childEnv() {
691
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
692
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
693
+ delete env[name];
694
+ }
695
+ return env;
696
+ }
697
+ async function git(args, options = {}) {
698
+ try {
699
+ const { stdout, stderr } = await execFileAsync2("git", args, {
700
+ ...options.cwd ? { cwd: options.cwd } : {},
701
+ timeout: options.timeoutMs ?? 3e4,
702
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
703
+ encoding: "utf8",
704
+ windowsHide: true,
705
+ env: childEnv()
706
+ });
707
+ return { ok: true, stdout, stderr, overflowed: false };
708
+ } catch (error) {
709
+ const failure = error;
710
+ return {
711
+ ok: false,
712
+ stdout: failure.stdout ?? "",
713
+ stderr: failure.stderr ?? "",
714
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
715
+ };
716
+ }
717
+ }
718
+ function transportReason(stderr) {
719
+ const text = stderr.toLowerCase();
720
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
721
+ return "repo-unauthorized";
722
+ }
723
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
724
+ return "ref-not-found";
725
+ }
726
+ return "remote-unreachable";
727
+ }
728
+
729
+ // src/remote-repo/validate.ts
730
+ var MAX_REF_LENGTH = 200;
731
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
732
+ function refShapeIsSafe(ref) {
733
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
734
+ if (ref.includes("..")) return false;
735
+ return REF_SHAPE.test(ref);
736
+ }
737
+ async function refIsWellFormed(ref) {
738
+ if (!refShapeIsSafe(ref)) return false;
739
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
740
+ return checked.ok;
741
+ }
742
+ function filePathIsSafe(file) {
743
+ const path = file.replace(/^\.\//, "");
744
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
745
+ return !path.split("/").includes("..");
746
+ }
747
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
748
+ function allowedProtocols() {
749
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
750
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
751
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
752
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
753
+ }
754
+ function isShortRepoName(repo) {
755
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
756
+ }
757
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
758
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
759
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
760
+ function repoUrlIsSafe(repo) {
761
+ const url = repo.trim();
762
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
763
+ const allowed = allowedProtocols();
764
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
765
+ const scheme = URL_SCHEME.exec(url);
766
+ if (!scheme?.[1]) return false;
767
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
768
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
769
+ const at = authority.lastIndexOf("@");
770
+ return at < 0 || !authority.slice(0, at).includes(":");
771
+ }
772
+ function protocolArgs() {
773
+ const allowed = allowedProtocols();
774
+ return [
775
+ "-c",
776
+ "protocol.ext.allow=never",
777
+ "-c",
778
+ `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
779
+ ];
780
+ }
781
+
782
+ // src/remote-repo/read.ts
783
+ var DEFAULT_REPO_CONCURRENCY = 4;
784
+ var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
785
+ async function readRemoteAnchors(wants, options = {}) {
786
+ const out = /* @__PURE__ */ new Map();
787
+ if (!wants.length) return out;
788
+ const cacheDir = repoCacheDir(options.cacheDir);
789
+ const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
790
+ const byRepo = /* @__PURE__ */ new Map();
791
+ for (const want of wants) {
792
+ const key2 = normalizeRepoUrl(want.repo);
793
+ const group2 = byRepo.get(key2) ?? { url: want.repo.trim(), wants: [] };
794
+ group2.wants.push(want);
795
+ byRepo.set(key2, group2);
796
+ }
797
+ const groups = [...byRepo.entries()];
798
+ const results = await mapLimit(
799
+ groups,
800
+ Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
801
+ ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
802
+ cacheDir,
803
+ timeoutMs,
804
+ offline: options.offline === true
805
+ })
806
+ );
807
+ for (const result of results) {
808
+ for (const [key2, read] of result) out.set(key2, read);
809
+ }
810
+ return out;
811
+ }
812
+ async function readOneRepo(repo, url, declared, context) {
813
+ let wants = declared;
814
+ const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
815
+ if (isShortRepoName(url)) {
816
+ return all({ ok: false, reason: "remote-unreachable" });
817
+ }
818
+ if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
819
+ const cache = cachePathFor(repo, context.cacheDir);
820
+ if (!cache) return all({ ok: false, reason: "remote-unreachable" });
821
+ const rejected = /* @__PURE__ */ new Map();
822
+ const usable = [];
823
+ for (const want of wants) {
824
+ const reason = wantReason(want);
825
+ if (reason)
826
+ rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
827
+ else usable.push(want);
828
+ }
829
+ if (!usable.length) return rejected;
830
+ wants = usable;
831
+ const opened = await openCache(cache, url, context);
832
+ if (opened) return new Map([...rejected, ...all(opened)]);
833
+ const wantsDefault = wants.some((want) => want.ref === void 0);
834
+ const branch = wantsDefault ? await defaultBranch(cache, context) : {};
835
+ const revs = /* @__PURE__ */ new Map();
836
+ for (const rev of distinctRevs(wants, branch.name)) {
837
+ revs.set(
838
+ rev,
839
+ await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
840
+ );
841
+ }
842
+ const reads = await mapLimit(
843
+ wants,
844
+ DEFAULT_IO_CONCURRENCY,
845
+ async (want) => {
846
+ const rev = want.ref ?? branch.name;
847
+ if (rev === void 0) {
848
+ return {
849
+ ok: false,
850
+ reason: branch.reason ?? "default-branch-unknown"
851
+ };
852
+ }
853
+ const failed = revs.get(rev);
854
+ if (failed) return failed;
855
+ return readBlob(cache, rev, want.file, context);
856
+ }
857
+ );
858
+ return new Map([
859
+ ...rejected,
860
+ ...wants.map(
861
+ (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
862
+ )
863
+ ]);
864
+ }
865
+ function wantReason(want) {
866
+ if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
867
+ return filePathIsSafe(want.file) ? void 0 : "outside-repo";
868
+ }
869
+ function distinctRevs(wants, branch) {
870
+ const revs = /* @__PURE__ */ new Set();
871
+ for (const want of wants) {
872
+ if (want.ref !== void 0) revs.add(want.ref);
873
+ else if (branch) revs.add(branch);
874
+ }
875
+ return [...revs];
876
+ }
877
+ async function openCache(cache, url, context) {
878
+ try {
879
+ await (0, import_promises.mkdir)(cache, { recursive: true });
880
+ } catch {
881
+ return { ok: false, reason: "remote-unreachable" };
882
+ }
883
+ const init = await git(["init", "--bare", "--quiet", cache], {
884
+ timeoutMs: context.timeoutMs
885
+ });
886
+ if (!init.ok) return { ok: false, reason: "remote-unreachable" };
887
+ const remote = await git(["config", "remote.origin.url", url], {
888
+ cwd: cache,
889
+ timeoutMs: context.timeoutMs
890
+ });
891
+ return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
892
+ }
893
+ async function defaultBranch(cache, context) {
894
+ if (!context.offline) {
895
+ const listed = await git(
896
+ [...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
897
+ {
898
+ cwd: cache,
899
+ timeoutMs: context.timeoutMs
900
+ }
901
+ );
902
+ const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
903
+ if (listed.ok && found?.[1]) {
904
+ const name = found[1];
905
+ await git(["config", "strauss.defaultBranch", name], { cwd: cache });
906
+ return { name };
907
+ }
908
+ if (!listed.ok) {
909
+ const reason = transportReason(listed.stderr);
910
+ if (reason !== "ref-not-found") {
911
+ const cached2 = await cachedBranch(cache);
912
+ return cached2 ? { name: cached2 } : { reason };
913
+ }
914
+ }
915
+ }
916
+ const cached = await cachedBranch(cache);
917
+ if (cached) return { name: cached };
918
+ return {
919
+ reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
920
+ };
921
+ }
922
+ async function cachedBranch(cache) {
923
+ const stored = await git(["config", "--get", "strauss.defaultBranch"], {
924
+ cwd: cache
925
+ });
926
+ if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
927
+ const head = await git(
928
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
929
+ {
930
+ cwd: cache
931
+ }
932
+ );
933
+ const name = head.stdout.trim().replace(/^origin\//, "");
934
+ return head.ok && name ? name : void 0;
935
+ }
936
+ async function ensureRev(cache, rev, context) {
937
+ const ref = revRef(rev);
938
+ const have = await git(
939
+ ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
940
+ {
941
+ cwd: cache
942
+ }
943
+ );
944
+ const cached = have.ok && have.stdout.trim().length > 0;
945
+ if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
946
+ if (context.offline) return { ok: false, reason: "remote-unreachable" };
947
+ const fetched = await git(
948
+ [
949
+ ...protocolArgs(),
950
+ "fetch",
951
+ "--depth",
952
+ "1",
953
+ "origin",
954
+ "--end-of-options",
955
+ rev
956
+ ],
957
+ { cwd: cache, timeoutMs: context.timeoutMs }
958
+ );
959
+ if (!fetched.ok) {
960
+ const reason = transportReason(fetched.stderr);
961
+ if (cached && reason !== "ref-not-found") return void 0;
962
+ return { ok: false, reason };
963
+ }
964
+ const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
965
+ const sha = head.stdout.trim();
966
+ if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
967
+ const updated = await git(["update-ref", ref, sha], { cwd: cache });
968
+ return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
969
+ }
970
+ async function readBlob(cache, rev, file, context) {
971
+ const path = file.replace(/^\.\//, "");
972
+ const blob = await git(
973
+ ["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
974
+ {
975
+ cwd: cache,
976
+ timeoutMs: context.timeoutMs
977
+ }
978
+ );
979
+ if (blob.ok) return { ok: true, source: blob.stdout };
980
+ if (blob.overflowed) return { ok: false, reason: "file-too-large" };
981
+ const text = blob.stderr.toLowerCase();
982
+ return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
983
+ }
984
+
520
985
  // src/adjudicate.ts
521
986
  var STANDING = {
522
987
  accepted: "current",
@@ -557,23 +1022,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
557
1022
  if (!record.frontmatter.verified?.length) {
558
1023
  warnings.push({ kind: "unverified" });
559
1024
  }
560
- const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
561
- (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
1025
+ const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
1026
+ (entry) => entry.state !== "match"
562
1027
  );
1028
+ const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
1029
+ const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
563
1030
  if (moved.length) {
1031
+ warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
1032
+ }
1033
+ if (unchecked2.length) {
564
1034
  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
- }))
1035
+ kind: "unchecked",
1036
+ anchors: unchecked2.map(warningAnchor)
572
1037
  });
573
1038
  }
574
1039
  return { record, standing: STANDING[status], heads, warnings };
575
1040
  });
576
1041
  }
1042
+ function warningAnchor(entry) {
1043
+ const { file, symbol, diffSize, reason, repo, remoteState } = entry;
1044
+ return {
1045
+ file,
1046
+ ...symbol !== void 0 ? { symbol } : {},
1047
+ diffSize,
1048
+ ...reason !== void 0 ? { reason } : {},
1049
+ ...repo !== void 0 ? { repo } : {},
1050
+ ...remoteState !== void 0 ? { remoteState } : {}
1051
+ };
1052
+ }
577
1053
  function resolveHeads(from, byId) {
578
1054
  const warnings = [];
579
1055
  const heads = /* @__PURE__ */ new Map();
@@ -623,14 +1099,130 @@ function successors(record, byId) {
623
1099
  return { records, missing };
624
1100
  }
625
1101
 
626
- // src/anchor-resolver.ts
627
- var import_node_child_process = require("child_process");
1102
+ // src/kb-stamp.ts
628
1103
  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;
1104
+ function sha256(contents) {
1105
+ return (0, import_node_crypto.createHash)("sha256").update(contents).digest("hex");
1106
+ }
1107
+ function bundleStamp(records, superseded) {
1108
+ const entries = [
1109
+ ...records.map((hit) => ({
1110
+ conceptId: hit.record.conceptId,
1111
+ digest: `current:${sha256(
1112
+ stringifyMarkdownWithFrontmatter(
1113
+ hit.record.body,
1114
+ hit.record.frontmatter
1115
+ )
1116
+ )}`
1117
+ })),
1118
+ ...superseded.map((entry) => ({
1119
+ conceptId: entry.conceptId,
1120
+ digest: `superseded:${sha256(JSON.stringify(entry))}`
1121
+ }))
1122
+ ].sort((a, b) => a.conceptId < b.conceptId ? -1 : 1);
1123
+ return {
1124
+ digest: sha256(
1125
+ entries.map((entry) => `${entry.conceptId}:${entry.digest}`).join("\n")
1126
+ ),
1127
+ records: entries
1128
+ };
1129
+ }
1130
+ function bundleDigest(records, superseded) {
1131
+ return bundleStamp(records, superseded).digest;
1132
+ }
1133
+
1134
+ // src/anchor-resolver/read.ts
1135
+ var import_promises2 = require("fs/promises");
1136
+ var import_node_path2 = require("path");
1137
+ function anchorFilePath(repoRoot, file) {
1138
+ const path = (0, import_node_path2.resolve)(repoRoot, file.replace(/^\.\//, ""));
1139
+ const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(repoRoot), path);
1140
+ if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path2.sep}`) || (0, import_node_path2.isAbsolute)(rel)) {
1141
+ return null;
1142
+ }
1143
+ return path;
1144
+ }
1145
+ function contains(root, path) {
1146
+ const rel = (0, import_node_path2.relative)(root, path);
1147
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`) && !(0, import_node_path2.isAbsolute)(rel);
1148
+ }
1149
+ function errorCode(error) {
1150
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
1151
+ }
1152
+ function anchorFileReader(repoRoot) {
1153
+ let rootOnce;
1154
+ const realRoot = () => {
1155
+ rootOnce ??= (0, import_promises2.realpath)((0, import_node_path2.resolve)(repoRoot)).catch((error) => {
1156
+ rootOnce = void 0;
1157
+ throw error;
1158
+ });
1159
+ return rootOnce;
1160
+ };
1161
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
1162
+ }
1163
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
1164
+ const lexical = anchorFilePath(repoRoot, file);
1165
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
1166
+ let root;
1167
+ let path;
1168
+ try {
1169
+ root = await realRoot();
1170
+ path = await (0, import_promises2.realpath)(lexical);
1171
+ } catch (error) {
1172
+ const code = errorCode(error);
1173
+ if (code === "ENOENT" || code === "ENOTDIR") {
1174
+ return { ok: false, reason: "file-missing" };
1175
+ }
1176
+ return { ok: false, reason: "file-unreadable" };
1177
+ }
1178
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
1179
+ try {
1180
+ const stats = await (0, import_promises2.stat)(path);
1181
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
1182
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
1183
+ return { ok: false, reason: "file-too-large" };
1184
+ }
1185
+ return { ok: true, source: await (0, import_promises2.readFile)(path, "utf8") };
1186
+ } catch (error) {
1187
+ const code = errorCode(error);
1188
+ if (code === "ENOENT" || code === "ENOTDIR") {
1189
+ return { ok: false, reason: "file-missing" };
1190
+ }
1191
+ return { ok: false, reason: "file-unreadable" };
1192
+ }
1193
+ }
1194
+ function looksLikeWrongRepoRoot(drift) {
1195
+ let checked = 0;
1196
+ for (const entries of drift.values()) {
1197
+ for (const entry of entries) {
1198
+ if (entry.repo !== void 0) continue;
1199
+ checked += 1;
1200
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
1201
+ return false;
1202
+ }
1203
+ }
1204
+ }
1205
+ return checked > 0;
1206
+ }
1207
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
1208
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
1209
+ throw new RangeError(
1210
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
1211
+ );
1212
+ }
1213
+ const wanted = [...new Set(files)];
1214
+ const results = await mapLimit(wanted, concurrency, async (file) => {
1215
+ try {
1216
+ return await read(file);
1217
+ } catch {
1218
+ return { ok: false, reason: "file-unreadable" };
1219
+ }
1220
+ });
1221
+ return new Map(wanted.map((file, at) => [file, results[at]]));
1222
+ }
1223
+
1224
+ // src/anchor-resolver/resolver.ts
1225
+ var import_node_crypto2 = require("crypto");
634
1226
  var PARENT_SCOPE_LINES = 50;
635
1227
  var CLEAN_STATE = { blockComment: false, template: false };
636
1228
  function stripLine(line, state) {
@@ -761,202 +1353,53 @@ var regexResolver = {
761
1353
  const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
762
1354
  const lines = source.split("\n");
763
1355
  for (const tier of TIERS) {
764
- const pattern = tier(escaped);
765
- let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
766
- if (!candidates.length) continue;
767
- if (parentPattern && candidates.length > 1) {
768
- const distances = candidates.map(
769
- (index) => distanceToParent(lines, index, parentPattern)
770
- );
771
- const nearest = Math.min(...distances);
772
- if (Number.isFinite(nearest)) {
773
- candidates = candidates.filter((_, at) => distances[at] === nearest);
774
- }
775
- }
776
- if (candidates.length !== 1) return null;
777
- const matchLine = candidates[0];
778
- return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
779
- }
780
- return null;
781
- }
782
- };
783
- function escapeRegExp(value) {
784
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
785
- }
786
- function distanceToParent(lines, index, parent) {
787
- const floor = Math.max(0, index - PARENT_SCOPE_LINES);
788
- for (let at = index; at >= floor; at--) {
789
- if (parent.test(lines[at] ?? "")) return index - at;
790
- }
791
- return Number.POSITIVE_INFINITY;
792
- }
793
- function hashAnchorText(text) {
794
- return `sha256:${(0, import_node_crypto.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
795
- }
796
- function resolveAnchor(source, anchor, resolver = regexResolver) {
797
- const normalized = source.replace(/\r\n/g, "\n");
798
- if (!anchor.symbol) {
799
- const lines = normalized.split("\n");
800
- if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
801
- return {
802
- text: normalized,
803
- startLine: 1,
804
- endLine: Math.max(1, lines.length)
805
- };
806
- }
807
- return resolver.resolve(normalized, anchor.symbol);
808
- }
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" };
1356
+ const pattern = tier(escaped);
1357
+ let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1358
+ if (!candidates.length) continue;
1359
+ if (parentPattern && candidates.length > 1) {
1360
+ const distances = candidates.map(
1361
+ (index) => distanceToParent(lines, index, parentPattern)
1362
+ );
1363
+ const nearest = Math.min(...distances);
1364
+ if (Number.isFinite(nearest)) {
1365
+ candidates = candidates.filter((_, at) => distances[at] === nearest);
1366
+ }
1367
+ }
1368
+ if (candidates.length !== 1) return null;
1369
+ const matchLine = candidates[0];
1370
+ return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
927
1371
  }
928
- return { ok: false, reason: "file-unreadable" };
1372
+ return null;
929
1373
  }
1374
+ };
1375
+ function escapeRegExp(value) {
1376
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
930
1377
  }
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
- }
1378
+ function distanceToParent(lines, index, parent) {
1379
+ const floor = Math.max(0, index - PARENT_SCOPE_LINES);
1380
+ for (let at = index; at >= floor; at--) {
1381
+ if (parent.test(lines[at] ?? "")) return index - at;
941
1382
  }
942
- return checked > 0;
1383
+ return Number.POSITIVE_INFINITY;
943
1384
  }
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
- );
1385
+ function hashAnchorText(text) {
1386
+ return `sha256:${(0, import_node_crypto2.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1387
+ }
1388
+ function resolveAnchor(source, anchor, resolver = regexResolver) {
1389
+ const normalized = source.replace(/\r\n/g, "\n");
1390
+ if (!anchor.symbol) {
1391
+ const lines = normalized.split("\n");
1392
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1393
+ return {
1394
+ text: normalized,
1395
+ startLine: 1,
1396
+ endLine: Math.max(1, lines.length)
1397
+ };
949
1398
  }
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]]));
1399
+ return resolver.resolve(normalized, anchor.symbol);
959
1400
  }
1401
+
1402
+ // src/anchor-resolver/drift.ts
960
1403
  async function detectAnchorDrift(records, options = {}) {
961
1404
  const repoRoot = options.repoRoot ?? process.cwd();
962
1405
  const resolver = options.resolver ?? regexResolver;
@@ -982,71 +1425,101 @@ async function detectAnchorDrift(records, options = {}) {
982
1425
  }
983
1426
  }
984
1427
  const files = [];
1428
+ const wants = [];
985
1429
  for (const entries of planned.values()) {
986
- for (const entry of entries) {
987
- if (!entry.foreign) files.push(entry.anchor.file);
1430
+ for (const { anchor, foreign } of entries) {
1431
+ if (!foreign) files.push(anchor.file);
1432
+ else wants.push(...remoteWants(anchor));
988
1433
  }
989
1434
  }
990
- const reads = await readAnchorFiles(
991
- files,
992
- options.reader ?? anchorFileReader(repoRoot),
993
- options.concurrency ?? DEFAULT_IO_CONCURRENCY
994
- );
1435
+ const [reads, remote] = await Promise.all([
1436
+ readAnchorFiles(
1437
+ files,
1438
+ options.reader ?? anchorFileReader(repoRoot),
1439
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
1440
+ ),
1441
+ (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1442
+ ]);
995
1443
  const drift = /* @__PURE__ */ new Map();
996
1444
  for (const record of records) {
997
1445
  const entries = [];
998
1446
  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
- });
1447
+ entries.push(
1448
+ foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1449
+ );
1041
1450
  }
1042
1451
  if (entries.length) drift.set(record.conceptId, entries);
1043
1452
  }
1044
1453
  return drift;
1045
1454
  }
1455
+ function remoteWants(anchor) {
1456
+ const repo = anchor.repo;
1457
+ const wants = [{ repo, file: anchor.file }];
1458
+ if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
1459
+ return wants;
1460
+ }
1461
+ function base(anchor) {
1462
+ return {
1463
+ file: anchor.file,
1464
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1465
+ storedHash: anchor.hash
1466
+ };
1467
+ }
1468
+ function unresolved(anchor, reason, repo) {
1469
+ return {
1470
+ ...base(anchor),
1471
+ state: "unresolved",
1472
+ diffSize: null,
1473
+ ...reason ? { reason } : {},
1474
+ ...repo ? { repo } : {}
1475
+ };
1476
+ }
1477
+ function hashIn(source, anchor, resolver) {
1478
+ const resolved = resolveAnchor(source, anchor, resolver);
1479
+ if (!resolved) return null;
1480
+ return {
1481
+ hash: hashAnchorText(resolved.text),
1482
+ lines: resolved.endLine - resolved.startLine + 1
1483
+ };
1484
+ }
1485
+ function compared(anchor, current, extra = {}) {
1486
+ return {
1487
+ ...base(anchor),
1488
+ state: current.hash === anchor.hash ? "match" : "drifted",
1489
+ currentHash: current.hash,
1490
+ diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1491
+ ...extra
1492
+ };
1493
+ }
1494
+ function localEntry(anchor, read, resolver) {
1495
+ if (!read.ok) return unresolved(anchor, read.reason);
1496
+ const current = hashIn(read.source, anchor, resolver);
1497
+ return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1498
+ }
1499
+ function remoteEntry(anchor, remote, resolver) {
1500
+ const repo = anchor.repo;
1501
+ const key2 = normalizeRepoUrl(repo);
1502
+ const atDefault = remote.get(wantKey(key2, void 0, anchor.file));
1503
+ const primary = anchor.ref ? remote.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
1504
+ if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1505
+ if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1506
+ const current = hashIn(primary.source, anchor, resolver);
1507
+ if (!current) return unresolved(anchor, "symbol-not-found", repo);
1508
+ if (!anchor.ref) return compared(anchor, current, { repo });
1509
+ if (current.hash !== anchor.hash) {
1510
+ return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1511
+ }
1512
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1513
+ return head && head.hash !== anchor.hash ? {
1514
+ ...compared(anchor, head, { repo }),
1515
+ state: "drifted",
1516
+ remoteState: "drifted-on-default"
1517
+ } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1518
+ }
1046
1519
 
1047
1520
  // src/search-index.ts
1048
- var import_promises2 = require("fs/promises");
1049
- var import_node_path2 = require("path");
1521
+ var import_promises3 = require("fs/promises");
1522
+ var import_node_path3 = require("path");
1050
1523
 
1051
1524
  // src/kb-log.ts
1052
1525
  var import_zod2 = require("zod");
@@ -1109,7 +1582,7 @@ async function searchBase(bundlePath2, query, options = {}) {
1109
1582
  let store = null;
1110
1583
  try {
1111
1584
  store = await qmd.createStore({
1112
- dbPath: (0, import_node_path2.join)(bundlePath2, SEARCH_INDEX_FILE),
1585
+ dbPath: (0, import_node_path3.join)(bundlePath2, SEARCH_INDEX_FILE),
1113
1586
  config: {
1114
1587
  collections: {
1115
1588
  [COLLECTION]: {
@@ -1144,7 +1617,7 @@ async function searchBase(bundlePath2, query, options = {}) {
1144
1617
  }
1145
1618
  }
1146
1619
  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);
1620
+ const indexAt = await (0, import_promises3.stat)((0, import_node_path3.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
1148
1621
  if (!indexAt) return true;
1149
1622
  const { readdir: readdir2 } = await import("fs/promises");
1150
1623
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -1153,7 +1626,7 @@ async function isStale(bundlePath2) {
1153
1626
  let stale = false;
1154
1627
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
1155
1628
  if (stale) return;
1156
- const at = await (0, import_promises2.stat)((0, import_node_path2.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
1629
+ const at = await (0, import_promises3.stat)((0, import_node_path3.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
1157
1630
  if (at > indexAt) stale = true;
1158
1631
  });
1159
1632
  return stale;
@@ -1743,7 +2216,7 @@ function appendUnionMergeLine(contents) {
1743
2216
  }
1744
2217
 
1745
2218
  // src/kb-store.ts
1746
- var KB_DIR = (0, import_node_path3.join)(".strauss", "kb");
2219
+ var KB_DIR = (0, import_node_path4.join)(".strauss", "kb");
1747
2220
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
1748
2221
  var DEFAULT_LOAD_BUDGET = 25e3;
1749
2222
  var KbStore = class {
@@ -1774,7 +2247,7 @@ var KbStore = class {
1774
2247
  const conceptId2 = `${input.type}.${input.slug}`;
1775
2248
  const root = this.root(bundlePath2);
1776
2249
  const target = this.recordPath(bundlePath2, conceptId2);
1777
- await (0, import_promises3.mkdir)(root, { recursive: true });
2250
+ await (0, import_promises4.mkdir)(root, { recursive: true });
1778
2251
  await this.publish(
1779
2252
  target,
1780
2253
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -1813,7 +2286,7 @@ var KbStore = class {
1813
2286
  const target = this.recordPath(bundlePath2, conceptId2);
1814
2287
  let raw;
1815
2288
  try {
1816
- raw = await (0, import_promises3.readFile)(target, "utf8");
2289
+ raw = await (0, import_promises4.readFile)(target, "utf8");
1817
2290
  } catch {
1818
2291
  return null;
1819
2292
  }
@@ -1830,7 +2303,7 @@ var KbStore = class {
1830
2303
  const root = this.root(bundlePath2);
1831
2304
  let names;
1832
2305
  try {
1833
- names = await (0, import_promises3.readdir)(root);
2306
+ names = await (0, import_promises4.readdir)(root);
1834
2307
  } catch {
1835
2308
  return [];
1836
2309
  }
@@ -1838,7 +2311,7 @@ var KbStore = class {
1838
2311
  const records = await mapLimit(
1839
2312
  wanted,
1840
2313
  DEFAULT_IO_CONCURRENCY,
1841
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises3.readFile)((0, import_node_path3.join)(root, name), "utf8"))
2314
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises4.readFile)((0, import_node_path4.join)(root, name), "utf8"))
1842
2315
  );
1843
2316
  return records.filter((record) => record !== null);
1844
2317
  }
@@ -2006,7 +2479,8 @@ ${answer}
2006
2479
  * at the repo root, and the MCP server's cwd is the workspace.
2007
2480
  *
2008
2481
  * 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.
2482
+ * sweep that failed to read the tree should report no drift, not fail — and
2483
+ * `offline: false` there, because a sweep is worth a fetch.
2010
2484
  *
2011
2485
  * When no root was given and not one anchored file was found, the finding is
2012
2486
  * discarded. A base read from somewhere other than the tree it describes
@@ -2018,10 +2492,13 @@ ${answer}
2018
2492
  * plausible, and the misses become findings again; an explicit `repoRoot` is
2019
2493
  * taken at its word either way.
2020
2494
  */
2021
- async detectDrift(records, repoRoot) {
2495
+ async detectDrift(records, repoRoot, options = {}) {
2022
2496
  try {
2023
2497
  const drift = await detectAnchorDrift(records, {
2024
- repoRoot: repoRoot ?? process.cwd()
2498
+ repoRoot: repoRoot ?? process.cwd(),
2499
+ // Offline by default: a read path must never spend a network fetch per
2500
+ // call. `doctor` and `anchor-resolve` are the verbs that go get it.
2501
+ remote: { offline: options.offline !== false }
2025
2502
  });
2026
2503
  if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
2027
2504
  this.logger.warn?.({
@@ -2109,6 +2586,28 @@ ${answer}
2109
2586
  digest: bundleDigestValue
2110
2587
  };
2111
2588
  }
2589
+ /**
2590
+ * `load`'s digest without `load`'s bodies — the same records, adjudicated
2591
+ * the same way, handed back as a stamp. Skips the anchor drift pass, which
2592
+ * reads source files and only ever adds warnings: no warning reaches the
2593
+ * digest, so the value is identical to the one `load` returns.
2594
+ */
2595
+ async stamp(bundlePath2) {
2596
+ const bundle = await this.list(bundlePath2);
2597
+ const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
2598
+ const current = adjudicated.filter((hit) => hit.standing !== "superseded");
2599
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
2600
+ const stamped = bundleStamp(current, superseded);
2601
+ const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
2602
+ return {
2603
+ path: bundlePath2,
2604
+ digest: stamped.digest,
2605
+ recordCount: bundle.length,
2606
+ superseded: superseded.length,
2607
+ newestAt: dates.at(-1) ?? null,
2608
+ records: stamped.records
2609
+ };
2610
+ }
2112
2611
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
2113
2612
  async trace(bundlePath2, seedId, options = {}) {
2114
2613
  return trace(seedId, await this.list(bundlePath2), options);
@@ -2139,11 +2638,11 @@ ${answer}
2139
2638
  async readIndex(bundlePath2) {
2140
2639
  const root = this.root(bundlePath2);
2141
2640
  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(
2641
+ const stored = await (0, import_promises4.readFile)((0, import_node_path4.join)(root, INDEX_FILE), "utf8").catch(
2143
2642
  () => null
2144
2643
  );
2145
2644
  if (indexIsStale(stored, expected)) {
2146
- await this.publish((0, import_node_path3.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
2645
+ await this.publish((0, import_node_path4.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
2147
2646
  this.logger.info?.({
2148
2647
  operation: "kb.index.repair",
2149
2648
  bundlePath: root,
@@ -2160,8 +2659,8 @@ ${answer}
2160
2659
  * knows which agent touched what. So a bad line is surfaced and left alone.
2161
2660
  */
2162
2661
  async readLog(bundlePath2) {
2163
- const raw = await (0, import_promises3.readFile)(
2164
- (0, import_node_path3.join)(this.root(bundlePath2), LOG_FILE),
2662
+ const raw = await (0, import_promises4.readFile)(
2663
+ (0, import_node_path4.join)(this.root(bundlePath2), LOG_FILE),
2165
2664
  "utf8"
2166
2665
  ).catch(() => "");
2167
2666
  const result = parseLog(raw);
@@ -2212,15 +2711,15 @@ ${answer}
2212
2711
  }
2213
2712
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
2214
2713
  const target = this.recordPath(bundlePath2, conceptId2);
2215
- const before = await (0, import_promises3.readFile)(target, "utf8").catch(() => null);
2714
+ const before = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
2216
2715
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
2217
2716
  const parsed = this.parse(conceptId2, before);
2218
2717
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
2219
2718
  const frontmatter = change(parsed.frontmatter);
2220
2719
  const body = changeBody(parsed.body);
2221
2720
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
2222
- const witness = await (0, import_promises3.readFile)(target, "utf8").catch(() => null);
2223
- if (witness === null || digest(witness) !== digest(before)) {
2721
+ const witness = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
2722
+ if (witness === null || sha256(witness) !== sha256(before)) {
2224
2723
  throw new KbWriteConflictError(conceptId2);
2225
2724
  }
2226
2725
  await this.publish(target, contents, true, conceptId2);
@@ -2245,20 +2744,20 @@ ${answer}
2245
2744
  */
2246
2745
  async publish(target, contents, overwrite, conceptId2) {
2247
2746
  const staging = `${target}.${process.pid}.tmp`;
2248
- await (0, import_promises3.writeFile)(staging, contents, "utf8");
2747
+ await (0, import_promises4.writeFile)(staging, contents, "utf8");
2249
2748
  try {
2250
2749
  if (overwrite) {
2251
- await (0, import_promises3.rename)(staging, target);
2750
+ await (0, import_promises4.rename)(staging, target);
2252
2751
  return;
2253
2752
  }
2254
- await (0, import_promises3.link)(staging, target);
2753
+ await (0, import_promises4.link)(staging, target);
2255
2754
  } catch (error) {
2256
2755
  if (error.code === "EEXIST") {
2257
2756
  throw new KbRecordAlreadyExistsError(conceptId2);
2258
2757
  }
2259
2758
  throw error;
2260
2759
  } finally {
2261
- await (0, import_promises3.unlink)(staging).catch(() => void 0);
2760
+ await (0, import_promises4.unlink)(staging).catch(() => void 0);
2262
2761
  }
2263
2762
  }
2264
2763
  /**
@@ -2302,18 +2801,18 @@ ${answer}
2302
2801
  * file must not fail the mutation it guards.
2303
2802
  */
2304
2803
  async ensureGitattributes(root) {
2305
- const target = (0, import_node_path3.join)(root, GITATTRIBUTES_FILE);
2804
+ const target = (0, import_node_path4.join)(root, GITATTRIBUTES_FILE);
2306
2805
  try {
2307
2806
  let existing;
2308
2807
  try {
2309
- existing = await (0, import_promises3.readFile)(target, "utf8");
2808
+ existing = await (0, import_promises4.readFile)(target, "utf8");
2310
2809
  } catch (error) {
2311
2810
  if (error.code !== "ENOENT") throw error;
2312
2811
  existing = null;
2313
2812
  }
2314
2813
  if (existing === null) {
2315
2814
  try {
2316
- await (0, import_promises3.writeFile)(target, appendUnionMergeLine(""), {
2815
+ await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
2317
2816
  encoding: "utf8",
2318
2817
  flag: "wx"
2319
2818
  });
@@ -2334,7 +2833,7 @@ ${answer}
2334
2833
  return;
2335
2834
  }
2336
2835
  if (!hasMergeDeclaration(existing)) {
2337
- await (0, import_promises3.appendFile)(target, appendUnionMergeLine(existing), "utf8");
2836
+ await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
2338
2837
  this.logger.info?.({
2339
2838
  operation: "kb.gitattributes.ensure",
2340
2839
  bundlePath: root,
@@ -2353,7 +2852,7 @@ ${answer}
2353
2852
  async record(root, entry) {
2354
2853
  await this.ensureGitattributes(root);
2355
2854
  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) => {
2855
+ await (0, import_promises4.appendFile)((0, import_node_path4.join)(root, LOG_FILE), line, "utf8").catch((error) => {
2357
2856
  this.logger.warn?.({
2358
2857
  operation: "kb.log.append",
2359
2858
  outcome: "failed",
@@ -2379,18 +2878,18 @@ ${answer}
2379
2878
  };
2380
2879
  }
2381
2880
  root(bundlePath2) {
2382
- return (0, import_node_path3.resolve)(bundlePath2);
2881
+ return (0, import_node_path4.resolve)(bundlePath2);
2383
2882
  }
2384
2883
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
2385
2884
  // bundle root; anything carrying a separator would escape it.
2386
2885
  recordPath(bundlePath2, conceptId2) {
2387
- if (conceptId2.includes(import_node_path3.sep) || conceptId2.includes("/")) {
2886
+ if (conceptId2.includes(import_node_path4.sep) || conceptId2.includes("/")) {
2388
2887
  throw new KbInvalidConceptIdError(
2389
2888
  "concept id must not contain a path separator",
2390
2889
  { conceptId: conceptId2 }
2391
2890
  );
2392
2891
  }
2393
- return (0, import_node_path3.join)(this.root(bundlePath2), `${conceptId2}.md`);
2892
+ return (0, import_node_path4.join)(this.root(bundlePath2), `${conceptId2}.md`);
2394
2893
  }
2395
2894
  };
2396
2895
  function estimateTokens(record) {
@@ -2428,25 +2927,6 @@ function normalizeActor(id) {
2428
2927
  if (colon === -1) return id.toLowerCase();
2429
2928
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
2430
2929
  }
2431
- function digest(contents) {
2432
- return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
2433
- }
2434
- function bundleDigest(records, superseded) {
2435
- const entries = [
2436
- ...records.map(
2437
- (hit) => `${hit.record.conceptId}:current:${digest(
2438
- stringifyMarkdownWithFrontmatter(
2439
- hit.record.body,
2440
- hit.record.frontmatter
2441
- )
2442
- )}`
2443
- ),
2444
- ...superseded.map(
2445
- (entry) => `${entry.conceptId}:superseded:${digest(JSON.stringify(entry))}`
2446
- )
2447
- ].sort();
2448
- return digest(entries.join("\n"));
2449
- }
2450
2930
 
2451
2931
  // src/compose.ts
2452
2932
  var import_zod3 = require("zod");
@@ -2619,18 +3099,18 @@ var KbBaseFrozenError = class extends Error {
2619
3099
  };
2620
3100
 
2621
3101
  // src/kb-pins/frozen.ts
2622
- var import_node_path6 = require("path");
3102
+ var import_node_path7 = require("path");
2623
3103
 
2624
3104
  // 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");
3105
+ var import_promises5 = require("fs/promises");
3106
+ var import_node_os2 = require("os");
3107
+ var import_node_path6 = require("path");
2628
3108
 
2629
3109
  // src/kb-pins/model.ts
2630
- var import_node_path4 = require("path");
3110
+ var import_node_path5 = require("path");
2631
3111
  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");
3112
+ var PINS_FILE = (0, import_node_path5.join)(".strauss", "kb-pins.json");
3113
+ var PINS_LOCAL_FILE = (0, import_node_path5.join)(".strauss", "kb-pins.local.json");
2634
3114
  var PIN_LAYERS = ["project", "local", "user"];
2635
3115
  var pinSchema = import_zod4.z.object({
2636
3116
  /** Relative to the manifest's root, so the file is committable. */
@@ -2677,13 +3157,13 @@ var pinsManifestSchema = import_zod4.z.object({
2677
3157
 
2678
3158
  // src/kb-pins/layers.ts
2679
3159
  function userRoot() {
2680
- return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
3160
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
2681
3161
  }
2682
3162
  function layerRoot(workspaceDir, layer) {
2683
- return layer === "user" ? userRoot() : (0, import_node_path5.resolve)(workspaceDir);
3163
+ return layer === "user" ? userRoot() : (0, import_node_path6.resolve)(workspaceDir);
2684
3164
  }
2685
3165
  function layerFile(workspaceDir, layer) {
2686
- return (0, import_node_path5.join)(
3166
+ return (0, import_node_path6.join)(
2687
3167
  layerRoot(workspaceDir, layer),
2688
3168
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
2689
3169
  );
@@ -2692,7 +3172,7 @@ async function readPinsLayer(workspaceDir, layer) {
2692
3172
  const file = layerFile(workspaceDir, layer);
2693
3173
  let raw;
2694
3174
  try {
2695
- raw = await (0, import_promises4.readFile)(file, "utf8");
3175
+ raw = await (0, import_promises5.readFile)(file, "utf8");
2696
3176
  } catch {
2697
3177
  return { pins: [] };
2698
3178
  }
@@ -2716,16 +3196,16 @@ async function readPinsLayer(workspaceDir, layer) {
2716
3196
  }
2717
3197
  async function writePinsLayer(workspaceDir, layer, manifest) {
2718
3198
  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)}
3199
+ await (0, import_promises5.mkdir)((0, import_node_path6.dirname)(file), { recursive: true });
3200
+ await (0, import_promises5.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
2721
3201
  `, "utf8");
2722
3202
  }
2723
3203
  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));
3204
+ 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
3205
  }
2726
3206
  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("/");
3207
+ const rel = (0, import_node_path6.relative)((0, import_node_path6.resolve)(rootDir), (0, import_node_path6.resolve)(bundlePath2));
3208
+ return (rel === "" ? "." : rel).split(import_node_path6.sep).join("/");
2729
3209
  }
2730
3210
  async function readMergedPins(workspaceDir) {
2731
3211
  const manifests = {};
@@ -2753,7 +3233,7 @@ async function readMergedPins(workspaceDir) {
2753
3233
  // src/kb-pins/frozen.ts
2754
3234
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
2755
3235
  const merged = await readMergedPins(workspaceDir);
2756
- const absolute = (0, import_node_path6.resolve)(bundlePath2);
3236
+ const absolute = (0, import_node_path7.resolve)(bundlePath2);
2757
3237
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
2758
3238
  if (pin?.frozen === true) {
2759
3239
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -2838,7 +3318,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
2838
3318
  }
2839
3319
 
2840
3320
  // src/kb-pins/unpin.ts
2841
- var import_node_path7 = require("path");
3321
+ var import_node_path8 = require("path");
2842
3322
  async function unpinBase(workspaceDir, bundlePath2) {
2843
3323
  const layers = [];
2844
3324
  for (const layer of PIN_LAYERS) {
@@ -2859,14 +3339,14 @@ async function unpinBase(workspaceDir, bundlePath2) {
2859
3339
  }
2860
3340
  }
2861
3341
  return {
2862
- path: storablePath((0, import_node_path7.resolve)(workspaceDir), bundlePath2),
3342
+ path: storablePath((0, import_node_path8.resolve)(workspaceDir), bundlePath2),
2863
3343
  removed: layers.length > 0,
2864
3344
  layers
2865
3345
  };
2866
3346
  }
2867
3347
 
2868
3348
  // src/kb-context.ts
2869
- var import_promises5 = require("fs/promises");
3349
+ var import_promises6 = require("fs/promises");
2870
3350
  var HEADING2 = "## Knowledge bases (pinned)";
2871
3351
  var DEFAULT_CONTEXT_BUDGET = 4e3;
2872
3352
  var CONTEXT_PROFILES = {
@@ -3025,7 +3505,7 @@ async function buildContext(store, workspaceDir, options = {}) {
3025
3505
  operation: "kb.context.refused",
3026
3506
  approxTokens: total,
3027
3507
  budgetTokens,
3028
- bases: bases.map((base) => base.path)
3508
+ bases: bases.map((base2) => base2.path)
3029
3509
  });
3030
3510
  const refusal = [
3031
3511
  HEADING2,
@@ -3035,7 +3515,7 @@ async function buildContext(store, workspaceDir, options = {}) {
3035
3515
  "from a complete one. The pinned bases:",
3036
3516
  "",
3037
3517
  ...bases.map(
3038
- (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
3518
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
3039
3519
  ),
3040
3520
  "",
3041
3521
  "For the question at hand, read what you need now \u2014 `kb_load` a base",
@@ -3070,13 +3550,13 @@ function toHookJson(block, event) {
3070
3550
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
3071
3551
  var CONTEXT_END = "<!-- strauss-kb:end -->";
3072
3552
  async function syncInstructions(file, block) {
3073
- const existing = await (0, import_promises5.readFile)(file, "utf8").catch(() => null);
3553
+ const existing = await (0, import_promises6.readFile)(file, "utf8").catch(() => null);
3074
3554
  const region = block ? `${CONTEXT_BEGIN}
3075
3555
  ${block.trim()}
3076
3556
  ${CONTEXT_END}` : null;
3077
3557
  if (existing === null) {
3078
3558
  if (!region) return { file, action: "unchanged" };
3079
- await (0, import_promises5.writeFile)(file, `${region}
3559
+ await (0, import_promises6.writeFile)(file, `${region}
3080
3560
  `, "utf8");
3081
3561
  return { file, action: "created" };
3082
3562
  }
@@ -3087,11 +3567,11 @@ ${CONTEXT_END}` : null;
3087
3567
  const after = existing.slice(end + CONTEXT_END.length);
3088
3568
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
3089
3569
  if (next === existing) return { file, action: "unchanged" };
3090
- await (0, import_promises5.writeFile)(file, next, "utf8");
3570
+ await (0, import_promises6.writeFile)(file, next, "utf8");
3091
3571
  return { file, action: region ? "replaced" : "removed" };
3092
3572
  }
3093
3573
  if (!region) return { file, action: "unchanged" };
3094
- await (0, import_promises5.writeFile)(
3574
+ await (0, import_promises6.writeFile)(
3095
3575
  file,
3096
3576
  `${existing.replace(/\n*$/, "\n\n")}${region}
3097
3577
  `,
@@ -3250,6 +3730,16 @@ function validateBundle(records) {
3250
3730
  );
3251
3731
  }
3252
3732
  }
3733
+ for (const anchor of fm.strauss_anchors ?? []) {
3734
+ if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
3735
+ report(
3736
+ "anchor_repo",
3737
+ conceptId2,
3738
+ `anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
3739
+ "warning"
3740
+ );
3741
+ }
3742
+ }
3253
3743
  if (fm.strauss_assumption && fm.sources?.length) {
3254
3744
  report("assumption", conceptId2, "marked an assumption but cites sources");
3255
3745
  }
@@ -3269,7 +3759,8 @@ var KB_DOCTOR_CHECKS = [
3269
3759
  "orphaned",
3270
3760
  "broken-supersession",
3271
3761
  "superseded-but-cited",
3272
- "drifted"
3762
+ "drifted",
3763
+ "unchecked"
3273
3764
  ];
3274
3765
  var CHECK_HEADLINES = {
3275
3766
  expired: "past its stale_after date",
@@ -3279,7 +3770,8 @@ var CHECK_HEADLINES = {
3279
3770
  orphaned: "no other record links to it",
3280
3771
  "broken-supersession": "the supersession pointers do not resolve",
3281
3772
  "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"
3773
+ drifted: "the code an anchor points at moved out from under its hash",
3774
+ unchecked: "an anchor in another repository nothing could reach"
3283
3775
  };
3284
3776
  var DAY_MS = 864e5;
3285
3777
  function doctor(bundle, options = {}) {
@@ -3304,7 +3796,8 @@ function doctor(bundle, options = {}) {
3304
3796
  group("orphaned", orphaned(bundle)),
3305
3797
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
3306
3798
  group("superseded-but-cited", supersededButCited(bundle, standings)),
3307
- group("drifted", drifted(inForce))
3799
+ group("drifted", drifted(inForce)),
3800
+ group("unchecked", unchecked(inForce))
3308
3801
  ];
3309
3802
  const counts = Object.fromEntries(
3310
3803
  groups.map((entry) => [entry.check, entry.count])
@@ -3491,21 +3984,38 @@ function supersededButCited(bundle, standings) {
3491
3984
  return findings;
3492
3985
  }
3493
3986
  function drifted(hits) {
3987
+ return anchorFindings(
3988
+ hits,
3989
+ "drifted",
3990
+ (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
3991
+ );
3992
+ }
3993
+ function unchecked(hits) {
3994
+ return anchorFindings(
3995
+ hits,
3996
+ "unchecked",
3997
+ (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
3998
+ );
3999
+ }
4000
+ function anchorFindings(hits, kind, headline) {
3494
4001
  const findings = [];
3495
4002
  for (const hit of hits) {
3496
- const warning = hit.warnings.find((entry) => entry.kind === "drifted");
4003
+ const warning = hit.warnings.find(
4004
+ (entry) => entry.kind === kind
4005
+ );
3497
4006
  if (!warning) continue;
4007
+ const byRepo = /* @__PURE__ */ new Map();
4008
+ for (const anchor of warning.anchors) {
4009
+ const repo = anchor.repo ?? "";
4010
+ byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
4011
+ }
4012
+ const detail = [...byRepo.entries()].map(
4013
+ ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
4014
+ );
3498
4015
  findings.push(
3499
4016
  finding(
3500
4017
  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(", ")}`
4018
+ `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
3509
4019
  )
3510
4020
  );
3511
4021
  }
@@ -3513,6 +4023,15 @@ function drifted(hits) {
3513
4023
  (left, right) => left.conceptId.localeCompare(right.conceptId)
3514
4024
  );
3515
4025
  }
4026
+ function describeAnchor(anchor) {
4027
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
4028
+ if (anchor.reason) return `${at} (${anchor.reason})`;
4029
+ if (anchor.remoteState === "drifted-on-default") {
4030
+ return `${at} (matches ref, moved on the default branch)`;
4031
+ }
4032
+ if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
4033
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
4034
+ }
3516
4035
  function replaces(later, earlier) {
3517
4036
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
3518
4037
  }
@@ -3615,12 +4134,15 @@ function argvFlag(argv, name) {
3615
4134
  var anchorResolveCommand = define({
3616
4135
  name: "anchor-resolve",
3617
4136
  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.",
4137
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
4138
+ 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
4139
  input: import_zod8.z.object({
3621
4140
  bundlePath,
3622
4141
  conceptId,
3623
4142
  repoRoot: import_zod8.z.string().min(1).optional(),
4143
+ offline: import_zod8.z.boolean().optional().describe(
4144
+ "Resolve foreign anchors from the local repo cache only, never fetching."
4145
+ ),
3624
4146
  rebaseline: import_zod8.z.boolean().optional().describe(
3625
4147
  "Accept the current code as the new baseline for anchors that drifted."
3626
4148
  ),
@@ -3632,10 +4154,11 @@ var anchorResolveCommand = define({
3632
4154
  bundlePath: path,
3633
4155
  conceptId: argv[1],
3634
4156
  repoRoot: argvFlag(argv, "--repo-root"),
4157
+ offline: argv.includes("--offline"),
3635
4158
  rebaseline: argv.includes("--rebaseline"),
3636
4159
  restamp: argv.includes("--restamp")
3637
4160
  }),
3638
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
4161
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
3639
4162
  const root = repoRoot ?? process.cwd();
3640
4163
  const record = await store.read(path, id);
3641
4164
  if (!record) throw new KbRecordNotFoundError(id);
@@ -3650,18 +4173,10 @@ var anchorResolveCommand = define({
3650
4173
  }
3651
4174
  const results = [];
3652
4175
  const updated = [];
3653
- const origin = new LazyOrigin(root);
3654
4176
  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
- );
4177
+ const sources = await readSources(anchors, root, offline === true);
3663
4178
  for (const anchor of anchors) {
3664
- const base = {
4179
+ const base2 = {
3665
4180
  file: anchor.file,
3666
4181
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
3667
4182
  // Carried onto unresolved findings too: an anchor that once hashed
@@ -3669,21 +4184,17 @@ var anchorResolveCommand = define({
3669
4184
  // has to be able to tell it from one nobody ever stamped.
3670
4185
  ...anchor.hash ? { storedHash: anchor.hash } : {}
3671
4186
  };
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 });
4187
+ const source = sources.get(anchor);
4188
+ if (source.repo) base2.repo = source.repo;
4189
+ if (!source.ok) {
4190
+ results.push({ ...base2, state: "unresolved", reason: source.reason });
3680
4191
  updated.push(anchor);
3681
4192
  continue;
3682
4193
  }
3683
- const resolved = resolveAnchor(fileRead.source, anchor);
4194
+ const resolved = resolveAnchor(source.source, anchor);
3684
4195
  if (!resolved) {
3685
4196
  results.push({
3686
- ...base,
4197
+ ...base2,
3687
4198
  state: "unresolved",
3688
4199
  reason: "symbol-not-found"
3689
4200
  });
@@ -3698,30 +4209,47 @@ var anchorResolveCommand = define({
3698
4209
  lines: currentLines,
3699
4210
  resolved_at: now()
3700
4211
  };
4212
+ const pinned = anchor.ref !== void 0 && source.repo !== void 0;
3701
4213
  if (!anchor.hash) {
3702
- results.push({ ...base, state: "stamped", currentHash });
4214
+ results.push({ ...base2, state: "stamped", currentHash });
3703
4215
  updated.push(stamped);
3704
4216
  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 {
4217
+ continue;
4218
+ }
4219
+ if (anchor.hash !== currentHash) {
3715
4220
  results.push({
3716
- ...base,
4221
+ ...base2,
3717
4222
  state: "drifted",
3718
4223
  currentHash,
3719
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
4224
+ diffSize: lineDelta(anchor, currentLines),
4225
+ ...pinned ? { remoteState: "drifted-from-ref" } : {},
3720
4226
  ...rebaseline ? { rebaselined: true } : {}
3721
4227
  });
3722
4228
  updated.push(rebaseline ? stamped : anchor);
3723
4229
  if (rebaseline) dirty = true;
4230
+ continue;
4231
+ }
4232
+ const onDefault = pinned ? headHash(source, anchor) : void 0;
4233
+ if (onDefault && onDefault.hash !== anchor.hash) {
4234
+ results.push({
4235
+ ...base2,
4236
+ state: "drifted",
4237
+ currentHash: onDefault.hash,
4238
+ diffSize: lineDelta(anchor, onDefault.lines),
4239
+ remoteState: "drifted-on-default"
4240
+ });
4241
+ updated.push(anchor);
4242
+ continue;
3724
4243
  }
4244
+ results.push({
4245
+ ...base2,
4246
+ state: "match",
4247
+ currentHash,
4248
+ ...pinned ? { remoteState: "matches-ref" } : {}
4249
+ });
4250
+ const refresh = restamp || anchor.resolved_at === void 0;
4251
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
4252
+ if (refresh) dirty = true;
3725
4253
  }
3726
4254
  let frozen = false;
3727
4255
  if (dirty) {
@@ -3734,16 +4262,19 @@ var anchorResolveCommand = define({
3734
4262
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
3735
4263
  }
3736
4264
  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");
4265
+ const unreachable = results.filter(
4266
+ (entry) => isUncheckedReason(entry.reason)
4267
+ ).length;
4268
+ const checked = results.length - unreachable;
4269
+ const matches2 = results.filter((entry) => entry.state === "match").length;
4270
+ const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
4271
+ const clean = checked > 0 && matches2 === checked && unreachable === 0;
3741
4272
  if (clean) {
3742
4273
  try {
3743
4274
  await store.verify(
3744
4275
  path,
3745
4276
  id,
3746
- `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
4277
+ `anchor-resolve: ${note} (regex resolver)`,
3747
4278
  actor,
3748
4279
  now()
3749
4280
  );
@@ -3759,18 +4290,81 @@ var anchorResolveCommand = define({
3759
4290
  }
3760
4291
  return { conceptId: id, results, verified: true, ...frozenNote };
3761
4292
  }
3762
- return { conceptId: id, results, verified: false, ...frozenNote };
4293
+ return {
4294
+ conceptId: id,
4295
+ results,
4296
+ verified: false,
4297
+ ...unreachable ? { note } : {},
4298
+ ...frozenNote
4299
+ };
3763
4300
  },
3764
4301
  // A stored hash that no longer resolves is a broken anchor, not an absence:
3765
4302
  // the file was deleted or the symbol renamed, and exiting zero on it would
3766
4303
  // let the one edit that destroys an anchor pass the gate that exists to
3767
4304
  // 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.
4305
+ // whose remote nothing could reach was never checked — failing CI on either
4306
+ // would gate on work this command did not do.
3770
4307
  failsWhen: (result) => result.results.some(
3771
- (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
4308
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
3772
4309
  )
3773
4310
  });
4311
+ function lineDelta(anchor, current) {
4312
+ return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
4313
+ }
4314
+ function headHash(source, anchor) {
4315
+ if (source.head === void 0) return void 0;
4316
+ const resolved = resolveAnchor(source.head, anchor);
4317
+ if (!resolved) return void 0;
4318
+ return {
4319
+ hash: hashAnchorText(resolved.text),
4320
+ lines: resolved.endLine - resolved.startLine + 1
4321
+ };
4322
+ }
4323
+ async function readSources(anchors, root, offline) {
4324
+ const origin = new LazyOrigin(root);
4325
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
4326
+ const foreign = new Map(
4327
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
4328
+ );
4329
+ const local = anchors.filter((anchor) => !foreign.get(anchor));
4330
+ const remote = anchors.filter((anchor) => foreign.get(anchor));
4331
+ const reads = await readAnchorFiles(
4332
+ local.map((anchor) => anchor.file),
4333
+ anchorFileReader(root)
4334
+ );
4335
+ const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
4336
+ offline
4337
+ });
4338
+ const sources = /* @__PURE__ */ new Map();
4339
+ for (const anchor of local) {
4340
+ const read = reads.get(anchor.file);
4341
+ sources.set(
4342
+ anchor,
4343
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
4344
+ );
4345
+ }
4346
+ for (const anchor of remote) {
4347
+ const repo = anchor.repo;
4348
+ const key2 = normalizeRepoUrl(repo);
4349
+ const atDefault = blobs.get(wantKey(key2, void 0, anchor.file));
4350
+ const primary = anchor.ref ? blobs.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
4351
+ if (!primary?.ok) {
4352
+ sources.set(anchor, {
4353
+ ok: false,
4354
+ reason: primary?.ok === false ? primary.reason : "remote-unreachable",
4355
+ repo
4356
+ });
4357
+ continue;
4358
+ }
4359
+ sources.set(anchor, {
4360
+ ok: true,
4361
+ source: primary.source,
4362
+ repo,
4363
+ ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
4364
+ });
4365
+ }
4366
+ return sources;
4367
+ }
3774
4368
 
3775
4369
  // src/commands/answer.ts
3776
4370
  var import_zod9 = require("zod");
@@ -3928,8 +4522,8 @@ var days = (what, fallback) => import_zod13.z.number().int().positive().optional
3928
4522
  var doctorCommand = define({
3929
4523
  name: "doctor",
3930
4524
  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.",
4525
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
4526
+ 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
4527
  input: import_zod13.z.object({
3934
4528
  bundlePath,
3935
4529
  repoRoot: REPO_ROOT,
@@ -3945,6 +4539,9 @@ var doctorCommand = define({
3945
4539
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
3946
4540
  DEFAULT_AGING_DAYS
3947
4541
  ),
4542
+ offline: import_zod13.z.boolean().optional().describe(
4543
+ "Read foreign anchors from the local repo cache only, never fetching."
4544
+ ),
3948
4545
  strict: import_zod13.z.boolean().optional().describe(
3949
4546
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
3950
4547
  )
@@ -3964,13 +4561,23 @@ var doctorCommand = define({
3964
4561
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
3965
4562
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
3966
4563
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
4564
+ ...argv.includes("--offline") ? { offline: true } : {},
3967
4565
  ...argv.includes("--strict") ? { strict: true } : {}
3968
4566
  };
3969
4567
  },
3970
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
4568
+ run: async ({ store, now }, {
4569
+ bundlePath: path,
4570
+ expiringDays,
4571
+ unverifiedDays,
4572
+ agingDays,
4573
+ repoRoot,
4574
+ offline
4575
+ }) => {
3971
4576
  const checkedAt = now();
3972
4577
  const records = await store.list(path);
3973
- const anchorDrift = await store.detectDrift(records, repoRoot);
4578
+ const anchorDrift = await store.detectDrift(records, repoRoot, {
4579
+ offline: offline === true
4580
+ });
3974
4581
  const report = doctor(records, {
3975
4582
  ...expiringDays !== void 0 ? { expiringDays } : {},
3976
4583
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
@@ -4380,17 +4987,115 @@ var schemaCommand = define({
4380
4987
  run: () => Promise.resolve(kbJsonSchemas())
4381
4988
  });
4382
4989
 
4383
- // src/commands/status.ts
4990
+ // src/commands/stamp.ts
4991
+ var import_promises7 = require("fs/promises");
4384
4992
  var import_zod25 = require("zod");
4993
+ var DIGEST = /^[0-9a-f]{64}$/;
4994
+ var stampCommand = define({
4995
+ name: "stamp",
4996
+ tool: "kb_stamp",
4997
+ usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
4998
+ description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids when the baseline is a prior stamp; silent when nothing changed. Reads, never writes.",
4999
+ input: import_zod25.z.object({
5000
+ bundlePath: import_zod25.z.string().min(1).optional().describe(
5001
+ "Absolute path to one knowledge base. Omit to stamp every pinned base."
5002
+ ),
5003
+ since: import_zod25.z.string().min(1).optional().describe(
5004
+ "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
5005
+ )
5006
+ }),
5007
+ fromArgv: (argv, path, _stdin, bundleExplicit) => {
5008
+ const since = argvFlag(argv, "--since");
5009
+ return {
5010
+ ...bundleExplicit ? { bundlePath: path } : {},
5011
+ ...since !== void 0 ? { since } : {}
5012
+ };
5013
+ },
5014
+ run: async ({ store }, { bundlePath: bundlePath2, since }) => {
5015
+ const targets = bundlePath2 ? [bundlePath2] : (await readMergedPins(process.cwd())).pins.map(
5016
+ (pin) => pin.absolutePath
5017
+ );
5018
+ if (since !== void 0 && DIGEST.test(since) && targets.length > 1) {
5019
+ throw new KbStampDigestBaselineError(since);
5020
+ }
5021
+ const stamps = await Promise.all(
5022
+ targets.map((target) => store.stamp(target))
5023
+ );
5024
+ if (since === void 0) {
5025
+ return stamps.map((stamp) => ({ ...stamp, changed: null }));
5026
+ }
5027
+ const baseline = await readBaseline(since);
5028
+ const reports = [];
5029
+ for (const stamp of stamps) {
5030
+ const before = baseline.byPath.get(stamp.path);
5031
+ if (baseline.digest !== null) {
5032
+ if (baseline.digest === stamp.digest) continue;
5033
+ reports.push({ ...stamp, changed: null });
5034
+ continue;
5035
+ }
5036
+ if (before && before.digest === stamp.digest) continue;
5037
+ reports.push({ ...stamp, changed: changedIds(before?.records, stamp) });
5038
+ }
5039
+ return reports;
5040
+ },
5041
+ render: (result) => result.map((report) => {
5042
+ const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
5043
+ const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
5044
+ return report.changed?.length ? `${head}
5045
+ changed: ${report.changed.join(", ")}` : head;
5046
+ }).join("\n")
5047
+ });
5048
+ function changedIds(before, stamp) {
5049
+ const now = new Map(
5050
+ stamp.records.map((record) => [record.conceptId, record.digest])
5051
+ );
5052
+ const ids = /* @__PURE__ */ new Set();
5053
+ for (const [conceptId2, digest] of now) {
5054
+ if (before?.get(conceptId2) !== digest) ids.add(conceptId2);
5055
+ }
5056
+ for (const conceptId2 of before?.keys() ?? []) {
5057
+ if (!now.has(conceptId2)) ids.add(conceptId2);
5058
+ }
5059
+ return [...ids].sort();
5060
+ }
5061
+ async function readBaseline(since) {
5062
+ if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
5063
+ let parsed;
5064
+ try {
5065
+ parsed = JSON.parse(await (0, import_promises7.readFile)(since, "utf8"));
5066
+ } catch {
5067
+ throw new KbStampBaselineError(since);
5068
+ }
5069
+ const entries = Array.isArray(parsed) ? parsed : parsed?.stamps ?? [];
5070
+ const byPath = /* @__PURE__ */ new Map();
5071
+ for (const entry of entries) {
5072
+ if (typeof entry?.path !== "string" || typeof entry?.digest !== "string") {
5073
+ continue;
5074
+ }
5075
+ byPath.set(entry.path, {
5076
+ digest: entry.digest,
5077
+ records: new Map(
5078
+ (entry.records ?? []).map((record) => [
5079
+ record.conceptId,
5080
+ record.digest
5081
+ ])
5082
+ )
5083
+ });
5084
+ }
5085
+ return { digest: null, byPath };
5086
+ }
5087
+
5088
+ // src/commands/status.ts
5089
+ var import_zod26 = require("zod");
4385
5090
  var statusCommand = define({
4386
5091
  name: "status",
4387
5092
  tool: "kb_status",
4388
5093
  usage: "status <concept-id> <status>",
4389
5094
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
4390
- input: import_zod25.z.object({
5095
+ input: import_zod26.z.object({
4391
5096
  bundlePath,
4392
5097
  conceptId,
4393
- status: import_zod25.z.enum(KB_RECORD_STATUSES)
5098
+ status: import_zod26.z.enum(KB_RECORD_STATUSES)
4394
5099
  }),
4395
5100
  fromArgv: (argv, path) => ({
4396
5101
  bundlePath: path,
@@ -4405,13 +5110,13 @@ var statusCommand = define({
4405
5110
  });
4406
5111
 
4407
5112
  // src/commands/supersede.ts
4408
- var import_zod26 = require("zod");
5113
+ var import_zod27 = require("zod");
4409
5114
  var supersedeCommand = define({
4410
5115
  name: "supersede",
4411
5116
  tool: "kb_supersede",
4412
5117
  usage: "supersede <concept-id> <replacement-id>",
4413
5118
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
4414
- input: import_zod26.z.object({ bundlePath, conceptId, replacementId: conceptId }),
5119
+ input: import_zod27.z.object({ bundlePath, conceptId, replacementId: conceptId }),
4415
5120
  fromArgv: (argv, path) => ({
4416
5121
  bundlePath: path,
4417
5122
  conceptId: argv[1],
@@ -4425,16 +5130,16 @@ var supersedeCommand = define({
4425
5130
  });
4426
5131
 
4427
5132
  // src/commands/sync-instructions.ts
4428
- var import_zod27 = require("zod");
5133
+ var import_zod28 = require("zod");
4429
5134
  var syncInstructionsCommand = define({
4430
5135
  name: "sync-instructions",
4431
5136
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
4432
5137
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
4433
- input: import_zod27.z.object({
4434
- file: import_zod27.z.string().min(1).describe("The instruction file to edit in place."),
4435
- budgetTokens: import_zod27.z.number().int().positive().optional(),
4436
- fullUnderTokens: import_zod27.z.number().int().positive().optional(),
4437
- profile: import_zod27.z.string().optional()
5138
+ input: import_zod28.z.object({
5139
+ file: import_zod28.z.string().min(1).describe("The instruction file to edit in place."),
5140
+ budgetTokens: import_zod28.z.number().int().positive().optional(),
5141
+ fullUnderTokens: import_zod28.z.number().int().positive().optional(),
5142
+ profile: import_zod28.z.string().optional()
4438
5143
  }),
4439
5144
  fromArgv: (argv) => {
4440
5145
  const budget = argvFlag(argv, "--budget");
@@ -4460,17 +5165,17 @@ var syncInstructionsCommand = define({
4460
5165
  });
4461
5166
 
4462
5167
  // src/commands/trace.ts
4463
- var import_zod28 = require("zod");
5168
+ var import_zod29 = require("zod");
4464
5169
  var traceCommand = define({
4465
5170
  name: "trace",
4466
5171
  tool: "kb_trace",
4467
5172
  usage: "trace <concept-id> [edges...]",
4468
5173
  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
- input: import_zod28.z.object({
5174
+ input: import_zod29.z.object({
4470
5175
  bundlePath,
4471
5176
  conceptId,
4472
- edges: import_zod28.z.array(import_zod28.z.enum(TRACE_EDGES)).optional(),
4473
- depth: import_zod28.z.number().int().positive().optional()
5177
+ edges: import_zod29.z.array(import_zod29.z.enum(TRACE_EDGES)).optional(),
5178
+ depth: import_zod29.z.number().int().positive().optional()
4474
5179
  }),
4475
5180
  fromArgv: (argv, path) => ({
4476
5181
  bundlePath: path,
@@ -4492,37 +5197,37 @@ var traceCommand = define({
4492
5197
  });
4493
5198
 
4494
5199
  // src/commands/types.ts
4495
- var import_zod29 = require("zod");
5200
+ var import_zod30 = require("zod");
4496
5201
  var typesCommand = define({
4497
5202
  name: "types",
4498
5203
  tool: "kb_types",
4499
5204
  usage: "types",
4500
5205
  description: "The twelve record types with their purpose, body sections, and starting status. Read this before writing rather than guessing headings \u2014 a section the type does not define is rejected.",
4501
- input: import_zod29.z.object({}),
5206
+ input: import_zod30.z.object({}),
4502
5207
  fromArgv: () => ({}),
4503
5208
  run: () => Promise.resolve(RECORD_TYPES)
4504
5209
  });
4505
5210
 
4506
5211
  // src/commands/unpin.ts
4507
- var import_zod30 = require("zod");
5212
+ var import_zod31 = require("zod");
4508
5213
  var unpinCommand = define({
4509
5214
  name: "unpin",
4510
5215
  tool: "kb_unpin",
4511
5216
  usage: "unpin [bundle-path]",
4512
5217
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
4513
- input: import_zod30.z.object({ bundlePath }),
5218
+ input: import_zod31.z.object({ bundlePath }),
4514
5219
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
4515
5220
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
4516
5221
  });
4517
5222
 
4518
5223
  // src/commands/validate.ts
4519
- var import_zod31 = require("zod");
5224
+ var import_zod32 = require("zod");
4520
5225
  var validateCommand = define({
4521
5226
  name: "validate",
4522
5227
  tool: "kb_validate",
4523
5228
  usage: "validate",
4524
5229
  description: "Check pointers no single record can see: supersession links that disagree between the two records, typed causal links, and assumptions that cite sources. Each finding carries a severity: errors fail the exit code, warnings do not.",
4525
- input: import_zod31.z.object({ bundlePath }),
5230
+ input: import_zod32.z.object({ bundlePath }),
4526
5231
  fromArgv: (_argv, path) => ({ bundlePath: path }),
4527
5232
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
4528
5233
  // Warnings never fail the exit code; every other severity does.
@@ -4532,16 +5237,16 @@ var validateCommand = define({
4532
5237
  });
4533
5238
 
4534
5239
  // src/commands/verify.ts
4535
- var import_zod32 = require("zod");
5240
+ var import_zod33 = require("zod");
4536
5241
  var verifyCommand = define({
4537
5242
  name: "verify",
4538
5243
  tool: "kb_verify",
4539
5244
  usage: "verify <concept-id> --note <text>",
4540
5245
  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
- input: import_zod32.z.object({
5246
+ input: import_zod33.z.object({
4542
5247
  bundlePath,
4543
5248
  conceptId,
4544
- note: import_zod32.z.string().refine((s) => s.trim().length > 0, {
5249
+ note: import_zod33.z.string().refine((s) => s.trim().length > 0, {
4545
5250
  message: "note must say what the check found"
4546
5251
  })
4547
5252
  }),
@@ -4561,15 +5266,15 @@ var verifyCommand = define({
4561
5266
  });
4562
5267
 
4563
5268
  // src/commands/write.ts
4564
- var import_zod33 = require("zod");
5269
+ var import_zod34 = require("zod");
4565
5270
  var writeCommand = define({
4566
5271
  name: "write",
4567
5272
  tool: "kb_write",
4568
5273
  usage: "write <type> < record.json",
4569
5274
  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.",
4570
- input: import_zod33.z.object({
5275
+ input: import_zod34.z.object({
4571
5276
  bundlePath,
4572
- type: import_zod33.z.enum(KB_RECORD_TYPES),
5277
+ type: import_zod34.z.enum(KB_RECORD_TYPES),
4573
5278
  input: composeInputSchema
4574
5279
  }),
4575
5280
  fromArgv: async (argv, path, stdin) => ({
@@ -4593,13 +5298,13 @@ var writeCommand = define({
4593
5298
  });
4594
5299
 
4595
5300
  // src/commands/write-decision.ts
4596
- var import_zod34 = require("zod");
5301
+ var import_zod35 = require("zod");
4597
5302
  var writeDecisionCommand = define({
4598
5303
  name: "write-decision",
4599
5304
  tool: "kb_write_decision",
4600
5305
  usage: "write-decision < decision.json",
4601
5306
  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.",
4602
- input: import_zod34.z.object({ bundlePath, input: decisionInputSchema }),
5307
+ input: import_zod35.z.object({ bundlePath, input: decisionInputSchema }),
4603
5308
  fromArgv: async (_argv, path, stdin) => ({
4604
5309
  bundlePath: path,
4605
5310
  input: JSON.parse(await stdin())
@@ -4639,6 +5344,7 @@ var KB_COMMANDS = [
4639
5344
  listCommand,
4640
5345
  readIndexCommand,
4641
5346
  logCommand,
5347
+ stampCommand,
4642
5348
  validateCommand,
4643
5349
  doctorCommand,
4644
5350
  schemaCommand,
@@ -4658,7 +5364,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
4658
5364
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
4659
5365
 
4660
5366
  // src/version.ts
4661
- var VERSION = true ? "0.1.14" : "0.0.0-dev";
5367
+ var VERSION = true ? "0.1.16" : "0.0.0-dev";
4662
5368
 
4663
5369
  // src/mcp.ts
4664
5370
  function createKbMcpServer() {
@@ -4697,10 +5403,14 @@ async function runKbMcpServer() {
4697
5403
  }
4698
5404
 
4699
5405
  // src/cli.ts
4700
- var import_node_path8 = require("path");
5406
+ var import_node_path9 = require("path");
4701
5407
  async function runKbCli(argv) {
4702
5408
  const { flags, literal } = takeLiteral(argv);
4703
- const { bundle, rest: withFlags } = takeBundle(flags);
5409
+ const {
5410
+ bundle,
5411
+ explicit: bundleExplicit,
5412
+ rest: withFlags
5413
+ } = takeBundle(flags);
4704
5414
  const name = withFlags[0] ?? "";
4705
5415
  if (!name || name === "-h" || name === "--help") {
4706
5416
  process.stdout.write(usage());
@@ -4721,7 +5431,7 @@ async function runKbCli(argv) {
4721
5431
  ...json ? withFlags.filter((argument) => argument !== "--json") : withFlags,
4722
5432
  ...literal
4723
5433
  ];
4724
- const raw = await command.fromArgv(rest, bundle, readStdin);
5434
+ const raw = await command.fromArgv(rest, bundle, readStdin, bundleExplicit);
4725
5435
  const parsed = command.input.safeParse(raw);
4726
5436
  if (!parsed.success) {
4727
5437
  die(
@@ -4743,6 +5453,7 @@ async function runKbCli(argv) {
4743
5453
  if (command.failsWhen?.(result, parsed.data)) process.exitCode = 1;
4744
5454
  if (result === "") return;
4745
5455
  const text = command.render && !json ? command.render(result) : typeof result === "string" ? result : JSON.stringify(result, null, 2);
5456
+ if (text === "") return;
4746
5457
  process.stdout.write(text.endsWith("\n") ? text : `${text}
4747
5458
  `);
4748
5459
  }
@@ -4754,11 +5465,15 @@ function takeLiteral(argv) {
4754
5465
  function takeBundle(argv) {
4755
5466
  const at = argv.indexOf("--bundle");
4756
5467
  if (at === -1) {
4757
- return { bundle: (0, import_node_path8.join)(process.cwd(), KB_DIR), rest: argv };
5468
+ return { bundle: (0, import_node_path9.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
4758
5469
  }
4759
5470
  const bundle = argv[at + 1];
4760
5471
  if (!bundle) die("--bundle requires a path");
4761
- return { bundle, rest: [...argv.slice(0, at), ...argv.slice(at + 2)] };
5472
+ return {
5473
+ bundle,
5474
+ explicit: true,
5475
+ rest: [...argv.slice(0, at), ...argv.slice(at + 2)]
5476
+ };
4762
5477
  }
4763
5478
  function readStdin() {
4764
5479
  return new Promise((resolve6, reject) => {
@@ -4869,6 +5584,7 @@ function usage() {
4869
5584
  impact,
4870
5585
  inboundIndex,
4871
5586
  indexIsStale,
5587
+ isCanonicalRepoUrl,
4872
5588
  isKbLinkRel,
4873
5589
  isKbRecordType,
4874
5590
  isNoDecisionRecord,
@@ -4892,11 +5608,13 @@ function usage() {
4892
5608
  pinBase,
4893
5609
  readMergedPins,
4894
5610
  readPinsLayer,
5611
+ readRemoteAnchors,
4895
5612
  regexResolver,
4896
5613
  renderCatalogLine,
4897
5614
  renderIndex,
4898
5615
  renderIndexLine,
4899
5616
  renderLogEntry,
5617
+ repoCacheDir,
4900
5618
  resolveAnchor,
4901
5619
  resolveHeads,
4902
5620
  resolveHits,