@saasontools/strauss-kb 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-main.cjs CHANGED
@@ -8,9 +8,9 @@ var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
9
  var __copyProps = (to, from, except, desc) => {
10
10
  if (from && typeof from === "object" || typeof from === "function") {
11
- for (let key of __getOwnPropNames(from))
12
- if (!__hasOwnProp.call(to, key) && key !== except)
13
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
11
+ for (let key2 of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key2) && key2 !== except)
13
+ __defProp(to, key2, { get: () => from[key2], enumerable: !(desc = __getOwnPropDesc(from, key2)) || desc.enumerable });
14
14
  }
15
15
  return to;
16
16
  };
@@ -28,7 +28,7 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
28
28
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
29
 
30
30
  // src/cli.ts
31
- var import_node_path12 = require("path");
31
+ var import_node_path15 = require("path");
32
32
 
33
33
  // src/decision-record.ts
34
34
  var import_zod3 = require("zod");
@@ -54,9 +54,24 @@ var kbVerifiedEventSchema = kbActorStampSchema.extend({
54
54
  message: "note must say what the check found"
55
55
  })
56
56
  });
57
+ var kbAnchorSpanSchema = import_zod.z.object({
58
+ start: import_zod.z.number().int().positive(),
59
+ end: import_zod.z.number().int().positive()
60
+ }).strict();
57
61
  var kbAnchorSchema = import_zod.z.object({
58
62
  file: import_zod.z.string().min(1),
59
63
  symbol: import_zod.z.string().min(1).optional(),
64
+ /**
65
+ * The lines the concept names, when no symbol covers them — deleted code,
66
+ * YAML, SQL, Markdown. Alternative to `symbol`, never a refinement of it.
67
+ */
68
+ span: kbAnchorSpanSchema.optional(),
69
+ /**
70
+ * Which side of the change the anchor describes. `old` is code as it was
71
+ * committed at `ref`, which is the only way to anchor something deleted;
72
+ * absent means the working tree.
73
+ */
74
+ side: import_zod.z.enum(["old", "new"]).optional(),
60
75
  /**
61
76
  * Which repository the file lives in — a remote URL
62
77
  * (`https://github.com/org/name`) or a short name. Absent means the base's
@@ -95,8 +110,38 @@ var kbAnchorSchema = import_zod.z.object({
95
110
  * before resolvers were named, which is read as `regex` — the only one
96
111
  * there was. A hash from a different resolver is drift, not a match.
97
112
  */
98
- resolver: import_zod.z.enum(["tree-sitter", "regex"]).optional()
113
+ resolver: import_zod.z.enum(["tree-sitter", "regex", "span"]).optional()
99
114
  }).strict();
115
+ var kbAnchorWriteSchema = kbAnchorSchema.superRefine((anchor, ctx) => {
116
+ if (anchor.span && anchor.symbol) {
117
+ ctx.addIssue({
118
+ code: import_zod.z.ZodIssueCode.custom,
119
+ path: ["span"],
120
+ message: "an anchor names a symbol or a span, not both"
121
+ });
122
+ }
123
+ if (anchor.span && anchor.span.end < anchor.span.start) {
124
+ ctx.addIssue({
125
+ code: import_zod.z.ZodIssueCode.custom,
126
+ path: ["span", "end"],
127
+ message: "span end must not precede start"
128
+ });
129
+ }
130
+ if (anchor.span && anchor.hash_kind === "ast") {
131
+ ctx.addIssue({
132
+ code: import_zod.z.ZodIssueCode.custom,
133
+ path: ["hash_kind"],
134
+ message: "a span is hashed raw, never ast"
135
+ });
136
+ }
137
+ if (anchor.side === "old" && !anchor.ref) {
138
+ ctx.addIssue({
139
+ code: import_zod.z.ZodIssueCode.custom,
140
+ path: ["ref"],
141
+ message: 'side: "old" needs a ref \u2014 committed code has no other address'
142
+ });
143
+ }
144
+ });
100
145
  var kbLinkSchema = import_zod.z.object({
101
146
  target: import_zod.z.string().min(1),
102
147
  rel: import_zod.z.string().min(1)
@@ -312,7 +357,7 @@ var composeInputSchema = import_zod2.z.object({
312
357
  why: import_zod2.z.string().min(1),
313
358
  /** Keyed by section heading from the type's spec. Unknown keys rejected. */
314
359
  sections: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string().min(1)).optional(),
315
- anchors: import_zod2.z.array(kbAnchorSchema).optional(),
360
+ anchors: import_zod2.z.array(kbAnchorWriteSchema).optional(),
316
361
  sources: import_zod2.z.array(kbSourceSchema).optional(),
317
362
  /** No source exists, as a claim rather than a sentinel in `sources`. */
318
363
  assumption: import_zod2.z.boolean().optional(),
@@ -457,6 +502,14 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
457
502
  writtenAt
458
503
  );
459
504
  }
505
+ function isNoDecisionRecord(record) {
506
+ return record.conceptId === `${DECISION_TYPE}.${NO_DECISION_SLUG}`;
507
+ }
508
+ function selectDecisions(records) {
509
+ return records.filter(
510
+ (record) => record.conceptId.startsWith(`${DECISION_TYPE}.`) && !isNoDecisionRecord(record)
511
+ );
512
+ }
460
513
 
461
514
  // src/commands/anchor-resolve.ts
462
515
  var import_zod7 = require("zod");
@@ -490,14 +543,258 @@ async function mapLimit(items, limit, fn) {
490
543
  return out;
491
544
  }
492
545
 
546
+ // src/drift/git.ts
547
+ var import_node_child_process2 = require("child_process");
548
+ var import_node_util2 = require("util");
549
+
550
+ // src/remote-repo/git.ts
551
+ var import_node_child_process = require("child_process");
552
+ var import_node_util = require("util");
553
+
554
+ // src/anchor-resolver/model.ts
555
+ var MAX_ANCHOR_FILE_BYTES = 1048576;
556
+
557
+ // src/remote-repo/git.ts
558
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
559
+ function childEnv() {
560
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
561
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
562
+ delete env[name];
563
+ }
564
+ return env;
565
+ }
566
+ async function git(args, options = {}) {
567
+ try {
568
+ const { stdout, stderr } = await execFileAsync("git", args, {
569
+ ...options.cwd ? { cwd: options.cwd } : {},
570
+ timeout: options.timeoutMs ?? 3e4,
571
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
572
+ encoding: "utf8",
573
+ windowsHide: true,
574
+ env: childEnv()
575
+ });
576
+ return { ok: true, stdout, stderr, overflowed: false };
577
+ } catch (error) {
578
+ const failure = error;
579
+ return {
580
+ ok: false,
581
+ stdout: failure.stdout ?? "",
582
+ stderr: failure.stderr ?? "",
583
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
584
+ };
585
+ }
586
+ }
587
+ function transportReason(stderr) {
588
+ const text = stderr.toLowerCase();
589
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
590
+ return "repo-unauthorized";
591
+ }
592
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
593
+ return "ref-not-found";
594
+ }
595
+ return "remote-unreachable";
596
+ }
597
+
598
+ // src/remote-repo/validate.ts
599
+ var MAX_REF_LENGTH = 200;
600
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
601
+ function refShapeIsSafe(ref) {
602
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
603
+ if (ref.includes("..")) return false;
604
+ return REF_SHAPE.test(ref);
605
+ }
606
+ function localRevShapeIsSafe(rev) {
607
+ if (!rev || rev.length > MAX_REF_LENGTH) return false;
608
+ if (rev.includes("..")) return false;
609
+ return /^[A-Za-z0-9][A-Za-z0-9._/^~-]*$/.test(rev);
610
+ }
611
+ async function refIsWellFormed(ref) {
612
+ if (!refShapeIsSafe(ref)) return false;
613
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
614
+ return checked.ok;
615
+ }
616
+ function filePathIsSafe(file) {
617
+ const path = file.replace(/^\.\//, "");
618
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
619
+ return !path.split("/").includes("..");
620
+ }
621
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
622
+ function allowedProtocols() {
623
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
624
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
625
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
626
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
627
+ }
628
+ function isShortRepoName(repo) {
629
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
630
+ }
631
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
632
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
633
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
634
+ function repoUrlIsSafe(repo) {
635
+ const url = repo.trim();
636
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
637
+ const allowed = allowedProtocols();
638
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
639
+ const scheme = URL_SCHEME.exec(url);
640
+ if (!scheme?.[1]) return false;
641
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
642
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
643
+ const at2 = authority.lastIndexOf("@");
644
+ return at2 < 0 || !authority.slice(0, at2).includes(":");
645
+ }
646
+ function protocolArgs() {
647
+ const allowed = allowedProtocols();
648
+ return [
649
+ "-c",
650
+ "protocol.ext.allow=never",
651
+ "-c",
652
+ `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
653
+ ];
654
+ }
655
+
656
+ // src/drift/git.ts
657
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
658
+ var MAX_GIT_OUTPUT_BYTES = 1048576;
659
+ var MAX_RANGE_DIFF_BYTES = 8 * 1048576;
660
+ var GIT_TIMEOUT_MS = 5e3;
661
+ var RANGE_DIFF_TIMEOUT_MS = 2e4;
662
+ async function git2(cwd, args, limits = {}) {
663
+ const env = { ...process.env };
664
+ delete env["GIT_DIR"];
665
+ delete env["GIT_WORK_TREE"];
666
+ delete env["GIT_INDEX_FILE"];
667
+ try {
668
+ const { stdout } = await execFileAsync2("git", ["-C", cwd, ...args], {
669
+ timeout: limits.timeoutMs ?? GIT_TIMEOUT_MS,
670
+ maxBuffer: limits.maxBytes ?? MAX_GIT_OUTPUT_BYTES,
671
+ env
672
+ });
673
+ return { ok: true, stdout };
674
+ } catch (error) {
675
+ return { ok: false, reason: failureOf(error) };
676
+ }
677
+ }
678
+ function failureOf(error) {
679
+ const { code, killed } = error;
680
+ if (code === "ENOENT") return "git-missing";
681
+ if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "too-large";
682
+ if (killed === true) return "timeout";
683
+ return "failed";
684
+ }
685
+ async function readFileAtRef(repoRoot, anchor) {
686
+ if (!filePathIsSafe(anchor.file))
687
+ return { ok: false, reason: "outside-repo" };
688
+ if (!anchor.ref || !refShapeIsSafe(anchor.ref)) {
689
+ return { ok: false, reason: "ref-unreadable" };
690
+ }
691
+ const blob = await catBlob(repoRoot, anchor.ref, anchor.file);
692
+ if (blob !== null) return { ok: true, source: blob };
693
+ return {
694
+ ok: false,
695
+ reason: await hasCommit(repoRoot, anchor.ref) ? "ref-unreadable" : "ref-unavailable"
696
+ };
697
+ }
698
+ async function hasCommit(repoRoot, ref) {
699
+ const found = await git2(repoRoot, [
700
+ "cat-file",
701
+ "-e",
702
+ "--end-of-options",
703
+ `${ref}^{commit}`
704
+ ]);
705
+ return found.ok;
706
+ }
707
+ async function listRepoFiles(repoRoot) {
708
+ const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
709
+ if (!result.ok) return [];
710
+ return result.stdout.split("\0").filter(Boolean);
711
+ }
712
+ var DIFF_RANGE = /^(.+?)(\.{2,3})(.+)$/;
713
+ async function readRangeDiff(repoRoot, range, maxBytes = MAX_RANGE_DIFF_BYTES) {
714
+ const parts = DIFF_RANGE.exec(range);
715
+ if (!parts) return { ok: false, reason: "bad-range" };
716
+ const [, base2 = "", dots = "", head = ""] = parts;
717
+ if (!localRevShapeIsSafe(base2) || !localRevShapeIsSafe(head)) {
718
+ return { ok: false, reason: "bad-range" };
719
+ }
720
+ const result = await git2(
721
+ repoRoot,
722
+ [
723
+ "-c",
724
+ "core.quotePath=false",
725
+ "diff",
726
+ "--unified=0",
727
+ "--no-color",
728
+ "--no-ext-diff",
729
+ "--no-textconv",
730
+ "--find-renames",
731
+ "--src-prefix=a/",
732
+ "--dst-prefix=b/",
733
+ "--end-of-options",
734
+ `${base2}${dots}${head}`,
735
+ "--"
736
+ ],
737
+ { maxBytes, timeoutMs: RANGE_DIFF_TIMEOUT_MS }
738
+ );
739
+ if (result.ok) return { ok: true, text: result.stdout };
740
+ return {
741
+ ok: false,
742
+ reason: result.reason === "failed" ? "bad-range" : result.reason
743
+ };
744
+ }
745
+ async function readOldSource(repoRoot, anchor) {
746
+ if (!filePathIsSafe(anchor.file))
747
+ return { ok: false, reason: "unrecoverable" };
748
+ if (anchor.ref && refShapeIsSafe(anchor.ref)) {
749
+ const shown2 = await catBlob(repoRoot, anchor.ref, anchor.file);
750
+ if (shown2 !== null) {
751
+ return {
752
+ ok: true,
753
+ source: shown2,
754
+ origin: { kind: "ref", ref: anchor.ref }
755
+ };
756
+ }
757
+ }
758
+ const at2 = anchor.resolved_at;
759
+ if (!at2 || Number.isNaN(Date.parse(at2))) {
760
+ return { ok: false, reason: "unrecoverable" };
761
+ }
762
+ const found = await git2(repoRoot, [
763
+ "log",
764
+ "-1",
765
+ "--format=%H",
766
+ `--before=${at2}`,
767
+ "--end-of-options",
768
+ "HEAD",
769
+ "--",
770
+ anchor.file
771
+ ]);
772
+ const sha = found.ok ? found.stdout.trim() : "";
773
+ if (!sha || !refShapeIsSafe(sha))
774
+ return { ok: false, reason: "unrecoverable" };
775
+ const shown = await catBlob(repoRoot, sha, anchor.file);
776
+ if (shown === null) return { ok: false, reason: "unrecoverable" };
777
+ return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
778
+ }
779
+ async function catBlob(repoRoot, ref, file) {
780
+ const path = file.replace(/^\.\//, "");
781
+ const result = await git2(repoRoot, [
782
+ "cat-file",
783
+ "blob",
784
+ "--end-of-options",
785
+ `${ref}:${path}`
786
+ ]);
787
+ return result.ok ? result.stdout : null;
788
+ }
789
+
493
790
  // src/remote-repo/cache.ts
494
791
  var import_node_os = require("os");
495
792
  var import_node_path = require("path");
496
793
 
497
794
  // src/anchor-resolver/repo-identity.ts
498
- var import_node_child_process = require("child_process");
499
- var import_node_util = require("util");
500
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
795
+ var import_node_child_process3 = require("child_process");
796
+ var import_node_util3 = require("util");
797
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
501
798
  function normalizeRepoUrl(value) {
502
799
  let url = value.trim().replace(/^git\+/, "");
503
800
  const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
@@ -532,7 +829,7 @@ function repoIdentifies(declared, originUrl) {
532
829
  }
533
830
  async function repoOriginUrl(repoRoot) {
534
831
  try {
535
- const { stdout } = await execFileAsync(
832
+ const { stdout } = await execFileAsync3(
536
833
  "git",
537
834
  ["-C", repoRoot, "config", "--get", "remote.origin.url"],
538
835
  { timeout: 5e3 }
@@ -604,7 +901,9 @@ function revRef(rev) {
604
901
  var UNCHECKED_REASONS = [
605
902
  "remote-unreachable",
606
903
  "repo-unauthorized",
607
- "default-branch-unknown"
904
+ "default-branch-unknown",
905
+ /** Local, but the same finding: a shallow clone has no rev to read. */
906
+ "ref-unavailable"
608
907
  ];
609
908
  function isUncheckedReason(reason) {
610
909
  return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
@@ -615,137 +914,34 @@ function wantKey(repo, ref, file) {
615
914
 
616
915
  // src/remote-repo/read.ts
617
916
  var import_promises = require("fs/promises");
618
-
619
- // src/remote-repo/git.ts
620
- var import_node_child_process2 = require("child_process");
621
- var import_node_util2 = require("util");
622
-
623
- // src/anchor-resolver/model.ts
624
- var MAX_ANCHOR_FILE_BYTES = 1048576;
625
-
626
- // src/remote-repo/git.ts
627
- var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
628
- function childEnv() {
629
- const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
630
- for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
631
- delete env[name];
917
+ var DEFAULT_REPO_CONCURRENCY = 4;
918
+ var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
919
+ async function readRemoteAnchors(wants, options = {}) {
920
+ const out = /* @__PURE__ */ new Map();
921
+ if (!wants.length) return out;
922
+ const cacheDir = repoCacheDir(options.cacheDir);
923
+ const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
924
+ const byRepo = /* @__PURE__ */ new Map();
925
+ for (const want of wants) {
926
+ const key2 = normalizeRepoUrl(want.repo);
927
+ const group2 = byRepo.get(key2) ?? { url: want.repo.trim(), wants: [] };
928
+ group2.wants.push(want);
929
+ byRepo.set(key2, group2);
632
930
  }
633
- return env;
634
- }
635
- async function git(args, options = {}) {
636
- try {
637
- const { stdout, stderr } = await execFileAsync2("git", args, {
638
- ...options.cwd ? { cwd: options.cwd } : {},
639
- timeout: options.timeoutMs ?? 3e4,
640
- maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
641
- encoding: "utf8",
642
- windowsHide: true,
643
- env: childEnv()
644
- });
645
- return { ok: true, stdout, stderr, overflowed: false };
646
- } catch (error) {
647
- const failure = error;
648
- return {
649
- ok: false,
650
- stdout: failure.stdout ?? "",
651
- stderr: failure.stderr ?? "",
652
- overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
653
- };
654
- }
655
- }
656
- function transportReason(stderr) {
657
- const text = stderr.toLowerCase();
658
- if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
659
- return "repo-unauthorized";
660
- }
661
- if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
662
- return "ref-not-found";
663
- }
664
- return "remote-unreachable";
665
- }
666
-
667
- // src/remote-repo/validate.ts
668
- var MAX_REF_LENGTH = 200;
669
- var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
670
- function refShapeIsSafe(ref) {
671
- if (!ref || ref.length > MAX_REF_LENGTH) return false;
672
- if (ref.includes("..")) return false;
673
- return REF_SHAPE.test(ref);
674
- }
675
- async function refIsWellFormed(ref) {
676
- if (!refShapeIsSafe(ref)) return false;
677
- const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
678
- return checked.ok;
679
- }
680
- function filePathIsSafe(file) {
681
- const path = file.replace(/^\.\//, "");
682
- if (!path || path.startsWith("-") || path.includes("\0")) return false;
683
- return !path.split("/").includes("..");
684
- }
685
- var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
686
- function allowedProtocols() {
687
- const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
688
- if (raw === void 0) return [...DEFAULT_PROTOCOLS];
689
- const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
690
- return listed.length ? listed : [...DEFAULT_PROTOCOLS];
691
- }
692
- function isShortRepoName(repo) {
693
- return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
694
- }
695
- var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
696
- var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
697
- var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
698
- function repoUrlIsSafe(repo) {
699
- const url = repo.trim();
700
- if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
701
- const allowed = allowedProtocols();
702
- if (SCP_LIKE.test(url)) return allowed.includes("ssh");
703
- const scheme = URL_SCHEME.exec(url);
704
- if (!scheme?.[1]) return false;
705
- if (!allowed.includes(scheme[1].toLowerCase())) return false;
706
- const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
707
- const at2 = authority.lastIndexOf("@");
708
- return at2 < 0 || !authority.slice(0, at2).includes(":");
709
- }
710
- function protocolArgs() {
711
- const allowed = allowedProtocols();
712
- return [
713
- "-c",
714
- "protocol.ext.allow=never",
715
- "-c",
716
- `protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
717
- ];
718
- }
719
-
720
- // src/remote-repo/read.ts
721
- var DEFAULT_REPO_CONCURRENCY = 4;
722
- var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
723
- async function readRemoteAnchors(wants, options = {}) {
724
- const out = /* @__PURE__ */ new Map();
725
- if (!wants.length) return out;
726
- const cacheDir = repoCacheDir(options.cacheDir);
727
- const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
728
- const byRepo = /* @__PURE__ */ new Map();
729
- for (const want of wants) {
730
- const key = normalizeRepoUrl(want.repo);
731
- const group2 = byRepo.get(key) ?? { url: want.repo.trim(), wants: [] };
732
- group2.wants.push(want);
733
- byRepo.set(key, group2);
734
- }
735
- const groups = [...byRepo.entries()];
736
- const results = await mapLimit(
737
- groups,
738
- Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
739
- ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
740
- cacheDir,
741
- timeoutMs,
742
- offline: options.offline === true
743
- })
744
- );
745
- for (const result of results) {
746
- for (const [key, read] of result) out.set(key, read);
747
- }
748
- return out;
931
+ const groups = [...byRepo.entries()];
932
+ const results = await mapLimit(
933
+ groups,
934
+ Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
935
+ ([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
936
+ cacheDir,
937
+ timeoutMs,
938
+ offline: options.offline === true
939
+ })
940
+ );
941
+ for (const result of results) {
942
+ for (const [key2, read] of result) out.set(key2, read);
943
+ }
944
+ return out;
749
945
  }
750
946
  async function readOneRepo(repo, url, declared, context) {
751
947
  let wants = declared;
@@ -985,6 +1181,7 @@ function looksLikeWrongRepoRoot(drift) {
985
1181
  for (const entries of drift.values()) {
986
1182
  for (const entry of entries) {
987
1183
  if (entry.repo !== void 0) continue;
1184
+ if (entry.side === "old") continue;
988
1185
  checked += 1;
989
1186
  if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
990
1187
  return false;
@@ -1120,7 +1317,7 @@ function size(bytes) {
1120
1317
  return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1121
1318
  }
1122
1319
  function pause(ms) {
1123
- return new Promise((resolve6) => setTimeout(resolve6, ms));
1320
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
1124
1321
  }
1125
1322
 
1126
1323
  // src/grammars/manifest.ts
@@ -1183,8 +1380,8 @@ async function ensureGrammar(language, options = {}) {
1183
1380
  if (!pack2) return null;
1184
1381
  const root = grammarsCacheRoot(options.cacheRoot);
1185
1382
  const wasm = grammarCachePath(root, language, pack2.wasm.sha256);
1186
- const key = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1187
- const existing = inFlight.get(key);
1383
+ const key2 = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1384
+ const existing = inFlight.get(key2);
1188
1385
  if (existing) return existing;
1189
1386
  const pending = (async () => {
1190
1387
  const grammar = await ensurePart(
@@ -1208,9 +1405,9 @@ ${lf(await (0, import_promises4.readFile)(path, "utf8"))}`);
1208
1405
  missing.delete(language);
1209
1406
  return { wasm, query: total ? parts.join("\n") : void 0 };
1210
1407
  })();
1211
- inFlight.set(key, pending);
1408
+ inFlight.set(key2, pending);
1212
1409
  const result = await pending;
1213
- if (result === null) inFlight.delete(key);
1410
+ if (result === null) inFlight.delete(key2);
1214
1411
  return result;
1215
1412
  }
1216
1413
  async function ensurePart(path, name, entry, options) {
@@ -1504,8 +1701,8 @@ var TreeSitterResolver = class {
1504
1701
  }
1505
1702
  /** Parsed trees are keyed by content hash, so an unchanged file parses once. */
1506
1703
  parse(language, loaded, source) {
1507
- const key = `${language}:${(0, import_node_crypto2.createHash)("sha256").update(source).digest("hex")}`;
1508
- const cached2 = this.trees.get(key);
1704
+ const key2 = `${language}:${(0, import_node_crypto2.createHash)("sha256").update(source).digest("hex")}`;
1705
+ const cached2 = this.trees.get(key2);
1509
1706
  if (cached2) {
1510
1707
  this.stats.cacheHits += 1;
1511
1708
  return cached2;
@@ -1529,7 +1726,7 @@ var TreeSitterResolver = class {
1529
1726
  this.trees.delete(oldest.value);
1530
1727
  }
1531
1728
  }
1532
- this.trees.set(key, parsed);
1729
+ this.trees.set(key2, parsed);
1533
1730
  return parsed;
1534
1731
  }
1535
1732
  /**
@@ -1689,8 +1886,8 @@ function captureBraceBlock(lines, matchLine) {
1689
1886
  }
1690
1887
  var PYTHON_HEADER = /^\s*(?:async\s+)?(?:def|class)\s+[A-Za-z_]\w*\s*[(:]/;
1691
1888
  function captureIndentedBlock(lines, matchLine) {
1692
- const header = lines[matchLine] ?? "";
1693
- const indent = header.length - header.trimStart().length;
1889
+ const header2 = lines[matchLine] ?? "";
1890
+ const indent = header2.length - header2.trimStart().length;
1694
1891
  let headerEnd = -1;
1695
1892
  for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1696
1893
  const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
@@ -1711,42 +1908,55 @@ function captureIndentedBlock(lines, matchLine) {
1711
1908
  }
1712
1909
  return end === headerEnd ? null : span(lines, matchLine, end);
1713
1910
  }
1714
- var TIERS = [
1715
- (name) => new RegExp(
1716
- `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
1717
- ),
1718
- (name) => new RegExp(`\\b${name}\\s*[:=]`),
1911
+ var declarationTier = (name) => new RegExp(
1912
+ `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
1913
+ );
1914
+ var assignmentTier = (name) => new RegExp(`\\b${name}\\s*[:=]`);
1915
+ var anchoredAssignmentTier = (name) => new RegExp(
1916
+ `^\\s*(?:export\\s+|readonly\\s+|pub\\s+|static\\s+|private\\s+|public\\s+|protected\\s+)*${name}\\s*[:=]`
1917
+ );
1918
+ var DEFINITION_TIERS = [declarationTier, anchoredAssignmentTier];
1919
+ var MENTION_TIERS = [
1719
1920
  (name) => new RegExp(`\\b${name}\\s*\\(`),
1720
1921
  (name) => new RegExp(`\\b${name}\\b`)
1721
1922
  ];
1923
+ var TIERS = [declarationTier, assignmentTier, ...MENTION_TIERS];
1924
+ function resolveWith(tiers, source, symbol) {
1925
+ const segments = symbol.split(".");
1926
+ const name = segments[segments.length - 1];
1927
+ if (!name) return null;
1928
+ const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
1929
+ const escaped = escapeRegExp(name);
1930
+ const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
1931
+ const lines = source.split("\n");
1932
+ for (const tier of tiers) {
1933
+ const pattern = tier(escaped);
1934
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1935
+ if (!candidates.length) continue;
1936
+ if (parentPattern && candidates.length > 1) {
1937
+ const distances = candidates.map(
1938
+ (index2) => distanceToParent(lines, index2, parentPattern)
1939
+ );
1940
+ const nearest = Math.min(...distances);
1941
+ if (Number.isFinite(nearest)) {
1942
+ candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1943
+ }
1944
+ }
1945
+ if (candidates.length !== 1) return null;
1946
+ const matchLine = candidates[0];
1947
+ return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
1948
+ }
1949
+ return null;
1950
+ }
1722
1951
  var regexResolver = {
1723
1952
  name: "regex",
1724
1953
  resolve(source, symbol) {
1725
- const segments = symbol.split(".");
1726
- const name = segments[segments.length - 1];
1727
- if (!name) return null;
1728
- const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
1729
- const escaped = escapeRegExp(name);
1730
- const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
1731
- const lines = source.split("\n");
1732
- for (const tier of TIERS) {
1733
- const pattern = tier(escaped);
1734
- let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1735
- if (!candidates.length) continue;
1736
- if (parentPattern && candidates.length > 1) {
1737
- const distances = candidates.map(
1738
- (index2) => distanceToParent(lines, index2, parentPattern)
1739
- );
1740
- const nearest = Math.min(...distances);
1741
- if (Number.isFinite(nearest)) {
1742
- candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1743
- }
1744
- }
1745
- if (candidates.length !== 1) return null;
1746
- const matchLine = candidates[0];
1747
- return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
1748
- }
1749
- return null;
1954
+ return resolveWith(TIERS, source, symbol);
1955
+ },
1956
+ attempt(source, symbol, _file, options) {
1957
+ const tiers = options?.afterParsedMiss ? DEFINITION_TIERS : TIERS;
1958
+ const span2 = resolveWith(tiers, source, symbol);
1959
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1750
1960
  }
1751
1961
  };
1752
1962
  function escapeRegExp(value) {
@@ -1764,6 +1974,7 @@ function hashAnchorText(text) {
1764
1974
  }
1765
1975
  function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1766
1976
  const normalized = source.replace(/\r\n/g, "\n");
1977
+ if (anchor.span) return sliceSpan(normalized, anchor.span);
1767
1978
  if (!anchor.symbol) {
1768
1979
  const lines = normalized.split("\n");
1769
1980
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
@@ -1776,11 +1987,17 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1776
1987
  }
1777
1988
  };
1778
1989
  }
1990
+ let afterParsedMiss = false;
1779
1991
  for (const resolver of resolvers) {
1780
- const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1992
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file, {
1993
+ afterParsedMiss
1994
+ }) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1781
1995
  if (attempt.kind === "abstain") continue;
1782
1996
  if (attempt.kind === "unresolved") {
1783
- if (attempt.reason === "symbol-not-found") continue;
1997
+ if (attempt.reason === "symbol-not-found") {
1998
+ if (resolver.attempt) afterParsedMiss = true;
1999
+ continue;
2000
+ }
1784
2001
  return { ok: false, reason: attempt.reason };
1785
2002
  }
1786
2003
  const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
@@ -1793,12 +2010,28 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1793
2010
  }
1794
2011
  return { ok: false, reason: "symbol-not-found" };
1795
2012
  }
2013
+ function sliceSpan(source, range) {
2014
+ const lines = source.split("\n");
2015
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
2016
+ if (range.end > lines.length) {
2017
+ return { ok: false, reason: "span-out-of-range" };
2018
+ }
2019
+ return {
2020
+ ok: true,
2021
+ span: {
2022
+ text: lines.slice(range.start - 1, range.end).join("\n"),
2023
+ startLine: range.start,
2024
+ endLine: range.end
2025
+ },
2026
+ resolver: "span"
2027
+ };
2028
+ }
1796
2029
  function fromResolve(resolver, source, symbol, file) {
1797
2030
  const span2 = resolver.resolve(source, symbol, file);
1798
2031
  return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1799
2032
  }
1800
2033
  function isResolverName(name) {
1801
- return name === "tree-sitter" || name === "regex";
2034
+ return name === "tree-sitter" || name === "regex" || name === "span";
1802
2035
  }
1803
2036
  async function prepareResolvers(resolvers, files) {
1804
2037
  for (const resolver of resolvers) await resolver.prepare?.(files);
@@ -1817,6 +2050,9 @@ function resolverChanged(source, anchor, produced) {
1817
2050
  return before !== null && hashAnchorText(before.text) === anchor.hash;
1818
2051
  }
1819
2052
  function anchorHashOf(anchor, outcome) {
2053
+ if (outcome.resolver === "span") {
2054
+ return { hash: hashAnchorText(outcome.span.text), kind: "raw" };
2055
+ }
1820
2056
  const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
1821
2057
  const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
1822
2058
  return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
@@ -1850,37 +2086,60 @@ async function detectAnchorDrift(records, options = {}) {
1850
2086
  }
1851
2087
  }
1852
2088
  const files = [];
2089
+ const committedWants = [];
1853
2090
  const wants = [];
1854
2091
  for (const entries of planned.values()) {
1855
2092
  for (const { anchor, foreign } of entries) {
1856
- if (!foreign) files.push(anchor.file);
1857
- else wants.push(...remoteWants(anchor));
2093
+ if (foreign) wants.push(...remoteWants(anchor));
2094
+ else if (anchor.side === "old") committedWants.push(anchor);
2095
+ else files.push(anchor.file);
1858
2096
  }
1859
2097
  }
1860
- const [reads, remote] = await Promise.all([
2098
+ const [reads, committed, remote] = await Promise.all([
1861
2099
  readAnchorFiles(
1862
2100
  files,
1863
2101
  options.reader ?? anchorFileReader(repoRoot),
1864
2102
  options.concurrency ?? DEFAULT_IO_CONCURRENCY
1865
2103
  ),
2104
+ readCommitted(repoRoot, committedWants, options),
1866
2105
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1867
2106
  ]);
1868
2107
  await prepareResolvers(resolvers, [
1869
2108
  ...files,
2109
+ ...committedWants.map((anchor) => anchor.file),
1870
2110
  ...wants.map((want) => want.file)
1871
2111
  ]);
1872
2112
  const drift = /* @__PURE__ */ new Map();
1873
2113
  for (const record of records) {
1874
2114
  const entries = [];
1875
2115
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
1876
- entries.push(
1877
- foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
1878
- );
2116
+ if (foreign) {
2117
+ entries.push(remoteEntry(anchor, remote, resolvers));
2118
+ continue;
2119
+ }
2120
+ const read = anchor.side === "old" ? committed.get(atRefKey(anchor)) : reads.get(anchor.file);
2121
+ entries.push(localEntry(anchor, read, resolvers));
1879
2122
  }
1880
2123
  if (entries.length) drift.set(record.conceptId, entries);
1881
2124
  }
1882
2125
  return drift;
1883
2126
  }
2127
+ function atRefKey(anchor) {
2128
+ return `${anchor.ref ?? ""}\0${anchor.file}`;
2129
+ }
2130
+ async function readCommitted(repoRoot, anchors, options = {}) {
2131
+ if (!anchors.length) return /* @__PURE__ */ new Map();
2132
+ const read = options.readAtRef ?? readFileAtRef;
2133
+ const byKey = /* @__PURE__ */ new Map();
2134
+ for (const anchor of anchors) byKey.set(atRefKey(anchor), anchor);
2135
+ const keys = [...byKey.keys()];
2136
+ const results = await mapLimit(
2137
+ keys,
2138
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY,
2139
+ (key2) => read(repoRoot, byKey.get(key2))
2140
+ );
2141
+ return new Map(keys.map((key2, at2) => [key2, results[at2]]));
2142
+ }
1884
2143
  function remoteWants(anchor) {
1885
2144
  const repo = anchor.repo;
1886
2145
  const wants = [{ repo, file: anchor.file }];
@@ -1891,6 +2150,7 @@ function base(anchor) {
1891
2150
  return {
1892
2151
  file: anchor.file,
1893
2152
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
2153
+ ...anchor.side === "old" ? { side: "old" } : {},
1894
2154
  storedHash: anchor.hash
1895
2155
  };
1896
2156
  }
@@ -1904,9 +2164,15 @@ function unresolved(anchor, reason, repo) {
1904
2164
  ...classOf(reason)
1905
2165
  };
1906
2166
  }
2167
+ var GONE_REASONS = /* @__PURE__ */ new Set([
2168
+ "file-missing",
2169
+ "symbol-not-found",
2170
+ "span-out-of-range",
2171
+ "ref-unreadable"
2172
+ ]);
1907
2173
  function provisionalDriftClass(entry) {
1908
2174
  if (entry.state === "unresolved") {
1909
- return entry.reason === "file-missing" || entry.reason === "symbol-not-found" ? "gone" : void 0;
2175
+ return GONE_REASONS.has(entry.reason) ? "gone" : void 0;
1910
2176
  }
1911
2177
  return entry.state === "drifted" ? "changed" : void 0;
1912
2178
  }
@@ -1958,9 +2224,9 @@ function localEntry(anchor, read, resolvers) {
1958
2224
  }
1959
2225
  function remoteEntry(anchor, remote, resolvers) {
1960
2226
  const repo = anchor.repo;
1961
- const key = normalizeRepoUrl(repo);
1962
- const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1963
- const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
2227
+ const key2 = normalizeRepoUrl(repo);
2228
+ const atDefault = remote.get(wantKey(key2, void 0, anchor.file));
2229
+ const primary = anchor.ref ? remote.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
1964
2230
  if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1965
2231
  if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1966
2232
  const found = hashIn(primary.source, anchor, resolvers);
@@ -1975,6 +2241,13 @@ function remoteEntry(anchor, remote, resolvers) {
1975
2241
  remoteState: "drifted-from-ref"
1976
2242
  });
1977
2243
  }
2244
+ if (anchor.side === "old") {
2245
+ return compared(anchor, current, {
2246
+ repo,
2247
+ ...extras,
2248
+ remoteState: "matches-ref"
2249
+ });
2250
+ }
1978
2251
  const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
1979
2252
  return head?.ok && head.current.hash !== anchor.hash ? {
1980
2253
  ...compared(anchor, head.current, {
@@ -2128,6 +2401,89 @@ var KbMissingFlagValueError = class extends BaseError {
2128
2401
  }
2129
2402
  flag;
2130
2403
  };
2404
+ var KbClassifyInputError = class extends BaseError {
2405
+ constructor(reason) {
2406
+ super({
2407
+ message: `classify: ${reason}`,
2408
+ errorType: "KbClassifyInput" /* KbClassifyInput */,
2409
+ code: 400,
2410
+ fault: "User" /* User */,
2411
+ retriable: false,
2412
+ reportToUser: true,
2413
+ details: { reason }
2414
+ });
2415
+ this.reason = reason;
2416
+ }
2417
+ reason;
2418
+ };
2419
+ var KbPromoteCollisionError = class extends BaseError {
2420
+ constructor(conceptId2, to) {
2421
+ super({
2422
+ message: `kb: ${to} already holds ${conceptId2} \u2014 re-run with force to overwrite it`,
2423
+ errorType: "KbPromoteCollision" /* KbPromoteCollision */,
2424
+ code: 409,
2425
+ fault: "User" /* User */,
2426
+ retriable: false,
2427
+ reportToUser: true,
2428
+ details: { conceptId: conceptId2, to, action: "refused" }
2429
+ });
2430
+ this.conceptId = conceptId2;
2431
+ this.to = to;
2432
+ }
2433
+ conceptId;
2434
+ to;
2435
+ };
2436
+ var KbPromoteStandingError = class extends BaseError {
2437
+ constructor(conceptId2, standing) {
2438
+ super({
2439
+ message: `kb: ${conceptId2} is ${standing} \u2014 only a record that still stands can be promoted`,
2440
+ errorType: "KbPromoteStanding" /* KbPromoteStanding */,
2441
+ code: 409,
2442
+ fault: "User" /* User */,
2443
+ retriable: false,
2444
+ reportToUser: true,
2445
+ details: { conceptId: conceptId2, standing, action: "refused" }
2446
+ });
2447
+ this.conceptId = conceptId2;
2448
+ this.standing = standing;
2449
+ }
2450
+ conceptId;
2451
+ standing;
2452
+ };
2453
+ var KbPromoteSelfError = class extends BaseError {
2454
+ constructor(to) {
2455
+ super({
2456
+ message: `kb: ${to} is the base being promoted from \u2014 name a different target`,
2457
+ errorType: "KbPromoteSelf" /* KbPromoteSelf */,
2458
+ code: 400,
2459
+ fault: "User" /* User */,
2460
+ retriable: false,
2461
+ reportToUser: true,
2462
+ details: { to, action: "refused" }
2463
+ });
2464
+ this.to = to;
2465
+ }
2466
+ to;
2467
+ };
2468
+ var KbPromoteStoppedError = class extends BaseError {
2469
+ constructor(conceptId2, landed, reason) {
2470
+ super({
2471
+ message: `kb: promotion stopped at ${conceptId2} (${reason}) \u2014 landed: ${landed.length ? landed.join(", ") : "nothing"}`,
2472
+ errorType: "KbPromoteStopped" /* KbPromoteStopped */,
2473
+ code: 500,
2474
+ fault: "System" /* System */,
2475
+ retriable: false,
2476
+ reportToUser: true,
2477
+ details: { conceptId: conceptId2, landed, reason, action: "stopped" }
2478
+ });
2479
+ this.conceptId = conceptId2;
2480
+ this.landed = landed;
2481
+ this.reason = reason;
2482
+ }
2483
+ conceptId;
2484
+ landed;
2485
+ reason;
2486
+ };
2131
2487
  var KbInvalidConceptIdError = class extends BaseError {
2132
2488
  constructor(message, details) {
2133
2489
  super({
@@ -2176,8 +2532,8 @@ var KbStampDigestBaselineError = class extends BaseError {
2176
2532
  function asBudgets(value) {
2177
2533
  if (value === null || typeof value !== "object") return {};
2178
2534
  const table2 = value;
2179
- const pick = (key, min) => {
2180
- const raw2 = table2[key];
2535
+ const pick = (key2, min) => {
2536
+ const raw2 = table2[key2];
2181
2537
  return typeof raw2 === "number" && Number.isInteger(raw2) && raw2 >= min ? raw2 : void 0;
2182
2538
  };
2183
2539
  const budgetTokens = pick("budgetTokens", 1);
@@ -2596,6 +2952,7 @@ var anchorResolveCommand = define({
2596
2952
  const base2 = {
2597
2953
  file: anchor.file,
2598
2954
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
2955
+ ...anchor.side === "old" ? { side: "old" } : {},
2599
2956
  // Carried onto unresolved findings too: an anchor that once hashed
2600
2957
  // and now resolves to nothing is a broken anchor, and the exit code
2601
2958
  // has to be able to tell it from one nobody ever stamped.
@@ -2772,12 +3129,18 @@ async function readSources(anchors, root, offline) {
2772
3129
  const foreign = new Map(
2773
3130
  anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
2774
3131
  );
2775
- const local = anchors.filter((anchor) => !foreign.get(anchor));
3132
+ const local = anchors.filter(
3133
+ (anchor) => !foreign.get(anchor) && anchor.side !== "old"
3134
+ );
3135
+ const committed = anchors.filter(
3136
+ (anchor) => !foreign.get(anchor) && anchor.side === "old"
3137
+ );
2776
3138
  const remote = anchors.filter((anchor) => foreign.get(anchor));
2777
3139
  const reads = await readAnchorFiles(
2778
3140
  local.map((anchor) => anchor.file),
2779
3141
  anchorFileReader(root)
2780
3142
  );
3143
+ const atRef = await readCommitted(root, committed);
2781
3144
  const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
2782
3145
  offline
2783
3146
  });
@@ -2789,11 +3152,18 @@ async function readSources(anchors, root, offline) {
2789
3152
  read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
2790
3153
  );
2791
3154
  }
3155
+ for (const anchor of committed) {
3156
+ const read = atRef.get(atRefKey(anchor));
3157
+ sources.set(
3158
+ anchor,
3159
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
3160
+ );
3161
+ }
2792
3162
  for (const anchor of remote) {
2793
3163
  const repo = anchor.repo;
2794
- const key = normalizeRepoUrl(repo);
2795
- const atDefault = blobs.get(wantKey(key, void 0, anchor.file));
2796
- const primary = anchor.ref ? blobs.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
3164
+ const key2 = normalizeRepoUrl(repo);
3165
+ const atDefault = blobs.get(wantKey(key2, void 0, anchor.file));
3166
+ const primary = anchor.ref ? blobs.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
2797
3167
  if (!primary?.ok) {
2798
3168
  sources.set(anchor, {
2799
3169
  ok: false,
@@ -2990,14 +3360,14 @@ function catalog(bundle, options = {}) {
2990
3360
  supersededBy: hit.heads.map((head) => head.conceptId),
2991
3361
  stale: hit.warnings.some((warning) => warning.kind === "stale")
2992
3362
  })).sort(byTypeThenTitle);
2993
- const standings = { ...EMPTY_STANDINGS };
2994
- for (const entry of entries) standings[entry.standing] += 1;
3363
+ const standings2 = { ...EMPTY_STANDINGS };
3364
+ for (const entry of entries) standings2[entry.standing] += 1;
2995
3365
  return {
2996
3366
  entries,
2997
3367
  recordCount: entries.length,
2998
- standings,
2999
- currentCount: standings.current,
3000
- supersededCount: standings.superseded,
3368
+ standings: standings2,
3369
+ currentCount: standings2.current,
3370
+ supersededCount: standings2.superseded,
3001
3371
  staleCount: entries.filter((entry) => entry.stale).length
3002
3372
  };
3003
3373
  }
@@ -3094,473 +3464,347 @@ function count(value, noun) {
3094
3464
  return `${value} ${value === 1 ? noun : `${noun}s`}`;
3095
3465
  }
3096
3466
 
3097
- // src/commands/context.ts
3098
- var import_zod11 = require("zod");
3099
-
3100
- // src/kb-context.ts
3101
- var import_promises6 = require("fs/promises");
3102
-
3103
- // src/kb-index.ts
3104
- var INDEX_FILE = "INDEX.md";
3105
- var HEADING = "# KB Index";
3106
- function renderIndex(records) {
3107
- const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map(renderIndexLine);
3108
- return `${HEADING}
3467
+ // src/commands/classify.ts
3468
+ var import_node_buffer = require("buffer");
3469
+ var import_promises7 = require("fs/promises");
3470
+ var import_node_path10 = require("path");
3471
+ var import_zod13 = require("zod");
3109
3472
 
3110
- ${lines.join("\n")}
3111
- `;
3473
+ // src/match-diff.ts
3474
+ function matchToDiff(files, records, options = {}) {
3475
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
3476
+ const anchored = records.filter(
3477
+ (record) => (record.frontmatter.strauss_anchors ?? []).length > 0
3478
+ );
3479
+ const matches3 = [];
3480
+ for (const file of files) {
3481
+ const candidates = anchored.map((record) => ({
3482
+ record,
3483
+ anchors: (record.frontmatter.strauss_anchors ?? []).filter(
3484
+ (anchor) => normalize(anchor.file) === normalize(file.filePath)
3485
+ )
3486
+ })).filter(({ anchors }) => anchors.length > 0);
3487
+ if (!candidates.length) continue;
3488
+ for (const hunk of file.hunks) {
3489
+ const hits = [];
3490
+ let precision = "symbol";
3491
+ for (const { record, anchors } of candidates) {
3492
+ const placement = place(anchors, file.filePath, hunk, ranges);
3493
+ if (placement.kind === "miss") continue;
3494
+ if (placement.kind === "file") precision = "file";
3495
+ hits.push(record);
3496
+ }
3497
+ if (!hits.length) continue;
3498
+ matches3.push({
3499
+ filePath: file.filePath,
3500
+ hunk,
3501
+ records: order(adjudicate(hits, records, options.now)),
3502
+ precision
3503
+ });
3504
+ }
3505
+ }
3506
+ return matches3;
3112
3507
  }
3113
- function renderIndexLine(record) {
3114
- const { frontmatter: fm } = record;
3115
- const parts = [fm.type, fm.strauss_status];
3116
- if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
3117
- if (fm.description) parts.push(fm.description);
3118
- return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
3508
+ function placeOnHunk(record, filePath, hunk, symbolRanges = []) {
3509
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
3510
+ (anchor) => normalize(anchor.file) === normalize(filePath)
3511
+ );
3512
+ return place(anchors, filePath, hunk, asIndex(symbolRanges));
3513
+ }
3514
+ function asIndex(ranges) {
3515
+ return isIndex(ranges) ? ranges : symbolRangeIndex(ranges);
3516
+ }
3517
+ function isIndex(ranges) {
3518
+ return !Array.isArray(ranges);
3519
+ }
3520
+ function place(anchors, filePath, hunk, ranges) {
3521
+ let fallback = { kind: "miss" };
3522
+ for (const anchor of anchors) {
3523
+ if (side(anchor.side) !== side(hunk.side)) continue;
3524
+ if (anchor.span) {
3525
+ if (overlaps(
3526
+ { startLine: anchor.span.start, endLine: anchor.span.end },
3527
+ hunk
3528
+ )) {
3529
+ return { kind: "symbol", anchor };
3530
+ }
3531
+ continue;
3532
+ }
3533
+ if (!anchor.symbol) return { kind: "file", anchor };
3534
+ const resolved = ranges.get(
3535
+ key(filePath, anchor.symbol, side(anchor.side))
3536
+ );
3537
+ if (!resolved?.length) {
3538
+ if (fallback.kind === "miss") fallback = { kind: "file", anchor };
3539
+ continue;
3540
+ }
3541
+ if (resolved.some((range) => overlaps(range, hunk))) {
3542
+ return { kind: "symbol", anchor };
3543
+ }
3544
+ }
3545
+ return fallback;
3119
3546
  }
3120
- function indexIsStale(stored, expected) {
3121
- return stored !== expected;
3547
+ function side(value) {
3548
+ return value ?? "new";
3549
+ }
3550
+ function overlaps(range, hunk) {
3551
+ return range.startLine <= hunk.endLine && hunk.startLine <= range.endLine;
3552
+ }
3553
+ function order(records) {
3554
+ const rank = {
3555
+ current: 0,
3556
+ unsettled: 1,
3557
+ open: 2,
3558
+ superseded: 3,
3559
+ rejected: 4
3560
+ };
3561
+ return [...records].sort(
3562
+ (left, right) => (rank[left.standing] ?? 9) - (rank[right.standing] ?? 9) || (left.record.frontmatter.generated?.at ?? "").localeCompare(
3563
+ right.record.frontmatter.generated?.at ?? ""
3564
+ )
3565
+ );
3566
+ }
3567
+ function symbolRangeIndex(ranges) {
3568
+ const byKey = /* @__PURE__ */ new Map();
3569
+ for (const range of ranges) {
3570
+ const id = key(range.file, range.symbol, side(range.side));
3571
+ byKey.set(id, [...byKey.get(id) ?? [], range]);
3572
+ }
3573
+ return byKey;
3574
+ }
3575
+ function key(file, symbol, at2) {
3576
+ return `${normalize(file)}#${symbol}#${at2}`;
3577
+ }
3578
+ function normalize(path) {
3579
+ return path.replace(/^\.\//, "");
3122
3580
  }
3123
3581
 
3124
- // src/kb-context.ts
3125
- var HEADING2 = "## Knowledge bases (pinned)";
3126
- var DEFAULT_CONTEXT_BUDGET = 4e3;
3127
- var CONTEXT_PROFILES = {
3128
- "session-start": { fullUnderTokens: 1500 },
3129
- compact: { budgetTokens: 2500 },
3130
- turn: { budgetTokens: 2500 }
3582
+ // src/classify/model.ts
3583
+ var DEFAULT_THRESHOLDS = {
3584
+ boilerplate: 0.8,
3585
+ rename: 90
3131
3586
  };
3132
- function approxTokens(text) {
3133
- return Math.ceil(text.length / 4);
3134
- }
3135
- function preamble() {
3136
- return [
3137
- HEADING2,
3138
- "",
3139
- "What follows is an index of this workspace's pinned knowledge bases \u2014",
3140
- "concept ids, titles and standing only. The record bodies are NOT in this",
3141
- "context.",
3142
- "",
3143
- "Consult records only through the strauss-kb MCP tools: `kb_load` (the",
3144
- "preferred first call), `kb_query`, and `kb_trace`, passing the",
3145
- "`bundlePath` listed with each base. Do not read record files directly:",
3146
- "a raw file read bypasses supersession resolution, and a superseded or",
3147
- "rejected record file reads exactly like a current one \u2014 only the store",
3148
- "resolves chains and standing.",
3149
- "",
3150
- "KB content loaded earlier in a long session may have been compacted",
3151
- "away. Before answering a question one of these bases governs, load it",
3152
- "again at the point of use \u2014 reloading a small base costs a few thousand",
3153
- "tokens."
3154
- ].join("\n");
3155
- }
3156
- async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens, excludeTags) {
3157
- const bundle = await store.list(absolutePath);
3158
- if (bundle.length === 0) {
3159
- return {
3160
- path,
3161
- absolutePath,
3162
- mode: "empty",
3163
- body: "No readable records yet \u2014 pinned ahead of being populated."
3164
- };
3587
+
3588
+ // src/classify/rules.ts
3589
+ var PATH_RULES = [
3590
+ {
3591
+ name: "test-path",
3592
+ class: "test",
3593
+ test: /(^|\/)(__tests__|__mocks__|tests?)\/|\.(spec|test)\.[^/]+$/
3594
+ },
3595
+ {
3596
+ name: "ci-path",
3597
+ class: "ci",
3598
+ test: /(^|\/)\.github\/|(^|\/)(\.circleci|\.buildkite|\.gitlab|ci)\/[^/]*\.ya?ml$|(^|\/)Dockerfile(\.[^/]*)?$|\.tf$/
3599
+ },
3600
+ {
3601
+ name: "docs-path",
3602
+ class: "docs",
3603
+ test: /\.md$|(^|\/)docs\/|(^|\/)LICENSE(\.(md|txt|rst))?$/
3604
+ },
3605
+ {
3606
+ name: "lockfile-path",
3607
+ class: "lockfile",
3608
+ test: /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|Cargo\.lock|go\.sum)$/
3609
+ },
3610
+ {
3611
+ name: "config-path",
3612
+ class: "config",
3613
+ // `.jsonl` rides with `.json`: an append-only log of JSON is configuration
3614
+ // data too, and calling it source would send a reviewer to read it. Every
3615
+ // arm is anchored at both ends: `src/tsconfig-loader.ts` and `report.env.ts`
3616
+ // are source, not config.
3617
+ test: /\.(jsonc?|jsonl|ya?ml|toml|ini)$|(^|\/)\.env(?![^/]*\.[cm]?[jt]sx?$)([.-][^/]*)?$|[^/]+\.env$|(^|\/)tsconfig[^/]*\.json$|\.config\.[^/]+$|(^|\/)\.(eslintrc|prettierrc)[^/]*$/
3165
3618
  }
3166
- const fullCap = pinMode === "full" ? budgetTokens : pinMode === "index" ? 0 : fullUnderTokens;
3167
- let degradedFrom;
3168
- if (fullCap > 0) {
3169
- const full = await store.load(absolutePath, {
3170
- budgetTokens: fullCap,
3171
- excludeTags
3172
- });
3173
- if (!full.loaded && pinMode === "full") {
3174
- degradedFrom = { approxTokens: full.approxTokens };
3175
- }
3176
- if (full.loaded) {
3177
- const records = full.records.map(
3178
- (hit) => [
3179
- `#### ${hit.record.conceptId} \u2014 ${hit.record.frontmatter.title ?? "(untitled)"} (${hit.standing})`,
3180
- "",
3181
- hit.record.body.trim()
3182
- ].join("\n")
3183
- );
3184
- const superseded2 = full.superseded.map(
3185
- (entry) => `- \`${entry.conceptId}\` \u2192 superseded by ${entry.supersededBy.map((id) => `\`${id}\``).join(", ") || "(missing replacement)"}`
3186
- );
3187
- return {
3188
- path,
3189
- absolutePath,
3190
- mode: "full",
3191
- body: [
3192
- ...records,
3193
- ...superseded2.length ? [
3194
- "#### Superseded (bodies withheld \u2014 kb_trace reaches them)",
3195
- ...superseded2
3196
- ] : []
3197
- ].join("\n\n")
3198
- };
3199
- }
3619
+ ];
3620
+ var HEADER_LINES = 20;
3621
+ var GENERATED_MARKERS = [
3622
+ /@generated\b/i,
3623
+ /\bdo not edit\b/i,
3624
+ /\bcode generated by\b/i,
3625
+ /\bthis file was automatically generated\b/i
3626
+ ];
3627
+ var BOILERPLATE_SHAPES = [
3628
+ { name: "import", test: /^import\b|^\}\s*from\s+["']/ },
3629
+ { name: "re-export", test: /^export\s+(\*|\{|type\s*[{*])/ },
3630
+ {
3631
+ name: "class-shell",
3632
+ test: /^(export\s+)?(default\s+)?(abstract\s+)?class\s+[\w$]+[^{]*\{\s*\}$/
3633
+ },
3634
+ { name: "punctuation", test: /^[{}()[\],;]+$/ }
3635
+ ];
3636
+ function generatedMarker(lines) {
3637
+ for (const line of lines.slice(0, HEADER_LINES)) {
3638
+ const hit = GENERATED_MARKERS.find((marker) => marker.test(line));
3639
+ if (hit) return hit.source.replaceAll("\\b", "");
3200
3640
  }
3201
- const adjudicated = adjudicate(bundle, bundle).filter(
3202
- (hit) => matchesTags(hit.record, { excludeTags })
3203
- );
3204
- const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
3205
- const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
3206
- (hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
3641
+ return void 0;
3642
+ }
3643
+ function isBoilerplateLine(line) {
3644
+ return BOILERPLATE_SHAPES.some((shape) => shape.test.test(line));
3645
+ }
3646
+
3647
+ // src/classify/classify.ts
3648
+ function classifyDiff(files, options = {}) {
3649
+ const overrides = currentOverrides(options.records ?? [], options.now);
3650
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
3651
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds };
3652
+ return files.map((file) => classifyFile(file, overrides, ranges, thresholds));
3653
+ }
3654
+ var OVERRIDE_CLASS = /* @__PURE__ */ new Map([
3655
+ ["review:generated", "generated"],
3656
+ ["review:boilerplate", "boilerplate"],
3657
+ ["review:move", "rename"]
3658
+ ]);
3659
+ var WHOLE_FILE = {
3660
+ startLine: 1,
3661
+ endLine: Number.MAX_SAFE_INTEGER
3662
+ };
3663
+ function classifyFile(file, overrides, ranges, thresholds) {
3664
+ const whole = overrides.find(
3665
+ ({ record }) => placeOnHunk(record, file.filePath, WHOLE_FILE, ranges).kind === "file"
3207
3666
  );
3667
+ const verdict = whole ? verdictOf(whole) : heuristic(file, thresholds);
3668
+ const hunks = file.hunks.map((hunk) => {
3669
+ const hit = whole ?? overrides.find(
3670
+ ({ record }) => placeOnHunk(record, file.filePath, hunk, ranges).kind !== "miss"
3671
+ );
3672
+ return {
3673
+ startLine: hunk.startLine,
3674
+ endLine: hunk.endLine,
3675
+ ...hit ? verdictOf(hit) : verdict
3676
+ };
3677
+ });
3208
3678
  return {
3209
- path,
3210
- absolutePath,
3211
- mode: "index",
3212
- body: [...lines, ...superseded].join("\n"),
3213
- ...degradedFrom ? { degradedFrom } : {}
3679
+ filePath: file.filePath,
3680
+ ...verdict,
3681
+ ...file.renamedFrom ? { renamedFrom: file.renamedFrom } : {},
3682
+ ...hunks.some((hunk) => hunk.class !== verdict.class) ? { hunks } : {}
3214
3683
  };
3215
3684
  }
3216
- async function buildContext(store, workspaceDir, options = {}) {
3217
- const builtin = options.profile ? CONTEXT_PROFILES[options.profile] ?? {} : {};
3218
- let budgetTokens = options.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
3219
- let fullUnderTokens = options.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
3220
- const merged = await readMergedPins(workspaceDir);
3221
- const fromManifest = mergedContextBudgets(merged, options.profile);
3222
- budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
3223
- fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
3224
- const excludeTags = options.excludeTags ?? fromManifest.excludeTags ?? builtin.excludeTags ?? [];
3225
- const pins = merged.pins.filter(
3226
- (pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
3227
- );
3228
- if (pins.length === 0) {
3685
+ function verdictOf(override) {
3686
+ return {
3687
+ class: override.class,
3688
+ reason: `kb-override ${override.record.conceptId}`
3689
+ };
3690
+ }
3691
+ function heuristic(file, thresholds) {
3692
+ const marker = generatedMarker(file.header ?? headOfDiff(file));
3693
+ if (marker)
3694
+ return { class: "generated", reason: `generated-header ${marker}` };
3695
+ const rule = PATH_RULES.find((entry) => entry.test.test(file.filePath));
3696
+ if (rule) return { class: rule.class, reason: rule.name };
3697
+ if (file.renamedFrom && !file.hunks.length && (file.similarity ?? 100) >= thresholds.rename) {
3698
+ return { class: "rename", reason: `rename ${file.renamedFrom}` };
3699
+ }
3700
+ const share = boilerplateShare(file);
3701
+ if (share !== void 0 && share >= thresholds.boilerplate) {
3229
3702
  return {
3230
- block: "",
3231
- refused: false,
3232
- approxTokens: 0,
3233
- budgetTokens,
3234
- bases: []
3703
+ class: "boilerplate",
3704
+ reason: `boilerplate ${Math.round(share * 100)}%`
3235
3705
  };
3236
3706
  }
3237
- const sections = await Promise.all(
3238
- pins.map(async (pin) => ({
3239
- section: await renderBase(
3240
- store,
3241
- pin.path,
3242
- pin.absolutePath,
3243
- fullUnderTokens,
3244
- pin.mode,
3245
- budgetTokens,
3246
- excludeTags
3247
- ),
3248
- frozen: pin.frozen === true
3249
- }))
3707
+ return { class: "source", reason: "default" };
3708
+ }
3709
+ function headOfDiff(file) {
3710
+ return file.hunks.flatMap(
3711
+ (hunk) => (hunk.side ?? "new") === "new" && hunk.startLine <= HEADER_LINES ? (hunk.lines ?? []).slice(0, HEADER_LINES - hunk.startLine + 1) : []
3250
3712
  );
3251
- const modeLabel = {
3252
- index: "index only \u2014 record bodies are not here",
3253
- full: "full records \u2014 this base arrives whole",
3254
- empty: "empty"
3713
+ }
3714
+ function boilerplateShare(file) {
3715
+ const lines = file.hunks.flatMap((hunk) => hunk.lines ?? []).map((line) => line.trim()).filter(Boolean);
3716
+ if (!lines.length) return void 0;
3717
+ return lines.filter(isBoilerplateLine).length / lines.length;
3718
+ }
3719
+ function currentOverrides(records, now) {
3720
+ const tagged = records.flatMap((record) => {
3721
+ if (record.frontmatter.type !== "fact") return [];
3722
+ const tag = (record.frontmatter.tags ?? []).find(
3723
+ (entry) => OVERRIDE_CLASS.has(entry)
3724
+ );
3725
+ const asserted = tag && OVERRIDE_CLASS.get(tag);
3726
+ return asserted ? [{ record, class: asserted }] : [];
3727
+ });
3728
+ const current = new Set(
3729
+ adjudicate(
3730
+ tagged.map(({ record }) => record),
3731
+ records,
3732
+ now
3733
+ ).filter((entry) => entry.standing === "current").map((entry) => entry.record.conceptId)
3734
+ );
3735
+ return tagged.filter(({ record }) => current.has(record.conceptId)).sort(
3736
+ (left, right) => left.record.conceptId.localeCompare(right.record.conceptId)
3737
+ );
3738
+ }
3739
+
3740
+ // src/drift/moved.ts
3741
+ var import_promises6 = require("fs/promises");
3742
+ var MAX_MOVED_SEARCH_FILES = 2e3;
3743
+ var SEARCH_BATCH = 64;
3744
+ function movedSearch(repoRoot, options = {}) {
3745
+ const read = options.reader ?? anchorFileReader(repoRoot);
3746
+ const sizeOf = options.sizeOf ?? diskSize(repoRoot);
3747
+ const resolver = new TreeSitterResolver();
3748
+ let repoFiles;
3749
+ const prepared = /* @__PURE__ */ new Set();
3750
+ const filesForLanguage = async (language) => {
3751
+ repoFiles ??= listRepoFiles(repoRoot);
3752
+ return (await repoFiles).filter((file) => languageForFile(file) === language).slice(0, MAX_MOVED_SEARCH_FILES);
3255
3753
  };
3256
- for (const { section } of sections) {
3257
- if (section.degradedFrom) {
3258
- options.warn?.({
3259
- operation: "kb.context.full-pin-degraded",
3260
- path: section.path,
3261
- approxTokens: section.degradedFrom.approxTokens,
3262
- budgetTokens
3263
- });
3754
+ return {
3755
+ async find(anchor) {
3756
+ const stored = anchor.hash;
3757
+ if (!stored) return void 0;
3758
+ if (anchor.span) return sameFileWindow(anchor, read, stored);
3759
+ const language = languageForFile(anchor.file);
3760
+ if (!language) return sameFileWindow(anchor, read, stored);
3761
+ const candidates = await filesForLanguage(language);
3762
+ if (!prepared.has(language)) {
3763
+ await resolver.prepare(candidates.length ? candidates : [anchor.file]);
3764
+ prepared.add(language);
3765
+ }
3766
+ const floor = anchor.lines ?? 0;
3767
+ for (let at2 = 0; at2 < candidates.length; at2 += SEARCH_BATCH) {
3768
+ const batch = candidates.slice(at2, at2 + SEARCH_BATCH);
3769
+ const hits = await mapLimit(
3770
+ batch,
3771
+ DEFAULT_IO_CONCURRENCY,
3772
+ async (file) => {
3773
+ const size2 = await sizeOf(file);
3774
+ if (size2 !== null && size2 < floor) return void 0;
3775
+ return matchIn(resolver, read, anchor, stored, file);
3776
+ }
3777
+ );
3778
+ const found = hits.find((hit) => hit !== void 0);
3779
+ if (found) return found;
3780
+ }
3781
+ return void 0;
3264
3782
  }
3265
- }
3266
- const rendered = sections.map(({ section, frozen }) => {
3267
- const label = section.degradedFrom ? `index only \u2014 pinned \`mode: full\`, but its ~${section.degradedFrom.approxTokens} tokens exceed this block's ${budgetTokens}-token budget; kb_load it directly (load's budget is separate), or raise this profile's budget` : modeLabel[section.mode];
3268
- return [
3269
- `### ${section.path} (${label}${frozen ? " \xB7 frozen, read-only" : ""})`,
3270
- "",
3271
- `bundlePath: \`${section.absolutePath}\``,
3272
- "",
3273
- section.body
3274
- ].join("\n");
3275
- });
3276
- const block = [preamble(), "", rendered.join("\n\n"), ""].join("\n");
3277
- const bases = sections.map(({ section }) => ({
3278
- path: section.path,
3279
- absolutePath: section.absolutePath,
3280
- approxTokens: approxTokens(section.body)
3281
- }));
3282
- const total = approxTokens(block);
3283
- if (total > budgetTokens) {
3284
- options.warn?.({
3285
- operation: "kb.context.refused",
3286
- approxTokens: total,
3287
- budgetTokens,
3288
- bases: bases.map((base2) => base2.path)
3289
- });
3290
- const refusal = [
3291
- HEADING2,
3292
- "",
3293
- `The pinned index runs to ~${total} tokens, past the ${budgetTokens}-token`,
3294
- "budget, and was not emitted \u2014 a truncated index is indistinguishable",
3295
- "from a complete one. The pinned bases:",
3296
- "",
3297
- ...bases.map(
3298
- (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
3299
- ),
3300
- "",
3301
- "For the question at hand, read what you need now \u2014 `kb_load` a base",
3302
- "(its own budget is separate), or `kb_index` for one base's shape.",
3303
- "",
3304
- "To bring this block back under budget, in order of preference:",
3305
- "- supersede or resolve stale records \u2014 the base shrinks, the knowledge keeps",
3306
- "- force a large base to index lines: `strauss-kb pin <path> --mode index`",
3307
- "- scope a pin to the profiles that need it: `strauss-kb pin <path> --profiles session-start`",
3308
- "- raise this profile's budget under `context` in .strauss/kb-pins.json",
3309
- "- unpin what no session actually needs",
3310
- ""
3311
- ].join("\n");
3783
+ };
3784
+ }
3785
+ async function matchIn(resolver, read, anchor, stored, file) {
3786
+ const source = await read(file);
3787
+ if (!source.ok) return void 0;
3788
+ const normalized = source.source.replace(/\r\n/g, "\n");
3789
+ for (const found of resolver.spans(normalized, file)) {
3790
+ const text = anchor.hash_kind === "ast" ? resolver.normalize(found.span.text, file) : found.span.text;
3791
+ if (text === null || hashAnchorText(text) !== stored) continue;
3792
+ if (file === anchor.file && found.symbol === anchor.symbol) continue;
3312
3793
  return {
3313
- block: refusal,
3314
- refused: true,
3315
- approxTokens: total,
3316
- budgetTokens,
3317
- bases
3794
+ file,
3795
+ symbol: found.symbol,
3796
+ startLine: found.span.startLine,
3797
+ endLine: found.span.endLine
3318
3798
  };
3319
3799
  }
3320
- return { block, refused: false, approxTokens: total, budgetTokens, bases };
3321
- }
3322
- function toHookJson(block, event) {
3323
- return JSON.stringify({
3324
- hookSpecificOutput: {
3325
- hookEventName: event,
3326
- additionalContext: block
3327
- }
3328
- });
3329
- }
3330
- var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
3331
- var CONTEXT_END = "<!-- strauss-kb:end -->";
3332
- async function syncInstructions(file, block) {
3333
- const existing = await (0, import_promises6.readFile)(file, "utf8").catch(() => null);
3334
- const region = block ? `${CONTEXT_BEGIN}
3335
- ${block.trim()}
3336
- ${CONTEXT_END}` : null;
3337
- if (existing === null) {
3338
- if (!region) return { file, action: "unchanged" };
3339
- await (0, import_promises6.writeFile)(file, `${region}
3340
- `, "utf8");
3341
- return { file, action: "created" };
3342
- }
3343
- const begin = existing.indexOf(CONTEXT_BEGIN);
3344
- const end = existing.indexOf(CONTEXT_END);
3345
- if (begin !== -1 && end !== -1 && end >= begin) {
3346
- const before = existing.slice(0, begin);
3347
- const after = existing.slice(end + CONTEXT_END.length);
3348
- const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
3349
- if (next === existing) return { file, action: "unchanged" };
3350
- await (0, import_promises6.writeFile)(file, next, "utf8");
3351
- return { file, action: region ? "replaced" : "removed" };
3352
- }
3353
- if (!region) return { file, action: "unchanged" };
3354
- await (0, import_promises6.writeFile)(
3355
- file,
3356
- `${existing.replace(/\n*$/, "\n\n")}${region}
3357
- `,
3358
- "utf8"
3359
- );
3360
- return { file, action: "appended" };
3361
- }
3362
-
3363
- // src/commands/context.ts
3364
- var contextCommand = define({
3365
- name: "context",
3366
- tool: "kb_context",
3367
- usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
3368
- 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.",
3369
- input: import_zod11.z.object({
3370
- budgetTokens: import_zod11.z.number().int().positive().optional().describe(
3371
- "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
3372
- ),
3373
- fullUnderTokens: import_zod11.z.number().int().positive().optional().describe(
3374
- "Per-base rendering threshold, applied before the budget: a base whose complete load fits under this arrives as full records instead of index lines, and the whole block still answers to budgetTokens. Off by default \u2014 index-only is the safe default at a context birth, because injected bodies outlive the qualifiers on them; the session-start profile opts tiny bases in at 1500."
3375
- ),
3376
- profile: import_zod11.z.string().optional().describe(
3377
- "Named budget set: built-ins are session-start (full-under 1500), compact and turn (budget 2500); the manifests' `context` tables override per repo. Unknown names fall through to defaults rather than failing."
3378
- ),
3379
- excludeTags: import_zod11.z.array(import_zod11.z.string().min(1)).optional().describe(
3380
- "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
3381
- ),
3382
- format: import_zod11.z.enum(["markdown", "json"]).optional().describe(
3383
- "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
3384
- ),
3385
- event: import_zod11.z.string().optional().describe(
3386
- "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
3387
- )
3388
- }),
3389
- fromArgv: (argv) => {
3390
- const budget = argvFlag(argv, "--budget");
3391
- const fullUnder = argvFlag(argv, "--full-under");
3392
- const profile = argvFlag(argv, "--profile");
3393
- const format = argvFlag(argv, "--format");
3394
- const event = argvFlag(argv, "--event");
3395
- const excludeTags = argvFlags(argv, "--exclude-tag");
3396
- return {
3397
- ...budget ? { budgetTokens: Number(budget) } : {},
3398
- ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
3399
- ...profile ? { profile } : {},
3400
- ...excludeTags.length ? { excludeTags } : {},
3401
- ...format ? { format } : {},
3402
- ...event ? { event } : {}
3403
- };
3404
- },
3405
- run: async ({ store }, { budgetTokens, fullUnderTokens, profile, excludeTags, format, event }) => {
3406
- const result = await buildContext(store, process.cwd(), {
3407
- ...budgetTokens ? { budgetTokens } : {},
3408
- ...fullUnderTokens ? { fullUnderTokens } : {},
3409
- ...profile ? { profile } : {},
3410
- ...excludeTags ? { excludeTags } : {},
3411
- // Degradations — a full pin that could not fit, a refused block — go
3412
- // to stderr as well as into the block itself: stderr is diagnostics on
3413
- // both surfaces (hooks discard it, MCP logs it), so an operator can
3414
- // see budget pressure without reading injected context.
3415
- warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
3416
- `)
3417
- });
3418
- if (!result.block) return "";
3419
- return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
3420
- }
3421
- });
3422
-
3423
- // src/commands/doctor.ts
3424
- var import_zod13 = require("zod");
3425
-
3426
- // src/drift/git.ts
3427
- var import_node_child_process3 = require("child_process");
3428
- var import_node_util3 = require("util");
3429
- var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
3430
- var MAX_GIT_OUTPUT_BYTES = 1048576;
3431
- var GIT_TIMEOUT_MS = 5e3;
3432
- async function git2(cwd, args) {
3433
- const env = { ...process.env };
3434
- delete env["GIT_DIR"];
3435
- delete env["GIT_WORK_TREE"];
3436
- delete env["GIT_INDEX_FILE"];
3437
- try {
3438
- const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
3439
- timeout: GIT_TIMEOUT_MS,
3440
- maxBuffer: MAX_GIT_OUTPUT_BYTES,
3441
- env
3442
- });
3443
- return { ok: true, stdout };
3444
- } catch {
3445
- return { ok: false };
3446
- }
3447
- }
3448
- async function listRepoFiles(repoRoot) {
3449
- const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
3450
- if (!result.ok) return [];
3451
- return result.stdout.split("\0").filter(Boolean);
3452
- }
3453
- async function readOldSource(repoRoot, anchor) {
3454
- if (!filePathIsSafe(anchor.file))
3455
- return { ok: false, reason: "unrecoverable" };
3456
- if (anchor.ref && refShapeIsSafe(anchor.ref)) {
3457
- const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
3458
- if (shown2 !== null) {
3459
- return {
3460
- ok: true,
3461
- source: shown2,
3462
- origin: { kind: "ref", ref: anchor.ref }
3463
- };
3464
- }
3465
- }
3466
- const at2 = anchor.resolved_at;
3467
- if (!at2 || Number.isNaN(Date.parse(at2))) {
3468
- return { ok: false, reason: "unrecoverable" };
3469
- }
3470
- const found = await git2(repoRoot, [
3471
- "log",
3472
- "-1",
3473
- "--format=%H",
3474
- `--before=${at2}`,
3475
- "--end-of-options",
3476
- "HEAD",
3477
- "--",
3478
- anchor.file
3479
- ]);
3480
- const sha = found.ok ? found.stdout.trim() : "";
3481
- if (!sha || !refShapeIsSafe(sha))
3482
- return { ok: false, reason: "unrecoverable" };
3483
- const shown = await showFile(repoRoot, sha, anchor.file);
3484
- if (shown === null) return { ok: false, reason: "unrecoverable" };
3485
- return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
3486
- }
3487
- async function showFile(repoRoot, ref, file) {
3488
- const path = file.replace(/^\.\//, "");
3489
- const result = await git2(repoRoot, [
3490
- "show",
3491
- "--end-of-options",
3492
- `${ref}:${path}`
3493
- ]);
3494
- return result.ok ? result.stdout : null;
3495
- }
3496
-
3497
- // src/drift/moved.ts
3498
- var import_promises7 = require("fs/promises");
3499
- var MAX_MOVED_SEARCH_FILES = 2e3;
3500
- var SEARCH_BATCH = 64;
3501
- function movedSearch(repoRoot, options = {}) {
3502
- const read = options.reader ?? anchorFileReader(repoRoot);
3503
- const sizeOf = options.sizeOf ?? diskSize(repoRoot);
3504
- const resolver = new TreeSitterResolver();
3505
- let repoFiles;
3506
- const prepared = /* @__PURE__ */ new Set();
3507
- const filesForLanguage = async (language) => {
3508
- repoFiles ??= listRepoFiles(repoRoot);
3509
- return (await repoFiles).filter((file) => languageForFile(file) === language).slice(0, MAX_MOVED_SEARCH_FILES);
3510
- };
3511
- return {
3512
- async find(anchor) {
3513
- const stored = anchor.hash;
3514
- if (!stored) return void 0;
3515
- const language = languageForFile(anchor.file);
3516
- if (!language) return sameFileWindow(anchor, read, stored);
3517
- const candidates = await filesForLanguage(language);
3518
- if (!prepared.has(language)) {
3519
- await resolver.prepare(candidates.length ? candidates : [anchor.file]);
3520
- prepared.add(language);
3521
- }
3522
- const floor = anchor.lines ?? 0;
3523
- for (let at2 = 0; at2 < candidates.length; at2 += SEARCH_BATCH) {
3524
- const batch = candidates.slice(at2, at2 + SEARCH_BATCH);
3525
- const hits = await mapLimit(
3526
- batch,
3527
- DEFAULT_IO_CONCURRENCY,
3528
- async (file) => {
3529
- const size2 = await sizeOf(file);
3530
- if (size2 !== null && size2 < floor) return void 0;
3531
- return matchIn(resolver, read, anchor, stored, file);
3532
- }
3533
- );
3534
- const found = hits.find((hit) => hit !== void 0);
3535
- if (found) return found;
3536
- }
3537
- return void 0;
3538
- }
3539
- };
3540
- }
3541
- async function matchIn(resolver, read, anchor, stored, file) {
3542
- const source = await read(file);
3543
- if (!source.ok) return void 0;
3544
- const normalized = source.source.replace(/\r\n/g, "\n");
3545
- for (const found of resolver.spans(normalized, file)) {
3546
- const text = anchor.hash_kind === "ast" ? resolver.normalize(found.span.text, file) : found.span.text;
3547
- if (text === null || hashAnchorText(text) !== stored) continue;
3548
- if (file === anchor.file && found.symbol === anchor.symbol) continue;
3549
- return {
3550
- file,
3551
- symbol: found.symbol,
3552
- startLine: found.span.startLine,
3553
- endLine: found.span.endLine
3554
- };
3555
- }
3556
- return void 0;
3800
+ return void 0;
3557
3801
  }
3558
3802
  function diskSize(repoRoot) {
3559
3803
  return async (file) => {
3560
3804
  const path = anchorFilePath(repoRoot, file);
3561
3805
  if (path === null) return null;
3562
3806
  try {
3563
- return (await (0, import_promises7.stat)(path)).size;
3807
+ return (await (0, import_promises6.stat)(path)).size;
3564
3808
  } catch {
3565
3809
  return null;
3566
3810
  }
@@ -3609,6 +3853,11 @@ async function classifyDrift(repoRoot, record, entries, options = {}) {
3609
3853
  );
3610
3854
  const out = [];
3611
3855
  for (const { anchor, entry } of wanted) {
3856
+ if (anchor.side === "old") {
3857
+ const settled2 = entry.class ?? "changed";
3858
+ out.push({ anchor, entry: { ...entry, class: settled2 }, class: settled2 });
3859
+ continue;
3860
+ }
3612
3861
  const movedTo = await search.find(anchor);
3613
3862
  if (movedTo) {
3614
3863
  out.push({
@@ -3682,8 +3931,8 @@ function unifiedDiff(before, after, options = {}) {
3682
3931
  }
3683
3932
  const truncated = body.length > max;
3684
3933
  const shown = truncated ? body.slice(0, max) : body;
3685
- const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
3686
- const lines = [header, ...shown];
3934
+ const header2 = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
3935
+ const lines = [header2, ...shown];
3687
3936
  if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
3688
3937
  return { text: lines.join("\n"), added, removed, truncated };
3689
3938
  }
@@ -3743,12 +3992,12 @@ async function reassessPacket(repoRoot, record, entries, options = {}) {
3743
3992
  ...options.search ? { search: options.search } : {},
3744
3993
  withHistory: options.withDiff !== false
3745
3994
  });
3746
- const open = classified.filter(
3995
+ const open2 = classified.filter(
3747
3996
  (found) => found.class === "changed" || found.class === "gone"
3748
3997
  );
3749
- if (!open.length) return { packet: null, classified };
3750
- const budget = diffBudget(open.length);
3751
- const anchors = open.map(
3998
+ if (!open2.length) return { packet: null, classified };
3999
+ const budget = diffBudget(open2.length);
4000
+ const anchors = open2.map(
3752
4001
  (found) => anchorPacket(found, options.withDiff === true, budget)
3753
4002
  );
3754
4003
  const type = record.frontmatter.type;
@@ -3787,41 +4036,893 @@ function anchorPacket(found, withDiff, maxLines) {
3787
4036
  diffSize: entry.diffSize,
3788
4037
  ...entry.movedTo ? { movedTo: entry.movedTo } : {}
3789
4038
  };
3790
- if (!withDiff) return base2;
3791
- if (found.oldText === void 0 || !found.oldOrigin) {
3792
- return { ...base2, diff: { status: "unrecoverable" } };
4039
+ if (!withDiff) return base2;
4040
+ if (found.oldText === void 0 || !found.oldOrigin) {
4041
+ return { ...base2, diff: { status: "unrecoverable" } };
4042
+ }
4043
+ const rendered = unifiedDiff(found.oldText, found.newText ?? "", {
4044
+ maxLines
4045
+ });
4046
+ return {
4047
+ ...base2,
4048
+ diff: {
4049
+ status: "ok",
4050
+ source: found.oldOrigin.kind,
4051
+ ref: found.oldOrigin.ref,
4052
+ unified: rendered.text,
4053
+ added: rendered.added,
4054
+ removed: rendered.removed,
4055
+ truncated: rendered.truncated
4056
+ }
4057
+ };
4058
+ }
4059
+ function claimOf(record) {
4060
+ const type = record.frontmatter.type;
4061
+ const section = isKbRecordType(type) ? RECORD_TYPES[type].sections[0] : void 0;
4062
+ if (!section) return null;
4063
+ const lines = record.body.replace(/\r\n/g, "\n").split("\n");
4064
+ const start = lines.findIndex(
4065
+ (line) => line.trim().toLowerCase() === `## ${section}`.toLowerCase()
4066
+ );
4067
+ if (start < 0) return null;
4068
+ const rest = lines.slice(start + 1);
4069
+ const end = rest.findIndex((line) => line.startsWith("## "));
4070
+ const text = (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
4071
+ return text ? { section, text } : null;
4072
+ }
4073
+
4074
+ // src/commands/match/command.ts
4075
+ var import_zod12 = require("zod");
4076
+
4077
+ // src/commands/match/errors.ts
4078
+ var KbMatchInputError = class extends BaseError {
4079
+ constructor(reason) {
4080
+ super({
4081
+ message: `match: ${reason}`,
4082
+ errorType: "KbMatchInput" /* KbMatchInput */,
4083
+ code: 400,
4084
+ fault: "User" /* User */,
4085
+ retriable: false,
4086
+ reportToUser: true,
4087
+ details: { reason }
4088
+ });
4089
+ this.reason = reason;
4090
+ }
4091
+ reason;
4092
+ };
4093
+
4094
+ // src/commands/match/model.ts
4095
+ var import_zod11 = require("zod");
4096
+ var diffHunkSchema = import_zod11.z.object({
4097
+ startLine: import_zod11.z.number().int().positive(),
4098
+ endLine: import_zod11.z.number().int().positive(),
4099
+ side: import_zod11.z.enum(["old", "new"]).optional()
4100
+ }).passthrough();
4101
+ var diffFileSchema = import_zod11.z.object({
4102
+ filePath: import_zod11.z.string().min(1).describe("Repo-relative, spelled the way anchors are."),
4103
+ hunks: import_zod11.z.array(diffHunkSchema)
4104
+ });
4105
+ var symbolRangeSchema = import_zod11.z.object({
4106
+ file: import_zod11.z.string().min(1),
4107
+ symbol: import_zod11.z.string().min(1),
4108
+ startLine: import_zod11.z.number().int().positive(),
4109
+ endLine: import_zod11.z.number().int().positive()
4110
+ });
4111
+
4112
+ // src/commands/match/parse-unified-diff.ts
4113
+ var FILE_HEADER = /^diff --git (.+)$/;
4114
+ var HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
4115
+ var SIMILARITY = /^similarity index (\d+)%$/;
4116
+ var KNOWN_PREFIX = /^[ab]\//;
4117
+ function parseUnifiedDiff(patch, options = {}) {
4118
+ const files = [];
4119
+ let current;
4120
+ let listed = false;
4121
+ let shared;
4122
+ let oldPath;
4123
+ let rename4 = {};
4124
+ let inHeader = false;
4125
+ let added;
4126
+ let removed;
4127
+ const list = () => {
4128
+ if (!current || listed) return;
4129
+ files.push(current);
4130
+ listed = true;
4131
+ };
4132
+ const open2 = (filePath) => {
4133
+ current = { filePath, hunks: [], ...rename4 };
4134
+ listed = false;
4135
+ };
4136
+ const amend = () => {
4137
+ if (current) Object.assign(current, rename4);
4138
+ };
4139
+ const close = () => {
4140
+ if (options.keepEmpty) list();
4141
+ current = void 0;
4142
+ listed = false;
4143
+ added = void 0;
4144
+ removed = void 0;
4145
+ };
4146
+ for (const raw of patch.split("\n")) {
4147
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
4148
+ const start = FILE_HEADER.exec(line);
4149
+ if (start) {
4150
+ close();
4151
+ oldPath = void 0;
4152
+ rename4 = {};
4153
+ shared = sharedHeaderPath(start[1]);
4154
+ inHeader = true;
4155
+ if (shared) open2(shared);
4156
+ continue;
4157
+ }
4158
+ if (inHeader) {
4159
+ const similarity = SIMILARITY.exec(line);
4160
+ if (similarity) {
4161
+ rename4.similarity = Number(similarity[1]);
4162
+ amend();
4163
+ continue;
4164
+ }
4165
+ if (line.startsWith("rename from ")) {
4166
+ rename4.renamedFrom = unquote(line.slice(12).trim());
4167
+ amend();
4168
+ continue;
4169
+ }
4170
+ if (line.startsWith("rename to ")) {
4171
+ open2(unquote(line.slice(10).trim()));
4172
+ continue;
4173
+ }
4174
+ if (line.startsWith("--- ")) {
4175
+ oldPath = sidePath(line.slice(4), shared);
4176
+ continue;
4177
+ }
4178
+ if (line.startsWith("+++ ")) {
4179
+ const path = sidePath(line.slice(4), shared) ?? oldPath;
4180
+ if (path) open2(path);
4181
+ else current = void 0;
4182
+ continue;
4183
+ }
4184
+ }
4185
+ const hunk = HUNK.exec(line);
4186
+ if (!hunk) {
4187
+ if (!options.withLines || inHeader) continue;
4188
+ if (line.startsWith("+")) added?.lines?.push(line.slice(1));
4189
+ else if (line.startsWith("-")) removed?.lines?.push(line.slice(1));
4190
+ continue;
4191
+ }
4192
+ inHeader = false;
4193
+ added = void 0;
4194
+ removed = void 0;
4195
+ if (!current) continue;
4196
+ const [next, before] = hunksOf(hunk, options.withLines === true);
4197
+ added = next;
4198
+ removed = before;
4199
+ list();
4200
+ current.hunks.push(...before ? [next, before] : [next]);
4201
+ }
4202
+ close();
4203
+ return files;
4204
+ }
4205
+ function hunksOf(hunk, withLines) {
4206
+ const oldStart = Number(hunk[1]);
4207
+ const oldCount = hunk[2] === void 0 ? 1 : Number(hunk[2]);
4208
+ const newStart = Number(hunk[3]);
4209
+ const newCount = hunk[4] === void 0 ? 1 : Number(hunk[4]);
4210
+ const lines = withLines ? { lines: [] } : {};
4211
+ const added = newCount === 0 ? { ...point(newStart), ...lines } : { startLine: newStart, endLine: newStart + newCount - 1, ...lines };
4212
+ if (oldCount === 0) return [added];
4213
+ return [
4214
+ added,
4215
+ {
4216
+ startLine: oldStart,
4217
+ endLine: oldStart + oldCount - 1,
4218
+ side: "old",
4219
+ ...withLines ? { lines: [] } : {}
4220
+ }
4221
+ ];
4222
+ }
4223
+ function point(start) {
4224
+ const at2 = Math.max(1, start);
4225
+ return { startLine: at2, endLine: at2 };
4226
+ }
4227
+ function sidePath(raw, shared) {
4228
+ const text = unquote(raw.replace(/\t.*$/, "").trim());
4229
+ if (text === "/dev/null") return void 0;
4230
+ if (KNOWN_PREFIX.test(text)) return text.slice(2);
4231
+ return shared ?? text;
4232
+ }
4233
+ function sharedHeaderPath(rest) {
4234
+ const pair = splitHeaderPair(rest);
4235
+ if (!pair || pair[0] === pair[1]) return void 0;
4236
+ const from = unquote(pair[0]).split("/");
4237
+ const to = unquote(pair[1]).split("/");
4238
+ const shared = [];
4239
+ while (from.length > 1 && to.length > 1 && from.at(-1) === to.at(-1)) {
4240
+ shared.unshift(from.pop());
4241
+ to.pop();
4242
+ }
4243
+ return shared.length ? shared.join("/") : void 0;
4244
+ }
4245
+ function splitHeaderPair(rest) {
4246
+ if (rest.startsWith('"')) {
4247
+ const end = endOfQuoted(rest);
4248
+ if (end < 0 || rest[end + 1] !== " ") return void 0;
4249
+ return [rest.slice(0, end + 1), rest.slice(end + 2)];
4250
+ }
4251
+ const mid = (rest.length - 1) / 2;
4252
+ if (Number.isInteger(mid) && rest[mid] === " ") {
4253
+ return [rest.slice(0, mid), rest.slice(mid + 1)];
4254
+ }
4255
+ const at2 = rest.indexOf(" ");
4256
+ return at2 === -1 ? void 0 : [rest.slice(0, at2), rest.slice(at2 + 1)];
4257
+ }
4258
+ function endOfQuoted(text) {
4259
+ for (let at2 = 1; at2 < text.length; at2 += 1) {
4260
+ if (text[at2] === "\\") {
4261
+ at2 += 1;
4262
+ continue;
4263
+ }
4264
+ if (text[at2] === '"') return at2;
4265
+ }
4266
+ return -1;
4267
+ }
4268
+ var ESCAPES = {
4269
+ a: 7,
4270
+ b: 8,
4271
+ f: 12,
4272
+ n: 10,
4273
+ r: 13,
4274
+ t: 9,
4275
+ v: 11,
4276
+ '"': 34,
4277
+ "\\": 92
4278
+ };
4279
+ var OCTAL = /^[0-7]{3}/;
4280
+ var utf8 = new TextEncoder();
4281
+ function unquote(text) {
4282
+ if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) {
4283
+ return text;
4284
+ }
4285
+ const body = text.slice(1, -1);
4286
+ const bytes = [];
4287
+ for (let at2 = 0; at2 < body.length; ) {
4288
+ const slash = body.indexOf("\\", at2);
4289
+ if (slash < 0) {
4290
+ bytes.push(...utf8.encode(body.slice(at2)));
4291
+ break;
4292
+ }
4293
+ if (slash > at2) bytes.push(...utf8.encode(body.slice(at2, slash)));
4294
+ const octal = OCTAL.exec(body.slice(slash + 1, slash + 4));
4295
+ if (octal) {
4296
+ bytes.push(Number.parseInt(octal[0], 8));
4297
+ at2 = slash + 4;
4298
+ continue;
4299
+ }
4300
+ const next = body[slash + 1];
4301
+ if (next === void 0) {
4302
+ bytes.push(ESCAPES["\\"]);
4303
+ break;
4304
+ }
4305
+ const mapped = ESCAPES[next];
4306
+ if (mapped === void 0) bytes.push(...utf8.encode(next));
4307
+ else bytes.push(mapped);
4308
+ at2 = slash + 2;
4309
+ }
4310
+ return new TextDecoder().decode(Uint8Array.from(bytes));
4311
+ }
4312
+
4313
+ // src/commands/match/symbol-ranges.ts
4314
+ async function resolveSymbolRanges(repoRoot, files, records, offline = false) {
4315
+ const changed = new Set(files.map((file) => strip(file.filePath)));
4316
+ const wanted = [];
4317
+ const seen = /* @__PURE__ */ new Set();
4318
+ for (const record of records) {
4319
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
4320
+ if (!anchor.symbol || anchor.repo) continue;
4321
+ if (!changed.has(strip(anchor.file))) continue;
4322
+ const key2 = `${strip(anchor.file)}#${anchor.symbol}`;
4323
+ if (seen.has(key2)) continue;
4324
+ seen.add(key2);
4325
+ wanted.push(anchor);
4326
+ }
4327
+ }
4328
+ if (!wanted.length) return [];
4329
+ const paths = [...new Set(wanted.map((anchor) => anchor.file))];
4330
+ const sources = await readAnchorFiles(paths, anchorFileReader(repoRoot));
4331
+ const resolvers = defaultAnchorResolvers({ offline });
4332
+ await prepareResolvers(resolvers, paths);
4333
+ const ranges = [];
4334
+ for (const anchor of wanted) {
4335
+ const read = sources.get(anchor.file);
4336
+ if (!read?.ok) continue;
4337
+ const outcome = resolveAnchorSpan(read.source, anchor, resolvers);
4338
+ if (!outcome.ok) continue;
4339
+ ranges.push({
4340
+ file: anchor.file,
4341
+ symbol: anchor.symbol,
4342
+ startLine: outcome.span.startLine,
4343
+ endLine: outcome.span.endLine
4344
+ });
4345
+ }
4346
+ return ranges;
4347
+ }
4348
+ function strip(path) {
4349
+ return path.replace(/^\.\//, "");
4350
+ }
4351
+
4352
+ // src/commands/match/command.ts
4353
+ var matchCommand = define({
4354
+ name: "match",
4355
+ tool: "kb_match",
4356
+ usage: "match --git <base>..<head> | --stdin [--repo-root <path>] [--offline] [--include-non-current]",
4357
+ description: "Which records sit on each changed hunk: the anchored records per file range, current first, each with its standing and the anchor that matched. kb_load hands over a whole base; this narrows a diff. Symbol ranges resolve from repoRoot when omitted; non-current records need includeNonCurrent.",
4358
+ input: import_zod12.z.object({
4359
+ bundlePath,
4360
+ files: import_zod12.z.array(diffFileSchema).describe("The changed files, each with its post-change line ranges."),
4361
+ symbolRanges: import_zod12.z.array(symbolRangeSchema).optional().describe(
4362
+ "Symbol spans the caller already has. Resolved from repoRoot when omitted."
4363
+ ),
4364
+ repoRoot: REPO_ROOT,
4365
+ offline: import_zod12.z.boolean().optional().describe(
4366
+ "Resolve symbol ranges from what is already on disk, never fetching a grammar."
4367
+ ),
4368
+ includeNonCurrent: import_zod12.z.boolean().optional().describe(
4369
+ "Return superseded, rejected and unsettled records too, each carrying its standing."
4370
+ )
4371
+ }),
4372
+ fromArgv: async (argv, path, stdin) => {
4373
+ const repoRoot = argvFlag(argv, "--repo-root");
4374
+ const range = argvFlag(argv, "--git");
4375
+ const base2 = {
4376
+ bundlePath: path,
4377
+ ...repoRoot !== void 0 ? { repoRoot } : {},
4378
+ ...argv.includes("--offline") ? { offline: true } : {},
4379
+ ...argv.includes("--include-non-current") ? { includeNonCurrent: true } : {}
4380
+ };
4381
+ if (range !== void 0) {
4382
+ const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
4383
+ if (!diff.ok) {
4384
+ throw new KbMatchInputError(`--git ${range} ${REFUSED[diff.reason]}`);
4385
+ }
4386
+ return { ...base2, files: parseUnifiedDiff(diff.text) };
4387
+ }
4388
+ if (!argv.includes("--stdin")) {
4389
+ throw new KbMatchInputError(
4390
+ "pass --git <base>..<head>, or --stdin with { files } as JSON"
4391
+ );
4392
+ }
4393
+ return { ...base2, ...fromStdin(await stdin()) };
4394
+ },
4395
+ run: async ({ store }, {
4396
+ bundlePath: path,
4397
+ files,
4398
+ symbolRanges,
4399
+ repoRoot,
4400
+ offline,
4401
+ includeNonCurrent
4402
+ }) => {
4403
+ const records = await store.list(path);
4404
+ const ranges = symbolRanges ?? await resolveSymbolRanges(
4405
+ repoRoot ?? process.cwd(),
4406
+ files,
4407
+ records,
4408
+ offline === true
4409
+ );
4410
+ const index2 = symbolRangeIndex(ranges);
4411
+ return matchToDiff(files, records, { symbolRanges: ranges }).flatMap(
4412
+ (match) => project(match, index2, includeNonCurrent === true)
4413
+ );
4414
+ }
4415
+ });
4416
+ var REFUSED = {
4417
+ "bad-range": "is not a range git could read here \u2014 both halves of <base>..<head> are required",
4418
+ "too-large": "diffs to a patch past the output cap \u2014 narrow the range",
4419
+ timeout: "took longer to diff than the runner allows \u2014 narrow the range",
4420
+ "git-missing": "needs git on PATH, and there is none"
4421
+ };
4422
+ function fromStdin(text) {
4423
+ let payload;
4424
+ try {
4425
+ payload = JSON.parse(text);
4426
+ } catch {
4427
+ throw new KbMatchInputError("stdin is not JSON");
4428
+ }
4429
+ if (!Array.isArray(payload?.files)) {
4430
+ throw new KbMatchInputError("stdin needs a files array");
4431
+ }
4432
+ return {
4433
+ files: payload.files,
4434
+ ...payload.symbolRanges !== void 0 ? { symbolRanges: payload.symbolRanges } : {}
4435
+ };
4436
+ }
4437
+ function project(match, ranges, all) {
4438
+ const kept = all ? match.records : match.records.filter((hit) => hit.standing === "current");
4439
+ if (!kept.length) return [];
4440
+ const placed = kept.map((hit) => ({
4441
+ hit,
4442
+ at: placeOnHunk(hit.record, match.filePath, match.hunk, ranges)
4443
+ }));
4444
+ return [
4445
+ {
4446
+ filePath: match.filePath,
4447
+ hunk: match.hunk,
4448
+ // Over the records returned, not the ones matched: a hunk holding only
4449
+ // symbol-placed records is not `file` because a dropped one was.
4450
+ precision: placed.every(({ at: at2 }) => at2.kind === "symbol") ? "symbol" : "file",
4451
+ records: placed.map(({ hit, at: { anchor } }) => {
4452
+ const { frontmatter } = hit.record;
4453
+ return {
4454
+ conceptId: hit.record.conceptId,
4455
+ type: frontmatter.type,
4456
+ title: frontmatter.title ?? null,
4457
+ standing: hit.standing,
4458
+ status: frontmatter.strauss_status,
4459
+ supersededBy: hit.heads.map((head) => head.conceptId),
4460
+ ...frontmatter.strauss_materiality ? { materiality: frontmatter.strauss_materiality } : {},
4461
+ ...frontmatter.strauss_confidence ? { confidence: frontmatter.strauss_confidence } : {},
4462
+ ...frontmatter.tags?.length ? { tags: frontmatter.tags } : {},
4463
+ ...anchor ? { anchor } : {}
4464
+ };
4465
+ })
4466
+ }
4467
+ ];
4468
+ }
4469
+
4470
+ // src/commands/classify.ts
4471
+ var classifyFileSchema = diffFileSchema.extend({
4472
+ hunks: import_zod13.z.array(
4473
+ diffHunkSchema.extend({ lines: import_zod13.z.array(import_zod13.z.string()).optional() })
4474
+ ),
4475
+ renamedFrom: import_zod13.z.string().min(1).optional().describe("Where `git diff -M` says the path came from."),
4476
+ similarity: import_zod13.z.number().min(0).max(100).optional()
4477
+ });
4478
+ var classifyCommand = define({
4479
+ name: "classify",
4480
+ tool: "kb_classify",
4481
+ usage: "classify --git <base>..<head> | --stdin [--repo-root <path>] [--offline]",
4482
+ description: "What kind of change each file carries: test, config, ci, docs, lockfile, generated, boilerplate, rename or source, with the rule that decided it. Derived from the diff and never stored; a `review:generated`, `review:boilerplate` or `review:move` fact anchored on a file overrides the heuristic. kb_match says what sits on a hunk; this says whether to read it.",
4483
+ input: import_zod13.z.object({
4484
+ bundlePath,
4485
+ files: import_zod13.z.array(classifyFileSchema).describe("The changed files, each with its line ranges."),
4486
+ repoRoot: REPO_ROOT,
4487
+ offline: import_zod13.z.boolean().optional().describe(
4488
+ "Resolve symbol ranges from what is already on disk, never fetching a grammar."
4489
+ )
4490
+ }),
4491
+ fromArgv: async (argv, path, stdin) => {
4492
+ const repoRoot = argvFlag(argv, "--repo-root");
4493
+ const range = argvFlag(argv, "--git");
4494
+ const base2 = {
4495
+ bundlePath: path,
4496
+ ...repoRoot !== void 0 ? { repoRoot } : {},
4497
+ ...argv.includes("--offline") ? { offline: true } : {}
4498
+ };
4499
+ if (range !== void 0) {
4500
+ const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
4501
+ if (!diff.ok) {
4502
+ throw new KbClassifyInputError(
4503
+ `--git ${range} ${REFUSED2[diff.reason]}`
4504
+ );
4505
+ }
4506
+ return {
4507
+ ...base2,
4508
+ files: parseUnifiedDiff(diff.text, {
4509
+ keepEmpty: true,
4510
+ withLines: true
4511
+ })
4512
+ };
4513
+ }
4514
+ if (!argv.includes("--stdin")) {
4515
+ throw new KbClassifyInputError(
4516
+ "pass --git <base>..<head>, or --stdin with { files } as JSON"
4517
+ );
4518
+ }
4519
+ return { ...base2, files: fromStdin2(await stdin()) };
4520
+ },
4521
+ run: async ({ store }, { bundlePath: path, files, repoRoot, offline }) => {
4522
+ const records = await store.list(path);
4523
+ const root = repoRoot ?? process.cwd();
4524
+ const withHeaders = await mapLimit2(files, READERS, async (file) => ({
4525
+ ...file,
4526
+ header: await header(root, file)
4527
+ }));
4528
+ const symbolRanges = await resolveSymbolRanges(
4529
+ root,
4530
+ files,
4531
+ records,
4532
+ offline === true
4533
+ );
4534
+ return { files: classifyDiff(withHeaders, { records, symbolRanges }) };
4535
+ },
4536
+ render: (result) => renderClassify(result)
4537
+ });
4538
+ var HEADER_BYTES = 65536;
4539
+ var READERS = 16;
4540
+ async function header(root, file) {
4541
+ if (!filePathIsSafe(file.filePath)) return void 0;
4542
+ let handle;
4543
+ try {
4544
+ handle = await (0, import_promises7.open)((0, import_node_path10.join)(root, file.filePath), "r");
4545
+ const buffer = import_node_buffer.Buffer.alloc(HEADER_BYTES);
4546
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_BYTES, 0);
4547
+ return buffer.toString("utf8", 0, bytesRead).split("\n").slice(0, HEADER_LINES);
4548
+ } catch {
4549
+ return void 0;
4550
+ } finally {
4551
+ await handle?.close();
4552
+ }
4553
+ }
4554
+ async function mapLimit2(items, limit, run) {
4555
+ const out = Array.from({ length: items.length });
4556
+ let next = 0;
4557
+ const worker = async () => {
4558
+ while (next < items.length) {
4559
+ const at2 = next;
4560
+ next += 1;
4561
+ out[at2] = await run(items[at2]);
4562
+ }
4563
+ };
4564
+ await Promise.all(
4565
+ Array.from({ length: Math.min(limit, items.length) }, () => worker())
4566
+ );
4567
+ return out;
4568
+ }
4569
+ var REFUSED2 = {
4570
+ "bad-range": "is not a range git could read here \u2014 both halves of <base>..<head> are required",
4571
+ "too-large": "diffs to a patch past the output cap \u2014 narrow the range",
4572
+ timeout: "took longer to diff than the runner allows \u2014 narrow the range",
4573
+ "git-missing": "needs git on PATH, and there is none"
4574
+ };
4575
+ function fromStdin2(text) {
4576
+ let payload;
4577
+ try {
4578
+ payload = JSON.parse(text);
4579
+ } catch {
4580
+ throw new KbClassifyInputError("stdin is not JSON");
4581
+ }
4582
+ if (!Array.isArray(payload?.files)) {
4583
+ throw new KbClassifyInputError("stdin needs a files array");
4584
+ }
4585
+ return payload.files;
4586
+ }
4587
+ function renderClassify(result) {
4588
+ const width2 = Math.max(
4589
+ 0,
4590
+ ...result.files.map((file) => file.class.length)
4591
+ );
4592
+ return result.files.map(
4593
+ (file) => `${file.class.padEnd(width2)} ${file.filePath} (${file.reason})`
4594
+ ).join("\n");
4595
+ }
4596
+
4597
+ // src/commands/context.ts
4598
+ var import_zod14 = require("zod");
4599
+
4600
+ // src/kb-context.ts
4601
+ var import_promises8 = require("fs/promises");
4602
+
4603
+ // src/kb-index.ts
4604
+ var INDEX_FILE = "INDEX.md";
4605
+ var HEADING = "# KB Index";
4606
+ function renderIndex(records) {
4607
+ const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map(renderIndexLine);
4608
+ return `${HEADING}
4609
+
4610
+ ${lines.join("\n")}
4611
+ `;
4612
+ }
4613
+ function renderIndexLine(record) {
4614
+ const { frontmatter: fm } = record;
4615
+ const parts = [fm.type, fm.strauss_status];
4616
+ if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
4617
+ if (fm.description) parts.push(fm.description);
4618
+ return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
4619
+ }
4620
+ function indexIsStale(stored, expected) {
4621
+ return stored !== expected;
4622
+ }
4623
+
4624
+ // src/kb-context.ts
4625
+ var HEADING2 = "## Knowledge bases (pinned)";
4626
+ var DEFAULT_CONTEXT_BUDGET = 4e3;
4627
+ var CONTEXT_PROFILES = {
4628
+ "session-start": { fullUnderTokens: 1500 },
4629
+ compact: { budgetTokens: 2500 },
4630
+ turn: { budgetTokens: 2500 }
4631
+ };
4632
+ function approxTokens(text) {
4633
+ return Math.ceil(text.length / 4);
4634
+ }
4635
+ function preamble() {
4636
+ return [
4637
+ HEADING2,
4638
+ "",
4639
+ "What follows is an index of this workspace's pinned knowledge bases \u2014",
4640
+ "concept ids, titles and standing only. The record bodies are NOT in this",
4641
+ "context.",
4642
+ "",
4643
+ "Consult records only through the strauss-kb MCP tools: `kb_load` (the",
4644
+ "preferred first call), `kb_query`, and `kb_trace`, passing the",
4645
+ "`bundlePath` listed with each base. Do not read record files directly:",
4646
+ "a raw file read bypasses supersession resolution, and a superseded or",
4647
+ "rejected record file reads exactly like a current one \u2014 only the store",
4648
+ "resolves chains and standing.",
4649
+ "",
4650
+ "KB content loaded earlier in a long session may have been compacted",
4651
+ "away. Before answering a question one of these bases governs, load it",
4652
+ "again at the point of use \u2014 reloading a small base costs a few thousand",
4653
+ "tokens."
4654
+ ].join("\n");
4655
+ }
4656
+ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens, excludeTags) {
4657
+ const bundle = await store.list(absolutePath);
4658
+ if (bundle.length === 0) {
4659
+ return {
4660
+ path,
4661
+ absolutePath,
4662
+ mode: "empty",
4663
+ body: "No readable records yet \u2014 pinned ahead of being populated."
4664
+ };
4665
+ }
4666
+ const fullCap = pinMode === "full" ? budgetTokens : pinMode === "index" ? 0 : fullUnderTokens;
4667
+ let degradedFrom;
4668
+ if (fullCap > 0) {
4669
+ const full = await store.load(absolutePath, {
4670
+ budgetTokens: fullCap,
4671
+ excludeTags
4672
+ });
4673
+ if (!full.loaded && pinMode === "full") {
4674
+ degradedFrom = { approxTokens: full.approxTokens };
4675
+ }
4676
+ if (full.loaded) {
4677
+ const records = full.records.map(
4678
+ (hit) => [
4679
+ `#### ${hit.record.conceptId} \u2014 ${hit.record.frontmatter.title ?? "(untitled)"} (${hit.standing})`,
4680
+ "",
4681
+ hit.record.body.trim()
4682
+ ].join("\n")
4683
+ );
4684
+ const superseded2 = full.superseded.map(
4685
+ (entry) => `- \`${entry.conceptId}\` \u2192 superseded by ${entry.supersededBy.map((id) => `\`${id}\``).join(", ") || "(missing replacement)"}`
4686
+ );
4687
+ return {
4688
+ path,
4689
+ absolutePath,
4690
+ mode: "full",
4691
+ body: [
4692
+ ...records,
4693
+ ...superseded2.length ? [
4694
+ "#### Superseded (bodies withheld \u2014 kb_trace reaches them)",
4695
+ ...superseded2
4696
+ ] : []
4697
+ ].join("\n\n")
4698
+ };
4699
+ }
4700
+ }
4701
+ const adjudicated = adjudicate(bundle, bundle).filter(
4702
+ (hit) => matchesTags(hit.record, { excludeTags })
4703
+ );
4704
+ const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
4705
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
4706
+ (hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
4707
+ );
4708
+ return {
4709
+ path,
4710
+ absolutePath,
4711
+ mode: "index",
4712
+ body: [...lines, ...superseded].join("\n"),
4713
+ ...degradedFrom ? { degradedFrom } : {}
4714
+ };
4715
+ }
4716
+ async function buildContext(store, workspaceDir, options = {}) {
4717
+ const builtin = options.profile ? CONTEXT_PROFILES[options.profile] ?? {} : {};
4718
+ let budgetTokens = options.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
4719
+ let fullUnderTokens = options.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
4720
+ const merged = await readMergedPins(workspaceDir);
4721
+ const fromManifest = mergedContextBudgets(merged, options.profile);
4722
+ budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
4723
+ fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
4724
+ const excludeTags = options.excludeTags ?? fromManifest.excludeTags ?? builtin.excludeTags ?? [];
4725
+ const pins = merged.pins.filter(
4726
+ (pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
4727
+ );
4728
+ if (pins.length === 0) {
4729
+ return {
4730
+ block: "",
4731
+ refused: false,
4732
+ approxTokens: 0,
4733
+ budgetTokens,
4734
+ bases: []
4735
+ };
4736
+ }
4737
+ const sections = await Promise.all(
4738
+ pins.map(async (pin) => ({
4739
+ section: await renderBase(
4740
+ store,
4741
+ pin.path,
4742
+ pin.absolutePath,
4743
+ fullUnderTokens,
4744
+ pin.mode,
4745
+ budgetTokens,
4746
+ excludeTags
4747
+ ),
4748
+ frozen: pin.frozen === true
4749
+ }))
4750
+ );
4751
+ const modeLabel = {
4752
+ index: "index only \u2014 record bodies are not here",
4753
+ full: "full records \u2014 this base arrives whole",
4754
+ empty: "empty"
4755
+ };
4756
+ for (const { section } of sections) {
4757
+ if (section.degradedFrom) {
4758
+ options.warn?.({
4759
+ operation: "kb.context.full-pin-degraded",
4760
+ path: section.path,
4761
+ approxTokens: section.degradedFrom.approxTokens,
4762
+ budgetTokens
4763
+ });
4764
+ }
3793
4765
  }
3794
- const rendered = unifiedDiff(found.oldText, found.newText ?? "", {
3795
- maxLines
4766
+ const rendered = sections.map(({ section, frozen }) => {
4767
+ const label = section.degradedFrom ? `index only \u2014 pinned \`mode: full\`, but its ~${section.degradedFrom.approxTokens} tokens exceed this block's ${budgetTokens}-token budget; kb_load it directly (load's budget is separate), or raise this profile's budget` : modeLabel[section.mode];
4768
+ return [
4769
+ `### ${section.path} (${label}${frozen ? " \xB7 frozen, read-only" : ""})`,
4770
+ "",
4771
+ `bundlePath: \`${section.absolutePath}\``,
4772
+ "",
4773
+ section.body
4774
+ ].join("\n");
3796
4775
  });
3797
- return {
3798
- ...base2,
3799
- diff: {
3800
- status: "ok",
3801
- source: found.oldOrigin.kind,
3802
- ref: found.oldOrigin.ref,
3803
- unified: rendered.text,
3804
- added: rendered.added,
3805
- removed: rendered.removed,
3806
- truncated: rendered.truncated
4776
+ const block = [preamble(), "", rendered.join("\n\n"), ""].join("\n");
4777
+ const bases = sections.map(({ section }) => ({
4778
+ path: section.path,
4779
+ absolutePath: section.absolutePath,
4780
+ approxTokens: approxTokens(section.body)
4781
+ }));
4782
+ const total = approxTokens(block);
4783
+ if (total > budgetTokens) {
4784
+ options.warn?.({
4785
+ operation: "kb.context.refused",
4786
+ approxTokens: total,
4787
+ budgetTokens,
4788
+ bases: bases.map((base2) => base2.path)
4789
+ });
4790
+ const refusal = [
4791
+ HEADING2,
4792
+ "",
4793
+ `The pinned index runs to ~${total} tokens, past the ${budgetTokens}-token`,
4794
+ "budget, and was not emitted \u2014 a truncated index is indistinguishable",
4795
+ "from a complete one. The pinned bases:",
4796
+ "",
4797
+ ...bases.map(
4798
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
4799
+ ),
4800
+ "",
4801
+ "For the question at hand, read what you need now \u2014 `kb_load` a base",
4802
+ "(its own budget is separate), or `kb_index` for one base's shape.",
4803
+ "",
4804
+ "To bring this block back under budget, in order of preference:",
4805
+ "- supersede or resolve stale records \u2014 the base shrinks, the knowledge keeps",
4806
+ "- force a large base to index lines: `strauss-kb pin <path> --mode index`",
4807
+ "- scope a pin to the profiles that need it: `strauss-kb pin <path> --profiles session-start`",
4808
+ "- raise this profile's budget under `context` in .strauss/kb-pins.json",
4809
+ "- unpin what no session actually needs",
4810
+ ""
4811
+ ].join("\n");
4812
+ return {
4813
+ block: refusal,
4814
+ refused: true,
4815
+ approxTokens: total,
4816
+ budgetTokens,
4817
+ bases
4818
+ };
4819
+ }
4820
+ return { block, refused: false, approxTokens: total, budgetTokens, bases };
4821
+ }
4822
+ function toHookJson(block, event) {
4823
+ return JSON.stringify({
4824
+ hookSpecificOutput: {
4825
+ hookEventName: event,
4826
+ additionalContext: block
3807
4827
  }
3808
- };
4828
+ });
3809
4829
  }
3810
- function claimOf(record) {
3811
- const type = record.frontmatter.type;
3812
- const section = isKbRecordType(type) ? RECORD_TYPES[type].sections[0] : void 0;
3813
- if (!section) return null;
3814
- const lines = record.body.replace(/\r\n/g, "\n").split("\n");
3815
- const start = lines.findIndex(
3816
- (line) => line.trim().toLowerCase() === `## ${section}`.toLowerCase()
4830
+ var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
4831
+ var CONTEXT_END = "<!-- strauss-kb:end -->";
4832
+ async function syncInstructions(file, block) {
4833
+ const existing = await (0, import_promises8.readFile)(file, "utf8").catch(() => null);
4834
+ const region = block ? `${CONTEXT_BEGIN}
4835
+ ${block.trim()}
4836
+ ${CONTEXT_END}` : null;
4837
+ if (existing === null) {
4838
+ if (!region) return { file, action: "unchanged" };
4839
+ await (0, import_promises8.writeFile)(file, `${region}
4840
+ `, "utf8");
4841
+ return { file, action: "created" };
4842
+ }
4843
+ const begin = existing.indexOf(CONTEXT_BEGIN);
4844
+ const end = existing.indexOf(CONTEXT_END);
4845
+ if (begin !== -1 && end !== -1 && end >= begin) {
4846
+ const before = existing.slice(0, begin);
4847
+ const after = existing.slice(end + CONTEXT_END.length);
4848
+ const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
4849
+ if (next === existing) return { file, action: "unchanged" };
4850
+ await (0, import_promises8.writeFile)(file, next, "utf8");
4851
+ return { file, action: region ? "replaced" : "removed" };
4852
+ }
4853
+ if (!region) return { file, action: "unchanged" };
4854
+ await (0, import_promises8.writeFile)(
4855
+ file,
4856
+ `${existing.replace(/\n*$/, "\n\n")}${region}
4857
+ `,
4858
+ "utf8"
3817
4859
  );
3818
- if (start < 0) return null;
3819
- const rest = lines.slice(start + 1);
3820
- const end = rest.findIndex((line) => line.startsWith("## "));
3821
- const text = (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
3822
- return text ? { section, text } : null;
4860
+ return { file, action: "appended" };
3823
4861
  }
3824
4862
 
4863
+ // src/commands/context.ts
4864
+ var contextCommand = define({
4865
+ name: "context",
4866
+ tool: "kb_context",
4867
+ usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
4868
+ 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.",
4869
+ input: import_zod14.z.object({
4870
+ budgetTokens: import_zod14.z.number().int().positive().optional().describe(
4871
+ "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
4872
+ ),
4873
+ fullUnderTokens: import_zod14.z.number().int().positive().optional().describe(
4874
+ "Per-base rendering threshold, applied before the budget: a base whose complete load fits under this arrives as full records instead of index lines, and the whole block still answers to budgetTokens. Off by default \u2014 index-only is the safe default at a context birth, because injected bodies outlive the qualifiers on them; the session-start profile opts tiny bases in at 1500."
4875
+ ),
4876
+ profile: import_zod14.z.string().optional().describe(
4877
+ "Named budget set: built-ins are session-start (full-under 1500), compact and turn (budget 2500); the manifests' `context` tables override per repo. Unknown names fall through to defaults rather than failing."
4878
+ ),
4879
+ excludeTags: import_zod14.z.array(import_zod14.z.string().min(1)).optional().describe(
4880
+ "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
4881
+ ),
4882
+ format: import_zod14.z.enum(["markdown", "json"]).optional().describe(
4883
+ "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
4884
+ ),
4885
+ event: import_zod14.z.string().optional().describe(
4886
+ "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
4887
+ )
4888
+ }),
4889
+ fromArgv: (argv) => {
4890
+ const budget = argvFlag(argv, "--budget");
4891
+ const fullUnder = argvFlag(argv, "--full-under");
4892
+ const profile = argvFlag(argv, "--profile");
4893
+ const format = argvFlag(argv, "--format");
4894
+ const event = argvFlag(argv, "--event");
4895
+ const excludeTags = argvFlags(argv, "--exclude-tag");
4896
+ return {
4897
+ ...budget ? { budgetTokens: Number(budget) } : {},
4898
+ ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
4899
+ ...profile ? { profile } : {},
4900
+ ...excludeTags.length ? { excludeTags } : {},
4901
+ ...format ? { format } : {},
4902
+ ...event ? { event } : {}
4903
+ };
4904
+ },
4905
+ run: async ({ store }, { budgetTokens, fullUnderTokens, profile, excludeTags, format, event }) => {
4906
+ const result = await buildContext(store, process.cwd(), {
4907
+ ...budgetTokens ? { budgetTokens } : {},
4908
+ ...fullUnderTokens ? { fullUnderTokens } : {},
4909
+ ...profile ? { profile } : {},
4910
+ ...excludeTags ? { excludeTags } : {},
4911
+ // Degradations — a full pin that could not fit, a refused block — go
4912
+ // to stderr as well as into the block itself: stderr is diagnostics on
4913
+ // both surfaces (hooks discard it, MCP logs it), so an operator can
4914
+ // see budget pressure without reading injected context.
4915
+ warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
4916
+ `)
4917
+ });
4918
+ if (!result.block) return "";
4919
+ return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
4920
+ }
4921
+ });
4922
+
4923
+ // src/commands/doctor.ts
4924
+ var import_zod16 = require("zod");
4925
+
3825
4926
  // src/kb-edges.ts
3826
4927
  var KB_EDGE_KINDS = [
3827
4928
  "body-link",
@@ -3974,6 +5075,34 @@ function validateBundle(records) {
3974
5075
  }
3975
5076
  }
3976
5077
  for (const anchor of fm.strauss_anchors ?? []) {
5078
+ if (anchor.span && anchor.symbol) {
5079
+ report(
5080
+ "anchor_span",
5081
+ conceptId2,
5082
+ `anchor ${anchor.file} names both a symbol and a span \u2014 one or the other`
5083
+ );
5084
+ }
5085
+ if (anchor.span && anchor.span.end < anchor.span.start) {
5086
+ report(
5087
+ "anchor_span",
5088
+ conceptId2,
5089
+ `anchor ${anchor.file} span ${anchor.span.start}-${anchor.span.end} ends before it starts`
5090
+ );
5091
+ }
5092
+ if (anchor.span && anchor.hash_kind === "ast") {
5093
+ report(
5094
+ "anchor_span",
5095
+ conceptId2,
5096
+ `anchor ${anchor.file} is a span with hash_kind: "ast" \u2014 a span is hashed raw`
5097
+ );
5098
+ }
5099
+ if (anchor.side === "old" && !anchor.ref) {
5100
+ report(
5101
+ "anchor_side",
5102
+ conceptId2,
5103
+ `anchor ${anchor.file} is side: "old" with no ref`
5104
+ );
5105
+ }
3977
5106
  if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
3978
5107
  report(
3979
5108
  "anchor_repo",
@@ -4009,14 +5138,19 @@ var DAY_MS = 864e5;
4009
5138
  function anchorResolverCounts(bundle) {
4010
5139
  let treeSitter = 0;
4011
5140
  let regex = 0;
5141
+ let span2 = 0;
5142
+ let oldSide = 0;
4012
5143
  for (const record of bundle) {
4013
5144
  for (const anchor of record.frontmatter.strauss_anchors ?? []) {
4014
- if (!anchor.hash || !anchor.symbol) continue;
4015
- if (anchor.resolver === "tree-sitter") treeSitter += 1;
5145
+ if (!anchor.hash) continue;
5146
+ if (anchor.side === "old") oldSide += 1;
5147
+ if (anchor.span) span2 += 1;
5148
+ else if (!anchor.symbol) continue;
5149
+ else if (anchor.resolver === "tree-sitter") treeSitter += 1;
4016
5150
  else regex += 1;
4017
5151
  }
4018
5152
  }
4019
- return { total: treeSitter + regex, treeSitter, regex };
5153
+ return { total: treeSitter + regex + span2, treeSitter, regex, span: span2, oldSide };
4020
5154
  }
4021
5155
  function doctor(bundle, options = {}) {
4022
5156
  const thresholds = {
@@ -4026,7 +5160,7 @@ function doctor(bundle, options = {}) {
4026
5160
  };
4027
5161
  const now = options.now ?? /* @__PURE__ */ new Date();
4028
5162
  const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
4029
- const standings = new Map(
5163
+ const standings2 = new Map(
4030
5164
  adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
4031
5165
  );
4032
5166
  const inForce = adjudicated.filter(
@@ -4039,7 +5173,7 @@ function doctor(bundle, options = {}) {
4039
5173
  group("aging", aging(inForce, now, thresholds.agingDays)),
4040
5174
  group("orphaned", orphaned(bundle)),
4041
5175
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
4042
- group("superseded-but-cited", supersededButCited(bundle, standings)),
5176
+ group("superseded-but-cited", supersededButCited(bundle, standings2)),
4043
5177
  group("drifted", drifted(inForce)),
4044
5178
  group("unchecked", unchecked(inForce))
4045
5179
  ];
@@ -4162,9 +5296,9 @@ function brokenSupersession(bundle, adjudicated) {
4162
5296
  const findings = [];
4163
5297
  const seen = /* @__PURE__ */ new Set();
4164
5298
  const add = (record, note) => {
4165
- const key = `${record.conceptId}\0${note}`;
4166
- if (seen.has(key)) return;
4167
- seen.add(key);
5299
+ const key2 = `${record.conceptId}\0${note}`;
5300
+ if (seen.has(key2)) return;
5301
+ seen.add(key2);
4168
5302
  findings.push(finding(record, note));
4169
5303
  };
4170
5304
  for (const problem of validateBundle(bundle)) {
@@ -4205,14 +5339,14 @@ function brokenSupersession(bundle, adjudicated) {
4205
5339
  (left, right) => left.conceptId.localeCompare(right.conceptId)
4206
5340
  );
4207
5341
  }
4208
- function supersededButCited(bundle, standings) {
5342
+ function supersededButCited(bundle, standings2) {
4209
5343
  const byId = new Map(bundle.map((record) => [record.conceptId, record]));
4210
5344
  const findings = [];
4211
5345
  for (const record of bundle) {
4212
- const standing = standings.get(record.conceptId);
5346
+ const standing = standings2.get(record.conceptId);
4213
5347
  if (standing === "superseded" || standing === "rejected") continue;
4214
5348
  for (const target of edgeNeighbours(record, bundle, "body-link")) {
4215
- const targetStanding = standings.get(target.conceptId);
5349
+ const targetStanding = standings2.get(target.conceptId);
4216
5350
  if (targetStanding !== "superseded" && targetStanding !== "rejected") {
4217
5351
  continue;
4218
5352
  }
@@ -4303,17 +5437,17 @@ function ageInDays(record, now) {
4303
5437
  }
4304
5438
 
4305
5439
  // src/commands/reassess.ts
4306
- var import_zod12 = require("zod");
5440
+ var import_zod15 = require("zod");
4307
5441
  var reassessCommand = define({
4308
5442
  name: "reassess",
4309
5443
  tool: "kb_reassess",
4310
5444
  usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
4311
5445
  description: "One drifted record, as something to judge: its claim, each anchor's drift class, the old-vs-new span diff, and the records that depend on it. Formatting-only drift is dropped. Empty when there is nothing to reassess. Writes: relocates moved anchors, keeping their hash; never verifies, supersedes, or changes standing.",
4312
- input: import_zod12.z.object({
5446
+ input: import_zod15.z.object({
4313
5447
  bundlePath,
4314
5448
  conceptId,
4315
5449
  repoRoot: REPO_ROOT,
4316
- withDiff: import_zod12.z.boolean().optional().describe(
5450
+ withDiff: import_zod15.z.boolean().optional().describe(
4317
5451
  "Recover each anchor's committed span and render the diff. Reads git history."
4318
5452
  )
4319
5453
  }),
@@ -4356,7 +5490,10 @@ var reassessCommand = define({
4356
5490
  relocated.set(found.anchor, {
4357
5491
  ...found.anchor,
4358
5492
  file: to.file,
4359
- ...to.symbol ? { symbol: to.symbol } : {}
5493
+ ...to.symbol ? { symbol: to.symbol } : {},
5494
+ // A span is the anchor's whole address, so relocating it means
5495
+ // moving the line range the same code now occupies.
5496
+ ...found.anchor.span ? { span: { start: to.startLine, end: to.endLine } } : {}
4360
5497
  });
4361
5498
  rebaselined.push({
4362
5499
  file: found.anchor.file,
@@ -4455,13 +5592,13 @@ function at(file, symbol) {
4455
5592
  }
4456
5593
 
4457
5594
  // src/commands/doctor.ts
4458
- var days = (what, fallback) => import_zod13.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5595
+ var days = (what, fallback) => import_zod16.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
4459
5596
  var doctorCommand = define({
4460
5597
  name: "doctor",
4461
5598
  tool: "kb_doctor",
4462
5599
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
4463
5600
  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. `drifted` narrows it to a reassessment packet per drifted record, `with_diff` adding each anchor's old-vs-new span.",
4464
- input: import_zod13.z.object({
5601
+ input: import_zod16.z.object({
4465
5602
  bundlePath,
4466
5603
  repoRoot: REPO_ROOT,
4467
5604
  expiringDays: days(
@@ -4476,16 +5613,16 @@ var doctorCommand = define({
4476
5613
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
4477
5614
  DEFAULT_AGING_DAYS
4478
5615
  ),
4479
- offline: import_zod13.z.boolean().optional().describe(
5616
+ offline: import_zod16.z.boolean().optional().describe(
4480
5617
  "Read foreign anchors from the local repo cache only, never fetching."
4481
5618
  ),
4482
- strict: import_zod13.z.boolean().optional().describe(
5619
+ strict: import_zod16.z.boolean().optional().describe(
4483
5620
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
4484
5621
  ),
4485
- drifted: import_zod13.z.boolean().optional().describe(
5622
+ drifted: import_zod16.z.boolean().optional().describe(
4486
5623
  "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
4487
5624
  ),
4488
- withDiff: import_zod13.z.boolean().optional().describe(
5625
+ withDiff: import_zod16.z.boolean().optional().describe(
4489
5626
  "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
4490
5627
  )
4491
5628
  }),
@@ -4541,7 +5678,7 @@ var doctorCommand = define({
4541
5678
  ...hints.length ? { hints } : {}
4542
5679
  };
4543
5680
  }
4544
- const standings = new Map(
5681
+ const standings2 = new Map(
4545
5682
  adjudicate(records, records, new Date(checkedAt)).map((hit) => [
4546
5683
  hit.record.conceptId,
4547
5684
  hit.standing
@@ -4555,7 +5692,7 @@ var doctorCommand = define({
4555
5692
  (entry) => entry.conceptId === found.conceptId
4556
5693
  );
4557
5694
  if (!record) continue;
4558
- const standing = standings.get(record.conceptId);
5695
+ const standing = standings2.get(record.conceptId);
4559
5696
  const built = await reassessPacket(
4560
5697
  repoRoot ?? process.cwd(),
4561
5698
  record,
@@ -4592,15 +5729,16 @@ var doctorCommand = define({
4592
5729
  });
4593
5730
  function render2(result) {
4594
5731
  if (result.packets) return renderPackets(result);
4595
- const { thresholds } = result;
5732
+ const { thresholds, anchorResolvers: counts } = result;
4596
5733
  const lines = [
4597
5734
  `# KB Doctor \u2014 ${result.bundlePath}`,
4598
5735
  `records: ${result.recordCount}`,
4599
5736
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
4600
5737
  `checked: ${result.checkedAt}`,
4601
- ...result.anchorResolvers.total ? [
4602
- `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
4603
- ] : [],
5738
+ ...counts.total ? [anchorLine(counts)] : [],
5739
+ // Its own line: an old-side anchor may name a whole file, which no
5740
+ // resolver bucket and no `total` counts.
5741
+ ...counts.oldSide ? [`old-side anchors: ${counts.oldSide}`] : [],
4604
5742
  ""
4605
5743
  ];
4606
5744
  const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
@@ -4625,6 +5763,14 @@ function render2(result) {
4625
5763
  );
4626
5764
  return lines.join("\n");
4627
5765
  }
5766
+ function anchorLine(counts) {
5767
+ const parts = [
5768
+ `${counts.treeSitter} tree-sitter`,
5769
+ `${counts.regex} regex`,
5770
+ ...counts.span ? [`${counts.span} span`] : []
5771
+ ];
5772
+ return `anchors: ${counts.total} hashed \u2014 ${parts.join(", ")}`;
5773
+ }
4628
5774
  function renderPackets(result) {
4629
5775
  const packets = result.packets ?? [];
4630
5776
  const lines = [
@@ -4637,33 +5783,168 @@ function renderPackets(result) {
4637
5783
  `moved, rebaseline with \`kb_reassess\`: ${result.rebaselinable.join(", ")}`
4638
5784
  );
4639
5785
  }
4640
- for (const packet of packets) {
4641
- lines.push(
4642
- renderReassess({
4643
- conceptId: packet.conceptId,
4644
- packet,
4645
- rebaselined: [],
4646
- cosmetic: 0
4647
- })
4648
- );
5786
+ for (const packet of packets) {
5787
+ lines.push(
5788
+ renderReassess({
5789
+ conceptId: packet.conceptId,
5790
+ packet,
5791
+ rebaselined: [],
5792
+ cosmetic: 0
5793
+ })
5794
+ );
5795
+ }
5796
+ return lines.join("\n");
5797
+ }
5798
+
5799
+ // src/commands/export.ts
5800
+ var import_promises9 = require("fs/promises");
5801
+ var import_node_path11 = require("path");
5802
+ var import_zod17 = require("zod");
5803
+ var NUMBERED = /^(\d{4})-(.+)\.md$/;
5804
+ var MARKER = "<!-- strauss-kb export: ";
5805
+ var exportCommand = define({
5806
+ name: "export",
5807
+ tool: "kb_export",
5808
+ usage: "export --format madr --to <dir>",
5809
+ description: "Write the base's decisions out as numbered MADR files, one per decision, for a repository that keeps ADRs of its own. Numbering is by slug, so a re-run rewrites its own files in place. A superseded decision is exported with what replaced it.",
5810
+ input: import_zod17.z.object({
5811
+ bundlePath,
5812
+ format: import_zod17.z.enum(["madr"]).describe("Output layout. `madr` is the only one so far."),
5813
+ to: import_zod17.z.string().min(1).describe("Directory the ADR files are written into.")
5814
+ }),
5815
+ fromArgv: (argv, path) => ({
5816
+ bundlePath: path,
5817
+ format: argvFlag(argv, "--format"),
5818
+ to: argvFlag(argv, "--to")
5819
+ }),
5820
+ run: async ({ store }, { bundlePath: path, to }) => {
5821
+ const bundle = await store.list(path);
5822
+ const decisions = selectDecisions(bundle).sort(
5823
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
5824
+ );
5825
+ const adjudicated = new Map(
5826
+ adjudicate(decisions, bundle).map((hit) => [hit.record.conceptId, hit])
5827
+ );
5828
+ await (0, import_promises9.mkdir)(to, { recursive: true });
5829
+ const taken = await existingFiles(to);
5830
+ let next = Math.max(0, ...[...taken.values()].map((row) => row.number)) + 1;
5831
+ const exported = [];
5832
+ const foreign = [];
5833
+ for (const record of decisions) {
5834
+ const slug = record.conceptId.slice(record.conceptId.indexOf(".") + 1);
5835
+ const held = taken.get(slug);
5836
+ if (held && !held.ours) {
5837
+ foreign.push({ conceptId: record.conceptId, file: held.file });
5838
+ continue;
5839
+ }
5840
+ const number = held?.number ?? next++;
5841
+ const file = `${String(number).padStart(4, "0")}-${slug}.md`;
5842
+ const status = statusLine(adjudicated.get(record.conceptId));
5843
+ await publish((0, import_node_path11.join)(to, file), renderMadr(record, status));
5844
+ exported.push({ conceptId: record.conceptId, file, status });
5845
+ }
5846
+ return { to, format: "madr", exported, foreign };
5847
+ },
5848
+ render: (result) => {
5849
+ const { exported, foreign, to } = result;
5850
+ return [
5851
+ `Wrote ${exported.length} MADR file${exported.length === 1 ? "" : "s"} to ${to}.`,
5852
+ ...exported.map(
5853
+ (entry) => `- ${entry.file} ${entry.conceptId} [${entry.status}]`
5854
+ ),
5855
+ ...foreign.map(
5856
+ (entry) => `- skipped ${entry.conceptId}: ${entry.file} was not written by export`
5857
+ )
5858
+ ].join("\n");
5859
+ }
5860
+ });
5861
+ async function publish(target, contents) {
5862
+ const staging = `${target}.${process.pid}.tmp`;
5863
+ await (0, import_promises9.writeFile)(staging, contents, "utf8");
5864
+ try {
5865
+ await (0, import_promises9.rename)(staging, target);
5866
+ } catch (error) {
5867
+ await (0, import_promises9.unlink)(staging).catch(() => void 0);
5868
+ throw error;
5869
+ }
5870
+ }
5871
+ async function existingFiles(to) {
5872
+ const names = await (0, import_promises9.readdir)(to).catch(() => []);
5873
+ const taken = /* @__PURE__ */ new Map();
5874
+ for (const name of names.sort()) {
5875
+ const [, number, slug] = NUMBERED.exec(name) ?? [];
5876
+ if (!number || !slug) continue;
5877
+ const text = await (0, import_promises9.readFile)((0, import_node_path11.join)(to, name), "utf8").catch(() => "");
5878
+ taken.set(slug, {
5879
+ file: name,
5880
+ number: Number(number),
5881
+ ours: text.includes(MARKER)
5882
+ });
4649
5883
  }
4650
- return lines.join("\n");
5884
+ return taken;
5885
+ }
5886
+ function statusLine(hit) {
5887
+ const status = hit?.record.frontmatter.strauss_status ?? "draft";
5888
+ if (status !== "superseded") return status;
5889
+ const by = (hit?.heads ?? []).map((head) => head.conceptId);
5890
+ return by.length ? `superseded by ${by.join(", ")}` : "superseded";
5891
+ }
5892
+ function renderMadr(record, status) {
5893
+ const sections = bodySections(record.body);
5894
+ const blocks = [
5895
+ `# ${record.frontmatter.title ?? record.conceptId}`,
5896
+ "## Status",
5897
+ status
5898
+ ];
5899
+ push(blocks, "Context and Problem Statement", record.frontmatter.description);
5900
+ push(blocks, "Considered Options", sections.get("Rejected"));
5901
+ push(blocks, "Decision Outcome", sections.get("Decision"));
5902
+ push(blocks, "Consequences", sections.get("Impact"));
5903
+ blocks.push(`${MARKER}${record.conceptId} -->`);
5904
+ return `${blocks.join("\n\n")}
5905
+ `;
5906
+ }
5907
+ function push(blocks, heading, text) {
5908
+ if (text?.trim()) blocks.push(`## ${heading}`, text.trim());
5909
+ }
5910
+ function bodySections(body) {
5911
+ const generated = new RegExp(
5912
+ `^(?:(?:${Object.values(LINK_RELS).map((spec) => spec.phrase).join("|")}) \\[[^\\]]+\\]\\([^)]+\\.md\\)\\.|\\[\\^[^\\]]+\\]: .*)$`
5913
+ );
5914
+ const sections = /* @__PURE__ */ new Map();
5915
+ let heading = null;
5916
+ let lines = [];
5917
+ const flush = () => {
5918
+ if (heading) sections.set(heading, lines.join("\n").trim());
5919
+ };
5920
+ for (const line of body.split("\n")) {
5921
+ const match = /^## (.+?)\s*$/.exec(line);
5922
+ if (match) {
5923
+ flush();
5924
+ heading = match[1] ?? null;
5925
+ lines = [];
5926
+ } else if (heading && !generated.test(line)) {
5927
+ lines.push(line);
5928
+ }
5929
+ }
5930
+ flush();
5931
+ return sections;
4651
5932
  }
4652
5933
 
4653
5934
  // src/commands/impact.ts
4654
- var import_zod14 = require("zod");
5935
+ var import_zod18 = require("zod");
4655
5936
  var impactCommand = define({
4656
5937
  name: "impact",
4657
5938
  tool: "kb_impact",
4658
5939
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
4659
5940
  description: "What breaks if this record changes: its transitive set of dependants, each with its standing. Each rel declares which of its ends depends on the other, and the walk follows each rel in its own direction. Naming `related_to` or an unknown rel in `rels` is an error. kb_backlinks gives one flat hop.",
4660
- input: import_zod14.z.object({
5941
+ input: import_zod18.z.object({
4661
5942
  bundlePath,
4662
5943
  conceptId,
4663
- depth: import_zod14.z.number().int().positive().optional().describe(
5944
+ depth: import_zod18.z.number().int().positive().optional().describe(
4664
5945
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
4665
5946
  ),
4666
- rels: import_zod14.z.array(import_zod14.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
5947
+ rels: import_zod18.z.array(import_zod18.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
4667
5948
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
4668
5949
  )
4669
5950
  }),
@@ -4684,15 +5965,15 @@ var impactCommand = define({
4684
5965
  });
4685
5966
 
4686
5967
  // src/commands/list.ts
4687
- var import_zod15 = require("zod");
5968
+ var import_zod19 = require("zod");
4688
5969
  var listCommand = define({
4689
5970
  name: "list",
4690
5971
  tool: "kb_list",
4691
5972
  usage: "list [type] [--tag T]...",
4692
5973
  description: "Every record, optionally one type or tag. For enumerating; use kb_query for a question.",
4693
- input: import_zod15.z.object({
5974
+ input: import_zod19.z.object({
4694
5975
  bundlePath,
4695
- type: import_zod15.z.enum(KB_RECORD_TYPES).optional(),
5976
+ type: import_zod19.z.enum(KB_RECORD_TYPES).optional(),
4696
5977
  tags: TAGS
4697
5978
  }),
4698
5979
  fromArgv: (argv, path) => {
@@ -4716,17 +5997,17 @@ var listCommand = define({
4716
5997
  });
4717
5998
 
4718
5999
  // src/commands/load.ts
4719
- var import_zod16 = require("zod");
6000
+ var import_zod20 = require("zod");
4720
6001
  var loadCommand = define({
4721
6002
  name: "load",
4722
6003
  tool: "kb_load",
4723
6004
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
4724
6005
  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.",
4725
- input: import_zod16.z.object({
6006
+ input: import_zod20.z.object({
4726
6007
  bundlePath,
4727
- type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
4728
- budgetTokens: import_zod16.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
4729
- all: import_zod16.z.boolean().optional().describe(
6008
+ type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
6009
+ budgetTokens: import_zod20.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6010
+ all: import_zod20.z.boolean().optional().describe(
4730
6011
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
4731
6012
  ),
4732
6013
  repoRoot: REPO_ROOT
@@ -4768,25 +6049,25 @@ var loadCommand = define({
4768
6049
  });
4769
6050
 
4770
6051
  // src/commands/log.ts
4771
- var import_zod17 = require("zod");
6052
+ var import_zod21 = require("zod");
4772
6053
  var logCommand = define({
4773
6054
  name: "log",
4774
6055
  tool: "kb_log",
4775
6056
  usage: "log",
4776
6057
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
4777
- input: import_zod17.z.object({ bundlePath }),
6058
+ input: import_zod21.z.object({ bundlePath }),
4778
6059
  fromArgv: (_argv, path) => ({ bundlePath: path }),
4779
6060
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
4780
6061
  });
4781
6062
 
4782
6063
  // src/commands/no-decision.ts
4783
- var import_zod18 = require("zod");
6064
+ var import_zod22 = require("zod");
4784
6065
  var noDecisionCommand = define({
4785
6066
  name: "no-decision",
4786
6067
  tool: "kb_no_decision",
4787
6068
  usage: "no-decision <reason...>",
4788
6069
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
4789
- input: import_zod18.z.object({ bundlePath, reason: import_zod18.z.string().min(1) }),
6070
+ input: import_zod22.z.object({ bundlePath, reason: import_zod22.z.string().min(1) }),
4790
6071
  fromArgv: (argv, path) => ({
4791
6072
  bundlePath: path,
4792
6073
  reason: argv.slice(1).join(" ").trim()
@@ -4803,20 +6084,20 @@ var noDecisionCommand = define({
4803
6084
  });
4804
6085
 
4805
6086
  // src/commands/pack.ts
4806
- var import_zod19 = require("zod");
6087
+ var import_zod23 = require("zod");
4807
6088
  var packCommand = define({
4808
6089
  name: "pack",
4809
6090
  tool: "kb_pack",
4810
6091
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
4811
6092
  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.",
4812
- input: import_zod19.z.object({
6093
+ input: import_zod23.z.object({
4813
6094
  bundlePath,
4814
6095
  conceptId,
4815
- hops: import_zod19.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
4816
- maxNodes: import_zod19.z.number().int().positive().optional().describe(
6096
+ hops: import_zod23.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6097
+ maxNodes: import_zod23.z.number().int().positive().optional().describe(
4817
6098
  "How many records the pack may hold, root included. Defaults to 20."
4818
6099
  ),
4819
- budgetTokens: import_zod19.z.number().int().positive().optional().describe(
6100
+ budgetTokens: import_zod23.z.number().int().positive().optional().describe(
4820
6101
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
4821
6102
  )
4822
6103
  }),
@@ -4903,22 +6184,22 @@ function warningLabel(warning) {
4903
6184
  }
4904
6185
 
4905
6186
  // src/commands/pin.ts
4906
- var import_zod20 = require("zod");
6187
+ var import_zod24 = require("zod");
4907
6188
  var pinCommand = define({
4908
6189
  name: "pin",
4909
6190
  tool: "kb_pin",
4910
6191
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
4911
6192
  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.",
4912
- input: import_zod20.z.object({
6193
+ input: import_zod24.z.object({
4913
6194
  bundlePath,
4914
- mode: import_zod20.z.enum(["full", "index"]).optional().describe(
6195
+ mode: import_zod24.z.enum(["full", "index"]).optional().describe(
4915
6196
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
4916
6197
  ),
4917
- profiles: import_zod20.z.array(import_zod20.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
4918
- layer: import_zod20.z.enum(["project", "local", "user"]).optional().describe(
6198
+ profiles: import_zod24.z.array(import_zod24.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6199
+ layer: import_zod24.z.enum(["project", "local", "user"]).optional().describe(
4919
6200
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
4920
6201
  ),
4921
- frozen: import_zod20.z.boolean().optional().describe(
6202
+ frozen: import_zod24.z.boolean().optional().describe(
4922
6203
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
4923
6204
  )
4924
6205
  }),
@@ -4947,29 +6228,440 @@ var pinCommand = define({
4947
6228
  });
4948
6229
 
4949
6230
  // src/commands/pins.ts
4950
- var import_zod21 = require("zod");
6231
+ var import_zod25 = require("zod");
4951
6232
  var pinsCommand = define({
4952
6233
  name: "pins",
4953
6234
  tool: "kb_pins",
4954
6235
  usage: "pins",
4955
6236
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
4956
- input: import_zod21.z.object({}),
6237
+ input: import_zod25.z.object({}),
4957
6238
  fromArgv: () => ({}),
4958
6239
  run: ({ store }) => listPins(store, process.cwd())
4959
6240
  });
4960
6241
 
6242
+ // src/commands/promote/command.ts
6243
+ var import_node_path12 = require("path");
6244
+
6245
+ // src/commands/promote/carry.ts
6246
+ var PROMOTION_SOURCE_ID = "promoted";
6247
+ var CARRIED_STATUS = {
6248
+ draft: "accepted",
6249
+ proposed: "accepted",
6250
+ accepted: "accepted",
6251
+ open: "open",
6252
+ resolved: "resolved",
6253
+ rejected: "rejected",
6254
+ superseded: "superseded"
6255
+ };
6256
+ function carry(record, promoted, source) {
6257
+ const {
6258
+ type: _type,
6259
+ // Both name records in the source base, and supersession in the target is
6260
+ // a separate question from whether this record belongs there at all.
6261
+ strauss_supersedes: _supersedes,
6262
+ strauss_superseded_by: _supersededBy,
6263
+ // A check run against the source repository, which the target never saw.
6264
+ verified: _verified,
6265
+ ...rest
6266
+ } = record.frontmatter;
6267
+ const links = rest.strauss_links ?? [];
6268
+ const kept = links.filter((link2) => promoted.has(link2.target));
6269
+ const dropped = links.filter((link2) => !promoted.has(link2.target));
6270
+ const tags = (rest.tags ?? []).filter((tag) => !isReviewTag(tag));
6271
+ const frontmatter = {
6272
+ ...rest,
6273
+ strauss_status: CARRIED_STATUS[rest.strauss_status]
6274
+ };
6275
+ setOrDrop(frontmatter, "tags", tags);
6276
+ setOrDrop(frontmatter, "strauss_links", kept);
6277
+ let body = withoutLinkSentences(record.body, dropped);
6278
+ if (source) {
6279
+ frontmatter.sources = [
6280
+ ...(rest.sources ?? []).filter(
6281
+ (entry) => entry.id !== PROMOTION_SOURCE_ID
6282
+ ),
6283
+ { id: PROMOTION_SOURCE_ID, resource: source }
6284
+ ];
6285
+ body = `${stripFootnote(body).trimEnd()}
6286
+
6287
+ [^${PROMOTION_SOURCE_ID}]: ${source}
6288
+ `;
6289
+ }
6290
+ return {
6291
+ frontmatter,
6292
+ body,
6293
+ droppedLinks: dropped.map(({ target, rel }) => ({ target, rel }))
6294
+ };
6295
+ }
6296
+ function isReviewTag(tag) {
6297
+ return tag === "review" || tag.startsWith("review:");
6298
+ }
6299
+ function withoutLinkSentences(body, dropped) {
6300
+ const sentences = new Set(
6301
+ dropped.filter((link2) => isKbLinkRel(link2.rel)).map(
6302
+ (link2) => `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
6303
+ )
6304
+ );
6305
+ if (!sentences.size) return body;
6306
+ return body.split("\n\n").filter((block) => !sentences.has(block.trim())).join("\n\n");
6307
+ }
6308
+ function stripFootnote(body) {
6309
+ return body.split("\n").filter((line) => !line.startsWith(`[^${PROMOTION_SOURCE_ID}]: `)).join("\n");
6310
+ }
6311
+ function setOrDrop(frontmatter, key2, value) {
6312
+ if (value.length) frontmatter[key2] = value;
6313
+ else delete frontmatter[key2];
6314
+ }
6315
+
6316
+ // src/kb-links/inbound.ts
6317
+ function inboundIndex(bundle) {
6318
+ const byTarget = /* @__PURE__ */ new Map();
6319
+ for (const record of bundle) {
6320
+ for (const link2 of record.frontmatter.strauss_links ?? []) {
6321
+ if (link2.target === record.conceptId) continue;
6322
+ const edges = byTarget.get(link2.target) ?? [];
6323
+ if (edges.some(
6324
+ (edge) => edge.from === record.conceptId && edge.rel === link2.rel
6325
+ )) {
6326
+ continue;
6327
+ }
6328
+ edges.push({ from: record.conceptId, rel: link2.rel });
6329
+ byTarget.set(link2.target, edges);
6330
+ }
6331
+ }
6332
+ return byTarget;
6333
+ }
6334
+
6335
+ // src/kb-links/backlinks.ts
6336
+ function backlinks(targetId, bundle) {
6337
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
6338
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
6339
+ const standingOf = new Map(
6340
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
6341
+ );
6342
+ const rows = [];
6343
+ for (const edge of inboundIndex(bundle).get(targetId) ?? []) {
6344
+ const record = byId.get(edge.from);
6345
+ if (!record) continue;
6346
+ const hit = standingOf.get(edge.from);
6347
+ rows.push({
6348
+ ...edge,
6349
+ title: record.frontmatter.title ?? null,
6350
+ standing: hit?.standing ?? "unsettled",
6351
+ warnings: hit?.warnings ?? []
6352
+ });
6353
+ }
6354
+ return {
6355
+ target: targetId,
6356
+ backlinks: rows.sort(
6357
+ (left, right) => left.from.localeCompare(right.from) || left.rel.localeCompare(right.rel)
6358
+ )
6359
+ };
6360
+ }
6361
+
6362
+ // src/kb-links/impact.ts
6363
+ function impact(targetId, bundle, options = {}) {
6364
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
6365
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
6366
+ const rels = resolveRels(options.rels);
6367
+ const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
6368
+ const inbound = inboundIndex(bundle);
6369
+ const standingOf = new Map(
6370
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
6371
+ );
6372
+ const reached = /* @__PURE__ */ new Map();
6373
+ const stopped = [];
6374
+ let frontier = [targetId];
6375
+ let depth = 0;
6376
+ while (frontier.length && depth < maxDepth) {
6377
+ depth += 1;
6378
+ const next = [];
6379
+ const consider = (dependantId, edge) => {
6380
+ if (dependantId === targetId) return;
6381
+ const existing = reached.get(dependantId);
6382
+ if (existing) {
6383
+ if (!hasEdge(existing.via, edge)) existing.via.push(edge);
6384
+ return;
6385
+ }
6386
+ const record = byId.get(dependantId);
6387
+ if (!record) return;
6388
+ const hit = standingOf.get(dependantId);
6389
+ const entry = {
6390
+ conceptId: dependantId,
6391
+ title: record.frontmatter.title ?? null,
6392
+ standing: hit?.standing ?? "unsettled",
6393
+ warnings: hit?.warnings ?? [],
6394
+ depth,
6395
+ via: [edge]
6396
+ };
6397
+ reached.set(dependantId, entry);
6398
+ if (entry.standing === "superseded" || entry.standing === "rejected") {
6399
+ stopped.push(dependantId);
6400
+ return;
6401
+ }
6402
+ next.push(dependantId);
6403
+ };
6404
+ for (const id of frontier) {
6405
+ for (const edge of inbound.get(id) ?? []) {
6406
+ if (!rels.has(edge.rel)) continue;
6407
+ if (dependantEnd(edge.rel) !== "source") continue;
6408
+ consider(edge.from, { source: edge.from, target: id, rel: edge.rel });
6409
+ }
6410
+ for (const link2 of byId.get(id)?.frontmatter.strauss_links ?? []) {
6411
+ if (!rels.has(link2.rel)) continue;
6412
+ if (dependantEnd(link2.rel) !== "target") continue;
6413
+ if (link2.target === id) continue;
6414
+ consider(link2.target, {
6415
+ source: id,
6416
+ target: link2.target,
6417
+ rel: link2.rel
6418
+ });
6419
+ }
6420
+ }
6421
+ frontier = next;
6422
+ }
6423
+ return {
6424
+ root: targetId,
6425
+ impacted: [...reached.values()].sort(
6426
+ (left, right) => left.depth - right.depth || left.conceptId.localeCompare(right.conceptId)
6427
+ ),
6428
+ stopped: stopped.sort(),
6429
+ truncated: frontier.length > 0,
6430
+ unexpanded: [...frontier].sort()
6431
+ };
6432
+ }
6433
+ function resolveRels(rels) {
6434
+ if (!rels?.length) return new Set(KB_CAUSAL_LINK_RELS);
6435
+ for (const rel of rels) {
6436
+ if (!isKbLinkRel(rel) || LINK_RELS[rel].dependant === null) {
6437
+ throw new KbUnknownLinkRelError(rel, KB_CAUSAL_LINK_RELS);
6438
+ }
6439
+ }
6440
+ return new Set(rels);
6441
+ }
6442
+ function dependantEnd(rel) {
6443
+ return isKbLinkRel(rel) ? LINK_RELS[rel].dependant : null;
6444
+ }
6445
+ function hasEdge(edges, edge) {
6446
+ return edges.some(
6447
+ (existing) => existing.source === edge.source && existing.target === edge.target && existing.rel === edge.rel
6448
+ );
6449
+ }
6450
+
6451
+ // src/commands/promote/standing.ts
6452
+ var WITHDRAWN = ["superseded", "rejected"];
6453
+ function standings(bundle) {
6454
+ return new Map(
6455
+ adjudicate(bundle, bundle).map((hit) => [
6456
+ hit.record.conceptId,
6457
+ hit.standing
6458
+ ])
6459
+ );
6460
+ }
6461
+ function isWithdrawn(standing) {
6462
+ return standing !== void 0 && WITHDRAWN.includes(standing);
6463
+ }
6464
+
6465
+ // src/commands/promote/candidates.ts
6466
+ var REVIEW_TAG = "review";
6467
+ var SETTLED = ["resolved"];
6468
+ function promoteCandidates(bundle) {
6469
+ const inbound = inboundIndex(bundle);
6470
+ const standing = standings(bundle);
6471
+ const rows = [];
6472
+ for (const record of bundle) {
6473
+ if (isWithdrawn(standing.get(record.conceptId))) continue;
6474
+ const why2 = candidateReason(record, inbound.get(record.conceptId) ?? []);
6475
+ if (!why2) continue;
6476
+ rows.push({
6477
+ conceptId: record.conceptId,
6478
+ type: recordType(record.conceptId),
6479
+ title: record.frontmatter.title ?? null,
6480
+ why: why2
6481
+ });
6482
+ }
6483
+ return rows;
6484
+ }
6485
+ function candidateReason(record, inbound) {
6486
+ const { strauss_status: status, tags } = record.frontmatter;
6487
+ switch (recordType(record.conceptId)) {
6488
+ case "decision":
6489
+ if (isNoDecisionRecord(record)) return null;
6490
+ return tags?.includes(REVIEW_TAG) ? null : "decision no longer under review";
6491
+ case "constraint":
6492
+ return status === "proposed" ? "constraint still proposed \u2014 the target base is where it settles" : null;
6493
+ case "contract":
6494
+ return "contract \u2014 it outlives the change that introduced it";
6495
+ case "requirement":
6496
+ return inbound.some((edge) => edge.rel === "satisfies") ? "requirement something in the base satisfies" : null;
6497
+ case "risk":
6498
+ return record.frontmatter.strauss_materiality === "blocking" && !SETTLED.includes(status) ? "blocking risk still open" : null;
6499
+ default:
6500
+ return null;
6501
+ }
6502
+ }
6503
+ function recordType(conceptId2) {
6504
+ return conceptId2.slice(0, conceptId2.indexOf("."));
6505
+ }
6506
+
6507
+ // src/commands/promote/model.ts
6508
+ var import_zod26 = require("zod");
6509
+ var promoteInputSchema = import_zod26.z.object({
6510
+ bundlePath,
6511
+ conceptIds: import_zod26.z.array(conceptId).max(64).optional().describe("Records to copy into the target base. Omit with `list`."),
6512
+ to: import_zod26.z.string().min(1).optional().describe("Absolute path to the base being promoted into."),
6513
+ source: import_zod26.z.string().min(1).optional().describe(
6514
+ "Where the promotion came from, usually the pull request URL. Recorded on each copy as a source."
6515
+ ),
6516
+ force: import_zod26.z.boolean().optional().describe("Overwrite a record the target base already holds."),
6517
+ list: import_zod26.z.boolean().optional().describe("List the source base's candidates instead of promoting.")
6518
+ }).refine((input) => input.list === true || input.to !== void 0, {
6519
+ message: "promote needs a target base \u2014 pass --to <bundle>, or --list",
6520
+ path: ["to"]
6521
+ }).refine(
6522
+ (input) => input.list === true || (input.conceptIds?.length ?? 0) > 0,
6523
+ {
6524
+ message: "name at least one concept id to promote, or pass --list",
6525
+ path: ["conceptIds"]
6526
+ }
6527
+ );
6528
+
6529
+ // src/commands/promote/command.ts
6530
+ var promoteCommand = define({
6531
+ name: "promote",
6532
+ tool: "kb_promote",
6533
+ usage: "promote <concept-id...> --to <bundle> [--source <url>] [--force] | --list",
6534
+ description: "Copy records into another base at the same slug, with the review tags dropped and a source naming where the promotion came from. Use at merge, to lift what a review base settled into the base that outlives it. `list` names the candidates instead. The originals stay put.",
6535
+ input: promoteInputSchema,
6536
+ fromArgv: (argv, path) => {
6537
+ const to = argvFlag(argv, "--to");
6538
+ const source = argvFlag(argv, "--source");
6539
+ const words = argv.slice(1);
6540
+ for (const flag of ["--to", "--source"]) {
6541
+ const at2 = words.indexOf(flag);
6542
+ if (at2 !== -1) words.splice(at2, 2);
6543
+ }
6544
+ const conceptIds = words.filter((word) => !word.startsWith("--"));
6545
+ return {
6546
+ bundlePath: path,
6547
+ ...conceptIds.length ? { conceptIds } : {},
6548
+ ...to !== void 0 ? { to } : {},
6549
+ ...source !== void 0 ? { source } : {},
6550
+ ...argv.includes("--force") ? { force: true } : {},
6551
+ ...argv.includes("--list") ? { list: true } : {}
6552
+ };
6553
+ },
6554
+ run: async ({ store, actor }, { bundlePath: path, conceptIds, to, source, force, list }) => {
6555
+ const from = (0, import_node_path12.resolve)(path);
6556
+ const bundle = await store.list(from);
6557
+ if (list) {
6558
+ return { mode: "list", candidates: promoteCandidates(bundle) };
6559
+ }
6560
+ const target = (0, import_node_path12.resolve)(to);
6561
+ if (target === from) throw new KbPromoteSelfError(target);
6562
+ const named = (conceptIds ?? []).map(namedRecord);
6563
+ const wanted = named.map(({ conceptId: conceptId2, type, slug }) => {
6564
+ const record = bundle.find((entry) => entry.conceptId === conceptId2);
6565
+ if (!record) throw new KbRecordNotFoundError(conceptId2);
6566
+ return { record, type, slug };
6567
+ });
6568
+ await assertBaseNotFrozen(process.cwd(), from);
6569
+ await assertBaseNotFrozen(process.cwd(), target);
6570
+ const standing = standings(bundle);
6571
+ for (const { record } of wanted) {
6572
+ const where = standing.get(record.conceptId);
6573
+ if (isWithdrawn(where)) {
6574
+ throw new KbPromoteStandingError(record.conceptId, where);
6575
+ }
6576
+ if (!force && await store.read(target, record.conceptId)) {
6577
+ throw new KbPromoteCollisionError(record.conceptId, target);
6578
+ }
6579
+ }
6580
+ const promotedIds = new Set(wanted.map(({ record }) => record.conceptId));
6581
+ const promoted = [];
6582
+ for (const { record, type, slug } of wanted) {
6583
+ const { frontmatter, body, droppedLinks } = carry(
6584
+ record,
6585
+ promotedIds,
6586
+ source
6587
+ );
6588
+ try {
6589
+ await store.write(
6590
+ target,
6591
+ { type, slug, frontmatter, body, overwrite: force === true },
6592
+ actor
6593
+ );
6594
+ } catch (error) {
6595
+ throw new KbPromoteStoppedError(
6596
+ record.conceptId,
6597
+ promoted.map((entry) => entry.conceptId),
6598
+ error instanceof Error ? error.message : "unknown"
6599
+ );
6600
+ }
6601
+ await store.note(target, {
6602
+ by: actor,
6603
+ operation: "promote-in",
6604
+ conceptId: record.conceptId,
6605
+ target: from
6606
+ });
6607
+ await store.note(from, {
6608
+ by: actor,
6609
+ operation: "promote-out",
6610
+ conceptId: record.conceptId,
6611
+ target
6612
+ });
6613
+ promoted.push({ conceptId: record.conceptId, droppedLinks });
6614
+ }
6615
+ return { mode: "promote", to: target, promoted };
6616
+ },
6617
+ render: (result) => renderPromote(result)
6618
+ });
6619
+ function namedRecord(conceptId2) {
6620
+ const at2 = conceptId2.indexOf(".");
6621
+ const type = at2 === -1 ? conceptId2 : conceptId2.slice(0, at2);
6622
+ const slug = at2 === -1 ? "" : conceptId2.slice(at2 + 1);
6623
+ if (!KB_SLUG_PATTERN.test(type) || !KB_SLUG_PATTERN.test(slug)) {
6624
+ throw new KbInvalidConceptIdError(
6625
+ "concept id must be <type>.<slug>, both kebab-case",
6626
+ { conceptId: conceptId2 }
6627
+ );
6628
+ }
6629
+ return { conceptId: conceptId2, type, slug };
6630
+ }
6631
+ function renderPromote(result) {
6632
+ if (result.mode === "list") {
6633
+ if (!result.candidates.length) return "No promotion candidates.";
6634
+ return result.candidates.flatMap((candidate) => [
6635
+ `${candidate.conceptId} [${candidate.type}]${candidate.title ? ` \u2014 ${candidate.title}` : ""}`,
6636
+ ` ${candidate.why}`
6637
+ ]).join("\n");
6638
+ }
6639
+ const lines = [
6640
+ `Promoted ${result.promoted.length} record${result.promoted.length === 1 ? "" : "s"} into ${result.to}.`
6641
+ ];
6642
+ for (const entry of result.promoted) {
6643
+ lines.push(`- ${entry.conceptId}`);
6644
+ for (const link2 of entry.droppedLinks) {
6645
+ lines.push(
6646
+ ` dropped ${link2.rel} \u2192 ${link2.target} (not promoted in this run)`
6647
+ );
6648
+ }
6649
+ }
6650
+ return lines.join("\n");
6651
+ }
6652
+
4961
6653
  // src/commands/query.ts
4962
- var import_zod22 = require("zod");
6654
+ var import_zod27 = require("zod");
4963
6655
  var queryCommand = define({
4964
6656
  name: "query",
4965
6657
  tool: "kb_query",
4966
6658
  usage: "query <text...> [--tag T]... [--repo-root PATH]",
4967
6659
  description: "Search; every hit carries its standing. Flagged, never filtered: a superseded hit returns with its replacement, a rejected one is marked. Prefer kb_load when the base fits its budget \u2014 a full read beats search. Results are volatile: place them at the tail, not the cached prefix. Never read record files directly.",
4968
- input: import_zod22.z.object({
6660
+ input: import_zod27.z.object({
4969
6661
  bundlePath,
4970
- text: import_zod22.z.string().optional(),
4971
- type: import_zod22.z.enum(KB_RECORD_TYPES).optional(),
4972
- includeNonCurrent: import_zod22.z.boolean().optional(),
6662
+ text: import_zod27.z.string().optional(),
6663
+ type: import_zod27.z.enum(KB_RECORD_TYPES).optional(),
6664
+ includeNonCurrent: import_zod27.z.boolean().optional(),
4973
6665
  tags: TAGS,
4974
6666
  repoRoot: REPO_ROOT
4975
6667
  }),
@@ -5003,27 +6695,27 @@ var queryCommand = define({
5003
6695
  });
5004
6696
 
5005
6697
  // src/commands/read-index.ts
5006
- var import_zod23 = require("zod");
6698
+ var import_zod28 = require("zod");
5007
6699
  var readIndexCommand = define({
5008
6700
  name: "index",
5009
6701
  tool: "kb_index",
5010
6702
  usage: "index",
5011
6703
  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.",
5012
- input: import_zod23.z.object({ bundlePath }),
6704
+ input: import_zod28.z.object({ bundlePath }),
5013
6705
  fromArgv: (_argv, path) => ({ bundlePath: path }),
5014
6706
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
5015
6707
  });
5016
6708
 
5017
6709
  // src/commands/schema.ts
5018
- var import_zod26 = require("zod");
6710
+ var import_zod31 = require("zod");
5019
6711
 
5020
6712
  // src/json-schema.ts
5021
- var import_zod25 = require("zod");
6713
+ var import_zod30 = require("zod");
5022
6714
 
5023
6715
  // src/kb-log.ts
5024
- var import_zod24 = require("zod");
6716
+ var import_zod29 = require("zod");
5025
6717
  var LOG_FILE = "log.jsonl";
5026
- var kbLogEntrySchema = import_zod24.z.object({
6718
+ var kbLogEntrySchema = import_zod29.z.object({
5027
6719
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
5028
6720
  // below), and a value that isn't actually chronological — a Unix
5029
6721
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -5032,23 +6724,32 @@ var kbLogEntrySchema = import_zod24.z.object({
5032
6724
  // and rejects everything else, including a non-`Z` offset — so a
5033
6725
  // malformed `at` is reported the same way a malformed line already is,
5034
6726
  // rather than silently sorting into the wrong place.
5035
- at: import_zod24.z.iso.datetime(),
5036
- by: import_zod24.z.string().min(1),
5037
- operation: import_zod24.z.string().min(1),
5038
- conceptId: import_zod24.z.string().min(1),
5039
- /** Second concept id, where the operation relates two — supersession. */
5040
- target: import_zod24.z.string().min(1).optional()
6727
+ at: import_zod29.z.iso.datetime(),
6728
+ by: import_zod29.z.string().min(1),
6729
+ operation: import_zod29.z.string().min(1),
6730
+ conceptId: import_zod29.z.string().min(1),
6731
+ /**
6732
+ * The operation's other end, where it has one: a second concept id for
6733
+ * supersession, the other base's path for promotion.
6734
+ */
6735
+ target: import_zod29.z.string().min(1).optional()
5041
6736
  }).strict();
5042
6737
  function renderLogEntry(entry) {
5043
6738
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
5044
6739
  `;
5045
6740
  }
6741
+ var CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})/;
5046
6742
  function parseLog(raw) {
5047
6743
  const entries = [];
5048
6744
  const malformed = [];
5049
6745
  const seen = /* @__PURE__ */ new Set();
6746
+ let conflicted = false;
5050
6747
  raw.split("\n").forEach((text, index2) => {
5051
6748
  if (!text.trim()) return;
6749
+ if (CONFLICT_MARKER.test(text)) {
6750
+ conflicted = true;
6751
+ return;
6752
+ }
5052
6753
  let value;
5053
6754
  try {
5054
6755
  value = JSON.parse(text);
@@ -5061,25 +6762,25 @@ function parseLog(raw) {
5061
6762
  malformed.push({ line: index2 + 1, text });
5062
6763
  return;
5063
6764
  }
5064
- const key = JSON.stringify(parsed.data);
5065
- if (seen.has(key)) return;
5066
- seen.add(key);
6765
+ const key2 = JSON.stringify(parsed.data);
6766
+ if (seen.has(key2)) return;
6767
+ seen.add(key2);
5067
6768
  entries.push(parsed.data);
5068
6769
  });
5069
6770
  entries.sort(
5070
6771
  (left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
5071
6772
  );
5072
- return { entries, malformed };
6773
+ return { entries, malformed, conflicted };
5073
6774
  }
5074
6775
 
5075
6776
  // src/json-schema.ts
5076
6777
  function kbJsonSchemas() {
5077
6778
  return {
5078
- recordFrontmatter: import_zod25.z.toJSONSchema(kbRecordFrontmatterSchema, {
6779
+ recordFrontmatter: import_zod30.z.toJSONSchema(kbRecordFrontmatterSchema, {
5079
6780
  io: "input"
5080
6781
  }),
5081
- composeInput: import_zod25.z.toJSONSchema(composeInputSchema, { io: "input" }),
5082
- logEntry: import_zod25.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
6782
+ composeInput: import_zod30.z.toJSONSchema(composeInputSchema, { io: "input" }),
6783
+ logEntry: import_zod30.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
5083
6784
  };
5084
6785
  }
5085
6786
 
@@ -5089,25 +6790,25 @@ var schemaCommand = define({
5089
6790
  tool: "kb_schema",
5090
6791
  usage: "schema",
5091
6792
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
5092
- input: import_zod26.z.object({}),
6793
+ input: import_zod31.z.object({}),
5093
6794
  fromArgv: () => ({}),
5094
6795
  run: () => Promise.resolve(kbJsonSchemas())
5095
6796
  });
5096
6797
 
5097
6798
  // src/commands/stamp.ts
5098
- var import_promises8 = require("fs/promises");
5099
- var import_zod27 = require("zod");
6799
+ var import_promises10 = require("fs/promises");
6800
+ var import_zod32 = require("zod");
5100
6801
  var DIGEST = /^[0-9a-f]{64}$/;
5101
6802
  var stampCommand = define({
5102
6803
  name: "stamp",
5103
6804
  tool: "kb_stamp",
5104
6805
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
5105
6806
  description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests, how many records have drifted anchors \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids. Reads, never writes.",
5106
- input: import_zod27.z.object({
5107
- bundlePath: import_zod27.z.string().min(1).optional().describe(
6807
+ input: import_zod32.z.object({
6808
+ bundlePath: import_zod32.z.string().min(1).optional().describe(
5108
6809
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
5109
6810
  ),
5110
- since: import_zod27.z.string().min(1).optional().describe(
6811
+ since: import_zod32.z.string().min(1).optional().describe(
5111
6812
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
5112
6813
  )
5113
6814
  }),
@@ -5169,7 +6870,7 @@ async function readBaseline(since) {
5169
6870
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
5170
6871
  let parsed;
5171
6872
  try {
5172
- parsed = JSON.parse(await (0, import_promises8.readFile)(since, "utf8"));
6873
+ parsed = JSON.parse(await (0, import_promises10.readFile)(since, "utf8"));
5173
6874
  } catch {
5174
6875
  throw new KbStampBaselineError(since);
5175
6876
  }
@@ -5193,16 +6894,16 @@ async function readBaseline(since) {
5193
6894
  }
5194
6895
 
5195
6896
  // src/commands/status.ts
5196
- var import_zod28 = require("zod");
6897
+ var import_zod33 = require("zod");
5197
6898
  var statusCommand = define({
5198
6899
  name: "status",
5199
6900
  tool: "kb_status",
5200
6901
  usage: "status <concept-id> <status>",
5201
6902
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
5202
- input: import_zod28.z.object({
6903
+ input: import_zod33.z.object({
5203
6904
  bundlePath,
5204
6905
  conceptId,
5205
- status: import_zod28.z.enum(KB_RECORD_STATUSES)
6906
+ status: import_zod33.z.enum(KB_RECORD_STATUSES)
5206
6907
  }),
5207
6908
  fromArgv: (argv, path) => ({
5208
6909
  bundlePath: path,
@@ -5217,13 +6918,13 @@ var statusCommand = define({
5217
6918
  });
5218
6919
 
5219
6920
  // src/commands/supersede.ts
5220
- var import_zod29 = require("zod");
6921
+ var import_zod34 = require("zod");
5221
6922
  var supersedeCommand = define({
5222
6923
  name: "supersede",
5223
6924
  tool: "kb_supersede",
5224
6925
  usage: "supersede <concept-id> <replacement-id>",
5225
6926
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
5226
- input: import_zod29.z.object({ bundlePath, conceptId, replacementId: conceptId }),
6927
+ input: import_zod34.z.object({ bundlePath, conceptId, replacementId: conceptId }),
5227
6928
  fromArgv: (argv, path) => ({
5228
6929
  bundlePath: path,
5229
6930
  conceptId: argv[1],
@@ -5236,17 +6937,153 @@ var supersedeCommand = define({
5236
6937
  }
5237
6938
  });
5238
6939
 
6940
+ // src/commands/sweep.ts
6941
+ var import_zod35 = require("zod");
6942
+ var TERMINAL = [
6943
+ "resolved",
6944
+ "rejected",
6945
+ "superseded"
6946
+ ];
6947
+ var sweepCommand = define({
6948
+ name: "sweep",
6949
+ tool: "kb_sweep",
6950
+ usage: "sweep --tag <tag> --terminal [--dry-run]",
6951
+ description: "Delete tagged records that are resolved, rejected or superseded. Refuses without --tag, keeps any record a surviving record still points at, and logs each deletion.",
6952
+ input: import_zod35.z.object({
6953
+ bundlePath,
6954
+ tag: import_zod35.z.string({ error: "sweep needs --tag: it never sweeps a whole base" }).min(1).describe("Only records carrying this tag are considered."),
6955
+ terminal: import_zod35.z.literal(true, {
6956
+ error: "sweep needs --terminal: it deletes only settled records"
6957
+ }).describe(
6958
+ "Required. Names the only scope sweep deletes: resolved, rejected and superseded records."
6959
+ ),
6960
+ dryRun: import_zod35.z.boolean().optional().describe("Report what would go, and delete nothing.")
6961
+ }),
6962
+ fromArgv: (argv, path) => ({
6963
+ bundlePath: path,
6964
+ tag: argvFlag(argv, "--tag"),
6965
+ ...argv.includes("--terminal") ? { terminal: true } : {},
6966
+ ...argv.includes("--dry-run") ? { dryRun: true } : {}
6967
+ }),
6968
+ run: async ({ store, actor }, { bundlePath: path, tag, dryRun }) => {
6969
+ const bundle = await store.list(path);
6970
+ const held = holderIndex(bundle);
6971
+ const candidates = adjudicate(bundle, bundle).filter(
6972
+ (hit) => sweepable(hit, tag)
6973
+ );
6974
+ const doomed = new Set(candidates.map((hit) => hit.record.conceptId));
6975
+ let changed = true;
6976
+ while (changed) {
6977
+ changed = false;
6978
+ for (const conceptId2 of [...doomed]) {
6979
+ if (survivorsHolding(conceptId2, held, doomed).length === 0) continue;
6980
+ doomed.delete(conceptId2);
6981
+ changed = true;
6982
+ }
6983
+ }
6984
+ const skipped = candidates.filter((hit) => !doomed.has(hit.record.conceptId)).map((hit) => ({
6985
+ conceptId: hit.record.conceptId,
6986
+ heldBy: survivorsHolding(hit.record.conceptId, held, doomed)
6987
+ }));
6988
+ const ordered = [...doomed].sort();
6989
+ if (dryRun) {
6990
+ return {
6991
+ tag,
6992
+ dryRun: true,
6993
+ deleted: [],
6994
+ candidates: ordered,
6995
+ skipped,
6996
+ failed: []
6997
+ };
6998
+ }
6999
+ await assertBaseNotFrozen(process.cwd(), path);
7000
+ const deleted = [];
7001
+ const failed = [];
7002
+ try {
7003
+ for (const conceptId2 of ordered) {
7004
+ try {
7005
+ const outcome = await store.deleteRecord(
7006
+ path,
7007
+ conceptId2,
7008
+ { tag, statuses: TERMINAL },
7009
+ actor
7010
+ );
7011
+ if (outcome === "deleted") deleted.push(conceptId2);
7012
+ else failed.push({ conceptId: conceptId2, reason: outcome });
7013
+ } catch (error) {
7014
+ failed.push({
7015
+ conceptId: conceptId2,
7016
+ reason: error instanceof Error ? error.message : "unknown"
7017
+ });
7018
+ }
7019
+ }
7020
+ } finally {
7021
+ await store.readIndex(path);
7022
+ await store.dropSearchIndex(path);
7023
+ }
7024
+ return {
7025
+ tag,
7026
+ dryRun: false,
7027
+ deleted,
7028
+ candidates: ordered,
7029
+ skipped,
7030
+ failed
7031
+ };
7032
+ },
7033
+ render: (result) => renderSweep(result)
7034
+ });
7035
+ function sweepable(hit, tag) {
7036
+ const { tags, strauss_status } = hit.record.frontmatter;
7037
+ return (tags ?? []).includes(tag) && // Supersession is a standing, settled against the whole base; the other
7038
+ // two are the record's own word for itself.
7039
+ (hit.standing === "superseded" || strauss_status === "resolved" || strauss_status === "rejected");
7040
+ }
7041
+ function holderIndex(bundle) {
7042
+ const byTarget = /* @__PURE__ */ new Map();
7043
+ const hold = (target, from) => {
7044
+ if (target === from) return;
7045
+ const holders = byTarget.get(target) ?? /* @__PURE__ */ new Set();
7046
+ holders.add(from);
7047
+ byTarget.set(target, holders);
7048
+ };
7049
+ for (const [target, edges] of inboundIndex(bundle)) {
7050
+ for (const edge of edges) hold(target, edge.from);
7051
+ }
7052
+ for (const record of bundle) {
7053
+ const { strauss_supersedes, strauss_superseded_by } = record.frontmatter;
7054
+ for (const old of strauss_supersedes ?? []) hold(old, record.conceptId);
7055
+ if (strauss_superseded_by) hold(strauss_superseded_by, record.conceptId);
7056
+ }
7057
+ return byTarget;
7058
+ }
7059
+ function survivorsHolding(conceptId2, held, doomed) {
7060
+ return [...held.get(conceptId2) ?? []].filter((from) => !doomed.has(from)).sort();
7061
+ }
7062
+ function renderSweep(result) {
7063
+ const shown = result.dryRun ? result.candidates : result.deleted;
7064
+ const verb = result.dryRun ? "would delete" : "deleted";
7065
+ const lines = [`${verb} ${shown.length} (tag: ${result.tag})`];
7066
+ for (const conceptId2 of shown) lines.push(`- ${conceptId2}`);
7067
+ for (const skip of result.skipped) {
7068
+ lines.push(`kept ${skip.conceptId} \u2014 held by ${skip.heldBy.join(", ")}`);
7069
+ }
7070
+ for (const failure of result.failed) {
7071
+ lines.push(`failed ${failure.conceptId} \u2014 ${failure.reason}`);
7072
+ }
7073
+ return lines.join("\n");
7074
+ }
7075
+
5239
7076
  // src/commands/sync-instructions.ts
5240
- var import_zod30 = require("zod");
7077
+ var import_zod36 = require("zod");
5241
7078
  var syncInstructionsCommand = define({
5242
7079
  name: "sync-instructions",
5243
7080
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
5244
7081
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
5245
- input: import_zod30.z.object({
5246
- file: import_zod30.z.string().min(1).describe("The instruction file to edit in place."),
5247
- budgetTokens: import_zod30.z.number().int().positive().optional(),
5248
- fullUnderTokens: import_zod30.z.number().int().positive().optional(),
5249
- profile: import_zod30.z.string().optional()
7082
+ input: import_zod36.z.object({
7083
+ file: import_zod36.z.string().min(1).describe("The instruction file to edit in place."),
7084
+ budgetTokens: import_zod36.z.number().int().positive().optional(),
7085
+ fullUnderTokens: import_zod36.z.number().int().positive().optional(),
7086
+ profile: import_zod36.z.string().optional()
5250
7087
  }),
5251
7088
  fromArgv: (argv) => {
5252
7089
  const budget = argvFlag(argv, "--budget");
@@ -5272,7 +7109,7 @@ var syncInstructionsCommand = define({
5272
7109
  });
5273
7110
 
5274
7111
  // src/commands/trace.ts
5275
- var import_zod31 = require("zod");
7112
+ var import_zod37 = require("zod");
5276
7113
 
5277
7114
  // src/trace.ts
5278
7115
  var TRACE_EDGES = [
@@ -5328,11 +7165,11 @@ var traceCommand = define({
5328
7165
  tool: "kb_trace",
5329
7166
  usage: "trace <concept-id> [edges...]",
5330
7167
  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".',
5331
- input: import_zod31.z.object({
7168
+ input: import_zod37.z.object({
5332
7169
  bundlePath,
5333
7170
  conceptId,
5334
- edges: import_zod31.z.array(import_zod31.z.enum(TRACE_EDGES)).optional(),
5335
- depth: import_zod31.z.number().int().positive().optional()
7171
+ edges: import_zod37.z.array(import_zod37.z.enum(TRACE_EDGES)).optional(),
7172
+ depth: import_zod37.z.number().int().positive().optional()
5336
7173
  }),
5337
7174
  fromArgv: (argv, path) => ({
5338
7175
  bundlePath: path,
@@ -5354,37 +7191,37 @@ var traceCommand = define({
5354
7191
  });
5355
7192
 
5356
7193
  // src/commands/types.ts
5357
- var import_zod32 = require("zod");
7194
+ var import_zod38 = require("zod");
5358
7195
  var typesCommand = define({
5359
7196
  name: "types",
5360
7197
  tool: "kb_types",
5361
7198
  usage: "types",
5362
7199
  description: "The twelve record types with their purpose, body sections, and starting status. Read this before writing rather than guessing headings \u2014 a section the type does not define is rejected.",
5363
- input: import_zod32.z.object({}),
7200
+ input: import_zod38.z.object({}),
5364
7201
  fromArgv: () => ({}),
5365
7202
  run: () => Promise.resolve(RECORD_TYPES)
5366
7203
  });
5367
7204
 
5368
7205
  // src/commands/unpin.ts
5369
- var import_zod33 = require("zod");
7206
+ var import_zod39 = require("zod");
5370
7207
  var unpinCommand = define({
5371
7208
  name: "unpin",
5372
7209
  tool: "kb_unpin",
5373
7210
  usage: "unpin [bundle-path]",
5374
7211
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
5375
- input: import_zod33.z.object({ bundlePath }),
7212
+ input: import_zod39.z.object({ bundlePath }),
5376
7213
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
5377
7214
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
5378
7215
  });
5379
7216
 
5380
7217
  // src/commands/validate.ts
5381
- var import_zod34 = require("zod");
7218
+ var import_zod40 = require("zod");
5382
7219
  var validateCommand = define({
5383
7220
  name: "validate",
5384
7221
  tool: "kb_validate",
5385
7222
  usage: "validate",
5386
7223
  description: "Check pointers no single record can see: supersession links that disagree between the two records, typed causal links, and assumptions that cite sources. Each finding carries a severity: errors fail the exit code, warnings do not.",
5387
- input: import_zod34.z.object({ bundlePath }),
7224
+ input: import_zod40.z.object({ bundlePath }),
5388
7225
  fromArgv: (_argv, path) => ({ bundlePath: path }),
5389
7226
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
5390
7227
  // Warnings never fail the exit code; every other severity does.
@@ -5394,16 +7231,16 @@ var validateCommand = define({
5394
7231
  });
5395
7232
 
5396
7233
  // src/commands/verify.ts
5397
- var import_zod35 = require("zod");
7234
+ var import_zod41 = require("zod");
5398
7235
  var verifyCommand = define({
5399
7236
  name: "verify",
5400
7237
  tool: "kb_verify",
5401
7238
  usage: "verify <concept-id> --note <text>",
5402
7239
  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.",
5403
- input: import_zod35.z.object({
7240
+ input: import_zod41.z.object({
5404
7241
  bundlePath,
5405
7242
  conceptId,
5406
- note: import_zod35.z.string().refine((s) => s.trim().length > 0, {
7243
+ note: import_zod41.z.string().refine((s) => s.trim().length > 0, {
5407
7244
  message: "note must say what the check found"
5408
7245
  })
5409
7246
  }),
@@ -5423,15 +7260,15 @@ var verifyCommand = define({
5423
7260
  });
5424
7261
 
5425
7262
  // src/commands/write.ts
5426
- var import_zod36 = require("zod");
7263
+ var import_zod42 = require("zod");
5427
7264
  var writeCommand = define({
5428
7265
  name: "write",
5429
7266
  tool: "kb_write",
5430
7267
  usage: "write <type> < record.json",
5431
7268
  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.",
5432
- input: import_zod36.z.object({
7269
+ input: import_zod42.z.object({
5433
7270
  bundlePath,
5434
- type: import_zod36.z.enum(KB_RECORD_TYPES),
7271
+ type: import_zod42.z.enum(KB_RECORD_TYPES),
5435
7272
  input: composeInputSchema
5436
7273
  }),
5437
7274
  fromArgv: async (argv, path, stdin) => ({
@@ -5455,13 +7292,13 @@ var writeCommand = define({
5455
7292
  });
5456
7293
 
5457
7294
  // src/commands/write-decision.ts
5458
- var import_zod37 = require("zod");
7295
+ var import_zod43 = require("zod");
5459
7296
  var writeDecisionCommand = define({
5460
7297
  name: "write-decision",
5461
7298
  tool: "kb_write_decision",
5462
7299
  usage: "write-decision < decision.json",
5463
7300
  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.",
5464
- input: import_zod37.z.object({ bundlePath, input: decisionInputSchema }),
7301
+ input: import_zod43.z.object({ bundlePath, input: decisionInputSchema }),
5465
7302
  fromArgv: async (_argv, path, stdin) => ({
5466
7303
  bundlePath: path,
5467
7304
  input: JSON.parse(await stdin())
@@ -5492,19 +7329,24 @@ var KB_COMMANDS = [
5492
7329
  verifyCommand,
5493
7330
  anchorResolveCommand,
5494
7331
  reassessCommand,
7332
+ promoteCommand,
5495
7333
  loadCommand,
5496
7334
  catalogCommand,
5497
7335
  packCommand,
7336
+ exportCommand,
5498
7337
  queryCommand,
5499
7338
  traceCommand,
5500
7339
  impactCommand,
5501
7340
  backlinksCommand,
7341
+ matchCommand,
7342
+ classifyCommand,
5502
7343
  listCommand,
5503
7344
  readIndexCommand,
5504
7345
  logCommand,
5505
7346
  stampCommand,
5506
7347
  validateCommand,
5507
7348
  doctorCommand,
7349
+ sweepCommand,
5508
7350
  schemaCommand,
5509
7351
  pinCommand,
5510
7352
  unpinCommand,
@@ -5518,8 +7360,8 @@ var KB_COMMANDS_BY_NAME = new Map(
5518
7360
  );
5519
7361
 
5520
7362
  // src/kb-store.ts
5521
- var import_promises10 = require("fs/promises");
5522
- var import_node_path11 = require("path");
7363
+ var import_promises12 = require("fs/promises");
7364
+ var import_node_path14 = require("path");
5523
7365
 
5524
7366
  // src/markdown.ts
5525
7367
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -5579,8 +7421,8 @@ function bundleDigest(records, superseded) {
5579
7421
  }
5580
7422
 
5581
7423
  // src/search-index.ts
5582
- var import_promises9 = require("fs/promises");
5583
- var import_node_path10 = require("path");
7424
+ var import_promises11 = require("fs/promises");
7425
+ var import_node_path13 = require("path");
5584
7426
  var SEARCH_INDEX_FILE = ".index.sqlite";
5585
7427
  var COLLECTION = "kb";
5586
7428
  async function searchBase(bundlePath2, query, options = {}) {
@@ -5589,7 +7431,7 @@ async function searchBase(bundlePath2, query, options = {}) {
5589
7431
  let store = null;
5590
7432
  try {
5591
7433
  store = await qmd.createStore({
5592
- dbPath: (0, import_node_path10.join)(bundlePath2, SEARCH_INDEX_FILE),
7434
+ dbPath: (0, import_node_path13.join)(bundlePath2, SEARCH_INDEX_FILE),
5593
7435
  config: {
5594
7436
  collections: {
5595
7437
  [COLLECTION]: {
@@ -5624,16 +7466,16 @@ async function searchBase(bundlePath2, query, options = {}) {
5624
7466
  }
5625
7467
  }
5626
7468
  async function isStale(bundlePath2) {
5627
- const indexAt = await (0, import_promises9.stat)((0, import_node_path10.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
7469
+ const indexAt = await (0, import_promises11.stat)((0, import_node_path13.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
5628
7470
  if (!indexAt) return true;
5629
- const { readdir: readdir2 } = await import("fs/promises");
5630
- const names = (await readdir2(bundlePath2).catch(() => [])).filter(
7471
+ const { readdir: readdir3 } = await import("fs/promises");
7472
+ const names = (await readdir3(bundlePath2).catch(() => [])).filter(
5631
7473
  (name) => name.endsWith(".md") && name !== INDEX_FILE
5632
7474
  );
5633
7475
  let stale = false;
5634
7476
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
5635
7477
  if (stale) return;
5636
- const at2 = await (0, import_promises9.stat)((0, import_node_path10.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
7478
+ const at2 = await (0, import_promises11.stat)((0, import_node_path13.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
5637
7479
  if (at2 > indexAt) stale = true;
5638
7480
  });
5639
7481
  return stale;
@@ -5751,144 +7593,13 @@ function typeRank(record) {
5751
7593
  return index2 === -1 ? TYPE_PRIORITY.length : index2;
5752
7594
  }
5753
7595
 
5754
- // src/kb-links/inbound.ts
5755
- function inboundIndex(bundle) {
5756
- const byTarget = /* @__PURE__ */ new Map();
5757
- for (const record of bundle) {
5758
- for (const link2 of record.frontmatter.strauss_links ?? []) {
5759
- if (link2.target === record.conceptId) continue;
5760
- const edges = byTarget.get(link2.target) ?? [];
5761
- if (edges.some(
5762
- (edge) => edge.from === record.conceptId && edge.rel === link2.rel
5763
- )) {
5764
- continue;
5765
- }
5766
- edges.push({ from: record.conceptId, rel: link2.rel });
5767
- byTarget.set(link2.target, edges);
5768
- }
5769
- }
5770
- return byTarget;
5771
- }
5772
-
5773
- // src/kb-links/backlinks.ts
5774
- function backlinks(targetId, bundle) {
5775
- const byId = new Map(bundle.map((record) => [record.conceptId, record]));
5776
- if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
5777
- const standingOf = new Map(
5778
- adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
5779
- );
5780
- const rows = [];
5781
- for (const edge of inboundIndex(bundle).get(targetId) ?? []) {
5782
- const record = byId.get(edge.from);
5783
- if (!record) continue;
5784
- const hit = standingOf.get(edge.from);
5785
- rows.push({
5786
- ...edge,
5787
- title: record.frontmatter.title ?? null,
5788
- standing: hit?.standing ?? "unsettled",
5789
- warnings: hit?.warnings ?? []
5790
- });
5791
- }
5792
- return {
5793
- target: targetId,
5794
- backlinks: rows.sort(
5795
- (left, right) => left.from.localeCompare(right.from) || left.rel.localeCompare(right.rel)
5796
- )
5797
- };
5798
- }
5799
-
5800
- // src/kb-links/impact.ts
5801
- function impact(targetId, bundle, options = {}) {
5802
- const byId = new Map(bundle.map((record) => [record.conceptId, record]));
5803
- if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
5804
- const rels = resolveRels(options.rels);
5805
- const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
5806
- const inbound = inboundIndex(bundle);
5807
- const standingOf = new Map(
5808
- adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
5809
- );
5810
- const reached = /* @__PURE__ */ new Map();
5811
- const stopped = [];
5812
- let frontier = [targetId];
5813
- let depth = 0;
5814
- while (frontier.length && depth < maxDepth) {
5815
- depth += 1;
5816
- const next = [];
5817
- const consider = (dependantId, edge) => {
5818
- if (dependantId === targetId) return;
5819
- const existing = reached.get(dependantId);
5820
- if (existing) {
5821
- if (!hasEdge(existing.via, edge)) existing.via.push(edge);
5822
- return;
5823
- }
5824
- const record = byId.get(dependantId);
5825
- if (!record) return;
5826
- const hit = standingOf.get(dependantId);
5827
- const entry = {
5828
- conceptId: dependantId,
5829
- title: record.frontmatter.title ?? null,
5830
- standing: hit?.standing ?? "unsettled",
5831
- warnings: hit?.warnings ?? [],
5832
- depth,
5833
- via: [edge]
5834
- };
5835
- reached.set(dependantId, entry);
5836
- if (entry.standing === "superseded" || entry.standing === "rejected") {
5837
- stopped.push(dependantId);
5838
- return;
5839
- }
5840
- next.push(dependantId);
5841
- };
5842
- for (const id of frontier) {
5843
- for (const edge of inbound.get(id) ?? []) {
5844
- if (!rels.has(edge.rel)) continue;
5845
- if (dependantEnd(edge.rel) !== "source") continue;
5846
- consider(edge.from, { source: edge.from, target: id, rel: edge.rel });
5847
- }
5848
- for (const link2 of byId.get(id)?.frontmatter.strauss_links ?? []) {
5849
- if (!rels.has(link2.rel)) continue;
5850
- if (dependantEnd(link2.rel) !== "target") continue;
5851
- if (link2.target === id) continue;
5852
- consider(link2.target, {
5853
- source: id,
5854
- target: link2.target,
5855
- rel: link2.rel
5856
- });
5857
- }
5858
- }
5859
- frontier = next;
5860
- }
5861
- return {
5862
- root: targetId,
5863
- impacted: [...reached.values()].sort(
5864
- (left, right) => left.depth - right.depth || left.conceptId.localeCompare(right.conceptId)
5865
- ),
5866
- stopped: stopped.sort(),
5867
- truncated: frontier.length > 0,
5868
- unexpanded: [...frontier].sort()
5869
- };
5870
- }
5871
- function resolveRels(rels) {
5872
- if (!rels?.length) return new Set(KB_CAUSAL_LINK_RELS);
5873
- for (const rel of rels) {
5874
- if (!isKbLinkRel(rel) || LINK_RELS[rel].dependant === null) {
5875
- throw new KbUnknownLinkRelError(rel, KB_CAUSAL_LINK_RELS);
5876
- }
5877
- }
5878
- return new Set(rels);
5879
- }
5880
- function dependantEnd(rel) {
5881
- return isKbLinkRel(rel) ? LINK_RELS[rel].dependant : null;
5882
- }
5883
- function hasEdge(edges, edge) {
5884
- return edges.some(
5885
- (existing) => existing.source === edge.source && existing.target === edge.target && existing.rel === edge.rel
5886
- );
5887
- }
7596
+ // src/kb-files.ts
7597
+ var STORE_OWNED_FILES = [INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE];
5888
7598
 
5889
7599
  // src/kb-gitattributes.ts
5890
7600
  var GITATTRIBUTES_FILE = ".gitattributes";
5891
- var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
7601
+ var GENERATED = "linguist-generated";
7602
+ var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union ${GENERATED}=true`;
5892
7603
  function parseLine(line) {
5893
7604
  const trimmed = line.trim();
5894
7605
  if (!trimmed || trimmed.startsWith("#")) return null;
@@ -5896,23 +7607,39 @@ function parseLine(line) {
5896
7607
  return pattern === void 0 ? null : { pattern, attrs };
5897
7608
  }
5898
7609
  function hasMergeDeclaration(contents) {
7610
+ return declares(contents, LOG_FILE, "merge");
7611
+ }
7612
+ function declares(contents, pattern, attribute) {
5899
7613
  return contents.split("\n").some((line) => {
5900
7614
  const parsed = parseLine(line);
5901
- if (!parsed || parsed.pattern !== LOG_FILE) return false;
7615
+ if (!parsed || parsed.pattern !== pattern) return false;
5902
7616
  return parsed.attrs.some(
5903
- (attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
7617
+ (attr) => attr === attribute || attr === `-${attribute}` || attr.startsWith(`${attribute}=`)
5904
7618
  );
5905
7619
  });
5906
7620
  }
5907
- function appendUnionMergeLine(contents) {
7621
+ function missingGitattributesLines(contents) {
7622
+ const needsMerge = !hasMergeDeclaration(contents);
7623
+ const generated = STORE_OWNED_FILES.filter(
7624
+ // The union-merge line carries the log's `linguist-generated` too, so the
7625
+ // log needs its own line only where that line is already there without it.
7626
+ (file) => !(needsMerge && file === LOG_FILE)
7627
+ ).filter((file) => !declares(contents, file, GENERATED)).map((file) => `${file} ${GENERATED}=true`);
7628
+ return needsMerge ? [UNION_MERGE_LINE, ...generated] : generated;
7629
+ }
7630
+ var GITATTRIBUTES_BLOCK = `${missingGitattributesLines("").join("\n")}
7631
+ `;
7632
+ function appendGitattributesLines(contents) {
7633
+ const lines = missingGitattributesLines(contents);
7634
+ if (lines.length === 0) return "";
5908
7635
  const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
5909
- return `${separator}${UNION_MERGE_LINE}
7636
+ return `${separator}${lines.join("\n")}
5910
7637
  `;
5911
7638
  }
5912
7639
 
5913
7640
  // src/kb-store.ts
5914
- var KB_DIR = (0, import_node_path11.join)(".strauss", "kb");
5915
- var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
7641
+ var KB_DIR = (0, import_node_path14.join)(".strauss", "kb");
7642
+ var STORE_OWNED = new Set(STORE_OWNED_FILES);
5916
7643
  var DEFAULT_LOAD_BUDGET = 25e3;
5917
7644
  var KbStore = class {
5918
7645
  constructor(logger = {}) {
@@ -5942,7 +7669,7 @@ var KbStore = class {
5942
7669
  const conceptId2 = `${input.type}.${input.slug}`;
5943
7670
  const root = this.root(bundlePath2);
5944
7671
  const target = this.recordPath(bundlePath2, conceptId2);
5945
- await (0, import_promises10.mkdir)(root, { recursive: true });
7672
+ await (0, import_promises12.mkdir)(root, { recursive: true });
5946
7673
  await this.publish(
5947
7674
  target,
5948
7675
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -5981,7 +7708,7 @@ var KbStore = class {
5981
7708
  const target = this.recordPath(bundlePath2, conceptId2);
5982
7709
  let raw;
5983
7710
  try {
5984
- raw = await (0, import_promises10.readFile)(target, "utf8");
7711
+ raw = await (0, import_promises12.readFile)(target, "utf8");
5985
7712
  } catch {
5986
7713
  return null;
5987
7714
  }
@@ -6001,7 +7728,7 @@ var KbStore = class {
6001
7728
  const root = this.root(bundlePath2);
6002
7729
  let names;
6003
7730
  try {
6004
- names = await (0, import_promises10.readdir)(root);
7731
+ names = await (0, import_promises12.readdir)(root);
6005
7732
  } catch {
6006
7733
  return [];
6007
7734
  }
@@ -6009,7 +7736,7 @@ var KbStore = class {
6009
7736
  const records = await mapLimit(
6010
7737
  wanted,
6011
7738
  DEFAULT_IO_CONCURRENCY,
6012
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises10.readFile)((0, import_node_path11.join)(root, name), "utf8"))
7739
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises12.readFile)((0, import_node_path14.join)(root, name), "utf8"))
6013
7740
  );
6014
7741
  return records.filter(
6015
7742
  (record) => record !== null && matchesTags(record, filter)
@@ -6039,12 +7766,16 @@ var KbStore = class {
6039
7766
  * Wholesale rather than merged: the caller just resolved the anchors it is
6040
7767
  * writing, so it holds the complete current set, and a merge would keep
6041
7768
  * stale entries the resolution pass deliberately dropped.
7769
+ *
7770
+ * Through the write schema: this is a write, and a defect a hand-edit put in
7771
+ * the frontmatter must not be published back out under an actor stamp.
6042
7772
  */
6043
7773
  async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
7774
+ const checked = anchors.map((anchor) => kbAnchorWriteSchema.parse(anchor));
6044
7775
  return this.mutate(
6045
7776
  bundlePath2,
6046
7777
  conceptId2,
6047
- (frontmatter) => ({ ...frontmatter, strauss_anchors: anchors }),
7778
+ (frontmatter) => ({ ...frontmatter, strauss_anchors: checked }),
6048
7779
  { operation: "anchor-resolve", by: actor }
6049
7780
  );
6050
7781
  }
@@ -6130,6 +7861,35 @@ ${answer}
6130
7861
  `
6131
7862
  );
6132
7863
  }
7864
+ /**
7865
+ * Removes one record, logged as `sweep`. The only path in this store that
7866
+ * deletes — see the specification for the scope that makes it safe.
7867
+ *
7868
+ * `expected` is re-read and re-checked immediately before the unlink, the
7869
+ * compare-and-swap `mutate` makes: a record retagged or moved out of a
7870
+ * terminal status since the caller listed it is reported, not removed.
7871
+ */
7872
+ async deleteRecord(bundlePath2, conceptId2, expected, actor = "unknown") {
7873
+ const target = this.recordPath(bundlePath2, conceptId2);
7874
+ const witness = await this.read(bundlePath2, conceptId2);
7875
+ if (!witness) throw new KbRecordNotFoundError(conceptId2);
7876
+ const { tags, strauss_status } = witness.frontmatter;
7877
+ if (!(tags ?? []).includes(expected.tag) || !expected.statuses.includes(strauss_status)) {
7878
+ return "changed-since-listing";
7879
+ }
7880
+ try {
7881
+ await (0, import_promises12.unlink)(target);
7882
+ } catch (error) {
7883
+ if (error.code !== "ENOENT") throw error;
7884
+ throw new KbRecordNotFoundError(conceptId2);
7885
+ }
7886
+ await this.record(this.root(bundlePath2), {
7887
+ operation: "sweep",
7888
+ by: actor,
7889
+ conceptId: conceptId2
7890
+ });
7891
+ return "deleted";
7892
+ }
6133
7893
  /**
6134
7894
  * Records matching a text query, each carrying its standing.
6135
7895
  *
@@ -6352,11 +8112,11 @@ ${answer}
6352
8112
  async readIndex(bundlePath2) {
6353
8113
  const root = this.root(bundlePath2);
6354
8114
  const expected = renderIndex(await this.list(bundlePath2));
6355
- const stored = await (0, import_promises10.readFile)((0, import_node_path11.join)(root, INDEX_FILE), "utf8").catch(
8115
+ const stored = await (0, import_promises12.readFile)((0, import_node_path14.join)(root, INDEX_FILE), "utf8").catch(
6356
8116
  () => null
6357
8117
  );
6358
8118
  if (indexIsStale(stored, expected)) {
6359
- await this.publish((0, import_node_path11.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
8119
+ await this.publish((0, import_node_path14.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
6360
8120
  this.logger.info?.({
6361
8121
  operation: "kb.index.repair",
6362
8122
  bundlePath: root,
@@ -6365,19 +8125,39 @@ ${answer}
6365
8125
  }
6366
8126
  return expected;
6367
8127
  }
8128
+ /**
8129
+ * Drops the derived search index, so the next search rebuilds it.
8130
+ *
8131
+ * `searchBase` re-indexes when a record is newer than the index, which no
8132
+ * deletion makes true — a swept record would stay findable until some other
8133
+ * record was written.
8134
+ */
8135
+ async dropSearchIndex(bundlePath2) {
8136
+ await (0, import_promises12.unlink)((0, import_node_path14.join)(this.root(bundlePath2), SEARCH_INDEX_FILE)).catch(
8137
+ () => void 0
8138
+ );
8139
+ }
6368
8140
  /**
6369
8141
  * The log, with unparseable lines reported rather than repaired.
6370
8142
  *
6371
8143
  * The log is the bundle's only artifact that cannot be reconstructed — the
6372
8144
  * records rebuild the index, and the code outlives both, but nothing else
6373
8145
  * knows which agent touched what. So a bad line is surfaced and left alone.
8146
+ * Conflict markers are read past rather than reported per line.
6374
8147
  */
6375
8148
  async readLog(bundlePath2) {
6376
- const raw = await (0, import_promises10.readFile)(
6377
- (0, import_node_path11.join)(this.root(bundlePath2), LOG_FILE),
8149
+ const raw = await (0, import_promises12.readFile)(
8150
+ (0, import_node_path14.join)(this.root(bundlePath2), LOG_FILE),
6378
8151
  "utf8"
6379
8152
  ).catch(() => "");
6380
8153
  const result = parseLog(raw);
8154
+ if (result.conflicted) {
8155
+ this.logger.warn?.({
8156
+ operation: "kb.log.parse",
8157
+ bundlePath: this.root(bundlePath2),
8158
+ outcome: "conflicted"
8159
+ });
8160
+ }
6381
8161
  for (const bad of result.malformed) {
6382
8162
  this.logger.warn?.({
6383
8163
  operation: "kb.log.parse",
@@ -6387,6 +8167,14 @@ ${answer}
6387
8167
  }
6388
8168
  return result;
6389
8169
  }
8170
+ /**
8171
+ * Appends one log entry for a move the store cannot see from one base.
8172
+ * Promotion writes into a target base and has to be legible from the source
8173
+ * base too, where nothing was written.
8174
+ */
8175
+ async note(bundlePath2, entry) {
8176
+ await this.record(this.root(bundlePath2), entry);
8177
+ }
6390
8178
  /**
6391
8179
  * `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
6392
8180
  * a missing target (a broken link, legal per compose.ts) or a CAS conflict
@@ -6425,14 +8213,14 @@ ${answer}
6425
8213
  }
6426
8214
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
6427
8215
  const target = this.recordPath(bundlePath2, conceptId2);
6428
- const before = await (0, import_promises10.readFile)(target, "utf8").catch(() => null);
8216
+ const before = await (0, import_promises12.readFile)(target, "utf8").catch(() => null);
6429
8217
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
6430
8218
  const parsed = this.parse(conceptId2, before);
6431
8219
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
6432
8220
  const frontmatter = change(parsed.frontmatter);
6433
8221
  const body = changeBody(parsed.body);
6434
8222
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
6435
- const witness = await (0, import_promises10.readFile)(target, "utf8").catch(() => null);
8223
+ const witness = await (0, import_promises12.readFile)(target, "utf8").catch(() => null);
6436
8224
  if (witness === null || sha2563(witness) !== sha2563(before)) {
6437
8225
  throw new KbWriteConflictError(conceptId2);
6438
8226
  }
@@ -6458,26 +8246,27 @@ ${answer}
6458
8246
  */
6459
8247
  async publish(target, contents, overwrite, conceptId2) {
6460
8248
  const staging = `${target}.${process.pid}.tmp`;
6461
- await (0, import_promises10.writeFile)(staging, contents, "utf8");
8249
+ await (0, import_promises12.writeFile)(staging, contents, "utf8");
6462
8250
  try {
6463
8251
  if (overwrite) {
6464
- await (0, import_promises10.rename)(staging, target);
8252
+ await (0, import_promises12.rename)(staging, target);
6465
8253
  return;
6466
8254
  }
6467
- await (0, import_promises10.link)(staging, target);
8255
+ await (0, import_promises12.link)(staging, target);
6468
8256
  } catch (error) {
6469
8257
  if (error.code === "EEXIST") {
6470
8258
  throw new KbRecordAlreadyExistsError(conceptId2);
6471
8259
  }
6472
8260
  throw error;
6473
8261
  } finally {
6474
- await (0, import_promises10.unlink)(staging).catch(() => void 0);
8262
+ await (0, import_promises12.unlink)(staging).catch(() => void 0);
6475
8263
  }
6476
8264
  }
6477
8265
  /**
6478
8266
  * Declares union merge for the log, so two worktrees writing the same
6479
8267
  * bundle interleave their `log.jsonl` lines on merge rather than one
6480
- * side's appends silently losing to git's ordinary line-level merge.
8268
+ * side's appends silently losing to git's ordinary line-level merge — and
8269
+ * marks every store-owned file generated, so GitHub collapses it in a diff.
6481
8270
  *
6482
8271
  * Called from `record` — every path that appends a log line, not just
6483
8272
  * `write` — so a bundle only ever mutated through `setStatus`/`verify`/
@@ -6490,10 +8279,9 @@ ${answer}
6490
8279
  * race and created the file between the `readFile` below and this call,
6491
8280
  * `wx` fails instead of truncating what that writer just wrote, and the
6492
8281
  * failure is swallowed by the catch below same as any other best-effort
6493
- * miss. A file that exists but declares no merge strategy for the log
6494
- * gets the line appended, never a wholesale rewrite; one that already
6495
- * declares any merge strategy — this one or a user's own — is left alone
6496
- * entirely (see `hasMergeDeclaration`).
8282
+ * miss. A file that exists gets only the lines it lacks appended, never a
8283
+ * wholesale rewrite; an attribute it already sets this one's value or a
8284
+ * user's own — is left alone (see `missingGitattributesLines`).
6497
8285
  *
6498
8286
  * `readFile` failing is `existing === null` only for `ENOENT` — genuinely
6499
8287
  * missing. Any other error (a permission problem, a transient `EMFILE`,
@@ -6504,29 +8292,29 @@ ${answer}
6504
8292
  * therefore left untouched and reported as a failure like any other.
6505
8293
  *
6506
8294
  * Two processes racing the append branch — both read a file without the
6507
- * line, both append it — is possible and left unguarded: `appendFile` is
6508
- * `O_APPEND`, so the result is two copies of the same line rather than a
6509
- * torn write, and `hasMergeDeclaration` sees a duplicate declaration as
6510
- * "already declared" on the next call. A cheap-to-detect, harmless-to-
6511
- * leave residue, not a reason to add a cross-process lock (see
6512
- * `ARCHITECTURE.md`'s rejection of one for the same trade on records).
8295
+ * lines, both append them — is possible and left unguarded: `appendFile` is
8296
+ * `O_APPEND`, so the result is two copies of the same lines rather than a
8297
+ * torn write, and the next call sees a duplicate declaration as "already
8298
+ * declared". A cheap-to-detect, harmless-to-leave residue, not a reason to
8299
+ * add a cross-process lock (see `ARCHITECTURE.md`'s rejection of one for
8300
+ * the same trade on records).
6513
8301
  *
6514
8302
  * Best-effort, like the log append it precedes: failing to write this
6515
8303
  * file must not fail the mutation it guards.
6516
8304
  */
6517
8305
  async ensureGitattributes(root) {
6518
- const target = (0, import_node_path11.join)(root, GITATTRIBUTES_FILE);
8306
+ const target = (0, import_node_path14.join)(root, GITATTRIBUTES_FILE);
6519
8307
  try {
6520
8308
  let existing;
6521
8309
  try {
6522
- existing = await (0, import_promises10.readFile)(target, "utf8");
8310
+ existing = await (0, import_promises12.readFile)(target, "utf8");
6523
8311
  } catch (error) {
6524
8312
  if (error.code !== "ENOENT") throw error;
6525
8313
  existing = null;
6526
8314
  }
6527
8315
  if (existing === null) {
6528
8316
  try {
6529
- await (0, import_promises10.writeFile)(target, appendUnionMergeLine(""), {
8317
+ await (0, import_promises12.writeFile)(target, appendGitattributesLines(""), {
6530
8318
  encoding: "utf8",
6531
8319
  flag: "wx"
6532
8320
  });
@@ -6546,8 +8334,9 @@ ${answer}
6546
8334
  });
6547
8335
  return;
6548
8336
  }
6549
- if (!hasMergeDeclaration(existing)) {
6550
- await (0, import_promises10.appendFile)(target, appendUnionMergeLine(existing), "utf8");
8337
+ const addition = appendGitattributesLines(existing);
8338
+ if (addition) {
8339
+ await (0, import_promises12.appendFile)(target, addition, "utf8");
6551
8340
  this.logger.info?.({
6552
8341
  operation: "kb.gitattributes.ensure",
6553
8342
  bundlePath: root,
@@ -6566,7 +8355,7 @@ ${answer}
6566
8355
  async record(root, entry) {
6567
8356
  await this.ensureGitattributes(root);
6568
8357
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
6569
- await (0, import_promises10.appendFile)((0, import_node_path11.join)(root, LOG_FILE), line, "utf8").catch((error) => {
8358
+ await (0, import_promises12.appendFile)((0, import_node_path14.join)(root, LOG_FILE), line, "utf8").catch((error) => {
6570
8359
  this.logger.warn?.({
6571
8360
  operation: "kb.log.append",
6572
8361
  outcome: "failed",
@@ -6592,18 +8381,18 @@ ${answer}
6592
8381
  };
6593
8382
  }
6594
8383
  root(bundlePath2) {
6595
- return (0, import_node_path11.resolve)(bundlePath2);
8384
+ return (0, import_node_path14.resolve)(bundlePath2);
6596
8385
  }
6597
8386
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
6598
8387
  // bundle root; anything carrying a separator would escape it.
6599
8388
  recordPath(bundlePath2, conceptId2) {
6600
- if (conceptId2.includes(import_node_path11.sep) || conceptId2.includes("/")) {
8389
+ if (conceptId2.includes(import_node_path14.sep) || conceptId2.includes("/")) {
6601
8390
  throw new KbInvalidConceptIdError(
6602
8391
  "concept id must not contain a path separator",
6603
8392
  { conceptId: conceptId2 }
6604
8393
  );
6605
8394
  }
6606
- return (0, import_node_path11.join)(this.root(bundlePath2), `${conceptId2}.md`);
8395
+ return (0, import_node_path14.join)(this.root(bundlePath2), `${conceptId2}.md`);
6607
8396
  }
6608
8397
  };
6609
8398
  function estimateTokens(record) {
@@ -6643,7 +8432,7 @@ function normalizeActor(id) {
6643
8432
  }
6644
8433
 
6645
8434
  // src/version.ts
6646
- var VERSION = true ? "0.1.19" : "0.0.0-dev";
8435
+ var VERSION = true ? "0.1.20" : "0.0.0-dev";
6647
8436
 
6648
8437
  // src/cli.ts
6649
8438
  async function runKbCli(argv) {
@@ -6707,7 +8496,7 @@ function takeLiteral(argv) {
6707
8496
  function takeBundle(argv) {
6708
8497
  const at2 = argv.indexOf("--bundle");
6709
8498
  if (at2 === -1) {
6710
- return { bundle: (0, import_node_path12.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
8499
+ return { bundle: (0, import_node_path15.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
6711
8500
  }
6712
8501
  const bundle = argv[at2 + 1];
6713
8502
  if (!bundle) die("--bundle requires a path");
@@ -6718,11 +8507,11 @@ function takeBundle(argv) {
6718
8507
  };
6719
8508
  }
6720
8509
  function readStdin() {
6721
- return new Promise((resolve6, reject) => {
8510
+ return new Promise((resolve7, reject) => {
6722
8511
  let text = "";
6723
8512
  process.stdin.setEncoding("utf8");
6724
8513
  process.stdin.on("data", (chunk) => text += chunk);
6725
- process.stdin.on("end", () => resolve6(text));
8514
+ process.stdin.on("end", () => resolve7(text));
6726
8515
  process.stdin.on("error", reject);
6727
8516
  });
6728
8517
  }