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