@saasontools/strauss-kb 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp-main.cjs CHANGED
@@ -59,16 +59,17 @@ var kbAnchorSchema = import_zod.z.object({
59
59
  * (`https://github.com/org/name`) or a short name. Absent means the base's
60
60
  * own repository, which is what nearly every anchor means.
61
61
  *
62
- * Unvalidated beyond not-blank: one repository has many spellings.
63
- * Matched after normalisation; see ARCHITECTURE.
62
+ * Unvalidated beyond not-blank: one repository has many spellings, matched
63
+ * after normalisation. Only a full URL can be fetched from, so `validate`
64
+ * warns on a short one; see ARCHITECTURE.
64
65
  */
65
66
  repo: import_zod.z.string().trim().min(1).optional(),
66
67
  /**
67
68
  * The git rev the evidence was taken at. Prefer a commit SHA: a branch
68
69
  * name is a moving pointer, so an anchor pinned to one says the evidence
69
70
  * came from wherever that branch happens to be now, which is not a
70
- * baseline. Recorded and preserved in v1; ref-pinned reads land with
71
- * SAA-709.
71
+ * baseline. A foreign anchor is checked at this rev, and compared against
72
+ * the remote's default branch on top of it.
72
73
  */
73
74
  ref: import_zod.z.string().trim().min(1).optional(),
74
75
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
@@ -443,13 +444,6 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
443
444
  // src/commands/anchor-resolve.ts
444
445
  var import_zod6 = require("zod");
445
446
 
446
- // src/anchor-resolver.ts
447
- var import_node_child_process = require("child_process");
448
- var import_node_crypto = require("crypto");
449
- var import_promises = require("fs/promises");
450
- var import_node_path = require("path");
451
- var import_node_util = require("util");
452
-
453
447
  // src/concurrency.ts
454
448
  var DEFAULT_IO_CONCURRENCY = 16;
455
449
  async function mapLimit(items, limit, fn) {
@@ -479,9 +473,528 @@ async function mapLimit(items, limit, fn) {
479
473
  return out;
480
474
  }
481
475
 
482
- // src/anchor-resolver.ts
476
+ // src/remote-repo/cache.ts
477
+ var import_node_os = require("os");
478
+ var import_node_path = require("path");
479
+
480
+ // src/anchor-resolver/repo-identity.ts
481
+ var import_node_child_process = require("child_process");
482
+ var import_node_util = require("util");
483
483
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
484
+ function normalizeRepoUrl(value) {
485
+ let url = value.trim().replace(/^git\+/, "");
486
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
487
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
488
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
489
+ url = trimTrailingSlashes(url);
490
+ if (url.endsWith(".git")) url = url.slice(0, -4);
491
+ return trimTrailingSlashes(url).toLowerCase();
492
+ }
493
+ function trimTrailingSlashes(value) {
494
+ let end = value.length;
495
+ while (end > 0 && value[end - 1] === "/") end -= 1;
496
+ return value.slice(0, end);
497
+ }
498
+ function isCanonicalRepoUrl(value) {
499
+ return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
500
+ }
501
+ function repoPath(normalized) {
502
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
503
+ const segments = withoutScheme.split("/").filter(Boolean);
504
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
505
+ }
506
+ function repoIdentifies(declared, originUrl) {
507
+ if (!originUrl) return false;
508
+ const origin = normalizeRepoUrl(originUrl);
509
+ const want = normalizeRepoUrl(declared);
510
+ if (!want || !origin) return false;
511
+ if (want === origin) return true;
512
+ const path = repoPath(origin);
513
+ if (!path) return false;
514
+ return want === path || want === (path.split("/").pop() ?? "");
515
+ }
516
+ async function repoOriginUrl(repoRoot) {
517
+ try {
518
+ const { stdout } = await execFileAsync(
519
+ "git",
520
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
521
+ { timeout: 5e3 }
522
+ );
523
+ return stdout.trim() || null;
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+ var LazyOrigin = class {
529
+ constructor(repoRoot) {
530
+ this.repoRoot = repoRoot;
531
+ }
532
+ repoRoot;
533
+ url = null;
534
+ asked = false;
535
+ /** Asks git once, so later `isForeign` calls need no await. */
536
+ async prime() {
537
+ if (this.asked) return;
538
+ this.url = await repoOriginUrl(this.repoRoot);
539
+ this.asked = true;
540
+ }
541
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
542
+ isForeign(anchor) {
543
+ if (!anchor.repo) return false;
544
+ return !repoIdentifies(anchor.repo, this.url);
545
+ }
546
+ async foreign(anchor) {
547
+ if (!anchor.repo) return false;
548
+ await this.prime();
549
+ return this.isForeign(anchor);
550
+ }
551
+ };
552
+
553
+ // src/remote-repo/cache.ts
554
+ function repoCacheDir(override) {
555
+ return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".strauss", "repo-cache");
556
+ }
557
+ var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
558
+ function fetchTimeoutMs(override) {
559
+ if (override !== void 0) return override;
560
+ const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
561
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
562
+ }
563
+ function cachePathFor(repo, cacheDir) {
564
+ const normalized = normalizeRepoUrl(repo);
565
+ const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
566
+ if (!scheme) return null;
567
+ const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
568
+ if (segments.length < 2 || segments.some((segment) => segment === null)) {
569
+ return null;
570
+ }
571
+ const path = segments;
572
+ return (0, import_node_path.join)(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
573
+ }
574
+ function safeSegment(value) {
575
+ return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
576
+ }
577
+ function revRef(rev) {
578
+ const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
579
+ let hash = 5381;
580
+ for (let at = 0; at < rev.length; at++) {
581
+ hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
582
+ }
583
+ return `refs/strauss/${safe}-${hash.toString(16)}`;
584
+ }
585
+
586
+ // src/remote-repo/model.ts
587
+ var UNCHECKED_REASONS = [
588
+ "remote-unreachable",
589
+ "repo-unauthorized",
590
+ "default-branch-unknown"
591
+ ];
592
+ function isUncheckedReason(reason) {
593
+ return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
594
+ }
595
+ function wantKey(repo, ref, file) {
596
+ return `${repo}\0${ref ?? ""}\0${file}`;
597
+ }
598
+
599
+ // src/remote-repo/read.ts
600
+ var import_promises = require("fs/promises");
601
+
602
+ // src/remote-repo/git.ts
603
+ var import_node_child_process2 = require("child_process");
604
+ var import_node_util2 = require("util");
605
+
606
+ // src/anchor-resolver/model.ts
484
607
  var MAX_ANCHOR_FILE_BYTES = 1048576;
608
+
609
+ // src/remote-repo/git.ts
610
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
611
+ function childEnv() {
612
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
613
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
614
+ delete env[name];
615
+ }
616
+ return env;
617
+ }
618
+ async function git(args, options = {}) {
619
+ try {
620
+ const { stdout, stderr } = await execFileAsync2("git", args, {
621
+ ...options.cwd ? { cwd: options.cwd } : {},
622
+ timeout: options.timeoutMs ?? 3e4,
623
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
624
+ encoding: "utf8",
625
+ windowsHide: true,
626
+ env: childEnv()
627
+ });
628
+ return { ok: true, stdout, stderr, overflowed: false };
629
+ } catch (error) {
630
+ const failure = error;
631
+ return {
632
+ ok: false,
633
+ stdout: failure.stdout ?? "",
634
+ stderr: failure.stderr ?? "",
635
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
636
+ };
637
+ }
638
+ }
639
+ function transportReason(stderr) {
640
+ const text = stderr.toLowerCase();
641
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
642
+ return "repo-unauthorized";
643
+ }
644
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
645
+ return "ref-not-found";
646
+ }
647
+ return "remote-unreachable";
648
+ }
649
+
650
+ // src/remote-repo/validate.ts
651
+ var MAX_REF_LENGTH = 200;
652
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
653
+ function refShapeIsSafe(ref) {
654
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
655
+ if (ref.includes("..")) return false;
656
+ return REF_SHAPE.test(ref);
657
+ }
658
+ async function refIsWellFormed(ref) {
659
+ if (!refShapeIsSafe(ref)) return false;
660
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
661
+ return checked.ok;
662
+ }
663
+ function filePathIsSafe(file) {
664
+ const path = file.replace(/^\.\//, "");
665
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
666
+ return !path.split("/").includes("..");
667
+ }
668
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
669
+ function allowedProtocols() {
670
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
671
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
672
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
673
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
674
+ }
675
+ function isShortRepoName(repo) {
676
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
677
+ }
678
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
679
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
680
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
681
+ function repoUrlIsSafe(repo) {
682
+ const url = repo.trim();
683
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
684
+ const allowed = allowedProtocols();
685
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
686
+ const scheme = URL_SCHEME.exec(url);
687
+ if (!scheme?.[1]) return false;
688
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
689
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
690
+ const at = authority.lastIndexOf("@");
691
+ return at < 0 || !authority.slice(0, at).includes(":");
692
+ }
693
+ function protocolArgs() {
694
+ const allowed = allowedProtocols();
695
+ return [
696
+ "-c",
697
+ "protocol.ext.allow=never",
698
+ "-c",
699
+ `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
700
+ ];
701
+ }
702
+
703
+ // src/remote-repo/read.ts
704
+ var DEFAULT_REPO_CONCURRENCY = 4;
705
+ var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
706
+ async function readRemoteAnchors(wants, options = {}) {
707
+ const out = /* @__PURE__ */ new Map();
708
+ if (!wants.length) return out;
709
+ const cacheDir = repoCacheDir(options.cacheDir);
710
+ const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
711
+ const byRepo = /* @__PURE__ */ new Map();
712
+ for (const want of wants) {
713
+ const key = normalizeRepoUrl(want.repo);
714
+ const group2 = byRepo.get(key) ?? { url: want.repo.trim(), wants: [] };
715
+ group2.wants.push(want);
716
+ byRepo.set(key, group2);
717
+ }
718
+ const groups = [...byRepo.entries()];
719
+ const results = await mapLimit(
720
+ groups,
721
+ Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
722
+ ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
723
+ cacheDir,
724
+ timeoutMs,
725
+ offline: options.offline === true
726
+ })
727
+ );
728
+ for (const result of results) {
729
+ for (const [key, read] of result) out.set(key, read);
730
+ }
731
+ return out;
732
+ }
733
+ async function readOneRepo(repo, url, declared, context) {
734
+ let wants = declared;
735
+ const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
736
+ if (isShortRepoName(url)) {
737
+ return all({ ok: false, reason: "remote-unreachable" });
738
+ }
739
+ if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
740
+ const cache = cachePathFor(repo, context.cacheDir);
741
+ if (!cache) return all({ ok: false, reason: "remote-unreachable" });
742
+ const rejected = /* @__PURE__ */ new Map();
743
+ const usable = [];
744
+ for (const want of wants) {
745
+ const reason = wantReason(want);
746
+ if (reason)
747
+ rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
748
+ else usable.push(want);
749
+ }
750
+ if (!usable.length) return rejected;
751
+ wants = usable;
752
+ const opened = await openCache(cache, url, context);
753
+ if (opened) return new Map([...rejected, ...all(opened)]);
754
+ const wantsDefault = wants.some((want) => want.ref === void 0);
755
+ const branch = wantsDefault ? await defaultBranch(cache, context) : {};
756
+ const revs = /* @__PURE__ */ new Map();
757
+ for (const rev of distinctRevs(wants, branch.name)) {
758
+ revs.set(
759
+ rev,
760
+ await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
761
+ );
762
+ }
763
+ const reads = await mapLimit(
764
+ wants,
765
+ DEFAULT_IO_CONCURRENCY,
766
+ async (want) => {
767
+ const rev = want.ref ?? branch.name;
768
+ if (rev === void 0) {
769
+ return {
770
+ ok: false,
771
+ reason: branch.reason ?? "default-branch-unknown"
772
+ };
773
+ }
774
+ const failed = revs.get(rev);
775
+ if (failed) return failed;
776
+ return readBlob(cache, rev, want.file, context);
777
+ }
778
+ );
779
+ return new Map([
780
+ ...rejected,
781
+ ...wants.map(
782
+ (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
783
+ )
784
+ ]);
785
+ }
786
+ function wantReason(want) {
787
+ if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
788
+ return filePathIsSafe(want.file) ? void 0 : "outside-repo";
789
+ }
790
+ function distinctRevs(wants, branch) {
791
+ const revs = /* @__PURE__ */ new Set();
792
+ for (const want of wants) {
793
+ if (want.ref !== void 0) revs.add(want.ref);
794
+ else if (branch) revs.add(branch);
795
+ }
796
+ return [...revs];
797
+ }
798
+ async function openCache(cache, url, context) {
799
+ try {
800
+ await (0, import_promises.mkdir)(cache, { recursive: true });
801
+ } catch {
802
+ return { ok: false, reason: "remote-unreachable" };
803
+ }
804
+ const init = await git(["init", "--bare", "--quiet", cache], {
805
+ timeoutMs: context.timeoutMs
806
+ });
807
+ if (!init.ok) return { ok: false, reason: "remote-unreachable" };
808
+ const remote = await git(["config", "remote.origin.url", url], {
809
+ cwd: cache,
810
+ timeoutMs: context.timeoutMs
811
+ });
812
+ return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
813
+ }
814
+ async function defaultBranch(cache, context) {
815
+ if (!context.offline) {
816
+ const listed = await git(
817
+ [...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
818
+ {
819
+ cwd: cache,
820
+ timeoutMs: context.timeoutMs
821
+ }
822
+ );
823
+ const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
824
+ if (listed.ok && found?.[1]) {
825
+ const name = found[1];
826
+ await git(["config", "strauss.defaultBranch", name], { cwd: cache });
827
+ return { name };
828
+ }
829
+ if (!listed.ok) {
830
+ const reason = transportReason(listed.stderr);
831
+ if (reason !== "ref-not-found") {
832
+ const cached2 = await cachedBranch(cache);
833
+ return cached2 ? { name: cached2 } : { reason };
834
+ }
835
+ }
836
+ }
837
+ const cached = await cachedBranch(cache);
838
+ if (cached) return { name: cached };
839
+ return {
840
+ reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
841
+ };
842
+ }
843
+ async function cachedBranch(cache) {
844
+ const stored = await git(["config", "--get", "strauss.defaultBranch"], {
845
+ cwd: cache
846
+ });
847
+ if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
848
+ const head = await git(
849
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
850
+ {
851
+ cwd: cache
852
+ }
853
+ );
854
+ const name = head.stdout.trim().replace(/^origin\//, "");
855
+ return head.ok && name ? name : void 0;
856
+ }
857
+ async function ensureRev(cache, rev, context) {
858
+ const ref = revRef(rev);
859
+ const have = await git(
860
+ ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
861
+ {
862
+ cwd: cache
863
+ }
864
+ );
865
+ const cached = have.ok && have.stdout.trim().length > 0;
866
+ if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
867
+ if (context.offline) return { ok: false, reason: "remote-unreachable" };
868
+ const fetched = await git(
869
+ [
870
+ ...protocolArgs(),
871
+ "fetch",
872
+ "--depth",
873
+ "1",
874
+ "origin",
875
+ "--end-of-options",
876
+ rev
877
+ ],
878
+ { cwd: cache, timeoutMs: context.timeoutMs }
879
+ );
880
+ if (!fetched.ok) {
881
+ const reason = transportReason(fetched.stderr);
882
+ if (cached && reason !== "ref-not-found") return void 0;
883
+ return { ok: false, reason };
884
+ }
885
+ const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
886
+ const sha = head.stdout.trim();
887
+ if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
888
+ const updated = await git(["update-ref", ref, sha], { cwd: cache });
889
+ return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
890
+ }
891
+ async function readBlob(cache, rev, file, context) {
892
+ const path = file.replace(/^\.\//, "");
893
+ const blob = await git(
894
+ ["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
895
+ {
896
+ cwd: cache,
897
+ timeoutMs: context.timeoutMs
898
+ }
899
+ );
900
+ if (blob.ok) return { ok: true, source: blob.stdout };
901
+ if (blob.overflowed) return { ok: false, reason: "file-too-large" };
902
+ const text = blob.stderr.toLowerCase();
903
+ return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
904
+ }
905
+
906
+ // src/anchor-resolver/read.ts
907
+ var import_promises2 = require("fs/promises");
908
+ var import_node_path2 = require("path");
909
+ function anchorFilePath(repoRoot, file) {
910
+ const path = (0, import_node_path2.resolve)(repoRoot, file.replace(/^\.\//, ""));
911
+ const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(repoRoot), path);
912
+ if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path2.sep}`) || (0, import_node_path2.isAbsolute)(rel)) {
913
+ return null;
914
+ }
915
+ return path;
916
+ }
917
+ function contains(root, path) {
918
+ const rel = (0, import_node_path2.relative)(root, path);
919
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`) && !(0, import_node_path2.isAbsolute)(rel);
920
+ }
921
+ function errorCode(error) {
922
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
923
+ }
924
+ function anchorFileReader(repoRoot) {
925
+ let rootOnce;
926
+ const realRoot = () => {
927
+ rootOnce ??= (0, import_promises2.realpath)((0, import_node_path2.resolve)(repoRoot)).catch((error) => {
928
+ rootOnce = void 0;
929
+ throw error;
930
+ });
931
+ return rootOnce;
932
+ };
933
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
934
+ }
935
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
936
+ const lexical = anchorFilePath(repoRoot, file);
937
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
938
+ let root;
939
+ let path;
940
+ try {
941
+ root = await realRoot();
942
+ path = await (0, import_promises2.realpath)(lexical);
943
+ } catch (error) {
944
+ const code = errorCode(error);
945
+ if (code === "ENOENT" || code === "ENOTDIR") {
946
+ return { ok: false, reason: "file-missing" };
947
+ }
948
+ return { ok: false, reason: "file-unreadable" };
949
+ }
950
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
951
+ try {
952
+ const stats = await (0, import_promises2.stat)(path);
953
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
954
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
955
+ return { ok: false, reason: "file-too-large" };
956
+ }
957
+ return { ok: true, source: await (0, import_promises2.readFile)(path, "utf8") };
958
+ } catch (error) {
959
+ const code = errorCode(error);
960
+ if (code === "ENOENT" || code === "ENOTDIR") {
961
+ return { ok: false, reason: "file-missing" };
962
+ }
963
+ return { ok: false, reason: "file-unreadable" };
964
+ }
965
+ }
966
+ function looksLikeWrongRepoRoot(drift) {
967
+ let checked = 0;
968
+ for (const entries of drift.values()) {
969
+ for (const entry of entries) {
970
+ if (entry.repo !== void 0) continue;
971
+ checked += 1;
972
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
973
+ return false;
974
+ }
975
+ }
976
+ }
977
+ return checked > 0;
978
+ }
979
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
980
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
981
+ throw new RangeError(
982
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
983
+ );
984
+ }
985
+ const wanted = [...new Set(files)];
986
+ const results = await mapLimit(wanted, concurrency, async (file) => {
987
+ try {
988
+ return await read(file);
989
+ } catch {
990
+ return { ok: false, reason: "file-unreadable" };
991
+ }
992
+ });
993
+ return new Map(wanted.map((file, at) => [file, results[at]]));
994
+ }
995
+
996
+ // src/anchor-resolver/resolver.ts
997
+ var import_node_crypto = require("crypto");
485
998
  var PARENT_SCOPE_LINES = 50;
486
999
  var CLEAN_STATE = { blockComment: false, template: false };
487
1000
  function stripLine(line, state) {
@@ -657,157 +1170,8 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
657
1170
  }
658
1171
  return resolver.resolve(normalized, anchor.symbol);
659
1172
  }
660
- function anchorFilePath(repoRoot, file) {
661
- const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
662
- const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
663
- if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
664
- return null;
665
- }
666
- return path;
667
- }
668
- function contains(root, path) {
669
- const rel = (0, import_node_path.relative)(root, path);
670
- return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
671
- }
672
- function normalizeRepoUrl(value) {
673
- let url = value.trim().replace(/^git\+/, "");
674
- const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
675
- if (scp) url = `https://${scp[1]}/${scp[2]}`;
676
- url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
677
- url = trimTrailingSlashes(url);
678
- if (url.endsWith(".git")) url = url.slice(0, -4);
679
- return trimTrailingSlashes(url).toLowerCase();
680
- }
681
- function trimTrailingSlashes(value) {
682
- let end = value.length;
683
- while (end > 0 && value[end - 1] === "/") end -= 1;
684
- return value.slice(0, end);
685
- }
686
- function repoPath(normalized) {
687
- const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
688
- const segments = withoutScheme.split("/").filter(Boolean);
689
- return segments.length > 1 ? segments.slice(1).join("/") : "";
690
- }
691
- function repoIdentifies(declared, originUrl) {
692
- if (!originUrl) return false;
693
- const origin = normalizeRepoUrl(originUrl);
694
- const want = normalizeRepoUrl(declared);
695
- if (!want || !origin) return false;
696
- if (want === origin) return true;
697
- const path = repoPath(origin);
698
- if (!path) return false;
699
- return want === path || want === (path.split("/").pop() ?? "");
700
- }
701
- async function repoOriginUrl(repoRoot) {
702
- try {
703
- const { stdout } = await execFileAsync(
704
- "git",
705
- ["-C", repoRoot, "config", "--get", "remote.origin.url"],
706
- { timeout: 5e3 }
707
- );
708
- return stdout.trim() || null;
709
- } catch {
710
- return null;
711
- }
712
- }
713
- var LazyOrigin = class {
714
- constructor(repoRoot) {
715
- this.repoRoot = repoRoot;
716
- }
717
- repoRoot;
718
- url = null;
719
- asked = false;
720
- /** Asks git once, so later `isForeign` calls need no await. */
721
- async prime() {
722
- if (this.asked) return;
723
- this.url = await repoOriginUrl(this.repoRoot);
724
- this.asked = true;
725
- }
726
- /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
727
- isForeign(anchor) {
728
- if (!anchor.repo) return false;
729
- return !repoIdentifies(anchor.repo, this.url);
730
- }
731
- async foreign(anchor) {
732
- if (!anchor.repo) return false;
733
- await this.prime();
734
- return this.isForeign(anchor);
735
- }
736
- };
737
- function errorCode(error) {
738
- return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
739
- }
740
- function anchorFileReader(repoRoot) {
741
- let rootOnce;
742
- const realRoot = () => {
743
- rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
744
- rootOnce = void 0;
745
- throw error;
746
- });
747
- return rootOnce;
748
- };
749
- return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
750
- }
751
- async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
752
- const lexical = anchorFilePath(repoRoot, file);
753
- if (lexical === null) return { ok: false, reason: "outside-repo" };
754
- let root;
755
- let path;
756
- try {
757
- root = await realRoot();
758
- path = await (0, import_promises.realpath)(lexical);
759
- } catch (error) {
760
- const code = errorCode(error);
761
- if (code === "ENOENT" || code === "ENOTDIR") {
762
- return { ok: false, reason: "file-missing" };
763
- }
764
- return { ok: false, reason: "file-unreadable" };
765
- }
766
- if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
767
- try {
768
- const stats = await (0, import_promises.stat)(path);
769
- if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
770
- if (stats.size > MAX_ANCHOR_FILE_BYTES) {
771
- return { ok: false, reason: "file-too-large" };
772
- }
773
- return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
774
- } catch (error) {
775
- const code = errorCode(error);
776
- if (code === "ENOENT" || code === "ENOTDIR") {
777
- return { ok: false, reason: "file-missing" };
778
- }
779
- return { ok: false, reason: "file-unreadable" };
780
- }
781
- }
782
- function looksLikeWrongRepoRoot(drift) {
783
- let checked = 0;
784
- for (const entries of drift.values()) {
785
- for (const entry of entries) {
786
- if (entry.reason === "foreign-repo") continue;
787
- checked += 1;
788
- if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
789
- return false;
790
- }
791
- }
792
- }
793
- return checked > 0;
794
- }
795
- async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
796
- if (!Number.isInteger(concurrency) || concurrency < 1) {
797
- throw new RangeError(
798
- `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
799
- );
800
- }
801
- const wanted = [...new Set(files)];
802
- const results = await mapLimit(wanted, concurrency, async (file) => {
803
- try {
804
- return await read(file);
805
- } catch {
806
- return { ok: false, reason: "file-unreadable" };
807
- }
808
- });
809
- return new Map(wanted.map((file, at) => [file, results[at]]));
810
- }
1173
+
1174
+ // src/anchor-resolver/drift.ts
811
1175
  async function detectAnchorDrift(records, options = {}) {
812
1176
  const repoRoot = options.repoRoot ?? process.cwd();
813
1177
  const resolver = options.resolver ?? regexResolver;
@@ -833,67 +1197,97 @@ async function detectAnchorDrift(records, options = {}) {
833
1197
  }
834
1198
  }
835
1199
  const files = [];
1200
+ const wants = [];
836
1201
  for (const entries of planned.values()) {
837
- for (const entry of entries) {
838
- if (!entry.foreign) files.push(entry.anchor.file);
1202
+ for (const { anchor, foreign } of entries) {
1203
+ if (!foreign) files.push(anchor.file);
1204
+ else wants.push(...remoteWants(anchor));
839
1205
  }
840
1206
  }
841
- const reads = await readAnchorFiles(
842
- files,
843
- options.reader ?? anchorFileReader(repoRoot),
844
- options.concurrency ?? DEFAULT_IO_CONCURRENCY
845
- );
1207
+ const [reads, remote] = await Promise.all([
1208
+ readAnchorFiles(
1209
+ files,
1210
+ options.reader ?? anchorFileReader(repoRoot),
1211
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
1212
+ ),
1213
+ (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1214
+ ]);
846
1215
  const drift = /* @__PURE__ */ new Map();
847
1216
  for (const record of records) {
848
1217
  const entries = [];
849
1218
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
850
- const base = {
851
- file: anchor.file,
852
- ...anchor.symbol ? { symbol: anchor.symbol } : {},
853
- storedHash: anchor.hash
854
- };
855
- if (foreign) {
856
- entries.push({
857
- ...base,
858
- state: "unresolved",
859
- diffSize: null,
860
- reason: "foreign-repo"
861
- });
862
- continue;
863
- }
864
- const read = reads.get(anchor.file);
865
- if (!read.ok) {
866
- entries.push({
867
- ...base,
868
- state: "unresolved",
869
- diffSize: null,
870
- reason: read.reason
871
- });
872
- continue;
873
- }
874
- const resolved = resolveAnchor(read.source, anchor, resolver);
875
- if (!resolved) {
876
- entries.push({
877
- ...base,
878
- state: "unresolved",
879
- diffSize: null,
880
- reason: "symbol-not-found"
881
- });
882
- continue;
883
- }
884
- const currentHash = hashAnchorText(resolved.text);
885
- const currentLines = resolved.endLine - resolved.startLine + 1;
886
- entries.push({
887
- ...base,
888
- state: currentHash === anchor.hash ? "match" : "drifted",
889
- currentHash,
890
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
891
- });
1219
+ entries.push(
1220
+ foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
1221
+ );
892
1222
  }
893
1223
  if (entries.length) drift.set(record.conceptId, entries);
894
1224
  }
895
1225
  return drift;
896
1226
  }
1227
+ function remoteWants(anchor) {
1228
+ const repo = anchor.repo;
1229
+ const wants = [{ repo, file: anchor.file }];
1230
+ if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
1231
+ return wants;
1232
+ }
1233
+ function base(anchor) {
1234
+ return {
1235
+ file: anchor.file,
1236
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1237
+ storedHash: anchor.hash
1238
+ };
1239
+ }
1240
+ function unresolved(anchor, reason, repo) {
1241
+ return {
1242
+ ...base(anchor),
1243
+ state: "unresolved",
1244
+ diffSize: null,
1245
+ ...reason ? { reason } : {},
1246
+ ...repo ? { repo } : {}
1247
+ };
1248
+ }
1249
+ function hashIn(source, anchor, resolver) {
1250
+ const resolved = resolveAnchor(source, anchor, resolver);
1251
+ if (!resolved) return null;
1252
+ return {
1253
+ hash: hashAnchorText(resolved.text),
1254
+ lines: resolved.endLine - resolved.startLine + 1
1255
+ };
1256
+ }
1257
+ function compared(anchor, current, extra = {}) {
1258
+ return {
1259
+ ...base(anchor),
1260
+ state: current.hash === anchor.hash ? "match" : "drifted",
1261
+ currentHash: current.hash,
1262
+ diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1263
+ ...extra
1264
+ };
1265
+ }
1266
+ function localEntry(anchor, read, resolver) {
1267
+ if (!read.ok) return unresolved(anchor, read.reason);
1268
+ const current = hashIn(read.source, anchor, resolver);
1269
+ return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
1270
+ }
1271
+ function remoteEntry(anchor, remote, resolver) {
1272
+ const repo = anchor.repo;
1273
+ const key = normalizeRepoUrl(repo);
1274
+ const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1275
+ const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1276
+ if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1277
+ if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1278
+ const current = hashIn(primary.source, anchor, resolver);
1279
+ if (!current) return unresolved(anchor, "symbol-not-found", repo);
1280
+ if (!anchor.ref) return compared(anchor, current, { repo });
1281
+ if (current.hash !== anchor.hash) {
1282
+ return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
1283
+ }
1284
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
1285
+ return head && head.hash !== anchor.hash ? {
1286
+ ...compared(anchor, head, { repo }),
1287
+ state: "drifted",
1288
+ remoteState: "drifted-on-default"
1289
+ } : compared(anchor, current, { repo, remoteState: "matches-ref" });
1290
+ }
897
1291
 
898
1292
  // src/errors.ts
899
1293
  var BaseError = class extends Error {
@@ -1096,18 +1490,18 @@ var KbBaseFrozenError = class extends Error {
1096
1490
  };
1097
1491
 
1098
1492
  // src/kb-pins/frozen.ts
1099
- var import_node_path4 = require("path");
1493
+ var import_node_path5 = require("path");
1100
1494
 
1101
1495
  // src/kb-pins/layers.ts
1102
- var import_promises2 = require("fs/promises");
1103
- var import_node_os = require("os");
1104
- var import_node_path3 = require("path");
1496
+ var import_promises3 = require("fs/promises");
1497
+ var import_node_os2 = require("os");
1498
+ var import_node_path4 = require("path");
1105
1499
 
1106
1500
  // src/kb-pins/model.ts
1107
- var import_node_path2 = require("path");
1501
+ var import_node_path3 = require("path");
1108
1502
  var import_zod4 = require("zod");
1109
- var PINS_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.json");
1110
- var PINS_LOCAL_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.local.json");
1503
+ var PINS_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.json");
1504
+ var PINS_LOCAL_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.local.json");
1111
1505
  var PIN_LAYERS = ["project", "local", "user"];
1112
1506
  var pinSchema = import_zod4.z.object({
1113
1507
  /** Relative to the manifest's root, so the file is committable. */
@@ -1154,13 +1548,13 @@ var pinsManifestSchema = import_zod4.z.object({
1154
1548
 
1155
1549
  // src/kb-pins/layers.ts
1156
1550
  function userRoot() {
1157
- return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
1551
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
1158
1552
  }
1159
1553
  function layerRoot(workspaceDir, layer) {
1160
- return layer === "user" ? userRoot() : (0, import_node_path3.resolve)(workspaceDir);
1554
+ return layer === "user" ? userRoot() : (0, import_node_path4.resolve)(workspaceDir);
1161
1555
  }
1162
1556
  function layerFile(workspaceDir, layer) {
1163
- return (0, import_node_path3.join)(
1557
+ return (0, import_node_path4.join)(
1164
1558
  layerRoot(workspaceDir, layer),
1165
1559
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1166
1560
  );
@@ -1169,7 +1563,7 @@ async function readPinsLayer(workspaceDir, layer) {
1169
1563
  const file = layerFile(workspaceDir, layer);
1170
1564
  let raw;
1171
1565
  try {
1172
- raw = await (0, import_promises2.readFile)(file, "utf8");
1566
+ raw = await (0, import_promises3.readFile)(file, "utf8");
1173
1567
  } catch {
1174
1568
  return { pins: [] };
1175
1569
  }
@@ -1193,16 +1587,16 @@ async function readPinsLayer(workspaceDir, layer) {
1193
1587
  }
1194
1588
  async function writePinsLayer(workspaceDir, layer, manifest) {
1195
1589
  const file = layerFile(workspaceDir, layer);
1196
- await (0, import_promises2.mkdir)((0, import_node_path3.dirname)(file), { recursive: true });
1197
- await (0, import_promises2.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1590
+ await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(file), { recursive: true });
1591
+ await (0, import_promises3.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1198
1592
  `, "utf8");
1199
1593
  }
1200
1594
  function resolvePinPath(rootDir, path) {
1201
- return (0, import_node_path3.isAbsolute)(path) ? (0, import_node_path3.resolve)(path) : (0, import_node_path3.resolve)(rootDir, path.split("/").join(import_node_path3.sep));
1595
+ return (0, import_node_path4.isAbsolute)(path) ? (0, import_node_path4.resolve)(path) : (0, import_node_path4.resolve)(rootDir, path.split("/").join(import_node_path4.sep));
1202
1596
  }
1203
1597
  function storablePath(rootDir, bundlePath2) {
1204
- const rel = (0, import_node_path3.relative)((0, import_node_path3.resolve)(rootDir), (0, import_node_path3.resolve)(bundlePath2));
1205
- return (rel === "" ? "." : rel).split(import_node_path3.sep).join("/");
1598
+ const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(rootDir), (0, import_node_path4.resolve)(bundlePath2));
1599
+ return (rel === "" ? "." : rel).split(import_node_path4.sep).join("/");
1206
1600
  }
1207
1601
  async function readMergedPins(workspaceDir) {
1208
1602
  const manifests = {};
@@ -1230,7 +1624,7 @@ async function readMergedPins(workspaceDir) {
1230
1624
  // src/kb-pins/frozen.ts
1231
1625
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
1232
1626
  const merged = await readMergedPins(workspaceDir);
1233
- const absolute = (0, import_node_path4.resolve)(bundlePath2);
1627
+ const absolute = (0, import_node_path5.resolve)(bundlePath2);
1234
1628
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
1235
1629
  if (pin?.frozen === true) {
1236
1630
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -1315,7 +1709,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1315
1709
  }
1316
1710
 
1317
1711
  // src/kb-pins/unpin.ts
1318
- var import_node_path5 = require("path");
1712
+ var import_node_path6 = require("path");
1319
1713
  async function unpinBase(workspaceDir, bundlePath2) {
1320
1714
  const layers = [];
1321
1715
  for (const layer of PIN_LAYERS) {
@@ -1336,7 +1730,7 @@ async function unpinBase(workspaceDir, bundlePath2) {
1336
1730
  }
1337
1731
  }
1338
1732
  return {
1339
- path: storablePath((0, import_node_path5.resolve)(workspaceDir), bundlePath2),
1733
+ path: storablePath((0, import_node_path6.resolve)(workspaceDir), bundlePath2),
1340
1734
  removed: layers.length > 0,
1341
1735
  layers
1342
1736
  };
@@ -1372,12 +1766,15 @@ function argvFlag(argv, name) {
1372
1766
  var anchorResolveCommand = define({
1373
1767
  name: "anchor-resolve",
1374
1768
  tool: "kb_anchor_resolve",
1375
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
1376
- description: "Resolve a record's anchors against the working tree: stamp a hash onto anchors that lack one, report drift where the code moved. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was, not whether the claim still holds. Anchors naming another repository are skipped. Exits non-zero on drift.",
1769
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
1770
+ description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
1377
1771
  input: import_zod6.z.object({
1378
1772
  bundlePath,
1379
1773
  conceptId,
1380
1774
  repoRoot: import_zod6.z.string().min(1).optional(),
1775
+ offline: import_zod6.z.boolean().optional().describe(
1776
+ "Resolve foreign anchors from the local repo cache only, never fetching."
1777
+ ),
1381
1778
  rebaseline: import_zod6.z.boolean().optional().describe(
1382
1779
  "Accept the current code as the new baseline for anchors that drifted."
1383
1780
  ),
@@ -1389,10 +1786,11 @@ var anchorResolveCommand = define({
1389
1786
  bundlePath: path,
1390
1787
  conceptId: argv[1],
1391
1788
  repoRoot: argvFlag(argv, "--repo-root"),
1789
+ offline: argv.includes("--offline"),
1392
1790
  rebaseline: argv.includes("--rebaseline"),
1393
1791
  restamp: argv.includes("--restamp")
1394
1792
  }),
1395
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
1793
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
1396
1794
  const root = repoRoot ?? process.cwd();
1397
1795
  const record = await store.read(path, id);
1398
1796
  if (!record) throw new KbRecordNotFoundError(id);
@@ -1407,18 +1805,10 @@ var anchorResolveCommand = define({
1407
1805
  }
1408
1806
  const results = [];
1409
1807
  const updated = [];
1410
- const origin = new LazyOrigin(root);
1411
1808
  let dirty = false;
1412
- if (anchors.some((anchor) => anchor.repo)) await origin.prime();
1413
- const foreign = new Map(
1414
- anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
1415
- );
1416
- const reads = await readAnchorFiles(
1417
- anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
1418
- anchorFileReader(root)
1419
- );
1809
+ const sources = await readSources(anchors, root, offline === true);
1420
1810
  for (const anchor of anchors) {
1421
- const base = {
1811
+ const base2 = {
1422
1812
  file: anchor.file,
1423
1813
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
1424
1814
  // Carried onto unresolved findings too: an anchor that once hashed
@@ -1426,21 +1816,17 @@ var anchorResolveCommand = define({
1426
1816
  // has to be able to tell it from one nobody ever stamped.
1427
1817
  ...anchor.hash ? { storedHash: anchor.hash } : {}
1428
1818
  };
1429
- if (foreign.get(anchor)) {
1430
- results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
1819
+ const source = sources.get(anchor);
1820
+ if (source.repo) base2.repo = source.repo;
1821
+ if (!source.ok) {
1822
+ results.push({ ...base2, state: "unresolved", reason: source.reason });
1431
1823
  updated.push(anchor);
1432
1824
  continue;
1433
1825
  }
1434
- const fileRead = reads.get(anchor.file);
1435
- if (!fileRead.ok) {
1436
- results.push({ ...base, state: "unresolved", reason: fileRead.reason });
1437
- updated.push(anchor);
1438
- continue;
1439
- }
1440
- const resolved = resolveAnchor(fileRead.source, anchor);
1826
+ const resolved = resolveAnchor(source.source, anchor);
1441
1827
  if (!resolved) {
1442
1828
  results.push({
1443
- ...base,
1829
+ ...base2,
1444
1830
  state: "unresolved",
1445
1831
  reason: "symbol-not-found"
1446
1832
  });
@@ -1455,30 +1841,47 @@ var anchorResolveCommand = define({
1455
1841
  lines: currentLines,
1456
1842
  resolved_at: now()
1457
1843
  };
1844
+ const pinned = anchor.ref !== void 0 && source.repo !== void 0;
1458
1845
  if (!anchor.hash) {
1459
- results.push({ ...base, state: "stamped", currentHash });
1846
+ results.push({ ...base2, state: "stamped", currentHash });
1460
1847
  updated.push(stamped);
1461
1848
  dirty = true;
1462
- } else if (anchor.hash === currentHash) {
1463
- results.push({
1464
- ...base,
1465
- state: "match",
1466
- currentHash
1467
- });
1468
- const refresh = restamp || anchor.resolved_at === void 0;
1469
- updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
1470
- if (refresh) dirty = true;
1471
- } else {
1849
+ continue;
1850
+ }
1851
+ if (anchor.hash !== currentHash) {
1472
1852
  results.push({
1473
- ...base,
1853
+ ...base2,
1474
1854
  state: "drifted",
1475
1855
  currentHash,
1476
- diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
1856
+ diffSize: lineDelta(anchor, currentLines),
1857
+ ...pinned ? { remoteState: "drifted-from-ref" } : {},
1477
1858
  ...rebaseline ? { rebaselined: true } : {}
1478
1859
  });
1479
1860
  updated.push(rebaseline ? stamped : anchor);
1480
1861
  if (rebaseline) dirty = true;
1862
+ continue;
1863
+ }
1864
+ const onDefault = pinned ? headHash(source, anchor) : void 0;
1865
+ if (onDefault && onDefault.hash !== anchor.hash) {
1866
+ results.push({
1867
+ ...base2,
1868
+ state: "drifted",
1869
+ currentHash: onDefault.hash,
1870
+ diffSize: lineDelta(anchor, onDefault.lines),
1871
+ remoteState: "drifted-on-default"
1872
+ });
1873
+ updated.push(anchor);
1874
+ continue;
1481
1875
  }
1876
+ results.push({
1877
+ ...base2,
1878
+ state: "match",
1879
+ currentHash,
1880
+ ...pinned ? { remoteState: "matches-ref" } : {}
1881
+ });
1882
+ const refresh = restamp || anchor.resolved_at === void 0;
1883
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
1884
+ if (refresh) dirty = true;
1482
1885
  }
1483
1886
  let frozen = false;
1484
1887
  if (dirty) {
@@ -1491,16 +1894,19 @@ var anchorResolveCommand = define({
1491
1894
  if (!frozen) await store.updateAnchors(path, id, updated, actor);
1492
1895
  }
1493
1896
  const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
1494
- const checked = results.filter((entry) => entry.reason !== "foreign-repo");
1495
- const skipped = results.length - checked.length;
1496
- const matches2 = checked.filter((entry) => entry.state === "match").length;
1497
- const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
1897
+ const unreachable = results.filter(
1898
+ (entry) => isUncheckedReason(entry.reason)
1899
+ ).length;
1900
+ const checked = results.length - unreachable;
1901
+ const matches2 = results.filter((entry) => entry.state === "match").length;
1902
+ const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
1903
+ const clean = checked > 0 && matches2 === checked && unreachable === 0;
1498
1904
  if (clean) {
1499
1905
  try {
1500
1906
  await store.verify(
1501
1907
  path,
1502
1908
  id,
1503
- `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
1909
+ `anchor-resolve: ${note} (regex resolver)`,
1504
1910
  actor,
1505
1911
  now()
1506
1912
  );
@@ -1516,18 +1922,81 @@ var anchorResolveCommand = define({
1516
1922
  }
1517
1923
  return { conceptId: id, results, verified: true, ...frozenNote };
1518
1924
  }
1519
- return { conceptId: id, results, verified: false, ...frozenNote };
1925
+ return {
1926
+ conceptId: id,
1927
+ results,
1928
+ verified: false,
1929
+ ...unreachable ? { note } : {},
1930
+ ...frozenNote
1931
+ };
1520
1932
  },
1521
1933
  // A stored hash that no longer resolves is a broken anchor, not an absence:
1522
1934
  // the file was deleted or the symbol renamed, and exiting zero on it would
1523
1935
  // let the one edit that destroys an anchor pass the gate that exists to
1524
1936
  // catch it. An anchor nobody ever stamped is still just unstamped, and one
1525
- // belonging to another repository was never this run's to check — failing CI
1526
- // on either would gate on work this command did not do.
1937
+ // whose remote nothing could reach was never checked — failing CI on either
1938
+ // would gate on work this command did not do.
1527
1939
  failsWhen: (result) => result.results.some(
1528
- (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
1940
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
1529
1941
  )
1530
1942
  });
1943
+ function lineDelta(anchor, current) {
1944
+ return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
1945
+ }
1946
+ function headHash(source, anchor) {
1947
+ if (source.head === void 0) return void 0;
1948
+ const resolved = resolveAnchor(source.head, anchor);
1949
+ if (!resolved) return void 0;
1950
+ return {
1951
+ hash: hashAnchorText(resolved.text),
1952
+ lines: resolved.endLine - resolved.startLine + 1
1953
+ };
1954
+ }
1955
+ async function readSources(anchors, root, offline) {
1956
+ const origin = new LazyOrigin(root);
1957
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
1958
+ const foreign = new Map(
1959
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
1960
+ );
1961
+ const local = anchors.filter((anchor) => !foreign.get(anchor));
1962
+ const remote = anchors.filter((anchor) => foreign.get(anchor));
1963
+ const reads = await readAnchorFiles(
1964
+ local.map((anchor) => anchor.file),
1965
+ anchorFileReader(root)
1966
+ );
1967
+ const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
1968
+ offline
1969
+ });
1970
+ const sources = /* @__PURE__ */ new Map();
1971
+ for (const anchor of local) {
1972
+ const read = reads.get(anchor.file);
1973
+ sources.set(
1974
+ anchor,
1975
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
1976
+ );
1977
+ }
1978
+ for (const anchor of remote) {
1979
+ const repo = anchor.repo;
1980
+ const key = normalizeRepoUrl(repo);
1981
+ const atDefault = blobs.get(wantKey(key, void 0, anchor.file));
1982
+ const primary = anchor.ref ? blobs.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
1983
+ if (!primary?.ok) {
1984
+ sources.set(anchor, {
1985
+ ok: false,
1986
+ reason: primary?.ok === false ? primary.reason : "remote-unreachable",
1987
+ repo
1988
+ });
1989
+ continue;
1990
+ }
1991
+ sources.set(anchor, {
1992
+ ok: true,
1993
+ source: primary.source,
1994
+ repo,
1995
+ ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
1996
+ });
1997
+ }
1998
+ return sources;
1999
+ }
1531
2000
 
1532
2001
  // src/commands/answer.ts
1533
2002
  var import_zod7 = require("zod");
@@ -1535,7 +2004,7 @@ var answerCommand = define({
1535
2004
  name: "answer",
1536
2005
  tool: "kb_answer",
1537
2006
  usage: "answer <concept-id> <answer...>",
1538
- description: "Resolve an open question: sets the status, stamps who answered and when, and appends an Answer section. If the answer overturns an assumption or a decision, that is a supersession \u2014 do it explicitly.",
2007
+ description: "Resolve an open question: set status, stamp who and when, append an Answer section. If the answer overturns a decision or assumption, supersede that record explicitly.",
1539
2008
  input: import_zod7.z.object({ bundlePath, conceptId, answer: import_zod7.z.string().min(1) }),
1540
2009
  fromArgv: (argv, path) => ({
1541
2010
  bundlePath: path,
@@ -1604,23 +2073,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
1604
2073
  if (!record.frontmatter.verified?.length) {
1605
2074
  warnings.push({ kind: "unverified" });
1606
2075
  }
1607
- const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
1608
- (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
2076
+ const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
2077
+ (entry) => entry.state !== "match"
1609
2078
  );
2079
+ const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
2080
+ const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
1610
2081
  if (moved.length) {
2082
+ warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
2083
+ }
2084
+ if (unchecked2.length) {
1611
2085
  warnings.push({
1612
- kind: "drifted",
1613
- anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
1614
- file,
1615
- ...symbol !== void 0 ? { symbol } : {},
1616
- diffSize,
1617
- ...reason !== void 0 ? { reason } : {}
1618
- }))
2086
+ kind: "unchecked",
2087
+ anchors: unchecked2.map(warningAnchor)
1619
2088
  });
1620
2089
  }
1621
2090
  return { record, standing: STANDING[status], heads, warnings };
1622
2091
  });
1623
2092
  }
2093
+ function warningAnchor(entry) {
2094
+ const { file, symbol, diffSize, reason, repo, remoteState } = entry;
2095
+ return {
2096
+ file,
2097
+ ...symbol !== void 0 ? { symbol } : {},
2098
+ diffSize,
2099
+ ...reason !== void 0 ? { reason } : {},
2100
+ ...repo !== void 0 ? { repo } : {},
2101
+ ...remoteState !== void 0 ? { remoteState } : {}
2102
+ };
2103
+ }
1624
2104
  function resolveHeads(from, byId) {
1625
2105
  const warnings = [];
1626
2106
  const heads = /* @__PURE__ */ new Map();
@@ -1782,7 +2262,7 @@ function count(value, noun) {
1782
2262
  var import_zod10 = require("zod");
1783
2263
 
1784
2264
  // src/kb-context.ts
1785
- var import_promises3 = require("fs/promises");
2265
+ var import_promises4 = require("fs/promises");
1786
2266
 
1787
2267
  // src/kb-index.ts
1788
2268
  var INDEX_FILE = "INDEX.md";
@@ -1964,7 +2444,7 @@ async function buildContext(store, workspaceDir, options = {}) {
1964
2444
  operation: "kb.context.refused",
1965
2445
  approxTokens: total,
1966
2446
  budgetTokens,
1967
- bases: bases.map((base) => base.path)
2447
+ bases: bases.map((base2) => base2.path)
1968
2448
  });
1969
2449
  const refusal = [
1970
2450
  HEADING2,
@@ -1974,7 +2454,7 @@ async function buildContext(store, workspaceDir, options = {}) {
1974
2454
  "from a complete one. The pinned bases:",
1975
2455
  "",
1976
2456
  ...bases.map(
1977
- (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
2457
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
1978
2458
  ),
1979
2459
  "",
1980
2460
  "For the question at hand, read what you need now \u2014 `kb_load` a base",
@@ -2009,13 +2489,13 @@ function toHookJson(block, event) {
2009
2489
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2010
2490
  var CONTEXT_END = "<!-- strauss-kb:end -->";
2011
2491
  async function syncInstructions(file, block) {
2012
- const existing = await (0, import_promises3.readFile)(file, "utf8").catch(() => null);
2492
+ const existing = await (0, import_promises4.readFile)(file, "utf8").catch(() => null);
2013
2493
  const region = block ? `${CONTEXT_BEGIN}
2014
2494
  ${block.trim()}
2015
2495
  ${CONTEXT_END}` : null;
2016
2496
  if (existing === null) {
2017
2497
  if (!region) return { file, action: "unchanged" };
2018
- await (0, import_promises3.writeFile)(file, `${region}
2498
+ await (0, import_promises4.writeFile)(file, `${region}
2019
2499
  `, "utf8");
2020
2500
  return { file, action: "created" };
2021
2501
  }
@@ -2026,11 +2506,11 @@ ${CONTEXT_END}` : null;
2026
2506
  const after = existing.slice(end + CONTEXT_END.length);
2027
2507
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2028
2508
  if (next === existing) return { file, action: "unchanged" };
2029
- await (0, import_promises3.writeFile)(file, next, "utf8");
2509
+ await (0, import_promises4.writeFile)(file, next, "utf8");
2030
2510
  return { file, action: region ? "replaced" : "removed" };
2031
2511
  }
2032
2512
  if (!region) return { file, action: "unchanged" };
2033
- await (0, import_promises3.writeFile)(
2513
+ await (0, import_promises4.writeFile)(
2034
2514
  file,
2035
2515
  `${existing.replace(/\n*$/, "\n\n")}${region}
2036
2516
  `,
@@ -2044,7 +2524,7 @@ var contextCommand = define({
2044
2524
  name: "context",
2045
2525
  tool: "kb_context",
2046
2526
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
2047
- description: "The pinned-base index block, for injection at every context birth \u2014 startup, clear, resume, and after compaction. An index, not the content: concept ids, titles and standing, with the bodies left behind kb_load at the point of use. Emits nothing when nothing is pinned. Refuses with the list of bases and their sizes rather than truncating past its budget. Budgets resolve most-specific-first: explicit flags, then the workspace manifests' `context` tables (per profile, over their `default`), then the built-in profile (session-start, compact, turn), then package defaults \u2014 so a repo tunes its own numbers in .strauss/kb-pins.json without touching hook commands. Like kb_schema and kb_types this takes no bundlePath \u2014 it reads the workspace pin manifests, because which bases a session should see is workspace state, not a property of one base.",
2527
+ description: "Index block of pinned bases (ids, titles, standing) for injection at context birth. Takes no bundlePath \u2014 reads the workspace pin manifests. Empty when nothing is pinned; refuses over budget rather than truncating. Budget precedence: flags, then the manifest `context[profile]` over `context.default`, then the built-in profile, then package defaults.",
2048
2528
  input: import_zod10.z.object({
2049
2529
  budgetTokens: import_zod10.z.number().int().positive().optional().describe(
2050
2530
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
@@ -2247,6 +2727,16 @@ function validateBundle(records) {
2247
2727
  );
2248
2728
  }
2249
2729
  }
2730
+ for (const anchor of fm.strauss_anchors ?? []) {
2731
+ if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
2732
+ report(
2733
+ "anchor_repo",
2734
+ conceptId2,
2735
+ `anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
2736
+ "warning"
2737
+ );
2738
+ }
2739
+ }
2250
2740
  if (fm.strauss_assumption && fm.sources?.length) {
2251
2741
  report("assumption", conceptId2, "marked an assumption but cites sources");
2252
2742
  }
@@ -2266,7 +2756,8 @@ var CHECK_HEADLINES = {
2266
2756
  orphaned: "no other record links to it",
2267
2757
  "broken-supersession": "the supersession pointers do not resolve",
2268
2758
  "superseded-but-cited": "a live record's body links to one that no longer holds",
2269
- drifted: "the code an anchor points at moved out from under its hash"
2759
+ drifted: "the code an anchor points at moved out from under its hash",
2760
+ unchecked: "an anchor in another repository nothing could reach"
2270
2761
  };
2271
2762
  var DAY_MS = 864e5;
2272
2763
  function doctor(bundle, options = {}) {
@@ -2291,7 +2782,8 @@ function doctor(bundle, options = {}) {
2291
2782
  group("orphaned", orphaned(bundle)),
2292
2783
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
2293
2784
  group("superseded-but-cited", supersededButCited(bundle, standings)),
2294
- group("drifted", drifted(inForce))
2785
+ group("drifted", drifted(inForce)),
2786
+ group("unchecked", unchecked(inForce))
2295
2787
  ];
2296
2788
  const counts = Object.fromEntries(
2297
2789
  groups.map((entry) => [entry.check, entry.count])
@@ -2478,21 +2970,38 @@ function supersededButCited(bundle, standings) {
2478
2970
  return findings;
2479
2971
  }
2480
2972
  function drifted(hits) {
2973
+ return anchorFindings(
2974
+ hits,
2975
+ "drifted",
2976
+ (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
2977
+ );
2978
+ }
2979
+ function unchecked(hits) {
2980
+ return anchorFindings(
2981
+ hits,
2982
+ "unchecked",
2983
+ (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
2984
+ );
2985
+ }
2986
+ function anchorFindings(hits, kind, headline) {
2481
2987
  const findings = [];
2482
2988
  for (const hit of hits) {
2483
- const warning = hit.warnings.find((entry) => entry.kind === "drifted");
2989
+ const warning = hit.warnings.find(
2990
+ (entry) => entry.kind === kind
2991
+ );
2484
2992
  if (!warning) continue;
2993
+ const byRepo = /* @__PURE__ */ new Map();
2994
+ for (const anchor of warning.anchors) {
2995
+ const repo = anchor.repo ?? "";
2996
+ byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
2997
+ }
2998
+ const detail = [...byRepo.entries()].map(
2999
+ ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
3000
+ );
2485
3001
  findings.push(
2486
3002
  finding(
2487
3003
  hit.record,
2488
- `${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
2489
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
2490
- if (anchor.reason) return `${at} (${anchor.reason})`;
2491
- if (anchor.diffSize === null) {
2492
- return `${at} (changed, size unrecorded)`;
2493
- }
2494
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
2495
- }).join(", ")}`
3004
+ `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
2496
3005
  )
2497
3006
  );
2498
3007
  }
@@ -2500,6 +3009,15 @@ function drifted(hits) {
2500
3009
  (left, right) => left.conceptId.localeCompare(right.conceptId)
2501
3010
  );
2502
3011
  }
3012
+ function describeAnchor(anchor) {
3013
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3014
+ if (anchor.reason) return `${at} (${anchor.reason})`;
3015
+ if (anchor.remoteState === "drifted-on-default") {
3016
+ return `${at} (matches ref, moved on the default branch)`;
3017
+ }
3018
+ if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
3019
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3020
+ }
2503
3021
  function replaces(later, earlier) {
2504
3022
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
2505
3023
  }
@@ -2527,8 +3045,8 @@ var days = (what, fallback) => import_zod11.z.number().int().positive().optional
2527
3045
  var doctorCommand = define({
2528
3046
  name: "doctor",
2529
3047
  tool: "kb_doctor",
2530
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
2531
- description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
3048
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
3049
+ description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
2532
3050
  input: import_zod11.z.object({
2533
3051
  bundlePath,
2534
3052
  repoRoot: REPO_ROOT,
@@ -2544,6 +3062,9 @@ var doctorCommand = define({
2544
3062
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
2545
3063
  DEFAULT_AGING_DAYS
2546
3064
  ),
3065
+ offline: import_zod11.z.boolean().optional().describe(
3066
+ "Read foreign anchors from the local repo cache only, never fetching."
3067
+ ),
2547
3068
  strict: import_zod11.z.boolean().optional().describe(
2548
3069
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
2549
3070
  )
@@ -2563,13 +3084,23 @@ var doctorCommand = define({
2563
3084
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
2564
3085
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
2565
3086
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
3087
+ ...argv.includes("--offline") ? { offline: true } : {},
2566
3088
  ...argv.includes("--strict") ? { strict: true } : {}
2567
3089
  };
2568
3090
  },
2569
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
3091
+ run: async ({ store, now }, {
3092
+ bundlePath: path,
3093
+ expiringDays,
3094
+ unverifiedDays,
3095
+ agingDays,
3096
+ repoRoot,
3097
+ offline
3098
+ }) => {
2570
3099
  const checkedAt = now();
2571
3100
  const records = await store.list(path);
2572
- const anchorDrift = await store.detectDrift(records, repoRoot);
3101
+ const anchorDrift = await store.detectDrift(records, repoRoot, {
3102
+ offline: offline === true
3103
+ });
2573
3104
  const report = doctor(records, {
2574
3105
  ...expiringDays !== void 0 ? { expiringDays } : {},
2575
3106
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
@@ -2658,7 +3189,7 @@ var listCommand = define({
2658
3189
  name: "list",
2659
3190
  tool: "kb_list",
2660
3191
  usage: "list [type]",
2661
- description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
3192
+ description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
2662
3193
  input: import_zod13.z.object({ bundlePath, type: import_zod13.z.enum(KB_RECORD_TYPES).optional() }),
2663
3194
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
2664
3195
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
@@ -2676,7 +3207,7 @@ var loadCommand = define({
2676
3207
  name: "load",
2677
3208
  tool: "kb_load",
2678
3209
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
2679
- description: "Loads the whole knowledge base at once, each record with its standing. Superseded records arrive as stubs; rejected and open records arrive whole. Refuses past the token budget \u2014 call kb_catalog, kb_pack on it; `all` bypasses the budget. Never read record files directly. Cache-stable; `digest` is the base's content stamp \u2014 hooks use it to tell you when to reload.",
3210
+ description: "Load the whole base, each record with its standing \u2014 call it first, at the point of use, since compaction drops it. Superseded records arrive as stubs; kb_trace has the history. Over budget it refuses: kb_catalog, then kb_pack, or narrow with `type`; `all` bypasses. Never read record files directly \u2014 only kb_* tools resolve supersession. `digest` stamps the base's content, so hooks know when to reload.",
2680
3211
  input: import_zod14.z.object({
2681
3212
  bundlePath,
2682
3213
  type: import_zod14.z.enum(KB_RECORD_TYPES).optional(),
@@ -2728,7 +3259,7 @@ var logCommand = define({
2728
3259
  name: "log",
2729
3260
  tool: "kb_log",
2730
3261
  usage: "log",
2731
- description: "What touched what, and when. The only artifact here that cannot be reconstructed from the records, so malformed lines are reported rather than repaired.",
3262
+ description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
2732
3263
  input: import_zod15.z.object({ bundlePath }),
2733
3264
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2734
3265
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
@@ -2740,7 +3271,7 @@ var noDecisionCommand = define({
2740
3271
  name: "no-decision",
2741
3272
  tool: "kb_no_decision",
2742
3273
  usage: "no-decision <reason...>",
2743
- description: 'Claim in one sentence that there was nothing to decide. Gating on "did you write a decision?" rewards writing a junk one; gating on "did you answer?" does not, so silence has to be expressible. Idempotent \u2014 restating it is not a collision.',
3274
+ description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
2744
3275
  input: import_zod16.z.object({ bundlePath, reason: import_zod16.z.string().min(1) }),
2745
3276
  fromArgv: (argv, path) => ({
2746
3277
  bundlePath: path,
@@ -2763,7 +3294,7 @@ var packCommand = define({
2763
3294
  name: "pack",
2764
3295
  tool: "kb_pack",
2765
3296
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
2766
- description: "The bounded neighbourhood around one record: everything within `hops` of the root, ranked and cut to `maxNodes`, with every cut record named under Excluded \u2014 a named gap is knowable, a silent one is not. Prefer this over kb_load when the base is too large to hold whole and the work centres on one record; prefer it over kb_query when the question needs the governed neighbourhood \u2014 what was settled and what binds near this record \u2014 rather than a lookup by wording. Superseded records arrive as name, replacement and date stubs exactly as kb_load emits them: their bodies no longer hold, and kb_trace has the history. Refuses outright rather than truncating when the pack would exceed its token budget \u2014 a partial pack is indistinguishable from a complete one \u2014 reporting the record count and every already-cut id so the caller can lower hops or maxNodes, or raise the budget. The header carries the bundle, root, budget and a timestamp; everything below the header is byte-identical across runs over an unchanged base, so two packs can be diffed and a changed byte means changed knowledge. This tool (with kb_load, kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.",
3297
+ description: "Bounded neighbourhood around one record: within `hops`, ranked, cut to `maxNodes`, with every cut record named under Excluded. Use when the base is over kb_load's budget and the work centres on a record you can name. Refuses over budget rather than truncating. Everything below the header is byte-stable across runs. Resolves supersession like kb_load.",
2767
3298
  input: import_zod17.z.object({
2768
3299
  bundlePath,
2769
3300
  conceptId,
@@ -2863,7 +3394,7 @@ var pinCommand = define({
2863
3394
  name: "pin",
2864
3395
  tool: "kb_pin",
2865
3396
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
2866
- description: "Pin a base into a workspace pin manifest, so `context` surfaces it at every context birth. Three layers, nearest wins: the committed project manifest (.strauss/kb-pins.json, the default), `--local` (.strauss/kb-pins.local.json, personal and gitignored), and `--user` (~/.strauss/kb-pins.json, every workspace). Idempotent \u2014 re-pinning changes nothing unless --mode, --profiles, or --frozen/--unfreeze are given, which update just those fields. `--mode full` preloads the whole base into the block regardless of the full-under threshold; `--mode index` never upgrades. `--profiles` scopes the pin to named context profiles. `--frozen` marks the base concluded: write commands against it refuse and `context` labels it read-only. A path with no records yet succeeds with a warning; bases are routinely pinned before they are populated. Pins are workspace state: the pinned base itself is never touched.",
3397
+ description: "Pin a base into a workspace manifest so kb_context surfaces it. Layers, nearest wins: project `.strauss/kb-pins.json` (default), `--local` (personal, gitignored), `--user` (`~/.strauss`). Idempotent; `--mode full|index`, `--profiles`, `--frozen`/`--unfreeze` update only those fields. A path with no records pins with a warning. Never touches the base itself.",
2867
3398
  input: import_zod18.z.object({
2868
3399
  bundlePath,
2869
3400
  mode: import_zod18.z.enum(["full", "index"]).optional().describe(
@@ -2907,7 +3438,7 @@ var pinsCommand = define({
2907
3438
  name: "pins",
2908
3439
  tool: "kb_pins",
2909
3440
  usage: "pins",
2910
- description: "Every pinned base across the manifest layers, each with its layer and whether it currently resolves to readable records. Reads the workspace manifests rather than any one base, like kb_context.",
3441
+ description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
2911
3442
  input: import_zod19.z.object({}),
2912
3443
  fromArgv: () => ({}),
2913
3444
  run: ({ store }) => listPins(store, process.cwd())
@@ -2961,7 +3492,7 @@ var readIndexCommand = define({
2961
3492
  name: "index",
2962
3493
  tool: "kb_index",
2963
3494
  usage: "index",
2964
- description: "The index, rebuilt if it disagrees with the records. One call gives the whole shape of the base: title, type, status, and description per record. The cheap re-orientation call after compaction or deep in a long session \u2014 a few hundred tokens; call it (or kb_context, when bases are pinned) first, then kb_load or fetch by concept id.",
3495
+ description: "The index \u2014 title, type, status, description per record \u2014 rebuilt if stale. Cheapest re-orientation after compaction: call it (or kb_context) first, then kb_load or fetch by id.",
2965
3496
  input: import_zod21.z.object({ bundlePath }),
2966
3497
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2967
3498
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
@@ -3041,7 +3572,7 @@ var schemaCommand = define({
3041
3572
  name: "schema",
3042
3573
  tool: "kb_schema",
3043
3574
  usage: "schema",
3044
- description: "JSON Schema for the frontmatter, the write input, and log entries \u2014 generated from the code that enforces them, so it cannot drift from what a write will accept.",
3575
+ description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
3045
3576
  input: import_zod24.z.object({}),
3046
3577
  fromArgv: () => ({}),
3047
3578
  run: () => Promise.resolve(kbJsonSchemas())
@@ -3053,7 +3584,7 @@ var statusCommand = define({
3053
3584
  name: "status",
3054
3585
  tool: "kb_status",
3055
3586
  usage: "status <concept-id> <status>",
3056
- description: "Move a record's status, leaving everything else alone. Uses a compare-and-swap, so a concurrent change fails loudly rather than being overwritten.",
3587
+ description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
3057
3588
  input: import_zod25.z.object({
3058
3589
  bundlePath,
3059
3590
  conceptId,
@@ -3077,7 +3608,7 @@ var supersedeCommand = define({
3077
3608
  name: "supersede",
3078
3609
  tool: "kb_supersede",
3079
3610
  usage: "supersede <concept-id> <replacement-id>",
3080
- description: "Mark a record superseded by another, linking both directions. Use this rather than editing a record whose meaning changed \u2014 a record that quietly becomes something else invalidates every reference to it, and the earlier understanding is what a later trace needs.",
3611
+ description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
3081
3612
  input: import_zod26.z.object({ bundlePath, conceptId, replacementId: conceptId }),
3082
3613
  fromArgv: (argv, path) => ({
3083
3614
  bundlePath: path,
@@ -3096,7 +3627,7 @@ var import_zod27 = require("zod");
3096
3627
  var syncInstructionsCommand = define({
3097
3628
  name: "sync-instructions",
3098
3629
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
3099
- description: "Idempotently plant the `context` block between sentinel comments in an instruction file (AGENTS.md, CLAUDE.md), creating the block when absent and leaving everything outside the sentinels alone. CLI-only: this is file plumbing for runtimes whose instruction files are re-read where their conversations are not, not an agent capability \u2014 the capability is kb_context.",
3630
+ description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
3100
3631
  input: import_zod27.z.object({
3101
3632
  file: import_zod27.z.string().min(1).describe("The instruction file to edit in place."),
3102
3633
  budgetTokens: import_zod27.z.number().int().positive().optional(),
@@ -3182,7 +3713,7 @@ var traceCommand = define({
3182
3713
  name: "trace",
3183
3714
  tool: "kb_trace",
3184
3715
  usage: "trace <concept-id> [edges...]",
3185
- description: 'How a position was arrived at, as a timeline ordered by when each record was written. Deliberately includes rejected, draft, and superseded records \u2014 in a history those are the content, not noise. Follows supersession, shared code anchors, and shared sources. Use when the question is "why is this the way it is" rather than "what do we hold now". This tool (with kb_load and kb_query) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.',
3716
+ description: 'Timeline of how a position was reached, ordered by write time, following supersession, shared anchors and shared sources. Includes rejected, draft and superseded records \u2014 in a history they are the content. For "why is it like this"; kb_load answers "what holds now".',
3186
3717
  input: import_zod28.z.object({
3187
3718
  bundlePath,
3188
3719
  conceptId,
@@ -3226,7 +3757,7 @@ var unpinCommand = define({
3226
3757
  name: "unpin",
3227
3758
  tool: "kb_unpin",
3228
3759
  usage: "unpin [bundle-path]",
3229
- description: "Remove a base from every pin manifest layer that holds it \u2014 project, local, and user \u2014 because unpinned means gone, not still injected from another file. Reports which layers were touched.",
3760
+ description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
3230
3761
  input: import_zod30.z.object({ bundlePath }),
3231
3762
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
3232
3763
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
@@ -3254,7 +3785,7 @@ var verifyCommand = define({
3254
3785
  name: "verify",
3255
3786
  tool: "kb_verify",
3256
3787
  usage: "verify <concept-id> --note <text>",
3257
- description: "Append one verified[] event \u2014 who checked the record, when, and what the check found. Appends only; prior events are never rewritten. A record's own generator is refused unless the actor is human: re-reading your own output is not an independent check.",
3788
+ description: "Append a verified[] event: who checked, when, and what was found. Append-only. A record's own generator is refused unless the actor is `human:`-prefixed.",
3258
3789
  input: import_zod32.z.object({
3259
3790
  bundlePath,
3260
3791
  conceptId,
@@ -3283,15 +3814,7 @@ var writeCommand = define({
3283
3814
  name: "write",
3284
3815
  tool: "kb_write",
3285
3816
  usage: "write <type> < record.json",
3286
- description: [
3287
- "Write one record. Search first \u2014 the same knowledge filed twice under different slugs is how a base rots, and a duplicate concept id is rejected rather than overwritten. Call kb_types for the sections each type accepts.",
3288
- "",
3289
- "Judgment the tool cannot enforce for you:",
3290
- "- An unsourced claim is an `assumption` record with assumption: true, never a `fact` with a vague source. The distinction is what lets a later reader separate what was established from what was guessed.",
3291
- "- When two records conflict, say so in a `risk`, an `open-question`, or a superseding `decision`. Quietly picking a winner destroys the disagreement, which is usually the useful part.",
3292
- "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
3293
- "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
3294
- ].join("\n"),
3817
+ 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.",
3295
3818
  input: import_zod33.z.object({
3296
3819
  bundlePath,
3297
3820
  type: import_zod33.z.enum(KB_RECORD_TYPES),
@@ -3323,14 +3846,7 @@ var writeDecisionCommand = define({
3323
3846
  name: "write-decision",
3324
3847
  tool: "kb_write_decision",
3325
3848
  usage: "write-decision < decision.json",
3326
- description: [
3327
- "Write a decision. Takes `alternative` and `impact` as fields rather than free sections, because what was rejected is the part a later reader cannot reconstruct from the code \u2014 a heading is too easy to leave empty.",
3328
- "",
3329
- "What belongs in one:",
3330
- '- Record a decision when a later reader would otherwise "simplify" the constraint away. If the diff already answers the question, there is nothing here to write.',
3331
- "- `alternative` is what you turned down and why, not a list of everything considered.",
3332
- "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
3333
- ].join("\n"),
3849
+ 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.",
3334
3850
  input: import_zod34.z.object({ bundlePath, input: decisionInputSchema }),
3335
3851
  fromArgv: async (_argv, path, stdin) => ({
3336
3852
  bundlePath: path,
@@ -3387,8 +3903,8 @@ var KB_COMMANDS_BY_NAME = new Map(
3387
3903
 
3388
3904
  // src/kb-store.ts
3389
3905
  var import_node_crypto2 = require("crypto");
3390
- var import_promises5 = require("fs/promises");
3391
- var import_node_path7 = require("path");
3906
+ var import_promises6 = require("fs/promises");
3907
+ var import_node_path8 = require("path");
3392
3908
 
3393
3909
  // src/markdown.ts
3394
3910
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -3416,8 +3932,8 @@ function parseMarkdownWithFrontmatter(text, schema) {
3416
3932
  }
3417
3933
 
3418
3934
  // src/search-index.ts
3419
- var import_promises4 = require("fs/promises");
3420
- var import_node_path6 = require("path");
3935
+ var import_promises5 = require("fs/promises");
3936
+ var import_node_path7 = require("path");
3421
3937
  var SEARCH_INDEX_FILE = ".index.sqlite";
3422
3938
  var COLLECTION = "kb";
3423
3939
  async function searchBase(bundlePath2, query, options = {}) {
@@ -3426,7 +3942,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3426
3942
  let store = null;
3427
3943
  try {
3428
3944
  store = await qmd.createStore({
3429
- dbPath: (0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE),
3945
+ dbPath: (0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE),
3430
3946
  config: {
3431
3947
  collections: {
3432
3948
  [COLLECTION]: {
@@ -3461,7 +3977,7 @@ async function searchBase(bundlePath2, query, options = {}) {
3461
3977
  }
3462
3978
  }
3463
3979
  async function isStale(bundlePath2) {
3464
- const indexAt = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3980
+ const indexAt = await (0, import_promises5.stat)((0, import_node_path7.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3465
3981
  if (!indexAt) return true;
3466
3982
  const { readdir: readdir2 } = await import("fs/promises");
3467
3983
  const names = (await readdir2(bundlePath2).catch(() => [])).filter(
@@ -3470,7 +3986,7 @@ async function isStale(bundlePath2) {
3470
3986
  let stale = false;
3471
3987
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
3472
3988
  if (stale) return;
3473
- const at = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3989
+ const at = await (0, import_promises5.stat)((0, import_node_path7.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3474
3990
  if (at > indexAt) stale = true;
3475
3991
  });
3476
3992
  return stale;
@@ -3748,7 +4264,7 @@ function appendUnionMergeLine(contents) {
3748
4264
  }
3749
4265
 
3750
4266
  // src/kb-store.ts
3751
- var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
4267
+ var KB_DIR = (0, import_node_path8.join)(".strauss", "kb");
3752
4268
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
3753
4269
  var DEFAULT_LOAD_BUDGET = 25e3;
3754
4270
  var KbStore = class {
@@ -3779,7 +4295,7 @@ var KbStore = class {
3779
4295
  const conceptId2 = `${input.type}.${input.slug}`;
3780
4296
  const root = this.root(bundlePath2);
3781
4297
  const target = this.recordPath(bundlePath2, conceptId2);
3782
- await (0, import_promises5.mkdir)(root, { recursive: true });
4298
+ await (0, import_promises6.mkdir)(root, { recursive: true });
3783
4299
  await this.publish(
3784
4300
  target,
3785
4301
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -3818,7 +4334,7 @@ var KbStore = class {
3818
4334
  const target = this.recordPath(bundlePath2, conceptId2);
3819
4335
  let raw;
3820
4336
  try {
3821
- raw = await (0, import_promises5.readFile)(target, "utf8");
4337
+ raw = await (0, import_promises6.readFile)(target, "utf8");
3822
4338
  } catch {
3823
4339
  return null;
3824
4340
  }
@@ -3835,7 +4351,7 @@ var KbStore = class {
3835
4351
  const root = this.root(bundlePath2);
3836
4352
  let names;
3837
4353
  try {
3838
- names = await (0, import_promises5.readdir)(root);
4354
+ names = await (0, import_promises6.readdir)(root);
3839
4355
  } catch {
3840
4356
  return [];
3841
4357
  }
@@ -3843,7 +4359,7 @@ var KbStore = class {
3843
4359
  const records = await mapLimit(
3844
4360
  wanted,
3845
4361
  DEFAULT_IO_CONCURRENCY,
3846
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises5.readFile)((0, import_node_path7.join)(root, name), "utf8"))
4362
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises6.readFile)((0, import_node_path8.join)(root, name), "utf8"))
3847
4363
  );
3848
4364
  return records.filter((record) => record !== null);
3849
4365
  }
@@ -4011,7 +4527,8 @@ ${answer}
4011
4527
  * at the repo root, and the MCP server's cwd is the workspace.
4012
4528
  *
4013
4529
  * Public because `doctor` needs the same map with the same degradation: a
4014
- * sweep that failed to read the tree should report no drift, not fail.
4530
+ * sweep that failed to read the tree should report no drift, not fail — and
4531
+ * `offline: false` there, because a sweep is worth a fetch.
4015
4532
  *
4016
4533
  * When no root was given and not one anchored file was found, the finding is
4017
4534
  * discarded. A base read from somewhere other than the tree it describes
@@ -4023,10 +4540,13 @@ ${answer}
4023
4540
  * plausible, and the misses become findings again; an explicit `repoRoot` is
4024
4541
  * taken at its word either way.
4025
4542
  */
4026
- async detectDrift(records, repoRoot) {
4543
+ async detectDrift(records, repoRoot, options = {}) {
4027
4544
  try {
4028
4545
  const drift = await detectAnchorDrift(records, {
4029
- repoRoot: repoRoot ?? process.cwd()
4546
+ repoRoot: repoRoot ?? process.cwd(),
4547
+ // Offline by default: a read path must never spend a network fetch per
4548
+ // call. `doctor` and `anchor-resolve` are the verbs that go get it.
4549
+ remote: { offline: options.offline !== false }
4030
4550
  });
4031
4551
  if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
4032
4552
  this.logger.warn?.({
@@ -4144,11 +4664,11 @@ ${answer}
4144
4664
  async readIndex(bundlePath2) {
4145
4665
  const root = this.root(bundlePath2);
4146
4666
  const expected = renderIndex(await this.list(bundlePath2));
4147
- const stored = await (0, import_promises5.readFile)((0, import_node_path7.join)(root, INDEX_FILE), "utf8").catch(
4667
+ const stored = await (0, import_promises6.readFile)((0, import_node_path8.join)(root, INDEX_FILE), "utf8").catch(
4148
4668
  () => null
4149
4669
  );
4150
4670
  if (indexIsStale(stored, expected)) {
4151
- await this.publish((0, import_node_path7.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
4671
+ await this.publish((0, import_node_path8.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
4152
4672
  this.logger.info?.({
4153
4673
  operation: "kb.index.repair",
4154
4674
  bundlePath: root,
@@ -4165,8 +4685,8 @@ ${answer}
4165
4685
  * knows which agent touched what. So a bad line is surfaced and left alone.
4166
4686
  */
4167
4687
  async readLog(bundlePath2) {
4168
- const raw = await (0, import_promises5.readFile)(
4169
- (0, import_node_path7.join)(this.root(bundlePath2), LOG_FILE),
4688
+ const raw = await (0, import_promises6.readFile)(
4689
+ (0, import_node_path8.join)(this.root(bundlePath2), LOG_FILE),
4170
4690
  "utf8"
4171
4691
  ).catch(() => "");
4172
4692
  const result = parseLog(raw);
@@ -4217,14 +4737,14 @@ ${answer}
4217
4737
  }
4218
4738
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
4219
4739
  const target = this.recordPath(bundlePath2, conceptId2);
4220
- const before = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
4740
+ const before = await (0, import_promises6.readFile)(target, "utf8").catch(() => null);
4221
4741
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
4222
4742
  const parsed = this.parse(conceptId2, before);
4223
4743
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
4224
4744
  const frontmatter = change(parsed.frontmatter);
4225
4745
  const body = changeBody(parsed.body);
4226
4746
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
4227
- const witness = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
4747
+ const witness = await (0, import_promises6.readFile)(target, "utf8").catch(() => null);
4228
4748
  if (witness === null || digest(witness) !== digest(before)) {
4229
4749
  throw new KbWriteConflictError(conceptId2);
4230
4750
  }
@@ -4250,20 +4770,20 @@ ${answer}
4250
4770
  */
4251
4771
  async publish(target, contents, overwrite, conceptId2) {
4252
4772
  const staging = `${target}.${process.pid}.tmp`;
4253
- await (0, import_promises5.writeFile)(staging, contents, "utf8");
4773
+ await (0, import_promises6.writeFile)(staging, contents, "utf8");
4254
4774
  try {
4255
4775
  if (overwrite) {
4256
- await (0, import_promises5.rename)(staging, target);
4776
+ await (0, import_promises6.rename)(staging, target);
4257
4777
  return;
4258
4778
  }
4259
- await (0, import_promises5.link)(staging, target);
4779
+ await (0, import_promises6.link)(staging, target);
4260
4780
  } catch (error) {
4261
4781
  if (error.code === "EEXIST") {
4262
4782
  throw new KbRecordAlreadyExistsError(conceptId2);
4263
4783
  }
4264
4784
  throw error;
4265
4785
  } finally {
4266
- await (0, import_promises5.unlink)(staging).catch(() => void 0);
4786
+ await (0, import_promises6.unlink)(staging).catch(() => void 0);
4267
4787
  }
4268
4788
  }
4269
4789
  /**
@@ -4307,18 +4827,18 @@ ${answer}
4307
4827
  * file must not fail the mutation it guards.
4308
4828
  */
4309
4829
  async ensureGitattributes(root) {
4310
- const target = (0, import_node_path7.join)(root, GITATTRIBUTES_FILE);
4830
+ const target = (0, import_node_path8.join)(root, GITATTRIBUTES_FILE);
4311
4831
  try {
4312
4832
  let existing;
4313
4833
  try {
4314
- existing = await (0, import_promises5.readFile)(target, "utf8");
4834
+ existing = await (0, import_promises6.readFile)(target, "utf8");
4315
4835
  } catch (error) {
4316
4836
  if (error.code !== "ENOENT") throw error;
4317
4837
  existing = null;
4318
4838
  }
4319
4839
  if (existing === null) {
4320
4840
  try {
4321
- await (0, import_promises5.writeFile)(target, appendUnionMergeLine(""), {
4841
+ await (0, import_promises6.writeFile)(target, appendUnionMergeLine(""), {
4322
4842
  encoding: "utf8",
4323
4843
  flag: "wx"
4324
4844
  });
@@ -4339,7 +4859,7 @@ ${answer}
4339
4859
  return;
4340
4860
  }
4341
4861
  if (!hasMergeDeclaration(existing)) {
4342
- await (0, import_promises5.appendFile)(target, appendUnionMergeLine(existing), "utf8");
4862
+ await (0, import_promises6.appendFile)(target, appendUnionMergeLine(existing), "utf8");
4343
4863
  this.logger.info?.({
4344
4864
  operation: "kb.gitattributes.ensure",
4345
4865
  bundlePath: root,
@@ -4358,7 +4878,7 @@ ${answer}
4358
4878
  async record(root, entry) {
4359
4879
  await this.ensureGitattributes(root);
4360
4880
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4361
- await (0, import_promises5.appendFile)((0, import_node_path7.join)(root, LOG_FILE), line, "utf8").catch((error) => {
4881
+ await (0, import_promises6.appendFile)((0, import_node_path8.join)(root, LOG_FILE), line, "utf8").catch((error) => {
4362
4882
  this.logger.warn?.({
4363
4883
  operation: "kb.log.append",
4364
4884
  outcome: "failed",
@@ -4384,18 +4904,18 @@ ${answer}
4384
4904
  };
4385
4905
  }
4386
4906
  root(bundlePath2) {
4387
- return (0, import_node_path7.resolve)(bundlePath2);
4907
+ return (0, import_node_path8.resolve)(bundlePath2);
4388
4908
  }
4389
4909
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
4390
4910
  // bundle root; anything carrying a separator would escape it.
4391
4911
  recordPath(bundlePath2, conceptId2) {
4392
- if (conceptId2.includes(import_node_path7.sep) || conceptId2.includes("/")) {
4912
+ if (conceptId2.includes(import_node_path8.sep) || conceptId2.includes("/")) {
4393
4913
  throw new KbInvalidConceptIdError(
4394
4914
  "concept id must not contain a path separator",
4395
4915
  { conceptId: conceptId2 }
4396
4916
  );
4397
4917
  }
4398
- return (0, import_node_path7.join)(this.root(bundlePath2), `${conceptId2}.md`);
4918
+ return (0, import_node_path8.join)(this.root(bundlePath2), `${conceptId2}.md`);
4399
4919
  }
4400
4920
  };
4401
4921
  function estimateTokens(record) {
@@ -4454,7 +4974,7 @@ function bundleDigest(records, superseded) {
4454
4974
  }
4455
4975
 
4456
4976
  // src/version.ts
4457
- var VERSION = true ? "0.1.13" : "0.0.0-dev";
4977
+ var VERSION = true ? "0.1.15" : "0.0.0-dev";
4458
4978
 
4459
4979
  // src/mcp.ts
4460
4980
  function createKbMcpServer() {