@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/mcp-main.cjs CHANGED
@@ -59,16 +59,17 @@ var kbAnchorSchema = import_zod.z.object({
59
59
  * (`https://github.com/org/name`) or a short name. Absent means the base's
60
60
  * own repository, which is what nearly every anchor means.
61
61
  *
62
- * Unvalidated beyond not-blank: one repository has many spellings.
63
- * Matched after normalisation; see ARCHITECTURE.
62
+ * Unvalidated beyond not-blank: one repository has many spellings, matched
63
+ * after normalisation. Only a full URL can be fetched from, so `validate`
64
+ * warns on a short one; see ARCHITECTURE.
64
65
  */
65
66
  repo: import_zod.z.string().trim().min(1).optional(),
66
67
  /**
67
68
  * The git rev the evidence was taken at. Prefer a commit SHA: a branch
68
69
  * name is a moving pointer, so an anchor pinned to one says the evidence
69
70
  * came from wherever that branch happens to be now, which is not a
70
- * baseline. Recorded and preserved in v1; ref-pinned reads land with
71
- * SAA-709.
71
+ * baseline. A foreign anchor is checked at this rev, and compared against
72
+ * the remote's default branch on top of it.
72
73
  */
73
74
  ref: import_zod.z.string().trim().min(1).optional(),
74
75
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
@@ -443,13 +444,6 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
443
444
  // src/commands/anchor-resolve.ts
444
445
  var import_zod6 = require("zod");
445
446
 
446
- // src/anchor-resolver.ts
447
- var import_node_child_process = require("child_process");
448
- var import_node_crypto = require("crypto");
449
- var import_promises = require("fs/promises");
450
- var import_node_path = require("path");
451
- var import_node_util = require("util");
452
-
453
447
  // src/concurrency.ts
454
448
  var DEFAULT_IO_CONCURRENCY = 16;
455
449
  async function mapLimit(items, limit, fn) {
@@ -479,9 +473,528 @@ async function mapLimit(items, limit, fn) {
479
473
  return out;
480
474
  }
481
475
 
482
- // src/anchor-resolver.ts
476
+ // src/remote-repo/cache.ts
477
+ var import_node_os = require("os");
478
+ var import_node_path = require("path");
479
+
480
+ // src/anchor-resolver/repo-identity.ts
481
+ var import_node_child_process = require("child_process");
482
+ var import_node_util = require("util");
483
483
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
484
+ function normalizeRepoUrl(value) {
485
+ let url = value.trim().replace(/^git\+/, "");
486
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
487
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
488
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
489
+ url = trimTrailingSlashes(url);
490
+ if (url.endsWith(".git")) url = url.slice(0, -4);
491
+ return trimTrailingSlashes(url).toLowerCase();
492
+ }
493
+ function trimTrailingSlashes(value) {
494
+ let end = value.length;
495
+ while (end > 0 && value[end - 1] === "/") end -= 1;
496
+ return value.slice(0, end);
497
+ }
498
+ function isCanonicalRepoUrl(value) {
499
+ return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
500
+ }
501
+ function repoPath(normalized) {
502
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
503
+ const segments = withoutScheme.split("/").filter(Boolean);
504
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
505
+ }
506
+ function repoIdentifies(declared, originUrl) {
507
+ if (!originUrl) return false;
508
+ const origin = normalizeRepoUrl(originUrl);
509
+ const want = normalizeRepoUrl(declared);
510
+ if (!want || !origin) return false;
511
+ if (want === origin) return true;
512
+ const path = repoPath(origin);
513
+ if (!path) return false;
514
+ return want === path || want === (path.split("/").pop() ?? "");
515
+ }
516
+ async function repoOriginUrl(repoRoot) {
517
+ try {
518
+ const { stdout } = await execFileAsync(
519
+ "git",
520
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
521
+ { timeout: 5e3 }
522
+ );
523
+ return stdout.trim() || null;
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+ var LazyOrigin = class {
529
+ constructor(repoRoot) {
530
+ this.repoRoot = repoRoot;
531
+ }
532
+ repoRoot;
533
+ url = null;
534
+ asked = false;
535
+ /** Asks git once, so later `isForeign` calls need no await. */
536
+ async prime() {
537
+ if (this.asked) return;
538
+ this.url = await repoOriginUrl(this.repoRoot);
539
+ this.asked = true;
540
+ }
541
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
542
+ isForeign(anchor) {
543
+ if (!anchor.repo) return false;
544
+ return !repoIdentifies(anchor.repo, this.url);
545
+ }
546
+ async foreign(anchor) {
547
+ if (!anchor.repo) return false;
548
+ await this.prime();
549
+ return this.isForeign(anchor);
550
+ }
551
+ };
552
+
553
+ // src/remote-repo/cache.ts
554
+ function repoCacheDir(override) {
555
+ return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".strauss", "repo-cache");
556
+ }
557
+ var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
558
+ function fetchTimeoutMs(override) {
559
+ if (override !== void 0) return override;
560
+ const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
561
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
562
+ }
563
+ function cachePathFor(repo, cacheDir) {
564
+ const normalized = normalizeRepoUrl(repo);
565
+ const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
566
+ if (!scheme) return null;
567
+ const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
568
+ if (segments.length < 2 || segments.some((segment) => segment === null)) {
569
+ return null;
570
+ }
571
+ const path = segments;
572
+ return (0, import_node_path.join)(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
573
+ }
574
+ function safeSegment(value) {
575
+ return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
576
+ }
577
+ function revRef(rev) {
578
+ const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
579
+ let hash = 5381;
580
+ for (let at = 0; at < rev.length; at++) {
581
+ hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
582
+ }
583
+ return `refs/strauss/${safe}-${hash.toString(16)}`;
584
+ }
585
+
586
+ // src/remote-repo/model.ts
587
+ var UNCHECKED_REASONS = [
588
+ "remote-unreachable",
589
+ "repo-unauthorized",
590
+ "default-branch-unknown"
591
+ ];
592
+ function isUncheckedReason(reason) {
593
+ return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
594
+ }
595
+ function wantKey(repo, ref, file) {
596
+ return `${repo}\0${ref ?? ""}\0${file}`;
597
+ }
598
+
599
+ // src/remote-repo/read.ts
600
+ var import_promises = require("fs/promises");
601
+
602
+ // src/remote-repo/git.ts
603
+ var import_node_child_process2 = require("child_process");
604
+ var import_node_util2 = require("util");
605
+
606
+ // src/anchor-resolver/model.ts
484
607
  var MAX_ANCHOR_FILE_BYTES = 1048576;
608
+
609
+ // src/remote-repo/git.ts
610
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
611
+ function childEnv() {
612
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
613
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
614
+ delete env[name];
615
+ }
616
+ return env;
617
+ }
618
+ async function git(args, options = {}) {
619
+ try {
620
+ const { stdout, stderr } = await execFileAsync2("git", args, {
621
+ ...options.cwd ? { cwd: options.cwd } : {},
622
+ timeout: options.timeoutMs ?? 3e4,
623
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
624
+ encoding: "utf8",
625
+ windowsHide: true,
626
+ env: childEnv()
627
+ });
628
+ return { ok: true, stdout, stderr, overflowed: false };
629
+ } catch (error) {
630
+ const failure = error;
631
+ return {
632
+ ok: false,
633
+ stdout: failure.stdout ?? "",
634
+ stderr: failure.stderr ?? "",
635
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
636
+ };
637
+ }
638
+ }
639
+ function transportReason(stderr) {
640
+ const text = stderr.toLowerCase();
641
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
642
+ return "repo-unauthorized";
643
+ }
644
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
645
+ return "ref-not-found";
646
+ }
647
+ return "remote-unreachable";
648
+ }
649
+
650
+ // src/remote-repo/validate.ts
651
+ var MAX_REF_LENGTH = 200;
652
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
653
+ function refShapeIsSafe(ref) {
654
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
655
+ if (ref.includes("..")) return false;
656
+ return REF_SHAPE.test(ref);
657
+ }
658
+ async function refIsWellFormed(ref) {
659
+ if (!refShapeIsSafe(ref)) return false;
660
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
661
+ return checked.ok;
662
+ }
663
+ function filePathIsSafe(file) {
664
+ const path = file.replace(/^\.\//, "");
665
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
666
+ return !path.split("/").includes("..");
667
+ }
668
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
669
+ function allowedProtocols() {
670
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
671
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
672
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
673
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
674
+ }
675
+ function isShortRepoName(repo) {
676
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
677
+ }
678
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
679
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
680
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
681
+ function repoUrlIsSafe(repo) {
682
+ const url = repo.trim();
683
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
684
+ const allowed = allowedProtocols();
685
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
686
+ const scheme = URL_SCHEME.exec(url);
687
+ if (!scheme?.[1]) return false;
688
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
689
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
690
+ const at = authority.lastIndexOf("@");
691
+ return at < 0 || !authority.slice(0, at).includes(":");
692
+ }
693
+ function protocolArgs() {
694
+ const allowed = allowedProtocols();
695
+ return [
696
+ "-c",
697
+ "protocol.ext.allow=never",
698
+ "-c",
699
+ `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
700
+ ];
701
+ }
702
+
703
+ // src/remote-repo/read.ts
704
+ var DEFAULT_REPO_CONCURRENCY = 4;
705
+ var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
706
+ async function readRemoteAnchors(wants, options = {}) {
707
+ const out = /* @__PURE__ */ new Map();
708
+ if (!wants.length) return out;
709
+ const cacheDir = repoCacheDir(options.cacheDir);
710
+ const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
711
+ const byRepo = /* @__PURE__ */ new Map();
712
+ for (const want of wants) {
713
+ const key = normalizeRepoUrl(want.repo);
714
+ const group2 = byRepo.get(key) ?? { url: want.repo.trim(), wants: [] };
715
+ group2.wants.push(want);
716
+ byRepo.set(key, group2);
717
+ }
718
+ const groups = [...byRepo.entries()];
719
+ const results = await mapLimit(
720
+ groups,
721
+ Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
722
+ ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
723
+ cacheDir,
724
+ timeoutMs,
725
+ offline: options.offline === true
726
+ })
727
+ );
728
+ for (const result of results) {
729
+ for (const [key, read] of result) out.set(key, read);
730
+ }
731
+ return out;
732
+ }
733
+ async function readOneRepo(repo, url, declared, context) {
734
+ let wants = declared;
735
+ const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
736
+ if (isShortRepoName(url)) {
737
+ return all({ ok: false, reason: "remote-unreachable" });
738
+ }
739
+ if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
740
+ const cache = cachePathFor(repo, context.cacheDir);
741
+ if (!cache) return all({ ok: false, reason: "remote-unreachable" });
742
+ const rejected = /* @__PURE__ */ new Map();
743
+ const usable = [];
744
+ for (const want of wants) {
745
+ const reason = wantReason(want);
746
+ if (reason)
747
+ rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
748
+ else usable.push(want);
749
+ }
750
+ if (!usable.length) return rejected;
751
+ wants = usable;
752
+ const opened = await openCache(cache, url, context);
753
+ if (opened) return new Map([...rejected, ...all(opened)]);
754
+ const wantsDefault = wants.some((want) => want.ref === void 0);
755
+ const branch = wantsDefault ? await defaultBranch(cache, context) : {};
756
+ const revs = /* @__PURE__ */ new Map();
757
+ for (const rev of distinctRevs(wants, branch.name)) {
758
+ revs.set(
759
+ rev,
760
+ await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
761
+ );
762
+ }
763
+ const reads = await mapLimit(
764
+ wants,
765
+ DEFAULT_IO_CONCURRENCY,
766
+ async (want) => {
767
+ const rev = want.ref ?? branch.name;
768
+ if (rev === void 0) {
769
+ return {
770
+ ok: false,
771
+ reason: branch.reason ?? "default-branch-unknown"
772
+ };
773
+ }
774
+ const failed = revs.get(rev);
775
+ if (failed) return failed;
776
+ return readBlob(cache, rev, want.file, context);
777
+ }
778
+ );
779
+ return new Map([
780
+ ...rejected,
781
+ ...wants.map(
782
+ (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
783
+ )
784
+ ]);
785
+ }
786
+ function wantReason(want) {
787
+ if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
788
+ return filePathIsSafe(want.file) ? void 0 : "outside-repo";
789
+ }
790
+ function distinctRevs(wants, branch) {
791
+ const revs = /* @__PURE__ */ new Set();
792
+ for (const want of wants) {
793
+ if (want.ref !== void 0) revs.add(want.ref);
794
+ else if (branch) revs.add(branch);
795
+ }
796
+ return [...revs];
797
+ }
798
+ async function openCache(cache, url, context) {
799
+ try {
800
+ await (0, import_promises.mkdir)(cache, { recursive: true });
801
+ } catch {
802
+ return { ok: false, reason: "remote-unreachable" };
803
+ }
804
+ const init = await git(["init", "--bare", "--quiet", cache], {
805
+ timeoutMs: context.timeoutMs
806
+ });
807
+ if (!init.ok) return { ok: false, reason: "remote-unreachable" };
808
+ const remote = await git(["config", "remote.origin.url", url], {
809
+ cwd: cache,
810
+ timeoutMs: context.timeoutMs
811
+ });
812
+ return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
813
+ }
814
+ async function defaultBranch(cache, context) {
815
+ if (!context.offline) {
816
+ const listed = await git(
817
+ [...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
818
+ {
819
+ cwd: cache,
820
+ timeoutMs: context.timeoutMs
821
+ }
822
+ );
823
+ const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
824
+ if (listed.ok && found?.[1]) {
825
+ const name = found[1];
826
+ await git(["config", "strauss.defaultBranch", name], { cwd: cache });
827
+ return { name };
828
+ }
829
+ if (!listed.ok) {
830
+ const reason = transportReason(listed.stderr);
831
+ if (reason !== "ref-not-found") {
832
+ const cached2 = await cachedBranch(cache);
833
+ return cached2 ? { name: cached2 } : { reason };
834
+ }
835
+ }
836
+ }
837
+ const cached = await cachedBranch(cache);
838
+ if (cached) return { name: cached };
839
+ return {
840
+ reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
841
+ };
842
+ }
843
+ async function cachedBranch(cache) {
844
+ const stored = await git(["config", "--get", "strauss.defaultBranch"], {
845
+ cwd: cache
846
+ });
847
+ if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
848
+ const head = await git(
849
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
850
+ {
851
+ cwd: cache
852
+ }
853
+ );
854
+ const name = head.stdout.trim().replace(/^origin\//, "");
855
+ return head.ok && name ? name : void 0;
856
+ }
857
+ async function ensureRev(cache, rev, context) {
858
+ const ref = revRef(rev);
859
+ const have = await git(
860
+ ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
861
+ {
862
+ cwd: cache
863
+ }
864
+ );
865
+ const cached = have.ok && have.stdout.trim().length > 0;
866
+ if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
867
+ if (context.offline) return { ok: false, reason: "remote-unreachable" };
868
+ const fetched = await git(
869
+ [
870
+ ...protocolArgs(),
871
+ "fetch",
872
+ "--depth",
873
+ "1",
874
+ "origin",
875
+ "--end-of-options",
876
+ rev
877
+ ],
878
+ { cwd: cache, timeoutMs: context.timeoutMs }
879
+ );
880
+ if (!fetched.ok) {
881
+ const reason = transportReason(fetched.stderr);
882
+ if (cached && reason !== "ref-not-found") return void 0;
883
+ return { ok: false, reason };
884
+ }
885
+ const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
886
+ const sha = head.stdout.trim();
887
+ if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
888
+ const updated = await git(["update-ref", ref, sha], { cwd: cache });
889
+ return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
890
+ }
891
+ async function readBlob(cache, rev, file, context) {
892
+ const path = file.replace(/^\.\//, "");
893
+ const blob = await git(
894
+ ["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
895
+ {
896
+ cwd: cache,
897
+ timeoutMs: context.timeoutMs
898
+ }
899
+ );
900
+ if (blob.ok) return { ok: true, source: blob.stdout };
901
+ if (blob.overflowed) return { ok: false, reason: "file-too-large" };
902
+ const text = blob.stderr.toLowerCase();
903
+ return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
904
+ }
905
+
906
+ // src/anchor-resolver/read.ts
907
+ var import_promises2 = require("fs/promises");
908
+ var import_node_path2 = require("path");
909
+ function anchorFilePath(repoRoot, file) {
910
+ const path = (0, import_node_path2.resolve)(repoRoot, file.replace(/^\.\//, ""));
911
+ const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(repoRoot), path);
912
+ if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path2.sep}`) || (0, import_node_path2.isAbsolute)(rel)) {
913
+ return null;
914
+ }
915
+ return path;
916
+ }
917
+ function contains(root, path) {
918
+ const rel = (0, import_node_path2.relative)(root, path);
919
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`) && !(0, import_node_path2.isAbsolute)(rel);
920
+ }
921
+ function errorCode(error) {
922
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
923
+ }
924
+ function anchorFileReader(repoRoot) {
925
+ let rootOnce;
926
+ const realRoot = () => {
927
+ rootOnce ??= (0, import_promises2.realpath)((0, import_node_path2.resolve)(repoRoot)).catch((error) => {
928
+ rootOnce = void 0;
929
+ throw error;
930
+ });
931
+ return rootOnce;
932
+ };
933
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
934
+ }
935
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
936
+ const lexical = anchorFilePath(repoRoot, file);
937
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
938
+ let root;
939
+ let path;
940
+ try {
941
+ root = await realRoot();
942
+ path = await (0, import_promises2.realpath)(lexical);
943
+ } catch (error) {
944
+ const code = errorCode(error);
945
+ if (code === "ENOENT" || code === "ENOTDIR") {
946
+ return { ok: false, reason: "file-missing" };
947
+ }
948
+ return { ok: false, reason: "file-unreadable" };
949
+ }
950
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
951
+ try {
952
+ const stats = await (0, import_promises2.stat)(path);
953
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
954
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
955
+ return { ok: false, reason: "file-too-large" };
956
+ }
957
+ return { ok: true, source: await (0, import_promises2.readFile)(path, "utf8") };
958
+ } catch (error) {
959
+ const code = errorCode(error);
960
+ if (code === "ENOENT" || code === "ENOTDIR") {
961
+ return { ok: false, reason: "file-missing" };
962
+ }
963
+ return { ok: false, reason: "file-unreadable" };
964
+ }
965
+ }
966
+ function looksLikeWrongRepoRoot(drift) {
967
+ let checked = 0;
968
+ for (const entries of drift.values()) {
969
+ for (const entry of entries) {
970
+ if (entry.repo !== void 0) continue;
971
+ checked += 1;
972
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
973
+ return false;
974
+ }
975
+ }
976
+ }
977
+ return checked > 0;
978
+ }
979
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
980
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
981
+ throw new RangeError(
982
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
983
+ );
984
+ }
985
+ const wanted = [...new Set(files)];
986
+ const results = await mapLimit(wanted, concurrency, async (file) => {
987
+ try {
988
+ return await read(file);
989
+ } catch {
990
+ return { ok: false, reason: "file-unreadable" };
991
+ }
992
+ });
993
+ return new Map(wanted.map((file, at) => [file, results[at]]));
994
+ }
995
+
996
+ // src/anchor-resolver/resolver.ts
997
+ var import_node_crypto = require("crypto");
485
998
  var PARENT_SCOPE_LINES = 50;
486
999
  var CLEAN_STATE = { blockComment: false, template: false };
487
1000
  function stripLine(line, state) {
@@ -644,170 +1157,21 @@ function distanceToParent(lines, index, parent) {
644
1157
  function hashAnchorText(text) {
645
1158
  return `sha256:${(0, import_node_crypto.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
646
1159
  }
647
- function resolveAnchor(source, anchor, resolver = regexResolver) {
648
- const normalized = source.replace(/\r\n/g, "\n");
649
- if (!anchor.symbol) {
650
- const lines = normalized.split("\n");
651
- if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
652
- return {
653
- text: normalized,
654
- startLine: 1,
655
- endLine: Math.max(1, lines.length)
656
- };
657
- }
658
- return resolver.resolve(normalized, anchor.symbol);
659
- }
660
- function anchorFilePath(repoRoot, file) {
661
- const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
662
- const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
663
- if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
664
- return null;
665
- }
666
- return path;
667
- }
668
- function contains(root, path) {
669
- const rel = (0, import_node_path.relative)(root, path);
670
- return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
671
- }
672
- function normalizeRepoUrl(value) {
673
- let url = value.trim().replace(/^git\+/, "");
674
- const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
675
- if (scp) url = `https://${scp[1]}/${scp[2]}`;
676
- url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
677
- url = trimTrailingSlashes(url);
678
- if (url.endsWith(".git")) url = url.slice(0, -4);
679
- return trimTrailingSlashes(url).toLowerCase();
680
- }
681
- function trimTrailingSlashes(value) {
682
- let end = value.length;
683
- while (end > 0 && value[end - 1] === "/") end -= 1;
684
- return value.slice(0, end);
685
- }
686
- function repoPath(normalized) {
687
- const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
688
- const segments = withoutScheme.split("/").filter(Boolean);
689
- return segments.length > 1 ? segments.slice(1).join("/") : "";
690
- }
691
- function repoIdentifies(declared, originUrl) {
692
- if (!originUrl) return false;
693
- const origin = normalizeRepoUrl(originUrl);
694
- const want = normalizeRepoUrl(declared);
695
- if (!want || !origin) return false;
696
- if (want === origin) return true;
697
- const path = repoPath(origin);
698
- if (!path) return false;
699
- return want === path || want === (path.split("/").pop() ?? "");
700
- }
701
- async function repoOriginUrl(repoRoot) {
702
- try {
703
- const { stdout } = await execFileAsync(
704
- "git",
705
- ["-C", repoRoot, "config", "--get", "remote.origin.url"],
706
- { timeout: 5e3 }
707
- );
708
- return stdout.trim() || null;
709
- } catch {
710
- return null;
711
- }
712
- }
713
- var LazyOrigin = class {
714
- constructor(repoRoot) {
715
- this.repoRoot = repoRoot;
716
- }
717
- repoRoot;
718
- url = null;
719
- asked = false;
720
- /** Asks git once, so later `isForeign` calls need no await. */
721
- async prime() {
722
- if (this.asked) return;
723
- this.url = await repoOriginUrl(this.repoRoot);
724
- this.asked = true;
725
- }
726
- /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
727
- isForeign(anchor) {
728
- if (!anchor.repo) return false;
729
- return !repoIdentifies(anchor.repo, this.url);
730
- }
731
- async foreign(anchor) {
732
- if (!anchor.repo) return false;
733
- await this.prime();
734
- return this.isForeign(anchor);
735
- }
736
- };
737
- function errorCode(error) {
738
- return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
739
- }
740
- function anchorFileReader(repoRoot) {
741
- let rootOnce;
742
- const realRoot = () => {
743
- rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
744
- rootOnce = void 0;
745
- throw error;
746
- });
747
- return rootOnce;
748
- };
749
- return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
750
- }
751
- async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
752
- const lexical = anchorFilePath(repoRoot, file);
753
- if (lexical === null) return { ok: false, reason: "outside-repo" };
754
- let root;
755
- let path;
756
- try {
757
- root = await realRoot();
758
- path = await (0, import_promises.realpath)(lexical);
759
- } catch (error) {
760
- const code = errorCode(error);
761
- if (code === "ENOENT" || code === "ENOTDIR") {
762
- return { ok: false, reason: "file-missing" };
763
- }
764
- return { ok: false, reason: "file-unreadable" };
765
- }
766
- if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
767
- try {
768
- const stats = await (0, import_promises.stat)(path);
769
- if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
770
- if (stats.size > MAX_ANCHOR_FILE_BYTES) {
771
- return { ok: false, reason: "file-too-large" };
772
- }
773
- return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
774
- } catch (error) {
775
- const code = errorCode(error);
776
- if (code === "ENOENT" || code === "ENOTDIR") {
777
- return { ok: false, reason: "file-missing" };
778
- }
779
- return { ok: false, reason: "file-unreadable" };
780
- }
781
- }
782
- function looksLikeWrongRepoRoot(drift) {
783
- let checked = 0;
784
- for (const entries of drift.values()) {
785
- for (const entry of entries) {
786
- if (entry.reason === "foreign-repo") continue;
787
- checked += 1;
788
- if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
789
- return false;
790
- }
791
- }
792
- }
793
- return checked > 0;
794
- }
795
- async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
796
- if (!Number.isInteger(concurrency) || concurrency < 1) {
797
- throw new RangeError(
798
- `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
799
- );
1160
+ function resolveAnchor(source, anchor, resolver = regexResolver) {
1161
+ const normalized = source.replace(/\r\n/g, "\n");
1162
+ if (!anchor.symbol) {
1163
+ const lines = normalized.split("\n");
1164
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1165
+ return {
1166
+ text: normalized,
1167
+ startLine: 1,
1168
+ endLine: Math.max(1, lines.length)
1169
+ };
800
1170
  }
801
- const wanted = [...new Set(files)];
802
- const results = await mapLimit(wanted, concurrency, async (file) => {
803
- try {
804
- return await read(file);
805
- } catch {
806
- return { ok: false, reason: "file-unreadable" };
807
- }
808
- });
809
- return new Map(wanted.map((file, at) => [file, results[at]]));
1171
+ return resolver.resolve(normalized, anchor.symbol);
810
1172
  }
1173
+
1174
+ // src/anchor-resolver/drift.ts
811
1175
  async function detectAnchorDrift(records, options = {}) {
812
1176
  const repoRoot = options.repoRoot ?? process.cwd();
813
1177
  const resolver = options.resolver ?? regexResolver;
@@ -833,67 +1197,97 @@ async function detectAnchorDrift(records, options = {}) {
833
1197
  }
834
1198
  }
835
1199
  const files = [];
1200
+ const wants = [];
836
1201
  for (const entries of planned.values()) {
837
- for (const entry of entries) {
838
- if (!entry.foreign) files.push(entry.anchor.file);
1202
+ for (const { anchor, foreign } of entries) {
1203
+ if (!foreign) files.push(anchor.file);
1204
+ else wants.push(...remoteWants(anchor));
839
1205
  }
840
1206
  }
841
- const reads = await readAnchorFiles(
842
- files,
843
- options.reader ?? anchorFileReader(repoRoot),
844
- options.concurrency ?? DEFAULT_IO_CONCURRENCY
845
- );
1207
+ const [reads, remote] = await Promise.all([
1208
+ readAnchorFiles(
1209
+ files,
1210
+ options.reader ?? anchorFileReader(repoRoot),
1211
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
1212
+ ),
1213
+ (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1214
+ ]);
846
1215
  const drift = /* @__PURE__ */ new Map();
847
1216
  for (const record of records) {
848
1217
  const entries = [];
849
1218
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
850
- const base = {
851
- file: anchor.file,
852
- ...anchor.symbol ? { symbol: anchor.symbol } : {},
853
- storedHash: anchor.hash
854
- };
855
- if (foreign) {
856
- entries.push({
857
- ...base,
858
- state: "unresolved",
859
- diffSize: null,
860
- reason: "foreign-repo"
861
- });
862
- continue;
863
- }
864
- const read = reads.get(anchor.file);
865
- if (!read.ok) {
866
- entries.push({
867
- ...base,
868
- state: "unresolved",
869
- diffSize: null,
870
- reason: read.reason
871
- });
872
- continue;
873
- }
874
- const resolved = resolveAnchor(read.source, anchor, resolver);
875
- if (!resolved) {
876
- entries.push({
877
- ...base,
878
- state: "unresolved",
879
- diffSize: null,
880
- reason: "symbol-not-found"
881
- });
882
- continue;
883
- }
884
- const currentHash = hashAnchorText(resolved.text);
885
- const currentLines = resolved.endLine - resolved.startLine + 1;
886
- entries.push({
887
- ...base,
888
- state: currentHash === anchor.hash ? "match" : "drifted",
889
- currentHash,
890
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
891
- });
1219
+ entries.push(
1220
+ foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1221
+ );
892
1222
  }
893
1223
  if (entries.length) drift.set(record.conceptId, entries);
894
1224
  }
895
1225
  return drift;
896
1226
  }
1227
+ function remoteWants(anchor) {
1228
+ const repo = anchor.repo;
1229
+ const wants = [{ repo, file: anchor.file }];
1230
+ if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
1231
+ return wants;
1232
+ }
1233
+ function base(anchor) {
1234
+ return {
1235
+ file: anchor.file,
1236
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1237
+ storedHash: anchor.hash
1238
+ };
1239
+ }
1240
+ function unresolved(anchor, reason, repo) {
1241
+ return {
1242
+ ...base(anchor),
1243
+ state: "unresolved",
1244
+ diffSize: null,
1245
+ ...reason ? { reason } : {},
1246
+ ...repo ? { repo } : {}
1247
+ };
1248
+ }
1249
+ function hashIn(source, anchor, resolver) {
1250
+ const resolved = resolveAnchor(source, anchor, resolver);
1251
+ if (!resolved) return null;
1252
+ return {
1253
+ hash: hashAnchorText(resolved.text),
1254
+ lines: resolved.endLine - resolved.startLine + 1
1255
+ };
1256
+ }
1257
+ function compared(anchor, current, extra = {}) {
1258
+ return {
1259
+ ...base(anchor),
1260
+ state: current.hash === anchor.hash ? "match" : "drifted",
1261
+ currentHash: current.hash,
1262
+ diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1263
+ ...extra
1264
+ };
1265
+ }
1266
+ function localEntry(anchor, read, resolver) {
1267
+ if (!read.ok) return unresolved(anchor, read.reason);
1268
+ const current = hashIn(read.source, anchor, resolver);
1269
+ return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1270
+ }
1271
+ function remoteEntry(anchor, remote, resolver) {
1272
+ const repo = anchor.repo;
1273
+ const key = normalizeRepoUrl(repo);
1274
+ const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1275
+ const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1276
+ if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1277
+ if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1278
+ const current = hashIn(primary.source, anchor, resolver);
1279
+ if (!current) return unresolved(anchor, "symbol-not-found", repo);
1280
+ if (!anchor.ref) return compared(anchor, current, { repo });
1281
+ if (current.hash !== anchor.hash) {
1282
+ return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1283
+ }
1284
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1285
+ return head && head.hash !== anchor.hash ? {
1286
+ ...compared(anchor, head, { repo }),
1287
+ state: "drifted",
1288
+ remoteState: "drifted-on-default"
1289
+ } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1290
+ }
897
1291
 
898
1292
  // src/errors.ts
899
1293
  var BaseError = class extends Error {
@@ -1046,6 +1440,36 @@ var KbInvalidConceptIdError = class extends BaseError {
1046
1440
  });
1047
1441
  }
1048
1442
  };
1443
+ var KbStampBaselineError = class extends BaseError {
1444
+ constructor(since) {
1445
+ super({
1446
+ message: `kb: --since ${since} is neither a 64-character digest nor a readable stamp file`,
1447
+ errorType: "KbStampBaselineUnreadable" /* KbStampBaselineUnreadable */,
1448
+ code: 400,
1449
+ fault: "User" /* User */,
1450
+ retriable: false,
1451
+ reportToUser: true,
1452
+ details: { since }
1453
+ });
1454
+ this.since = since;
1455
+ }
1456
+ since;
1457
+ };
1458
+ var KbStampDigestBaselineError = class extends BaseError {
1459
+ constructor(since) {
1460
+ super({
1461
+ message: `kb: --since ${since} is a digest, which needs --bundle (one base) \u2014 a file baseline works for many`,
1462
+ errorType: "KbStampDigestBaselineAmbiguous" /* KbStampDigestBaselineAmbiguous */,
1463
+ code: 400,
1464
+ fault: "User" /* User */,
1465
+ retriable: false,
1466
+ reportToUser: true,
1467
+ details: { since }
1468
+ });
1469
+ this.since = since;
1470
+ }
1471
+ since;
1472
+ };
1049
1473
 
1050
1474
  // src/kb-pins/budgets.ts
1051
1475
  function asBudgets(value) {
@@ -1096,18 +1520,18 @@ var KbBaseFrozenError = class extends Error {
1096
1520
  };
1097
1521
 
1098
1522
  // src/kb-pins/frozen.ts
1099
- var import_node_path4 = require("path");
1523
+ var import_node_path5 = require("path");
1100
1524
 
1101
1525
  // src/kb-pins/layers.ts
1102
- var import_promises2 = require("fs/promises");
1103
- var import_node_os = require("os");
1104
- var import_node_path3 = require("path");
1526
+ var import_promises3 = require("fs/promises");
1527
+ var import_node_os2 = require("os");
1528
+ var import_node_path4 = require("path");
1105
1529
 
1106
1530
  // src/kb-pins/model.ts
1107
- var import_node_path2 = require("path");
1531
+ var import_node_path3 = require("path");
1108
1532
  var import_zod4 = require("zod");
1109
- var PINS_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.json");
1110
- var PINS_LOCAL_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.local.json");
1533
+ var PINS_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.json");
1534
+ var PINS_LOCAL_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.local.json");
1111
1535
  var PIN_LAYERS = ["project", "local", "user"];
1112
1536
  var pinSchema = import_zod4.z.object({
1113
1537
  /** Relative to the manifest's root, so the file is committable. */
@@ -1154,13 +1578,13 @@ var pinsManifestSchema = import_zod4.z.object({
1154
1578
 
1155
1579
  // src/kb-pins/layers.ts
1156
1580
  function userRoot() {
1157
- return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
1581
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
1158
1582
  }
1159
1583
  function layerRoot(workspaceDir, layer) {
1160
- return layer === "user" ? userRoot() : (0, import_node_path3.resolve)(workspaceDir);
1584
+ return layer === "user" ? userRoot() : (0, import_node_path4.resolve)(workspaceDir);
1161
1585
  }
1162
1586
  function layerFile(workspaceDir, layer) {
1163
- return (0, import_node_path3.join)(
1587
+ return (0, import_node_path4.join)(
1164
1588
  layerRoot(workspaceDir, layer),
1165
1589
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1166
1590
  );
@@ -1169,7 +1593,7 @@ async function readPinsLayer(workspaceDir, layer) {
1169
1593
  const file = layerFile(workspaceDir, layer);
1170
1594
  let raw;
1171
1595
  try {
1172
- raw = await (0, import_promises2.readFile)(file, "utf8");
1596
+ raw = await (0, import_promises3.readFile)(file, "utf8");
1173
1597
  } catch {
1174
1598
  return { pins: [] };
1175
1599
  }
@@ -1193,16 +1617,16 @@ async function readPinsLayer(workspaceDir, layer) {
1193
1617
  }
1194
1618
  async function writePinsLayer(workspaceDir, layer, manifest) {
1195
1619
  const file = layerFile(workspaceDir, layer);
1196
- await (0, import_promises2.mkdir)((0, import_node_path3.dirname)(file), { recursive: true });
1197
- await (0, import_promises2.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1620
+ await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(file), { recursive: true });
1621
+ await (0, import_promises3.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1198
1622
  `, "utf8");
1199
1623
  }
1200
1624
  function resolvePinPath(rootDir, path) {
1201
- return (0, import_node_path3.isAbsolute)(path) ? (0, import_node_path3.resolve)(path) : (0, import_node_path3.resolve)(rootDir, path.split("/").join(import_node_path3.sep));
1625
+ return (0, import_node_path4.isAbsolute)(path) ? (0, import_node_path4.resolve)(path) : (0, import_node_path4.resolve)(rootDir, path.split("/").join(import_node_path4.sep));
1202
1626
  }
1203
1627
  function storablePath(rootDir, bundlePath2) {
1204
- const rel = (0, import_node_path3.relative)((0, import_node_path3.resolve)(rootDir), (0, import_node_path3.resolve)(bundlePath2));
1205
- return (rel === "" ? "." : rel).split(import_node_path3.sep).join("/");
1628
+ const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(rootDir), (0, import_node_path4.resolve)(bundlePath2));
1629
+ return (rel === "" ? "." : rel).split(import_node_path4.sep).join("/");
1206
1630
  }
1207
1631
  async function readMergedPins(workspaceDir) {
1208
1632
  const manifests = {};
@@ -1230,7 +1654,7 @@ async function readMergedPins(workspaceDir) {
1230
1654
  // src/kb-pins/frozen.ts
1231
1655
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
1232
1656
  const merged = await readMergedPins(workspaceDir);
1233
- const absolute = (0, import_node_path4.resolve)(bundlePath2);
1657
+ const absolute = (0, import_node_path5.resolve)(bundlePath2);
1234
1658
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
1235
1659
  if (pin?.frozen === true) {
1236
1660
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -1315,7 +1739,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1315
1739
  }
1316
1740
 
1317
1741
  // src/kb-pins/unpin.ts
1318
- var import_node_path5 = require("path");
1742
+ var import_node_path6 = require("path");
1319
1743
  async function unpinBase(workspaceDir, bundlePath2) {
1320
1744
  const layers = [];
1321
1745
  for (const layer of PIN_LAYERS) {
@@ -1336,7 +1760,7 @@ async function unpinBase(workspaceDir, bundlePath2) {
1336
1760
  }
1337
1761
  }
1338
1762
  return {
1339
- path: storablePath((0, import_node_path5.resolve)(workspaceDir), bundlePath2),
1763
+ path: storablePath((0, import_node_path6.resolve)(workspaceDir), bundlePath2),
1340
1764
  removed: layers.length > 0,
1341
1765
  layers
1342
1766
  };
@@ -1372,12 +1796,15 @@ function argvFlag(argv, name) {
1372
1796
  var anchorResolveCommand = define({
1373
1797
  name: "anchor-resolve",
1374
1798
  tool: "kb_anchor_resolve",
1375
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
1376
- 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.",
1799
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
1800
+ 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.",
1377
1801
  input: import_zod6.z.object({
1378
1802
  bundlePath,
1379
1803
  conceptId,
1380
1804
  repoRoot: import_zod6.z.string().min(1).optional(),
1805
+ offline: import_zod6.z.boolean().optional().describe(
1806
+ "Resolve foreign anchors from the local repo cache only, never fetching."
1807
+ ),
1381
1808
  rebaseline: import_zod6.z.boolean().optional().describe(
1382
1809
  "Accept the current code as the new baseline for anchors that drifted."
1383
1810
  ),
@@ -1389,10 +1816,11 @@ var anchorResolveCommand = define({
1389
1816
  bundlePath: path,
1390
1817
  conceptId: argv[1],
1391
1818
  repoRoot: argvFlag(argv, "--repo-root"),
1819
+ offline: argv.includes("--offline"),
1392
1820
  rebaseline: argv.includes("--rebaseline"),
1393
1821
  restamp: argv.includes("--restamp")
1394
1822
  }),
1395
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
1823
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
1396
1824
  const root = repoRoot ?? process.cwd();
1397
1825
  const record = await store.read(path, id);
1398
1826
  if (!record) throw new KbRecordNotFoundError(id);
@@ -1407,18 +1835,10 @@ var anchorResolveCommand = define({
1407
1835
  }
1408
1836
  const results = [];
1409
1837
  const updated = [];
1410
- const origin = new LazyOrigin(root);
1411
1838
  let dirty = false;
1412
- if (anchors.some((anchor) => anchor.repo)) await origin.prime();
1413
- const foreign = new Map(
1414
- anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
1415
- );
1416
- const reads = await readAnchorFiles(
1417
- anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
1418
- anchorFileReader(root)
1419
- );
1839
+ const sources = await readSources(anchors, root, offline === true);
1420
1840
  for (const anchor of anchors) {
1421
- const base = {
1841
+ const base2 = {
1422
1842
  file: anchor.file,
1423
1843
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
1424
1844
  // Carried onto unresolved findings too: an anchor that once hashed
@@ -1426,21 +1846,17 @@ var anchorResolveCommand = define({
1426
1846
  // has to be able to tell it from one nobody ever stamped.
1427
1847
  ...anchor.hash ? { storedHash: anchor.hash } : {}
1428
1848
  };
1429
- if (foreign.get(anchor)) {
1430
- results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
1431
- updated.push(anchor);
1432
- continue;
1433
- }
1434
- const fileRead = reads.get(anchor.file);
1435
- if (!fileRead.ok) {
1436
- results.push({ ...base, state: "unresolved", reason: fileRead.reason });
1849
+ const source = sources.get(anchor);
1850
+ if (source.repo) base2.repo = source.repo;
1851
+ if (!source.ok) {
1852
+ results.push({ ...base2, state: "unresolved", reason: source.reason });
1437
1853
  updated.push(anchor);
1438
1854
  continue;
1439
1855
  }
1440
- const resolved = resolveAnchor(fileRead.source, anchor);
1856
+ const resolved = resolveAnchor(source.source, anchor);
1441
1857
  if (!resolved) {
1442
1858
  results.push({
1443
- ...base,
1859
+ ...base2,
1444
1860
  state: "unresolved",
1445
1861
  reason: "symbol-not-found"
1446
1862
  });
@@ -1455,30 +1871,47 @@ var anchorResolveCommand = define({
1455
1871
  lines: currentLines,
1456
1872
  resolved_at: now()
1457
1873
  };
1874
+ const pinned = anchor.ref !== void 0 && source.repo !== void 0;
1458
1875
  if (!anchor.hash) {
1459
- results.push({ ...base, state: "stamped", currentHash });
1876
+ results.push({ ...base2, state: "stamped", currentHash });
1460
1877
  updated.push(stamped);
1461
1878
  dirty = true;
1462
- } else if (anchor.hash === currentHash) {
1463
- results.push({
1464
- ...base,
1465
- state: "match",
1466
- currentHash
1467
- });
1468
- const refresh = restamp || anchor.resolved_at === void 0;
1469
- updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
1470
- if (refresh) dirty = true;
1471
- } else {
1879
+ continue;
1880
+ }
1881
+ if (anchor.hash !== currentHash) {
1472
1882
  results.push({
1473
- ...base,
1883
+ ...base2,
1474
1884
  state: "drifted",
1475
1885
  currentHash,
1476
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
1886
+ diffSize: lineDelta(anchor, currentLines),
1887
+ ...pinned ? { remoteState: "drifted-from-ref" } : {},
1477
1888
  ...rebaseline ? { rebaselined: true } : {}
1478
1889
  });
1479
1890
  updated.push(rebaseline ? stamped : anchor);
1480
1891
  if (rebaseline) dirty = true;
1892
+ continue;
1893
+ }
1894
+ const onDefault = pinned ? headHash(source, anchor) : void 0;
1895
+ if (onDefault && onDefault.hash !== anchor.hash) {
1896
+ results.push({
1897
+ ...base2,
1898
+ state: "drifted",
1899
+ currentHash: onDefault.hash,
1900
+ diffSize: lineDelta(anchor, onDefault.lines),
1901
+ remoteState: "drifted-on-default"
1902
+ });
1903
+ updated.push(anchor);
1904
+ continue;
1481
1905
  }
1906
+ results.push({
1907
+ ...base2,
1908
+ state: "match",
1909
+ currentHash,
1910
+ ...pinned ? { remoteState: "matches-ref" } : {}
1911
+ });
1912
+ const refresh = restamp || anchor.resolved_at === void 0;
1913
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
1914
+ if (refresh) dirty = true;
1482
1915
  }
1483
1916
  let frozen = false;
1484
1917
  if (dirty) {
@@ -1491,16 +1924,19 @@ var anchorResolveCommand = define({
1491
1924
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
1492
1925
  }
1493
1926
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
1494
- const checked = results.filter((entry) => entry.reason !== "foreign-repo");
1495
- const skipped = results.length - checked.length;
1496
- const matches2 = checked.filter((entry) => entry.state === "match").length;
1497
- const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
1927
+ const unreachable = results.filter(
1928
+ (entry) => isUncheckedReason(entry.reason)
1929
+ ).length;
1930
+ const checked = results.length - unreachable;
1931
+ const matches2 = results.filter((entry) => entry.state === "match").length;
1932
+ const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
1933
+ const clean = checked > 0 && matches2 === checked && unreachable === 0;
1498
1934
  if (clean) {
1499
1935
  try {
1500
1936
  await store.verify(
1501
1937
  path,
1502
1938
  id,
1503
- `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
1939
+ `anchor-resolve: ${note} (regex resolver)`,
1504
1940
  actor,
1505
1941
  now()
1506
1942
  );
@@ -1516,18 +1952,81 @@ var anchorResolveCommand = define({
1516
1952
  }
1517
1953
  return { conceptId: id, results, verified: true, ...frozenNote };
1518
1954
  }
1519
- return { conceptId: id, results, verified: false, ...frozenNote };
1955
+ return {
1956
+ conceptId: id,
1957
+ results,
1958
+ verified: false,
1959
+ ...unreachable ? { note } : {},
1960
+ ...frozenNote
1961
+ };
1520
1962
  },
1521
1963
  // A stored hash that no longer resolves is a broken anchor, not an absence:
1522
1964
  // the file was deleted or the symbol renamed, and exiting zero on it would
1523
1965
  // let the one edit that destroys an anchor pass the gate that exists to
1524
1966
  // catch it. An anchor nobody ever stamped is still just unstamped, and one
1525
- // belonging to another repository was never this run's to check — failing CI
1526
- // on either would gate on work this command did not do.
1967
+ // whose remote nothing could reach was never checked — failing CI on either
1968
+ // would gate on work this command did not do.
1527
1969
  failsWhen: (result) => result.results.some(
1528
- (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
1970
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
1529
1971
  )
1530
1972
  });
1973
+ function lineDelta(anchor, current) {
1974
+ return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
1975
+ }
1976
+ function headHash(source, anchor) {
1977
+ if (source.head === void 0) return void 0;
1978
+ const resolved = resolveAnchor(source.head, anchor);
1979
+ if (!resolved) return void 0;
1980
+ return {
1981
+ hash: hashAnchorText(resolved.text),
1982
+ lines: resolved.endLine - resolved.startLine + 1
1983
+ };
1984
+ }
1985
+ async function readSources(anchors, root, offline) {
1986
+ const origin = new LazyOrigin(root);
1987
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
1988
+ const foreign = new Map(
1989
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
1990
+ );
1991
+ const local = anchors.filter((anchor) => !foreign.get(anchor));
1992
+ const remote = anchors.filter((anchor) => foreign.get(anchor));
1993
+ const reads = await readAnchorFiles(
1994
+ local.map((anchor) => anchor.file),
1995
+ anchorFileReader(root)
1996
+ );
1997
+ const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
1998
+ offline
1999
+ });
2000
+ const sources = /* @__PURE__ */ new Map();
2001
+ for (const anchor of local) {
2002
+ const read = reads.get(anchor.file);
2003
+ sources.set(
2004
+ anchor,
2005
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
2006
+ );
2007
+ }
2008
+ for (const anchor of remote) {
2009
+ const repo = anchor.repo;
2010
+ const key = normalizeRepoUrl(repo);
2011
+ const atDefault = blobs.get(wantKey(key, void 0, anchor.file));
2012
+ const primary = anchor.ref ? blobs.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
2013
+ if (!primary?.ok) {
2014
+ sources.set(anchor, {
2015
+ ok: false,
2016
+ reason: primary?.ok === false ? primary.reason : "remote-unreachable",
2017
+ repo
2018
+ });
2019
+ continue;
2020
+ }
2021
+ sources.set(anchor, {
2022
+ ok: true,
2023
+ source: primary.source,
2024
+ repo,
2025
+ ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
2026
+ });
2027
+ }
2028
+ return sources;
2029
+ }
1531
2030
 
1532
2031
  // src/commands/answer.ts
1533
2032
  var import_zod7 = require("zod");
@@ -1604,23 +2103,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
1604
2103
  if (!record.frontmatter.verified?.length) {
1605
2104
  warnings.push({ kind: "unverified" });
1606
2105
  }
1607
- const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
1608
- (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
2106
+ const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
2107
+ (entry) => entry.state !== "match"
1609
2108
  );
2109
+ const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
2110
+ const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
1610
2111
  if (moved.length) {
2112
+ warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
2113
+ }
2114
+ if (unchecked2.length) {
1611
2115
  warnings.push({
1612
- kind: "drifted",
1613
- anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
1614
- file,
1615
- ...symbol !== void 0 ? { symbol } : {},
1616
- diffSize,
1617
- ...reason !== void 0 ? { reason } : {}
1618
- }))
2116
+ kind: "unchecked",
2117
+ anchors: unchecked2.map(warningAnchor)
1619
2118
  });
1620
2119
  }
1621
2120
  return { record, standing: STANDING[status], heads, warnings };
1622
2121
  });
1623
2122
  }
2123
+ function warningAnchor(entry) {
2124
+ const { file, symbol, diffSize, reason, repo, remoteState } = entry;
2125
+ return {
2126
+ file,
2127
+ ...symbol !== void 0 ? { symbol } : {},
2128
+ diffSize,
2129
+ ...reason !== void 0 ? { reason } : {},
2130
+ ...repo !== void 0 ? { repo } : {},
2131
+ ...remoteState !== void 0 ? { remoteState } : {}
2132
+ };
2133
+ }
1624
2134
  function resolveHeads(from, byId) {
1625
2135
  const warnings = [];
1626
2136
  const heads = /* @__PURE__ */ new Map();
@@ -1782,7 +2292,7 @@ function count(value, noun) {
1782
2292
  var import_zod10 = require("zod");
1783
2293
 
1784
2294
  // src/kb-context.ts
1785
- var import_promises3 = require("fs/promises");
2295
+ var import_promises4 = require("fs/promises");
1786
2296
 
1787
2297
  // src/kb-index.ts
1788
2298
  var INDEX_FILE = "INDEX.md";
@@ -1964,7 +2474,7 @@ async function buildContext(store, workspaceDir, options = {}) {
1964
2474
  operation: "kb.context.refused",
1965
2475
  approxTokens: total,
1966
2476
  budgetTokens,
1967
- bases: bases.map((base) => base.path)
2477
+ bases: bases.map((base2) => base2.path)
1968
2478
  });
1969
2479
  const refusal = [
1970
2480
  HEADING2,
@@ -1974,7 +2484,7 @@ async function buildContext(store, workspaceDir, options = {}) {
1974
2484
  "from a complete one. The pinned bases:",
1975
2485
  "",
1976
2486
  ...bases.map(
1977
- (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
2487
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
1978
2488
  ),
1979
2489
  "",
1980
2490
  "For the question at hand, read what you need now \u2014 `kb_load` a base",
@@ -2009,13 +2519,13 @@ function toHookJson(block, event) {
2009
2519
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2010
2520
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2011
2521
  async function syncInstructions(file, block) {
2012
- const existing = await (0, import_promises3.readFile)(file, "utf8").catch(() => null);
2522
+ const existing = await (0, import_promises4.readFile)(file, "utf8").catch(() => null);
2013
2523
  const region = block ? `${CONTEXT_BEGIN}
2014
2524
  ${block.trim()}
2015
2525
  ${CONTEXT_END}` : null;
2016
2526
  if (existing === null) {
2017
2527
  if (!region) return { file, action: "unchanged" };
2018
- await (0, import_promises3.writeFile)(file, `${region}
2528
+ await (0, import_promises4.writeFile)(file, `${region}
2019
2529
  `, "utf8");
2020
2530
  return { file, action: "created" };
2021
2531
  }
@@ -2026,11 +2536,11 @@ ${CONTEXT_END}` : null;
2026
2536
  const after = existing.slice(end + CONTEXT_END.length);
2027
2537
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2028
2538
  if (next === existing) return { file, action: "unchanged" };
2029
- await (0, import_promises3.writeFile)(file, next, "utf8");
2539
+ await (0, import_promises4.writeFile)(file, next, "utf8");
2030
2540
  return { file, action: region ? "replaced" : "removed" };
2031
2541
  }
2032
2542
  if (!region) return { file, action: "unchanged" };
2033
- await (0, import_promises3.writeFile)(
2543
+ await (0, import_promises4.writeFile)(
2034
2544
  file,
2035
2545
  `${existing.replace(/\n*$/, "\n\n")}${region}
2036
2546
  `,
@@ -2247,6 +2757,16 @@ function validateBundle(records) {
2247
2757
  );
2248
2758
  }
2249
2759
  }
2760
+ for (const anchor of fm.strauss_anchors ?? []) {
2761
+ if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
2762
+ report(
2763
+ "anchor_repo",
2764
+ conceptId2,
2765
+ `anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
2766
+ "warning"
2767
+ );
2768
+ }
2769
+ }
2250
2770
  if (fm.strauss_assumption && fm.sources?.length) {
2251
2771
  report("assumption", conceptId2, "marked an assumption but cites sources");
2252
2772
  }
@@ -2266,7 +2786,8 @@ var CHECK_HEADLINES = {
2266
2786
  orphaned: "no other record links to it",
2267
2787
  "broken-supersession": "the supersession pointers do not resolve",
2268
2788
  "superseded-but-cited": "a live record's body links to one that no longer holds",
2269
- drifted: "the code an anchor points at moved out from under its hash"
2789
+ drifted: "the code an anchor points at moved out from under its hash",
2790
+ unchecked: "an anchor in another repository nothing could reach"
2270
2791
  };
2271
2792
  var DAY_MS = 864e5;
2272
2793
  function doctor(bundle, options = {}) {
@@ -2291,7 +2812,8 @@ function doctor(bundle, options = {}) {
2291
2812
  group("orphaned", orphaned(bundle)),
2292
2813
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
2293
2814
  group("superseded-but-cited", supersededButCited(bundle, standings)),
2294
- group("drifted", drifted(inForce))
2815
+ group("drifted", drifted(inForce)),
2816
+ group("unchecked", unchecked(inForce))
2295
2817
  ];
2296
2818
  const counts = Object.fromEntries(
2297
2819
  groups.map((entry) => [entry.check, entry.count])
@@ -2478,21 +3000,38 @@ function supersededButCited(bundle, standings) {
2478
3000
  return findings;
2479
3001
  }
2480
3002
  function drifted(hits) {
3003
+ return anchorFindings(
3004
+ hits,
3005
+ "drifted",
3006
+ (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
3007
+ );
3008
+ }
3009
+ function unchecked(hits) {
3010
+ return anchorFindings(
3011
+ hits,
3012
+ "unchecked",
3013
+ (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
3014
+ );
3015
+ }
3016
+ function anchorFindings(hits, kind, headline) {
2481
3017
  const findings = [];
2482
3018
  for (const hit of hits) {
2483
- const warning = hit.warnings.find((entry) => entry.kind === "drifted");
3019
+ const warning = hit.warnings.find(
3020
+ (entry) => entry.kind === kind
3021
+ );
2484
3022
  if (!warning) continue;
3023
+ const byRepo = /* @__PURE__ */ new Map();
3024
+ for (const anchor of warning.anchors) {
3025
+ const repo = anchor.repo ?? "";
3026
+ byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
3027
+ }
3028
+ const detail = [...byRepo.entries()].map(
3029
+ ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
3030
+ );
2485
3031
  findings.push(
2486
3032
  finding(
2487
3033
  hit.record,
2488
- `${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
2489
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
2490
- if (anchor.reason) return `${at} (${anchor.reason})`;
2491
- if (anchor.diffSize === null) {
2492
- return `${at} (changed, size unrecorded)`;
2493
- }
2494
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
2495
- }).join(", ")}`
3034
+ `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
2496
3035
  )
2497
3036
  );
2498
3037
  }
@@ -2500,6 +3039,15 @@ function drifted(hits) {
2500
3039
  (left, right) => left.conceptId.localeCompare(right.conceptId)
2501
3040
  );
2502
3041
  }
3042
+ function describeAnchor(anchor) {
3043
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3044
+ if (anchor.reason) return `${at} (${anchor.reason})`;
3045
+ if (anchor.remoteState === "drifted-on-default") {
3046
+ return `${at} (matches ref, moved on the default branch)`;
3047
+ }
3048
+ if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
3049
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3050
+ }
2503
3051
  function replaces(later, earlier) {
2504
3052
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
2505
3053
  }
@@ -2527,8 +3075,8 @@ var days = (what, fallback) => import_zod11.z.number().int().positive().optional
2527
3075
  var doctorCommand = define({
2528
3076
  name: "doctor",
2529
3077
  tool: "kb_doctor",
2530
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
2531
- 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.",
3078
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3079
+ 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.",
2532
3080
  input: import_zod11.z.object({
2533
3081
  bundlePath,
2534
3082
  repoRoot: REPO_ROOT,
@@ -2544,6 +3092,9 @@ var doctorCommand = define({
2544
3092
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
2545
3093
  DEFAULT_AGING_DAYS
2546
3094
  ),
3095
+ offline: import_zod11.z.boolean().optional().describe(
3096
+ "Read foreign anchors from the local repo cache only, never fetching."
3097
+ ),
2547
3098
  strict: import_zod11.z.boolean().optional().describe(
2548
3099
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
2549
3100
  )
@@ -2563,13 +3114,23 @@ var doctorCommand = define({
2563
3114
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
2564
3115
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
2565
3116
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
3117
+ ...argv.includes("--offline") ? { offline: true } : {},
2566
3118
  ...argv.includes("--strict") ? { strict: true } : {}
2567
3119
  };
2568
3120
  },
2569
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
3121
+ run: async ({ store, now }, {
3122
+ bundlePath: path,
3123
+ expiringDays,
3124
+ unverifiedDays,
3125
+ agingDays,
3126
+ repoRoot,
3127
+ offline
3128
+ }) => {
2570
3129
  const checkedAt = now();
2571
3130
  const records = await store.list(path);
2572
- const anchorDrift = await store.detectDrift(records, repoRoot);
3131
+ const anchorDrift = await store.detectDrift(records, repoRoot, {
3132
+ offline: offline === true
3133
+ });
2573
3134
  const report = doctor(records, {
2574
3135
  ...expiringDays !== void 0 ? { expiringDays } : {},
2575
3136
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
@@ -3047,17 +3608,115 @@ var schemaCommand = define({
3047
3608
  run: () => Promise.resolve(kbJsonSchemas())
3048
3609
  });
3049
3610
 
3050
- // src/commands/status.ts
3611
+ // src/commands/stamp.ts
3612
+ var import_promises5 = require("fs/promises");
3051
3613
  var import_zod25 = require("zod");
3614
+ var DIGEST = /^[0-9a-f]{64}$/;
3615
+ var stampCommand = define({
3616
+ name: "stamp",
3617
+ tool: "kb_stamp",
3618
+ usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
3619
+ 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.",
3620
+ input: import_zod25.z.object({
3621
+ bundlePath: import_zod25.z.string().min(1).optional().describe(
3622
+ "Absolute path to one knowledge base. Omit to stamp every pinned base."
3623
+ ),
3624
+ since: import_zod25.z.string().min(1).optional().describe(
3625
+ "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
3626
+ )
3627
+ }),
3628
+ fromArgv: (argv, path, _stdin, bundleExplicit) => {
3629
+ const since = argvFlag(argv, "--since");
3630
+ return {
3631
+ ...bundleExplicit ? { bundlePath: path } : {},
3632
+ ...since !== void 0 ? { since } : {}
3633
+ };
3634
+ },
3635
+ run: async ({ store }, { bundlePath: bundlePath2, since }) => {
3636
+ const targets = bundlePath2 ? [bundlePath2] : (await readMergedPins(process.cwd())).pins.map(
3637
+ (pin) => pin.absolutePath
3638
+ );
3639
+ if (since !== void 0 && DIGEST.test(since) && targets.length > 1) {
3640
+ throw new KbStampDigestBaselineError(since);
3641
+ }
3642
+ const stamps = await Promise.all(
3643
+ targets.map((target) => store.stamp(target))
3644
+ );
3645
+ if (since === void 0) {
3646
+ return stamps.map((stamp) => ({ ...stamp, changed: null }));
3647
+ }
3648
+ const baseline = await readBaseline(since);
3649
+ const reports = [];
3650
+ for (const stamp of stamps) {
3651
+ const before = baseline.byPath.get(stamp.path);
3652
+ if (baseline.digest !== null) {
3653
+ if (baseline.digest === stamp.digest) continue;
3654
+ reports.push({ ...stamp, changed: null });
3655
+ continue;
3656
+ }
3657
+ if (before && before.digest === stamp.digest) continue;
3658
+ reports.push({ ...stamp, changed: changedIds(before?.records, stamp) });
3659
+ }
3660
+ return reports;
3661
+ },
3662
+ render: (result) => result.map((report) => {
3663
+ const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
3664
+ const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
3665
+ return report.changed?.length ? `${head}
3666
+ changed: ${report.changed.join(", ")}` : head;
3667
+ }).join("\n")
3668
+ });
3669
+ function changedIds(before, stamp) {
3670
+ const now = new Map(
3671
+ stamp.records.map((record) => [record.conceptId, record.digest])
3672
+ );
3673
+ const ids = /* @__PURE__ */ new Set();
3674
+ for (const [conceptId2, digest] of now) {
3675
+ if (before?.get(conceptId2) !== digest) ids.add(conceptId2);
3676
+ }
3677
+ for (const conceptId2 of before?.keys() ?? []) {
3678
+ if (!now.has(conceptId2)) ids.add(conceptId2);
3679
+ }
3680
+ return [...ids].sort();
3681
+ }
3682
+ async function readBaseline(since) {
3683
+ if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
3684
+ let parsed;
3685
+ try {
3686
+ parsed = JSON.parse(await (0, import_promises5.readFile)(since, "utf8"));
3687
+ } catch {
3688
+ throw new KbStampBaselineError(since);
3689
+ }
3690
+ const entries = Array.isArray(parsed) ? parsed : parsed?.stamps ?? [];
3691
+ const byPath = /* @__PURE__ */ new Map();
3692
+ for (const entry of entries) {
3693
+ if (typeof entry?.path !== "string" || typeof entry?.digest !== "string") {
3694
+ continue;
3695
+ }
3696
+ byPath.set(entry.path, {
3697
+ digest: entry.digest,
3698
+ records: new Map(
3699
+ (entry.records ?? []).map((record) => [
3700
+ record.conceptId,
3701
+ record.digest
3702
+ ])
3703
+ )
3704
+ });
3705
+ }
3706
+ return { digest: null, byPath };
3707
+ }
3708
+
3709
+ // src/commands/status.ts
3710
+ var import_zod26 = require("zod");
3052
3711
  var statusCommand = define({
3053
3712
  name: "status",
3054
3713
  tool: "kb_status",
3055
3714
  usage: "status <concept-id> <status>",
3056
3715
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
3057
- input: import_zod25.z.object({
3716
+ input: import_zod26.z.object({
3058
3717
  bundlePath,
3059
3718
  conceptId,
3060
- status: import_zod25.z.enum(KB_RECORD_STATUSES)
3719
+ status: import_zod26.z.enum(KB_RECORD_STATUSES)
3061
3720
  }),
3062
3721
  fromArgv: (argv, path) => ({
3063
3722
  bundlePath: path,
@@ -3072,13 +3731,13 @@ var statusCommand = define({
3072
3731
  });
3073
3732
 
3074
3733
  // src/commands/supersede.ts
3075
- var import_zod26 = require("zod");
3734
+ var import_zod27 = require("zod");
3076
3735
  var supersedeCommand = define({
3077
3736
  name: "supersede",
3078
3737
  tool: "kb_supersede",
3079
3738
  usage: "supersede <concept-id> <replacement-id>",
3080
3739
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
3081
- input: import_zod26.z.object({ bundlePath, conceptId, replacementId: conceptId }),
3740
+ input: import_zod27.z.object({ bundlePath, conceptId, replacementId: conceptId }),
3082
3741
  fromArgv: (argv, path) => ({
3083
3742
  bundlePath: path,
3084
3743
  conceptId: argv[1],
@@ -3092,16 +3751,16 @@ var supersedeCommand = define({
3092
3751
  });
3093
3752
 
3094
3753
  // src/commands/sync-instructions.ts
3095
- var import_zod27 = require("zod");
3754
+ var import_zod28 = require("zod");
3096
3755
  var syncInstructionsCommand = define({
3097
3756
  name: "sync-instructions",
3098
3757
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
3099
3758
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
3100
- input: import_zod27.z.object({
3101
- file: import_zod27.z.string().min(1).describe("The instruction file to edit in place."),
3102
- budgetTokens: import_zod27.z.number().int().positive().optional(),
3103
- fullUnderTokens: import_zod27.z.number().int().positive().optional(),
3104
- profile: import_zod27.z.string().optional()
3759
+ input: import_zod28.z.object({
3760
+ file: import_zod28.z.string().min(1).describe("The instruction file to edit in place."),
3761
+ budgetTokens: import_zod28.z.number().int().positive().optional(),
3762
+ fullUnderTokens: import_zod28.z.number().int().positive().optional(),
3763
+ profile: import_zod28.z.string().optional()
3105
3764
  }),
3106
3765
  fromArgv: (argv) => {
3107
3766
  const budget = argvFlag(argv, "--budget");
@@ -3127,7 +3786,7 @@ var syncInstructionsCommand = define({
3127
3786
  });
3128
3787
 
3129
3788
  // src/commands/trace.ts
3130
- var import_zod28 = require("zod");
3789
+ var import_zod29 = require("zod");
3131
3790
 
3132
3791
  // src/trace.ts
3133
3792
  var TRACE_EDGES = [
@@ -3183,11 +3842,11 @@ var traceCommand = define({
3183
3842
  tool: "kb_trace",
3184
3843
  usage: "trace <concept-id> [edges...]",
3185
3844
  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".',
3186
- input: import_zod28.z.object({
3845
+ input: import_zod29.z.object({
3187
3846
  bundlePath,
3188
3847
  conceptId,
3189
- edges: import_zod28.z.array(import_zod28.z.enum(TRACE_EDGES)).optional(),
3190
- depth: import_zod28.z.number().int().positive().optional()
3848
+ edges: import_zod29.z.array(import_zod29.z.enum(TRACE_EDGES)).optional(),
3849
+ depth: import_zod29.z.number().int().positive().optional()
3191
3850
  }),
3192
3851
  fromArgv: (argv, path) => ({
3193
3852
  bundlePath: path,
@@ -3209,37 +3868,37 @@ var traceCommand = define({
3209
3868
  });
3210
3869
 
3211
3870
  // src/commands/types.ts
3212
- var import_zod29 = require("zod");
3871
+ var import_zod30 = require("zod");
3213
3872
  var typesCommand = define({
3214
3873
  name: "types",
3215
3874
  tool: "kb_types",
3216
3875
  usage: "types",
3217
3876
  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.",
3218
- input: import_zod29.z.object({}),
3877
+ input: import_zod30.z.object({}),
3219
3878
  fromArgv: () => ({}),
3220
3879
  run: () => Promise.resolve(RECORD_TYPES)
3221
3880
  });
3222
3881
 
3223
3882
  // src/commands/unpin.ts
3224
- var import_zod30 = require("zod");
3883
+ var import_zod31 = require("zod");
3225
3884
  var unpinCommand = define({
3226
3885
  name: "unpin",
3227
3886
  tool: "kb_unpin",
3228
3887
  usage: "unpin [bundle-path]",
3229
3888
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
3230
- input: import_zod30.z.object({ bundlePath }),
3889
+ input: import_zod31.z.object({ bundlePath }),
3231
3890
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3232
3891
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
3233
3892
  });
3234
3893
 
3235
3894
  // src/commands/validate.ts
3236
- var import_zod31 = require("zod");
3895
+ var import_zod32 = require("zod");
3237
3896
  var validateCommand = define({
3238
3897
  name: "validate",
3239
3898
  tool: "kb_validate",
3240
3899
  usage: "validate",
3241
3900
  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.",
3242
- input: import_zod31.z.object({ bundlePath }),
3901
+ input: import_zod32.z.object({ bundlePath }),
3243
3902
  fromArgv: (_argv, path) => ({ bundlePath: path }),
3244
3903
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
3245
3904
  // Warnings never fail the exit code; every other severity does.
@@ -3249,16 +3908,16 @@ var validateCommand = define({
3249
3908
  });
3250
3909
 
3251
3910
  // src/commands/verify.ts
3252
- var import_zod32 = require("zod");
3911
+ var import_zod33 = require("zod");
3253
3912
  var verifyCommand = define({
3254
3913
  name: "verify",
3255
3914
  tool: "kb_verify",
3256
3915
  usage: "verify <concept-id> --note <text>",
3257
3916
  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.",
3258
- input: import_zod32.z.object({
3917
+ input: import_zod33.z.object({
3259
3918
  bundlePath,
3260
3919
  conceptId,
3261
- note: import_zod32.z.string().refine((s) => s.trim().length > 0, {
3920
+ note: import_zod33.z.string().refine((s) => s.trim().length > 0, {
3262
3921
  message: "note must say what the check found"
3263
3922
  })
3264
3923
  }),
@@ -3278,15 +3937,15 @@ var verifyCommand = define({
3278
3937
  });
3279
3938
 
3280
3939
  // src/commands/write.ts
3281
- var import_zod33 = require("zod");
3940
+ var import_zod34 = require("zod");
3282
3941
  var writeCommand = define({
3283
3942
  name: "write",
3284
3943
  tool: "kb_write",
3285
3944
  usage: "write <type> < record.json",
3286
3945
  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.",
3287
- input: import_zod33.z.object({
3946
+ input: import_zod34.z.object({
3288
3947
  bundlePath,
3289
- type: import_zod33.z.enum(KB_RECORD_TYPES),
3948
+ type: import_zod34.z.enum(KB_RECORD_TYPES),
3290
3949
  input: composeInputSchema
3291
3950
  }),
3292
3951
  fromArgv: async (argv, path, stdin) => ({
@@ -3310,13 +3969,13 @@ var writeCommand = define({
3310
3969
  });
3311
3970
 
3312
3971
  // src/commands/write-decision.ts
3313
- var import_zod34 = require("zod");
3972
+ var import_zod35 = require("zod");
3314
3973
  var writeDecisionCommand = define({
3315
3974
  name: "write-decision",
3316
3975
  tool: "kb_write_decision",
3317
3976
  usage: "write-decision < decision.json",
3318
3977
  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.",
3319
- input: import_zod34.z.object({ bundlePath, input: decisionInputSchema }),
3978
+ input: import_zod35.z.object({ bundlePath, input: decisionInputSchema }),
3320
3979
  fromArgv: async (_argv, path, stdin) => ({
3321
3980
  bundlePath: path,
3322
3981
  input: JSON.parse(await stdin())
@@ -3356,6 +4015,7 @@ var KB_COMMANDS = [
3356
4015
  listCommand,
3357
4016
  readIndexCommand,
3358
4017
  logCommand,
4018
+ stampCommand,
3359
4019
  validateCommand,
3360
4020
  doctorCommand,
3361
4021
  schemaCommand,
@@ -3371,9 +4031,8 @@ var KB_COMMANDS_BY_NAME = new Map(
3371
4031
  );
3372
4032
 
3373
4033
  // src/kb-store.ts
3374
- var import_node_crypto2 = require("crypto");
3375
- var import_promises5 = require("fs/promises");
3376
- var import_node_path7 = require("path");
4034
+ var import_promises7 = require("fs/promises");
4035
+ var import_node_path8 = require("path");
3377
4036
 
3378
4037
  // src/markdown.ts
3379
4038
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -3400,9 +4059,41 @@ function parseMarkdownWithFrontmatter(text, schema) {
3400
4059
  };
3401
4060
  }
3402
4061
 
4062
+ // src/kb-stamp.ts
4063
+ var import_node_crypto2 = require("crypto");
4064
+ function sha256(contents) {
4065
+ return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
4066
+ }
4067
+ function bundleStamp(records, superseded) {
4068
+ const entries = [
4069
+ ...records.map((hit) => ({
4070
+ conceptId: hit.record.conceptId,
4071
+ digest: `current:${sha256(
4072
+ stringifyMarkdownWithFrontmatter(
4073
+ hit.record.body,
4074
+ hit.record.frontmatter
4075
+ )
4076
+ )}`
4077
+ })),
4078
+ ...superseded.map((entry) => ({
4079
+ conceptId: entry.conceptId,
4080
+ digest: `superseded:${sha256(JSON.stringify(entry))}`
4081
+ }))
4082
+ ].sort((a, b) => a.conceptId < b.conceptId ? -1 : 1);
4083
+ return {
4084
+ digest: sha256(
4085
+ entries.map((entry) => `${entry.conceptId}:${entry.digest}`).join("\n")
4086
+ ),
4087
+ records: entries
4088
+ };
4089
+ }
4090
+ function bundleDigest(records, superseded) {
4091
+ return bundleStamp(records, superseded).digest;
4092
+ }
4093
+
3403
4094
  // src/search-index.ts
3404
- var import_promises4 = require("fs/promises");
3405
- var import_node_path6 = require("path");
4095
+ var import_promises6 = require("fs/promises");
4096
+ var import_node_path7 = require("path");
3406
4097
  var SEARCH_INDEX_FILE = ".index.sqlite";
3407
4098
  var COLLECTION = "kb";
3408
4099
  async function searchBase(bundlePath2, query, options = {}) {
@@ -3411,7 +4102,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3411
4102
  let store = null;
3412
4103
  try {
3413
4104
  store = await qmd.createStore({
3414
- dbPath: (0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE),
4105
+ dbPath: (0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE),
3415
4106
  config: {
3416
4107
  collections: {
3417
4108
  [COLLECTION]: {
@@ -3446,7 +4137,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3446
4137
  }
3447
4138
  }
3448
4139
  async function isStale(bundlePath2) {
3449
- const indexAt = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
4140
+ const indexAt = await (0, import_promises6.stat)((0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3450
4141
  if (!indexAt) return true;
3451
4142
  const { readdir: readdir2 } = await import("fs/promises");
3452
4143
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -3455,7 +4146,7 @@ async function isStale(bundlePath2) {
3455
4146
  let stale = false;
3456
4147
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
3457
4148
  if (stale) return;
3458
- const at = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
4149
+ const at = await (0, import_promises6.stat)((0, import_node_path7.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3459
4150
  if (at > indexAt) stale = true;
3460
4151
  });
3461
4152
  return stale;
@@ -3733,7 +4424,7 @@ function appendUnionMergeLine(contents) {
3733
4424
  }
3734
4425
 
3735
4426
  // src/kb-store.ts
3736
- var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
4427
+ var KB_DIR = (0, import_node_path8.join)(".strauss", "kb");
3737
4428
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
3738
4429
  var DEFAULT_LOAD_BUDGET = 25e3;
3739
4430
  var KbStore = class {
@@ -3764,7 +4455,7 @@ var KbStore = class {
3764
4455
  const conceptId2 = `${input.type}.${input.slug}`;
3765
4456
  const root = this.root(bundlePath2);
3766
4457
  const target = this.recordPath(bundlePath2, conceptId2);
3767
- await (0, import_promises5.mkdir)(root, { recursive: true });
4458
+ await (0, import_promises7.mkdir)(root, { recursive: true });
3768
4459
  await this.publish(
3769
4460
  target,
3770
4461
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -3803,7 +4494,7 @@ var KbStore = class {
3803
4494
  const target = this.recordPath(bundlePath2, conceptId2);
3804
4495
  let raw;
3805
4496
  try {
3806
- raw = await (0, import_promises5.readFile)(target, "utf8");
4497
+ raw = await (0, import_promises7.readFile)(target, "utf8");
3807
4498
  } catch {
3808
4499
  return null;
3809
4500
  }
@@ -3820,7 +4511,7 @@ var KbStore = class {
3820
4511
  const root = this.root(bundlePath2);
3821
4512
  let names;
3822
4513
  try {
3823
- names = await (0, import_promises5.readdir)(root);
4514
+ names = await (0, import_promises7.readdir)(root);
3824
4515
  } catch {
3825
4516
  return [];
3826
4517
  }
@@ -3828,7 +4519,7 @@ var KbStore = class {
3828
4519
  const records = await mapLimit(
3829
4520
  wanted,
3830
4521
  DEFAULT_IO_CONCURRENCY,
3831
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises5.readFile)((0, import_node_path7.join)(root, name), "utf8"))
4522
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises7.readFile)((0, import_node_path8.join)(root, name), "utf8"))
3832
4523
  );
3833
4524
  return records.filter((record) => record !== null);
3834
4525
  }
@@ -3996,7 +4687,8 @@ ${answer}
3996
4687
  * at the repo root, and the MCP server's cwd is the workspace.
3997
4688
  *
3998
4689
  * Public because `doctor` needs the same map with the same degradation: a
3999
- * sweep that failed to read the tree should report no drift, not fail.
4690
+ * sweep that failed to read the tree should report no drift, not fail — and
4691
+ * `offline: false` there, because a sweep is worth a fetch.
4000
4692
  *
4001
4693
  * When no root was given and not one anchored file was found, the finding is
4002
4694
  * discarded. A base read from somewhere other than the tree it describes
@@ -4008,10 +4700,13 @@ ${answer}
4008
4700
  * plausible, and the misses become findings again; an explicit `repoRoot` is
4009
4701
  * taken at its word either way.
4010
4702
  */
4011
- async detectDrift(records, repoRoot) {
4703
+ async detectDrift(records, repoRoot, options = {}) {
4012
4704
  try {
4013
4705
  const drift = await detectAnchorDrift(records, {
4014
- repoRoot: repoRoot ?? process.cwd()
4706
+ repoRoot: repoRoot ?? process.cwd(),
4707
+ // Offline by default: a read path must never spend a network fetch per
4708
+ // call. `doctor` and `anchor-resolve` are the verbs that go get it.
4709
+ remote: { offline: options.offline !== false }
4015
4710
  });
4016
4711
  if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
4017
4712
  this.logger.warn?.({
@@ -4099,6 +4794,28 @@ ${answer}
4099
4794
  digest: bundleDigestValue
4100
4795
  };
4101
4796
  }
4797
+ /**
4798
+ * `load`'s digest without `load`'s bodies — the same records, adjudicated
4799
+ * the same way, handed back as a stamp. Skips the anchor drift pass, which
4800
+ * reads source files and only ever adds warnings: no warning reaches the
4801
+ * digest, so the value is identical to the one `load` returns.
4802
+ */
4803
+ async stamp(bundlePath2) {
4804
+ const bundle = await this.list(bundlePath2);
4805
+ const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
4806
+ const current = adjudicated.filter((hit) => hit.standing !== "superseded");
4807
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
4808
+ const stamped = bundleStamp(current, superseded);
4809
+ const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
4810
+ return {
4811
+ path: bundlePath2,
4812
+ digest: stamped.digest,
4813
+ recordCount: bundle.length,
4814
+ superseded: superseded.length,
4815
+ newestAt: dates.at(-1) ?? null,
4816
+ records: stamped.records
4817
+ };
4818
+ }
4102
4819
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
4103
4820
  async trace(bundlePath2, seedId, options = {}) {
4104
4821
  return trace(seedId, await this.list(bundlePath2), options);
@@ -4129,11 +4846,11 @@ ${answer}
4129
4846
  async readIndex(bundlePath2) {
4130
4847
  const root = this.root(bundlePath2);
4131
4848
  const expected = renderIndex(await this.list(bundlePath2));
4132
- const stored = await (0, import_promises5.readFile)((0, import_node_path7.join)(root, INDEX_FILE), "utf8").catch(
4849
+ const stored = await (0, import_promises7.readFile)((0, import_node_path8.join)(root, INDEX_FILE), "utf8").catch(
4133
4850
  () => null
4134
4851
  );
4135
4852
  if (indexIsStale(stored, expected)) {
4136
- await this.publish((0, import_node_path7.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
4853
+ await this.publish((0, import_node_path8.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
4137
4854
  this.logger.info?.({
4138
4855
  operation: "kb.index.repair",
4139
4856
  bundlePath: root,
@@ -4150,8 +4867,8 @@ ${answer}
4150
4867
  * knows which agent touched what. So a bad line is surfaced and left alone.
4151
4868
  */
4152
4869
  async readLog(bundlePath2) {
4153
- const raw = await (0, import_promises5.readFile)(
4154
- (0, import_node_path7.join)(this.root(bundlePath2), LOG_FILE),
4870
+ const raw = await (0, import_promises7.readFile)(
4871
+ (0, import_node_path8.join)(this.root(bundlePath2), LOG_FILE),
4155
4872
  "utf8"
4156
4873
  ).catch(() => "");
4157
4874
  const result = parseLog(raw);
@@ -4202,15 +4919,15 @@ ${answer}
4202
4919
  }
4203
4920
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
4204
4921
  const target = this.recordPath(bundlePath2, conceptId2);
4205
- const before = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
4922
+ const before = await (0, import_promises7.readFile)(target, "utf8").catch(() => null);
4206
4923
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
4207
4924
  const parsed = this.parse(conceptId2, before);
4208
4925
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
4209
4926
  const frontmatter = change(parsed.frontmatter);
4210
4927
  const body = changeBody(parsed.body);
4211
4928
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
4212
- const witness = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
4213
- if (witness === null || digest(witness) !== digest(before)) {
4929
+ const witness = await (0, import_promises7.readFile)(target, "utf8").catch(() => null);
4930
+ if (witness === null || sha256(witness) !== sha256(before)) {
4214
4931
  throw new KbWriteConflictError(conceptId2);
4215
4932
  }
4216
4933
  await this.publish(target, contents, true, conceptId2);
@@ -4235,20 +4952,20 @@ ${answer}
4235
4952
  */
4236
4953
  async publish(target, contents, overwrite, conceptId2) {
4237
4954
  const staging = `${target}.${process.pid}.tmp`;
4238
- await (0, import_promises5.writeFile)(staging, contents, "utf8");
4955
+ await (0, import_promises7.writeFile)(staging, contents, "utf8");
4239
4956
  try {
4240
4957
  if (overwrite) {
4241
- await (0, import_promises5.rename)(staging, target);
4958
+ await (0, import_promises7.rename)(staging, target);
4242
4959
  return;
4243
4960
  }
4244
- await (0, import_promises5.link)(staging, target);
4961
+ await (0, import_promises7.link)(staging, target);
4245
4962
  } catch (error) {
4246
4963
  if (error.code === "EEXIST") {
4247
4964
  throw new KbRecordAlreadyExistsError(conceptId2);
4248
4965
  }
4249
4966
  throw error;
4250
4967
  } finally {
4251
- await (0, import_promises5.unlink)(staging).catch(() => void 0);
4968
+ await (0, import_promises7.unlink)(staging).catch(() => void 0);
4252
4969
  }
4253
4970
  }
4254
4971
  /**
@@ -4292,18 +5009,18 @@ ${answer}
4292
5009
  * file must not fail the mutation it guards.
4293
5010
  */
4294
5011
  async ensureGitattributes(root) {
4295
- const target = (0, import_node_path7.join)(root, GITATTRIBUTES_FILE);
5012
+ const target = (0, import_node_path8.join)(root, GITATTRIBUTES_FILE);
4296
5013
  try {
4297
5014
  let existing;
4298
5015
  try {
4299
- existing = await (0, import_promises5.readFile)(target, "utf8");
5016
+ existing = await (0, import_promises7.readFile)(target, "utf8");
4300
5017
  } catch (error) {
4301
5018
  if (error.code !== "ENOENT") throw error;
4302
5019
  existing = null;
4303
5020
  }
4304
5021
  if (existing === null) {
4305
5022
  try {
4306
- await (0, import_promises5.writeFile)(target, appendUnionMergeLine(""), {
5023
+ await (0, import_promises7.writeFile)(target, appendUnionMergeLine(""), {
4307
5024
  encoding: "utf8",
4308
5025
  flag: "wx"
4309
5026
  });
@@ -4324,7 +5041,7 @@ ${answer}
4324
5041
  return;
4325
5042
  }
4326
5043
  if (!hasMergeDeclaration(existing)) {
4327
- await (0, import_promises5.appendFile)(target, appendUnionMergeLine(existing), "utf8");
5044
+ await (0, import_promises7.appendFile)(target, appendUnionMergeLine(existing), "utf8");
4328
5045
  this.logger.info?.({
4329
5046
  operation: "kb.gitattributes.ensure",
4330
5047
  bundlePath: root,
@@ -4343,7 +5060,7 @@ ${answer}
4343
5060
  async record(root, entry) {
4344
5061
  await this.ensureGitattributes(root);
4345
5062
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4346
- await (0, import_promises5.appendFile)((0, import_node_path7.join)(root, LOG_FILE), line, "utf8").catch((error) => {
5063
+ await (0, import_promises7.appendFile)((0, import_node_path8.join)(root, LOG_FILE), line, "utf8").catch((error) => {
4347
5064
  this.logger.warn?.({
4348
5065
  operation: "kb.log.append",
4349
5066
  outcome: "failed",
@@ -4369,18 +5086,18 @@ ${answer}
4369
5086
  };
4370
5087
  }
4371
5088
  root(bundlePath2) {
4372
- return (0, import_node_path7.resolve)(bundlePath2);
5089
+ return (0, import_node_path8.resolve)(bundlePath2);
4373
5090
  }
4374
5091
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
4375
5092
  // bundle root; anything carrying a separator would escape it.
4376
5093
  recordPath(bundlePath2, conceptId2) {
4377
- if (conceptId2.includes(import_node_path7.sep) || conceptId2.includes("/")) {
5094
+ if (conceptId2.includes(import_node_path8.sep) || conceptId2.includes("/")) {
4378
5095
  throw new KbInvalidConceptIdError(
4379
5096
  "concept id must not contain a path separator",
4380
5097
  { conceptId: conceptId2 }
4381
5098
  );
4382
5099
  }
4383
- return (0, import_node_path7.join)(this.root(bundlePath2), `${conceptId2}.md`);
5100
+ return (0, import_node_path8.join)(this.root(bundlePath2), `${conceptId2}.md`);
4384
5101
  }
4385
5102
  };
4386
5103
  function estimateTokens(record) {
@@ -4418,28 +5135,9 @@ function normalizeActor(id) {
4418
5135
  if (colon === -1) return id.toLowerCase();
4419
5136
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
4420
5137
  }
4421
- function digest(contents) {
4422
- return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
4423
- }
4424
- function bundleDigest(records, superseded) {
4425
- const entries = [
4426
- ...records.map(
4427
- (hit) => `${hit.record.conceptId}:current:${digest(
4428
- stringifyMarkdownWithFrontmatter(
4429
- hit.record.body,
4430
- hit.record.frontmatter
4431
- )
4432
- )}`
4433
- ),
4434
- ...superseded.map(
4435
- (entry) => `${entry.conceptId}:superseded:${digest(JSON.stringify(entry))}`
4436
- )
4437
- ].sort();
4438
- return digest(entries.join("\n"));
4439
- }
4440
5138
 
4441
5139
  // src/version.ts
4442
- var VERSION = true ? "0.1.14" : "0.0.0-dev";
5140
+ var VERSION = true ? "0.1.16" : "0.0.0-dev";
4443
5141
 
4444
5142
  // src/mcp.ts
4445
5143
  function createKbMcpServer() {