@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.
@@ -16,9 +16,24 @@ var kbVerifiedEventSchema = kbActorStampSchema.extend({
16
16
  message: "note must say what the check found"
17
17
  })
18
18
  });
19
+ var kbAnchorSpanSchema = z.object({
20
+ start: z.number().int().positive(),
21
+ end: z.number().int().positive()
22
+ }).strict();
19
23
  var kbAnchorSchema = z.object({
20
24
  file: z.string().min(1),
21
25
  symbol: z.string().min(1).optional(),
26
+ /**
27
+ * The lines the concept names, when no symbol covers them — deleted code,
28
+ * YAML, SQL, Markdown. Alternative to `symbol`, never a refinement of it.
29
+ */
30
+ span: kbAnchorSpanSchema.optional(),
31
+ /**
32
+ * Which side of the change the anchor describes. `old` is code as it was
33
+ * committed at `ref`, which is the only way to anchor something deleted;
34
+ * absent means the working tree.
35
+ */
36
+ side: z.enum(["old", "new"]).optional(),
22
37
  /**
23
38
  * Which repository the file lives in — a remote URL
24
39
  * (`https://github.com/org/name`) or a short name. Absent means the base's
@@ -57,8 +72,38 @@ var kbAnchorSchema = z.object({
57
72
  * before resolvers were named, which is read as `regex` — the only one
58
73
  * there was. A hash from a different resolver is drift, not a match.
59
74
  */
60
- resolver: z.enum(["tree-sitter", "regex"]).optional()
75
+ resolver: z.enum(["tree-sitter", "regex", "span"]).optional()
61
76
  }).strict();
77
+ var kbAnchorWriteSchema = kbAnchorSchema.superRefine((anchor, ctx) => {
78
+ if (anchor.span && anchor.symbol) {
79
+ ctx.addIssue({
80
+ code: z.ZodIssueCode.custom,
81
+ path: ["span"],
82
+ message: "an anchor names a symbol or a span, not both"
83
+ });
84
+ }
85
+ if (anchor.span && anchor.span.end < anchor.span.start) {
86
+ ctx.addIssue({
87
+ code: z.ZodIssueCode.custom,
88
+ path: ["span", "end"],
89
+ message: "span end must not precede start"
90
+ });
91
+ }
92
+ if (anchor.span && anchor.hash_kind === "ast") {
93
+ ctx.addIssue({
94
+ code: z.ZodIssueCode.custom,
95
+ path: ["hash_kind"],
96
+ message: "a span is hashed raw, never ast"
97
+ });
98
+ }
99
+ if (anchor.side === "old" && !anchor.ref) {
100
+ ctx.addIssue({
101
+ code: z.ZodIssueCode.custom,
102
+ path: ["ref"],
103
+ message: 'side: "old" needs a ref \u2014 committed code has no other address'
104
+ });
105
+ }
106
+ });
62
107
  var kbLinkSchema = z.object({
63
108
  target: z.string().min(1),
64
109
  rel: z.string().min(1)
@@ -275,7 +320,7 @@ var composeInputSchema = z2.object({
275
320
  why: z2.string().min(1),
276
321
  /** Keyed by section heading from the type's spec. Unknown keys rejected. */
277
322
  sections: z2.record(z2.string(), z2.string().min(1)).optional(),
278
- anchors: z2.array(kbAnchorSchema).optional(),
323
+ anchors: z2.array(kbAnchorWriteSchema).optional(),
279
324
  sources: z2.array(kbSourceSchema).optional(),
280
325
  /** No source exists, as a claim rather than a sentinel in `sources`. */
281
326
  assumption: z2.boolean().optional(),
@@ -622,7 +667,9 @@ function transportReason(stderr) {
622
667
  var UNCHECKED_REASONS = [
623
668
  "remote-unreachable",
624
669
  "repo-unauthorized",
625
- "default-branch-unknown"
670
+ "default-branch-unknown",
671
+ /** Local, but the same finding: a shallow clone has no rev to read. */
672
+ "ref-unavailable"
626
673
  ];
627
674
  function isUncheckedReason(reason) {
628
675
  return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
@@ -639,6 +686,11 @@ function refShapeIsSafe(ref) {
639
686
  if (ref.includes("..")) return false;
640
687
  return REF_SHAPE.test(ref);
641
688
  }
689
+ function localRevShapeIsSafe(rev) {
690
+ if (!rev || rev.length > MAX_REF_LENGTH) return false;
691
+ if (rev.includes("..")) return false;
692
+ return /^[A-Za-z0-9][A-Za-z0-9._/^~-]*$/.test(rev);
693
+ }
642
694
  async function refIsWellFormed(ref) {
643
695
  if (!refShapeIsSafe(ref)) return false;
644
696
  const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
@@ -694,10 +746,10 @@ async function readRemoteAnchors(wants, options = {}) {
694
746
  const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
695
747
  const byRepo = /* @__PURE__ */ new Map();
696
748
  for (const want of wants) {
697
- const key = normalizeRepoUrl(want.repo);
698
- const group2 = byRepo.get(key) ?? { url: want.repo.trim(), wants: [] };
749
+ const key2 = normalizeRepoUrl(want.repo);
750
+ const group2 = byRepo.get(key2) ?? { url: want.repo.trim(), wants: [] };
699
751
  group2.wants.push(want);
700
- byRepo.set(key, group2);
752
+ byRepo.set(key2, group2);
701
753
  }
702
754
  const groups = [...byRepo.entries()];
703
755
  const results = await mapLimit(
@@ -710,7 +762,7 @@ async function readRemoteAnchors(wants, options = {}) {
710
762
  })
711
763
  );
712
764
  for (const result of results) {
713
- for (const [key, read] of result) out.set(key, read);
765
+ for (const [key2, read] of result) out.set(key2, read);
714
766
  }
715
767
  return out;
716
768
  }
@@ -952,6 +1004,7 @@ function looksLikeWrongRepoRoot(drift) {
952
1004
  for (const entries of drift.values()) {
953
1005
  for (const entry of entries) {
954
1006
  if (entry.repo !== void 0) continue;
1007
+ if (entry.side === "old") continue;
955
1008
  checked += 1;
956
1009
  if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
957
1010
  return false;
@@ -1128,7 +1181,7 @@ function size(bytes) {
1128
1181
  return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1129
1182
  }
1130
1183
  function pause(ms) {
1131
- return new Promise((resolve6) => setTimeout(resolve6, ms));
1184
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
1132
1185
  }
1133
1186
 
1134
1187
  // src/grammars/index.ts
@@ -1144,8 +1197,8 @@ async function ensureGrammar(language, options = {}) {
1144
1197
  if (!pack2) return null;
1145
1198
  const root = grammarsCacheRoot(options.cacheRoot);
1146
1199
  const wasm = grammarCachePath(root, language, pack2.wasm.sha256);
1147
- const key = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1148
- const existing = inFlight.get(key);
1200
+ const key2 = `${wasm} ${grammarsBaseUrl(options.baseUrl) ?? ""}`;
1201
+ const existing = inFlight.get(key2);
1149
1202
  if (existing) return existing;
1150
1203
  const pending = (async () => {
1151
1204
  const grammar = await ensurePart(
@@ -1169,9 +1222,9 @@ ${lf(await readFile3(path, "utf8"))}`);
1169
1222
  missing.delete(language);
1170
1223
  return { wasm, query: total ? parts.join("\n") : void 0 };
1171
1224
  })();
1172
- inFlight.set(key, pending);
1225
+ inFlight.set(key2, pending);
1173
1226
  const result = await pending;
1174
- if (result === null) inFlight.delete(key);
1227
+ if (result === null) inFlight.delete(key2);
1175
1228
  return result;
1176
1229
  }
1177
1230
  async function ensurePart(path, name, entry, options) {
@@ -1469,8 +1522,8 @@ var TreeSitterResolver = class {
1469
1522
  }
1470
1523
  /** Parsed trees are keyed by content hash, so an unchanged file parses once. */
1471
1524
  parse(language, loaded, source) {
1472
- const key = `${language}:${createHash2("sha256").update(source).digest("hex")}`;
1473
- const cached2 = this.trees.get(key);
1525
+ const key2 = `${language}:${createHash2("sha256").update(source).digest("hex")}`;
1526
+ const cached2 = this.trees.get(key2);
1474
1527
  if (cached2) {
1475
1528
  this.stats.cacheHits += 1;
1476
1529
  return cached2;
@@ -1494,7 +1547,7 @@ var TreeSitterResolver = class {
1494
1547
  this.trees.delete(oldest.value);
1495
1548
  }
1496
1549
  }
1497
- this.trees.set(key, parsed);
1550
+ this.trees.set(key2, parsed);
1498
1551
  return parsed;
1499
1552
  }
1500
1553
  /**
@@ -1655,8 +1708,8 @@ function captureBraceBlock(lines, matchLine) {
1655
1708
  }
1656
1709
  var PYTHON_HEADER = /^\s*(?:async\s+)?(?:def|class)\s+[A-Za-z_]\w*\s*[(:]/;
1657
1710
  function captureIndentedBlock(lines, matchLine) {
1658
- const header = lines[matchLine] ?? "";
1659
- const indent = header.length - header.trimStart().length;
1711
+ const header2 = lines[matchLine] ?? "";
1712
+ const indent = header2.length - header2.trimStart().length;
1660
1713
  let headerEnd = -1;
1661
1714
  for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1662
1715
  const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
@@ -1677,42 +1730,55 @@ function captureIndentedBlock(lines, matchLine) {
1677
1730
  }
1678
1731
  return end === headerEnd ? null : span(lines, matchLine, end);
1679
1732
  }
1680
- var TIERS = [
1681
- (name) => new RegExp(
1682
- `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
1683
- ),
1684
- (name) => new RegExp(`\\b${name}\\s*[:=]`),
1733
+ var declarationTier = (name) => new RegExp(
1734
+ `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
1735
+ );
1736
+ var assignmentTier = (name) => new RegExp(`\\b${name}\\s*[:=]`);
1737
+ var anchoredAssignmentTier = (name) => new RegExp(
1738
+ `^\\s*(?:export\\s+|readonly\\s+|pub\\s+|static\\s+|private\\s+|public\\s+|protected\\s+)*${name}\\s*[:=]`
1739
+ );
1740
+ var DEFINITION_TIERS = [declarationTier, anchoredAssignmentTier];
1741
+ var MENTION_TIERS = [
1685
1742
  (name) => new RegExp(`\\b${name}\\s*\\(`),
1686
1743
  (name) => new RegExp(`\\b${name}\\b`)
1687
1744
  ];
1745
+ var TIERS = [declarationTier, assignmentTier, ...MENTION_TIERS];
1746
+ function resolveWith(tiers, source, symbol) {
1747
+ const segments = symbol.split(".");
1748
+ const name = segments[segments.length - 1];
1749
+ if (!name) return null;
1750
+ const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
1751
+ const escaped = escapeRegExp(name);
1752
+ const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
1753
+ const lines = source.split("\n");
1754
+ for (const tier of tiers) {
1755
+ const pattern = tier(escaped);
1756
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1757
+ if (!candidates.length) continue;
1758
+ if (parentPattern && candidates.length > 1) {
1759
+ const distances = candidates.map(
1760
+ (index2) => distanceToParent(lines, index2, parentPattern)
1761
+ );
1762
+ const nearest = Math.min(...distances);
1763
+ if (Number.isFinite(nearest)) {
1764
+ candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1765
+ }
1766
+ }
1767
+ if (candidates.length !== 1) return null;
1768
+ const matchLine = candidates[0];
1769
+ return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
1770
+ }
1771
+ return null;
1772
+ }
1688
1773
  var regexResolver = {
1689
1774
  name: "regex",
1690
1775
  resolve(source, symbol) {
1691
- const segments = symbol.split(".");
1692
- const name = segments[segments.length - 1];
1693
- if (!name) return null;
1694
- const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
1695
- const escaped = escapeRegExp(name);
1696
- const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
1697
- const lines = source.split("\n");
1698
- for (const tier of TIERS) {
1699
- const pattern = tier(escaped);
1700
- let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1701
- if (!candidates.length) continue;
1702
- if (parentPattern && candidates.length > 1) {
1703
- const distances = candidates.map(
1704
- (index2) => distanceToParent(lines, index2, parentPattern)
1705
- );
1706
- const nearest = Math.min(...distances);
1707
- if (Number.isFinite(nearest)) {
1708
- candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1709
- }
1710
- }
1711
- if (candidates.length !== 1) return null;
1712
- const matchLine = candidates[0];
1713
- return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
1714
- }
1715
- return null;
1776
+ return resolveWith(TIERS, source, symbol);
1777
+ },
1778
+ attempt(source, symbol, _file, options) {
1779
+ const tiers = options?.afterParsedMiss ? DEFINITION_TIERS : TIERS;
1780
+ const span2 = resolveWith(tiers, source, symbol);
1781
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1716
1782
  }
1717
1783
  };
1718
1784
  function escapeRegExp(value) {
@@ -1734,6 +1800,7 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
1734
1800
  }
1735
1801
  function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1736
1802
  const normalized = source.replace(/\r\n/g, "\n");
1803
+ if (anchor.span) return sliceSpan(normalized, anchor.span);
1737
1804
  if (!anchor.symbol) {
1738
1805
  const lines = normalized.split("\n");
1739
1806
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
@@ -1746,11 +1813,17 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1746
1813
  }
1747
1814
  };
1748
1815
  }
1816
+ let afterParsedMiss = false;
1749
1817
  for (const resolver of resolvers) {
1750
- const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1818
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file, {
1819
+ afterParsedMiss
1820
+ }) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1751
1821
  if (attempt.kind === "abstain") continue;
1752
1822
  if (attempt.kind === "unresolved") {
1753
- if (attempt.reason === "symbol-not-found") continue;
1823
+ if (attempt.reason === "symbol-not-found") {
1824
+ if (resolver.attempt) afterParsedMiss = true;
1825
+ continue;
1826
+ }
1754
1827
  return { ok: false, reason: attempt.reason };
1755
1828
  }
1756
1829
  const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
@@ -1763,12 +1836,28 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1763
1836
  }
1764
1837
  return { ok: false, reason: "symbol-not-found" };
1765
1838
  }
1839
+ function sliceSpan(source, range) {
1840
+ const lines = source.split("\n");
1841
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1842
+ if (range.end > lines.length) {
1843
+ return { ok: false, reason: "span-out-of-range" };
1844
+ }
1845
+ return {
1846
+ ok: true,
1847
+ span: {
1848
+ text: lines.slice(range.start - 1, range.end).join("\n"),
1849
+ startLine: range.start,
1850
+ endLine: range.end
1851
+ },
1852
+ resolver: "span"
1853
+ };
1854
+ }
1766
1855
  function fromResolve(resolver, source, symbol, file) {
1767
1856
  const span2 = resolver.resolve(source, symbol, file);
1768
1857
  return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1769
1858
  }
1770
1859
  function isResolverName(name) {
1771
- return name === "tree-sitter" || name === "regex";
1860
+ return name === "tree-sitter" || name === "regex" || name === "span";
1772
1861
  }
1773
1862
  async function prepareResolvers(resolvers, files) {
1774
1863
  for (const resolver of resolvers) await resolver.prepare?.(files);
@@ -1787,11 +1876,150 @@ function resolverChanged(source, anchor, produced) {
1787
1876
  return before !== null && hashAnchorText(before.text) === anchor.hash;
1788
1877
  }
1789
1878
  function anchorHashOf(anchor, outcome) {
1879
+ if (outcome.resolver === "span") {
1880
+ return { hash: hashAnchorText(outcome.span.text), kind: "raw" };
1881
+ }
1790
1882
  const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
1791
1883
  const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
1792
1884
  return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
1793
1885
  }
1794
1886
 
1887
+ // src/drift/git.ts
1888
+ import { execFile as execFile3 } from "child_process";
1889
+ import { promisify as promisify3 } from "util";
1890
+ var execFileAsync3 = promisify3(execFile3);
1891
+ var MAX_GIT_OUTPUT_BYTES = 1048576;
1892
+ var MAX_RANGE_DIFF_BYTES = 8 * 1048576;
1893
+ var GIT_TIMEOUT_MS = 5e3;
1894
+ var RANGE_DIFF_TIMEOUT_MS = 2e4;
1895
+ async function git2(cwd, args, limits = {}) {
1896
+ const env = { ...process.env };
1897
+ delete env["GIT_DIR"];
1898
+ delete env["GIT_WORK_TREE"];
1899
+ delete env["GIT_INDEX_FILE"];
1900
+ try {
1901
+ const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
1902
+ timeout: limits.timeoutMs ?? GIT_TIMEOUT_MS,
1903
+ maxBuffer: limits.maxBytes ?? MAX_GIT_OUTPUT_BYTES,
1904
+ env
1905
+ });
1906
+ return { ok: true, stdout };
1907
+ } catch (error) {
1908
+ return { ok: false, reason: failureOf(error) };
1909
+ }
1910
+ }
1911
+ function failureOf(error) {
1912
+ const { code, killed } = error;
1913
+ if (code === "ENOENT") return "git-missing";
1914
+ if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "too-large";
1915
+ if (killed === true) return "timeout";
1916
+ return "failed";
1917
+ }
1918
+ async function readFileAtRef(repoRoot, anchor) {
1919
+ if (!filePathIsSafe(anchor.file))
1920
+ return { ok: false, reason: "outside-repo" };
1921
+ if (!anchor.ref || !refShapeIsSafe(anchor.ref)) {
1922
+ return { ok: false, reason: "ref-unreadable" };
1923
+ }
1924
+ const blob = await catBlob(repoRoot, anchor.ref, anchor.file);
1925
+ if (blob !== null) return { ok: true, source: blob };
1926
+ return {
1927
+ ok: false,
1928
+ reason: await hasCommit(repoRoot, anchor.ref) ? "ref-unreadable" : "ref-unavailable"
1929
+ };
1930
+ }
1931
+ async function hasCommit(repoRoot, ref) {
1932
+ const found = await git2(repoRoot, [
1933
+ "cat-file",
1934
+ "-e",
1935
+ "--end-of-options",
1936
+ `${ref}^{commit}`
1937
+ ]);
1938
+ return found.ok;
1939
+ }
1940
+ async function listRepoFiles(repoRoot) {
1941
+ const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
1942
+ if (!result.ok) return [];
1943
+ return result.stdout.split("\0").filter(Boolean);
1944
+ }
1945
+ var DIFF_RANGE = /^(.+?)(\.{2,3})(.+)$/;
1946
+ async function readRangeDiff(repoRoot, range, maxBytes = MAX_RANGE_DIFF_BYTES) {
1947
+ const parts = DIFF_RANGE.exec(range);
1948
+ if (!parts) return { ok: false, reason: "bad-range" };
1949
+ const [, base2 = "", dots = "", head = ""] = parts;
1950
+ if (!localRevShapeIsSafe(base2) || !localRevShapeIsSafe(head)) {
1951
+ return { ok: false, reason: "bad-range" };
1952
+ }
1953
+ const result = await git2(
1954
+ repoRoot,
1955
+ [
1956
+ "-c",
1957
+ "core.quotePath=false",
1958
+ "diff",
1959
+ "--unified=0",
1960
+ "--no-color",
1961
+ "--no-ext-diff",
1962
+ "--no-textconv",
1963
+ "--find-renames",
1964
+ "--src-prefix=a/",
1965
+ "--dst-prefix=b/",
1966
+ "--end-of-options",
1967
+ `${base2}${dots}${head}`,
1968
+ "--"
1969
+ ],
1970
+ { maxBytes, timeoutMs: RANGE_DIFF_TIMEOUT_MS }
1971
+ );
1972
+ if (result.ok) return { ok: true, text: result.stdout };
1973
+ return {
1974
+ ok: false,
1975
+ reason: result.reason === "failed" ? "bad-range" : result.reason
1976
+ };
1977
+ }
1978
+ async function readOldSource(repoRoot, anchor) {
1979
+ if (!filePathIsSafe(anchor.file))
1980
+ return { ok: false, reason: "unrecoverable" };
1981
+ if (anchor.ref && refShapeIsSafe(anchor.ref)) {
1982
+ const shown2 = await catBlob(repoRoot, anchor.ref, anchor.file);
1983
+ if (shown2 !== null) {
1984
+ return {
1985
+ ok: true,
1986
+ source: shown2,
1987
+ origin: { kind: "ref", ref: anchor.ref }
1988
+ };
1989
+ }
1990
+ }
1991
+ const at2 = anchor.resolved_at;
1992
+ if (!at2 || Number.isNaN(Date.parse(at2))) {
1993
+ return { ok: false, reason: "unrecoverable" };
1994
+ }
1995
+ const found = await git2(repoRoot, [
1996
+ "log",
1997
+ "-1",
1998
+ "--format=%H",
1999
+ `--before=${at2}`,
2000
+ "--end-of-options",
2001
+ "HEAD",
2002
+ "--",
2003
+ anchor.file
2004
+ ]);
2005
+ const sha = found.ok ? found.stdout.trim() : "";
2006
+ if (!sha || !refShapeIsSafe(sha))
2007
+ return { ok: false, reason: "unrecoverable" };
2008
+ const shown = await catBlob(repoRoot, sha, anchor.file);
2009
+ if (shown === null) return { ok: false, reason: "unrecoverable" };
2010
+ return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
2011
+ }
2012
+ async function catBlob(repoRoot, ref, file) {
2013
+ const path = file.replace(/^\.\//, "");
2014
+ const result = await git2(repoRoot, [
2015
+ "cat-file",
2016
+ "blob",
2017
+ "--end-of-options",
2018
+ `${ref}:${path}`
2019
+ ]);
2020
+ return result.ok ? result.stdout : null;
2021
+ }
2022
+
1795
2023
  // src/anchor-resolver/drift.ts
1796
2024
  async function detectAnchorDrift(records, options = {}) {
1797
2025
  const repoRoot = options.repoRoot ?? process.cwd();
@@ -1820,37 +2048,60 @@ async function detectAnchorDrift(records, options = {}) {
1820
2048
  }
1821
2049
  }
1822
2050
  const files = [];
2051
+ const committedWants = [];
1823
2052
  const wants = [];
1824
2053
  for (const entries of planned.values()) {
1825
2054
  for (const { anchor, foreign } of entries) {
1826
- if (!foreign) files.push(anchor.file);
1827
- else wants.push(...remoteWants(anchor));
2055
+ if (foreign) wants.push(...remoteWants(anchor));
2056
+ else if (anchor.side === "old") committedWants.push(anchor);
2057
+ else files.push(anchor.file);
1828
2058
  }
1829
2059
  }
1830
- const [reads, remote] = await Promise.all([
2060
+ const [reads, committed, remote] = await Promise.all([
1831
2061
  readAnchorFiles(
1832
2062
  files,
1833
2063
  options.reader ?? anchorFileReader(repoRoot),
1834
2064
  options.concurrency ?? DEFAULT_IO_CONCURRENCY
1835
2065
  ),
2066
+ readCommitted(repoRoot, committedWants, options),
1836
2067
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1837
2068
  ]);
1838
2069
  await prepareResolvers(resolvers, [
1839
2070
  ...files,
2071
+ ...committedWants.map((anchor) => anchor.file),
1840
2072
  ...wants.map((want) => want.file)
1841
2073
  ]);
1842
2074
  const drift = /* @__PURE__ */ new Map();
1843
2075
  for (const record of records) {
1844
2076
  const entries = [];
1845
2077
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
1846
- entries.push(
1847
- foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
1848
- );
2078
+ if (foreign) {
2079
+ entries.push(remoteEntry(anchor, remote, resolvers));
2080
+ continue;
2081
+ }
2082
+ const read = anchor.side === "old" ? committed.get(atRefKey(anchor)) : reads.get(anchor.file);
2083
+ entries.push(localEntry(anchor, read, resolvers));
1849
2084
  }
1850
2085
  if (entries.length) drift.set(record.conceptId, entries);
1851
2086
  }
1852
2087
  return drift;
1853
2088
  }
2089
+ function atRefKey(anchor) {
2090
+ return `${anchor.ref ?? ""}\0${anchor.file}`;
2091
+ }
2092
+ async function readCommitted(repoRoot, anchors, options = {}) {
2093
+ if (!anchors.length) return /* @__PURE__ */ new Map();
2094
+ const read = options.readAtRef ?? readFileAtRef;
2095
+ const byKey = /* @__PURE__ */ new Map();
2096
+ for (const anchor of anchors) byKey.set(atRefKey(anchor), anchor);
2097
+ const keys = [...byKey.keys()];
2098
+ const results = await mapLimit(
2099
+ keys,
2100
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY,
2101
+ (key2) => read(repoRoot, byKey.get(key2))
2102
+ );
2103
+ return new Map(keys.map((key2, at2) => [key2, results[at2]]));
2104
+ }
1854
2105
  function remoteWants(anchor) {
1855
2106
  const repo = anchor.repo;
1856
2107
  const wants = [{ repo, file: anchor.file }];
@@ -1861,6 +2112,7 @@ function base(anchor) {
1861
2112
  return {
1862
2113
  file: anchor.file,
1863
2114
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
2115
+ ...anchor.side === "old" ? { side: "old" } : {},
1864
2116
  storedHash: anchor.hash
1865
2117
  };
1866
2118
  }
@@ -1874,9 +2126,15 @@ function unresolved(anchor, reason, repo) {
1874
2126
  ...classOf(reason)
1875
2127
  };
1876
2128
  }
2129
+ var GONE_REASONS = /* @__PURE__ */ new Set([
2130
+ "file-missing",
2131
+ "symbol-not-found",
2132
+ "span-out-of-range",
2133
+ "ref-unreadable"
2134
+ ]);
1877
2135
  function provisionalDriftClass(entry) {
1878
2136
  if (entry.state === "unresolved") {
1879
- return entry.reason === "file-missing" || entry.reason === "symbol-not-found" ? "gone" : void 0;
2137
+ return GONE_REASONS.has(entry.reason) ? "gone" : void 0;
1880
2138
  }
1881
2139
  return entry.state === "drifted" ? "changed" : void 0;
1882
2140
  }
@@ -1928,9 +2186,9 @@ function localEntry(anchor, read, resolvers) {
1928
2186
  }
1929
2187
  function remoteEntry(anchor, remote, resolvers) {
1930
2188
  const repo = anchor.repo;
1931
- const key = normalizeRepoUrl(repo);
1932
- const atDefault = remote.get(wantKey(key, void 0, anchor.file));
1933
- const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
2189
+ const key2 = normalizeRepoUrl(repo);
2190
+ const atDefault = remote.get(wantKey(key2, void 0, anchor.file));
2191
+ const primary = anchor.ref ? remote.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
1934
2192
  if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1935
2193
  if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1936
2194
  const found = hashIn(primary.source, anchor, resolvers);
@@ -1945,6 +2203,13 @@ function remoteEntry(anchor, remote, resolvers) {
1945
2203
  remoteState: "drifted-from-ref"
1946
2204
  });
1947
2205
  }
2206
+ if (anchor.side === "old") {
2207
+ return compared(anchor, current, {
2208
+ repo,
2209
+ ...extras,
2210
+ remoteState: "matches-ref"
2211
+ });
2212
+ }
1948
2213
  const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
1949
2214
  return head?.ok && head.current.hash !== anchor.hash ? {
1950
2215
  ...compared(anchor, head.current, {
@@ -1969,9 +2234,15 @@ var Fault = /* @__PURE__ */ ((Fault2) => {
1969
2234
  })(Fault || {});
1970
2235
  var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
1971
2236
  ErrorTypes2["KbRecordAlreadyExists"] = "KbRecordAlreadyExists";
2237
+ ErrorTypes2["KbClassifyInput"] = "KbClassifyInput";
1972
2238
  ErrorTypes2["KbInvalidConceptId"] = "KbInvalidConceptId";
2239
+ ErrorTypes2["KbMatchInput"] = "KbMatchInput";
1973
2240
  ErrorTypes2["KbMissingFlagValue"] = "KbMissingFlagValue";
1974
2241
  ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
2242
+ ErrorTypes2["KbPromoteCollision"] = "KbPromoteCollision";
2243
+ ErrorTypes2["KbPromoteSelf"] = "KbPromoteSelf";
2244
+ ErrorTypes2["KbPromoteStanding"] = "KbPromoteStanding";
2245
+ ErrorTypes2["KbPromoteStopped"] = "KbPromoteStopped";
1975
2246
  ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
1976
2247
  ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
1977
2248
  ErrorTypes2["KbStampBaselineUnreadable"] = "KbStampBaselineUnreadable";
@@ -2117,6 +2388,89 @@ var KbMissingFlagValueError = class extends BaseError {
2117
2388
  }
2118
2389
  flag;
2119
2390
  };
2391
+ var KbClassifyInputError = class extends BaseError {
2392
+ constructor(reason) {
2393
+ super({
2394
+ message: `classify: ${reason}`,
2395
+ errorType: "KbClassifyInput" /* KbClassifyInput */,
2396
+ code: 400,
2397
+ fault: "User" /* User */,
2398
+ retriable: false,
2399
+ reportToUser: true,
2400
+ details: { reason }
2401
+ });
2402
+ this.reason = reason;
2403
+ }
2404
+ reason;
2405
+ };
2406
+ var KbPromoteCollisionError = class extends BaseError {
2407
+ constructor(conceptId2, to) {
2408
+ super({
2409
+ message: `kb: ${to} already holds ${conceptId2} \u2014 re-run with force to overwrite it`,
2410
+ errorType: "KbPromoteCollision" /* KbPromoteCollision */,
2411
+ code: 409,
2412
+ fault: "User" /* User */,
2413
+ retriable: false,
2414
+ reportToUser: true,
2415
+ details: { conceptId: conceptId2, to, action: "refused" }
2416
+ });
2417
+ this.conceptId = conceptId2;
2418
+ this.to = to;
2419
+ }
2420
+ conceptId;
2421
+ to;
2422
+ };
2423
+ var KbPromoteStandingError = class extends BaseError {
2424
+ constructor(conceptId2, standing) {
2425
+ super({
2426
+ message: `kb: ${conceptId2} is ${standing} \u2014 only a record that still stands can be promoted`,
2427
+ errorType: "KbPromoteStanding" /* KbPromoteStanding */,
2428
+ code: 409,
2429
+ fault: "User" /* User */,
2430
+ retriable: false,
2431
+ reportToUser: true,
2432
+ details: { conceptId: conceptId2, standing, action: "refused" }
2433
+ });
2434
+ this.conceptId = conceptId2;
2435
+ this.standing = standing;
2436
+ }
2437
+ conceptId;
2438
+ standing;
2439
+ };
2440
+ var KbPromoteSelfError = class extends BaseError {
2441
+ constructor(to) {
2442
+ super({
2443
+ message: `kb: ${to} is the base being promoted from \u2014 name a different target`,
2444
+ errorType: "KbPromoteSelf" /* KbPromoteSelf */,
2445
+ code: 400,
2446
+ fault: "User" /* User */,
2447
+ retriable: false,
2448
+ reportToUser: true,
2449
+ details: { to, action: "refused" }
2450
+ });
2451
+ this.to = to;
2452
+ }
2453
+ to;
2454
+ };
2455
+ var KbPromoteStoppedError = class extends BaseError {
2456
+ constructor(conceptId2, landed, reason) {
2457
+ super({
2458
+ message: `kb: promotion stopped at ${conceptId2} (${reason}) \u2014 landed: ${landed.length ? landed.join(", ") : "nothing"}`,
2459
+ errorType: "KbPromoteStopped" /* KbPromoteStopped */,
2460
+ code: 500,
2461
+ fault: "System" /* System */,
2462
+ retriable: false,
2463
+ reportToUser: true,
2464
+ details: { conceptId: conceptId2, landed, reason, action: "stopped" }
2465
+ });
2466
+ this.conceptId = conceptId2;
2467
+ this.landed = landed;
2468
+ this.reason = reason;
2469
+ }
2470
+ conceptId;
2471
+ landed;
2472
+ reason;
2473
+ };
2120
2474
  var KbInvalidConceptIdError = class extends BaseError {
2121
2475
  constructor(message, details) {
2122
2476
  super({
@@ -2165,8 +2519,8 @@ var KbStampDigestBaselineError = class extends BaseError {
2165
2519
  function asBudgets(value) {
2166
2520
  if (value === null || typeof value !== "object") return {};
2167
2521
  const table2 = value;
2168
- const pick = (key, min) => {
2169
- const raw2 = table2[key];
2522
+ const pick = (key2, min) => {
2523
+ const raw2 = table2[key2];
2170
2524
  return typeof raw2 === "number" && Number.isInteger(raw2) && raw2 >= min ? raw2 : void 0;
2171
2525
  };
2172
2526
  const budgetTokens = pick("budgetTokens", 1);
@@ -2598,14 +2952,14 @@ function catalog(bundle, options = {}) {
2598
2952
  supersededBy: hit.heads.map((head) => head.conceptId),
2599
2953
  stale: hit.warnings.some((warning) => warning.kind === "stale")
2600
2954
  })).sort(byTypeThenTitle);
2601
- const standings = { ...EMPTY_STANDINGS };
2602
- for (const entry of entries) standings[entry.standing] += 1;
2955
+ const standings2 = { ...EMPTY_STANDINGS };
2956
+ for (const entry of entries) standings2[entry.standing] += 1;
2603
2957
  return {
2604
2958
  entries,
2605
2959
  recordCount: entries.length,
2606
- standings,
2607
- currentCount: standings.current,
2608
- supersededCount: standings.superseded,
2960
+ standings: standings2,
2961
+ currentCount: standings2.current,
2962
+ supersededCount: standings2.superseded,
2609
2963
  staleCount: entries.filter((entry) => entry.stale).length
2610
2964
  };
2611
2965
  }
@@ -2626,336 +2980,285 @@ function renderCatalogLine(entry) {
2626
2980
  return `- ${parts.join(" \xB7 ")}`;
2627
2981
  }
2628
2982
 
2629
- // src/kb-index.ts
2630
- var INDEX_FILE = "INDEX.md";
2631
- var HEADING = "# KB Index";
2632
- function renderIndex(records) {
2633
- const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map(renderIndexLine);
2634
- return `${HEADING}
2635
-
2636
- ${lines.join("\n")}
2637
- `;
2983
+ // src/match-diff.ts
2984
+ function matchToDiff(files, records, options = {}) {
2985
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
2986
+ const anchored = records.filter(
2987
+ (record) => (record.frontmatter.strauss_anchors ?? []).length > 0
2988
+ );
2989
+ const matches3 = [];
2990
+ for (const file of files) {
2991
+ const candidates = anchored.map((record) => ({
2992
+ record,
2993
+ anchors: (record.frontmatter.strauss_anchors ?? []).filter(
2994
+ (anchor) => normalize(anchor.file) === normalize(file.filePath)
2995
+ )
2996
+ })).filter(({ anchors }) => anchors.length > 0);
2997
+ if (!candidates.length) continue;
2998
+ for (const hunk of file.hunks) {
2999
+ const hits = [];
3000
+ let precision = "symbol";
3001
+ for (const { record, anchors } of candidates) {
3002
+ const placement = place(anchors, file.filePath, hunk, ranges);
3003
+ if (placement.kind === "miss") continue;
3004
+ if (placement.kind === "file") precision = "file";
3005
+ hits.push(record);
3006
+ }
3007
+ if (!hits.length) continue;
3008
+ matches3.push({
3009
+ filePath: file.filePath,
3010
+ hunk,
3011
+ records: order(adjudicate(hits, records, options.now)),
3012
+ precision
3013
+ });
3014
+ }
3015
+ }
3016
+ return matches3;
2638
3017
  }
2639
- function renderIndexLine(record) {
2640
- const { frontmatter: fm } = record;
2641
- const parts = [fm.type, fm.strauss_status];
2642
- if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
2643
- if (fm.description) parts.push(fm.description);
2644
- return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
3018
+ function placeOnHunk(record, filePath, hunk, symbolRanges = []) {
3019
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
3020
+ (anchor) => normalize(anchor.file) === normalize(filePath)
3021
+ );
3022
+ return place(anchors, filePath, hunk, asIndex(symbolRanges));
2645
3023
  }
2646
- function indexIsStale(stored, expected) {
2647
- return stored !== expected;
3024
+ function asIndex(ranges) {
3025
+ return isIndex(ranges) ? ranges : symbolRangeIndex(ranges);
2648
3026
  }
2649
-
2650
- // src/kb-context.ts
2651
- import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
2652
- var HEADING2 = "## Knowledge bases (pinned)";
2653
- var DEFAULT_CONTEXT_BUDGET = 4e3;
2654
- var CONTEXT_PROFILES = {
2655
- "session-start": { fullUnderTokens: 1500 },
2656
- compact: { budgetTokens: 2500 },
2657
- turn: { budgetTokens: 2500 }
2658
- };
2659
- function approxTokens(text) {
2660
- return Math.ceil(text.length / 4);
3027
+ function isIndex(ranges) {
3028
+ return !Array.isArray(ranges);
2661
3029
  }
2662
- function preamble() {
2663
- return [
2664
- HEADING2,
2665
- "",
2666
- "What follows is an index of this workspace's pinned knowledge bases \u2014",
2667
- "concept ids, titles and standing only. The record bodies are NOT in this",
2668
- "context.",
2669
- "",
2670
- "Consult records only through the strauss-kb MCP tools: `kb_load` (the",
2671
- "preferred first call), `kb_query`, and `kb_trace`, passing the",
2672
- "`bundlePath` listed with each base. Do not read record files directly:",
2673
- "a raw file read bypasses supersession resolution, and a superseded or",
2674
- "rejected record file reads exactly like a current one \u2014 only the store",
2675
- "resolves chains and standing.",
2676
- "",
2677
- "KB content loaded earlier in a long session may have been compacted",
2678
- "away. Before answering a question one of these bases governs, load it",
2679
- "again at the point of use \u2014 reloading a small base costs a few thousand",
2680
- "tokens."
2681
- ].join("\n");
3030
+ function anchorOnHunk(record, filePath, hunk, symbolRanges = []) {
3031
+ return placeOnHunk(record, filePath, hunk, symbolRanges).anchor;
2682
3032
  }
2683
- async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens, excludeTags) {
2684
- const bundle = await store.list(absolutePath);
2685
- if (bundle.length === 0) {
2686
- return {
2687
- path,
2688
- absolutePath,
2689
- mode: "empty",
2690
- body: "No readable records yet \u2014 pinned ahead of being populated."
2691
- };
3033
+ function place(anchors, filePath, hunk, ranges) {
3034
+ let fallback = { kind: "miss" };
3035
+ for (const anchor of anchors) {
3036
+ if (side(anchor.side) !== side(hunk.side)) continue;
3037
+ if (anchor.span) {
3038
+ if (overlaps(
3039
+ { startLine: anchor.span.start, endLine: anchor.span.end },
3040
+ hunk
3041
+ )) {
3042
+ return { kind: "symbol", anchor };
3043
+ }
3044
+ continue;
3045
+ }
3046
+ if (!anchor.symbol) return { kind: "file", anchor };
3047
+ const resolved = ranges.get(
3048
+ key(filePath, anchor.symbol, side(anchor.side))
3049
+ );
3050
+ if (!resolved?.length) {
3051
+ if (fallback.kind === "miss") fallback = { kind: "file", anchor };
3052
+ continue;
3053
+ }
3054
+ if (resolved.some((range) => overlaps(range, hunk))) {
3055
+ return { kind: "symbol", anchor };
3056
+ }
2692
3057
  }
2693
- const fullCap = pinMode === "full" ? budgetTokens : pinMode === "index" ? 0 : fullUnderTokens;
2694
- let degradedFrom;
2695
- if (fullCap > 0) {
2696
- const full = await store.load(absolutePath, {
2697
- budgetTokens: fullCap,
2698
- excludeTags
2699
- });
2700
- if (!full.loaded && pinMode === "full") {
2701
- degradedFrom = { approxTokens: full.approxTokens };
2702
- }
2703
- if (full.loaded) {
2704
- const records = full.records.map(
2705
- (hit) => [
2706
- `#### ${hit.record.conceptId} \u2014 ${hit.record.frontmatter.title ?? "(untitled)"} (${hit.standing})`,
2707
- "",
2708
- hit.record.body.trim()
2709
- ].join("\n")
2710
- );
2711
- const superseded2 = full.superseded.map(
2712
- (entry) => `- \`${entry.conceptId}\` \u2192 superseded by ${entry.supersededBy.map((id) => `\`${id}\``).join(", ") || "(missing replacement)"}`
2713
- );
2714
- return {
2715
- path,
2716
- absolutePath,
2717
- mode: "full",
2718
- body: [
2719
- ...records,
2720
- ...superseded2.length ? [
2721
- "#### Superseded (bodies withheld \u2014 kb_trace reaches them)",
2722
- ...superseded2
2723
- ] : []
2724
- ].join("\n\n")
2725
- };
2726
- }
2727
- }
2728
- const adjudicated = adjudicate(bundle, bundle).filter(
2729
- (hit) => matchesTags(hit.record, { excludeTags })
2730
- );
2731
- const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
2732
- const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
2733
- (hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
2734
- );
2735
- return {
2736
- path,
2737
- absolutePath,
2738
- mode: "index",
2739
- body: [...lines, ...superseded].join("\n"),
2740
- ...degradedFrom ? { degradedFrom } : {}
2741
- };
3058
+ return fallback;
2742
3059
  }
2743
- async function buildContext(store, workspaceDir, options = {}) {
2744
- const builtin = options.profile ? CONTEXT_PROFILES[options.profile] ?? {} : {};
2745
- let budgetTokens = options.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
2746
- let fullUnderTokens = options.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
2747
- const merged = await readMergedPins(workspaceDir);
2748
- const fromManifest = mergedContextBudgets(merged, options.profile);
2749
- budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
2750
- fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
2751
- const excludeTags = options.excludeTags ?? fromManifest.excludeTags ?? builtin.excludeTags ?? [];
2752
- const pins = merged.pins.filter(
2753
- (pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
2754
- );
2755
- if (pins.length === 0) {
2756
- return {
2757
- block: "",
2758
- refused: false,
2759
- approxTokens: 0,
2760
- budgetTokens,
2761
- bases: []
2762
- };
2763
- }
2764
- const sections = await Promise.all(
2765
- pins.map(async (pin) => ({
2766
- section: await renderBase(
2767
- store,
2768
- pin.path,
2769
- pin.absolutePath,
2770
- fullUnderTokens,
2771
- pin.mode,
2772
- budgetTokens,
2773
- excludeTags
2774
- ),
2775
- frozen: pin.frozen === true
2776
- }))
2777
- );
2778
- const modeLabel = {
2779
- index: "index only \u2014 record bodies are not here",
2780
- full: "full records \u2014 this base arrives whole",
2781
- empty: "empty"
3060
+ function side(value) {
3061
+ return value ?? "new";
3062
+ }
3063
+ function overlaps(range, hunk) {
3064
+ return range.startLine <= hunk.endLine && hunk.startLine <= range.endLine;
3065
+ }
3066
+ function order(records) {
3067
+ const rank = {
3068
+ current: 0,
3069
+ unsettled: 1,
3070
+ open: 2,
3071
+ superseded: 3,
3072
+ rejected: 4
2782
3073
  };
2783
- for (const { section } of sections) {
2784
- if (section.degradedFrom) {
2785
- options.warn?.({
2786
- operation: "kb.context.full-pin-degraded",
2787
- path: section.path,
2788
- approxTokens: section.degradedFrom.approxTokens,
2789
- budgetTokens
2790
- });
2791
- }
2792
- }
2793
- const rendered = sections.map(({ section, frozen }) => {
2794
- 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];
2795
- return [
2796
- `### ${section.path} (${label}${frozen ? " \xB7 frozen, read-only" : ""})`,
2797
- "",
2798
- `bundlePath: \`${section.absolutePath}\``,
2799
- "",
2800
- section.body
2801
- ].join("\n");
2802
- });
2803
- const block = [preamble(), "", rendered.join("\n\n"), ""].join("\n");
2804
- const bases = sections.map(({ section }) => ({
2805
- path: section.path,
2806
- absolutePath: section.absolutePath,
2807
- approxTokens: approxTokens(section.body)
2808
- }));
2809
- const total = approxTokens(block);
2810
- if (total > budgetTokens) {
2811
- options.warn?.({
2812
- operation: "kb.context.refused",
2813
- approxTokens: total,
2814
- budgetTokens,
2815
- bases: bases.map((base2) => base2.path)
2816
- });
2817
- const refusal = [
2818
- HEADING2,
2819
- "",
2820
- `The pinned index runs to ~${total} tokens, past the ${budgetTokens}-token`,
2821
- "budget, and was not emitted \u2014 a truncated index is indistinguishable",
2822
- "from a complete one. The pinned bases:",
2823
- "",
2824
- ...bases.map(
2825
- (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
2826
- ),
2827
- "",
2828
- "For the question at hand, read what you need now \u2014 `kb_load` a base",
2829
- "(its own budget is separate), or `kb_index` for one base's shape.",
2830
- "",
2831
- "To bring this block back under budget, in order of preference:",
2832
- "- supersede or resolve stale records \u2014 the base shrinks, the knowledge keeps",
2833
- "- force a large base to index lines: `strauss-kb pin <path> --mode index`",
2834
- "- scope a pin to the profiles that need it: `strauss-kb pin <path> --profiles session-start`",
2835
- "- raise this profile's budget under `context` in .strauss/kb-pins.json",
2836
- "- unpin what no session actually needs",
2837
- ""
2838
- ].join("\n");
2839
- return {
2840
- block: refusal,
2841
- refused: true,
2842
- approxTokens: total,
2843
- budgetTokens,
2844
- bases
2845
- };
3074
+ return [...records].sort(
3075
+ (left, right) => (rank[left.standing] ?? 9) - (rank[right.standing] ?? 9) || (left.record.frontmatter.generated?.at ?? "").localeCompare(
3076
+ right.record.frontmatter.generated?.at ?? ""
3077
+ )
3078
+ );
3079
+ }
3080
+ function symbolRangeIndex(ranges) {
3081
+ const byKey = /* @__PURE__ */ new Map();
3082
+ for (const range of ranges) {
3083
+ const id = key(range.file, range.symbol, side(range.side));
3084
+ byKey.set(id, [...byKey.get(id) ?? [], range]);
2846
3085
  }
2847
- return { block, refused: false, approxTokens: total, budgetTokens, bases };
3086
+ return byKey;
2848
3087
  }
2849
- function toHookJson(block, event) {
2850
- return JSON.stringify({
2851
- hookSpecificOutput: {
2852
- hookEventName: event,
2853
- additionalContext: block
2854
- }
2855
- });
3088
+ function key(file, symbol, at2) {
3089
+ return `${normalize(file)}#${symbol}#${at2}`;
2856
3090
  }
2857
- var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
2858
- var CONTEXT_END = "<!-- strauss-kb:end -->";
2859
- async function syncInstructions(file, block) {
2860
- const existing = await readFile5(file, "utf8").catch(() => null);
2861
- const region = block ? `${CONTEXT_BEGIN}
2862
- ${block.trim()}
2863
- ${CONTEXT_END}` : null;
2864
- if (existing === null) {
2865
- if (!region) return { file, action: "unchanged" };
2866
- await writeFile3(file, `${region}
2867
- `, "utf8");
2868
- return { file, action: "created" };
3091
+ function normalize(path) {
3092
+ return path.replace(/^\.\//, "");
3093
+ }
3094
+
3095
+ // src/classify/model.ts
3096
+ var KB_CLASSES = [
3097
+ "test",
3098
+ "config",
3099
+ "ci",
3100
+ "docs",
3101
+ "lockfile",
3102
+ "generated",
3103
+ "boilerplate",
3104
+ "rename",
3105
+ "source"
3106
+ ];
3107
+ var DEFAULT_THRESHOLDS = {
3108
+ boilerplate: 0.8,
3109
+ rename: 90
3110
+ };
3111
+
3112
+ // src/classify/rules.ts
3113
+ var PATH_RULES = [
3114
+ {
3115
+ name: "test-path",
3116
+ class: "test",
3117
+ test: /(^|\/)(__tests__|__mocks__|tests?)\/|\.(spec|test)\.[^/]+$/
3118
+ },
3119
+ {
3120
+ name: "ci-path",
3121
+ class: "ci",
3122
+ test: /(^|\/)\.github\/|(^|\/)(\.circleci|\.buildkite|\.gitlab|ci)\/[^/]*\.ya?ml$|(^|\/)Dockerfile(\.[^/]*)?$|\.tf$/
3123
+ },
3124
+ {
3125
+ name: "docs-path",
3126
+ class: "docs",
3127
+ test: /\.md$|(^|\/)docs\/|(^|\/)LICENSE(\.(md|txt|rst))?$/
3128
+ },
3129
+ {
3130
+ name: "lockfile-path",
3131
+ class: "lockfile",
3132
+ test: /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|Cargo\.lock|go\.sum)$/
3133
+ },
3134
+ {
3135
+ name: "config-path",
3136
+ class: "config",
3137
+ // `.jsonl` rides with `.json`: an append-only log of JSON is configuration
3138
+ // data too, and calling it source would send a reviewer to read it. Every
3139
+ // arm is anchored at both ends: `src/tsconfig-loader.ts` and `report.env.ts`
3140
+ // are source, not config.
3141
+ test: /\.(jsonc?|jsonl|ya?ml|toml|ini)$|(^|\/)\.env(?![^/]*\.[cm]?[jt]sx?$)([.-][^/]*)?$|[^/]+\.env$|(^|\/)tsconfig[^/]*\.json$|\.config\.[^/]+$|(^|\/)\.(eslintrc|prettierrc)[^/]*$/
2869
3142
  }
2870
- const begin = existing.indexOf(CONTEXT_BEGIN);
2871
- const end = existing.indexOf(CONTEXT_END);
2872
- if (begin !== -1 && end !== -1 && end >= begin) {
2873
- const before = existing.slice(0, begin);
2874
- const after = existing.slice(end + CONTEXT_END.length);
2875
- const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
2876
- if (next === existing) return { file, action: "unchanged" };
2877
- await writeFile3(file, next, "utf8");
2878
- return { file, action: region ? "replaced" : "removed" };
3143
+ ];
3144
+ var HEADER_LINES = 20;
3145
+ var GENERATED_MARKERS = [
3146
+ /@generated\b/i,
3147
+ /\bdo not edit\b/i,
3148
+ /\bcode generated by\b/i,
3149
+ /\bthis file was automatically generated\b/i
3150
+ ];
3151
+ var BOILERPLATE_SHAPES = [
3152
+ { name: "import", test: /^import\b|^\}\s*from\s+["']/ },
3153
+ { name: "re-export", test: /^export\s+(\*|\{|type\s*[{*])/ },
3154
+ {
3155
+ name: "class-shell",
3156
+ test: /^(export\s+)?(default\s+)?(abstract\s+)?class\s+[\w$]+[^{]*\{\s*\}$/
3157
+ },
3158
+ { name: "punctuation", test: /^[{}()[\],;]+$/ }
3159
+ ];
3160
+ function generatedMarker(lines) {
3161
+ for (const line of lines.slice(0, HEADER_LINES)) {
3162
+ const hit = GENERATED_MARKERS.find((marker) => marker.test(line));
3163
+ if (hit) return hit.source.replaceAll("\\b", "");
2879
3164
  }
2880
- if (!region) return { file, action: "unchanged" };
2881
- await writeFile3(
2882
- file,
2883
- `${existing.replace(/\n*$/, "\n\n")}${region}
2884
- `,
2885
- "utf8"
2886
- );
2887
- return { file, action: "appended" };
3165
+ return void 0;
3166
+ }
3167
+ function isBoilerplateLine(line) {
3168
+ return BOILERPLATE_SHAPES.some((shape) => shape.test.test(line));
2888
3169
  }
2889
3170
 
2890
- // src/drift/git.ts
2891
- import { execFile as execFile3 } from "child_process";
2892
- import { promisify as promisify3 } from "util";
2893
- var execFileAsync3 = promisify3(execFile3);
2894
- var MAX_GIT_OUTPUT_BYTES = 1048576;
2895
- var GIT_TIMEOUT_MS = 5e3;
2896
- async function git2(cwd, args) {
2897
- const env = { ...process.env };
2898
- delete env["GIT_DIR"];
2899
- delete env["GIT_WORK_TREE"];
2900
- delete env["GIT_INDEX_FILE"];
2901
- try {
2902
- const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
2903
- timeout: GIT_TIMEOUT_MS,
2904
- maxBuffer: MAX_GIT_OUTPUT_BYTES,
2905
- env
2906
- });
2907
- return { ok: true, stdout };
2908
- } catch {
2909
- return { ok: false };
2910
- }
3171
+ // src/classify/classify.ts
3172
+ function classifyDiff(files, options = {}) {
3173
+ const overrides = currentOverrides(options.records ?? [], options.now);
3174
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
3175
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds };
3176
+ return files.map((file) => classifyFile(file, overrides, ranges, thresholds));
3177
+ }
3178
+ var OVERRIDE_CLASS = /* @__PURE__ */ new Map([
3179
+ ["review:generated", "generated"],
3180
+ ["review:boilerplate", "boilerplate"],
3181
+ ["review:move", "rename"]
3182
+ ]);
3183
+ var WHOLE_FILE = {
3184
+ startLine: 1,
3185
+ endLine: Number.MAX_SAFE_INTEGER
3186
+ };
3187
+ function classifyFile(file, overrides, ranges, thresholds) {
3188
+ const whole = overrides.find(
3189
+ ({ record }) => placeOnHunk(record, file.filePath, WHOLE_FILE, ranges).kind === "file"
3190
+ );
3191
+ const verdict = whole ? verdictOf(whole) : heuristic(file, thresholds);
3192
+ const hunks = file.hunks.map((hunk) => {
3193
+ const hit = whole ?? overrides.find(
3194
+ ({ record }) => placeOnHunk(record, file.filePath, hunk, ranges).kind !== "miss"
3195
+ );
3196
+ return {
3197
+ startLine: hunk.startLine,
3198
+ endLine: hunk.endLine,
3199
+ ...hit ? verdictOf(hit) : verdict
3200
+ };
3201
+ });
3202
+ return {
3203
+ filePath: file.filePath,
3204
+ ...verdict,
3205
+ ...file.renamedFrom ? { renamedFrom: file.renamedFrom } : {},
3206
+ ...hunks.some((hunk) => hunk.class !== verdict.class) ? { hunks } : {}
3207
+ };
2911
3208
  }
2912
- async function listRepoFiles(repoRoot) {
2913
- const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
2914
- if (!result.ok) return [];
2915
- return result.stdout.split("\0").filter(Boolean);
3209
+ function verdictOf(override) {
3210
+ return {
3211
+ class: override.class,
3212
+ reason: `kb-override ${override.record.conceptId}`
3213
+ };
2916
3214
  }
2917
- async function readOldSource(repoRoot, anchor) {
2918
- if (!filePathIsSafe(anchor.file))
2919
- return { ok: false, reason: "unrecoverable" };
2920
- if (anchor.ref && refShapeIsSafe(anchor.ref)) {
2921
- const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
2922
- if (shown2 !== null) {
2923
- return {
2924
- ok: true,
2925
- source: shown2,
2926
- origin: { kind: "ref", ref: anchor.ref }
2927
- };
2928
- }
2929
- }
2930
- const at2 = anchor.resolved_at;
2931
- if (!at2 || Number.isNaN(Date.parse(at2))) {
2932
- return { ok: false, reason: "unrecoverable" };
3215
+ function heuristic(file, thresholds) {
3216
+ const marker = generatedMarker(file.header ?? headOfDiff(file));
3217
+ if (marker)
3218
+ return { class: "generated", reason: `generated-header ${marker}` };
3219
+ const rule = PATH_RULES.find((entry) => entry.test.test(file.filePath));
3220
+ if (rule) return { class: rule.class, reason: rule.name };
3221
+ if (file.renamedFrom && !file.hunks.length && (file.similarity ?? 100) >= thresholds.rename) {
3222
+ return { class: "rename", reason: `rename ${file.renamedFrom}` };
3223
+ }
3224
+ const share = boilerplateShare(file);
3225
+ if (share !== void 0 && share >= thresholds.boilerplate) {
3226
+ return {
3227
+ class: "boilerplate",
3228
+ reason: `boilerplate ${Math.round(share * 100)}%`
3229
+ };
2933
3230
  }
2934
- const found = await git2(repoRoot, [
2935
- "log",
2936
- "-1",
2937
- "--format=%H",
2938
- `--before=${at2}`,
2939
- "--end-of-options",
2940
- "HEAD",
2941
- "--",
2942
- anchor.file
2943
- ]);
2944
- const sha = found.ok ? found.stdout.trim() : "";
2945
- if (!sha || !refShapeIsSafe(sha))
2946
- return { ok: false, reason: "unrecoverable" };
2947
- const shown = await showFile(repoRoot, sha, anchor.file);
2948
- if (shown === null) return { ok: false, reason: "unrecoverable" };
2949
- return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
3231
+ return { class: "source", reason: "default" };
2950
3232
  }
2951
- async function showFile(repoRoot, ref, file) {
2952
- const path = file.replace(/^\.\//, "");
2953
- const result = await git2(repoRoot, [
2954
- "show",
2955
- "--end-of-options",
2956
- `${ref}:${path}`
2957
- ]);
2958
- return result.ok ? result.stdout : null;
3233
+ function headOfDiff(file) {
3234
+ return file.hunks.flatMap(
3235
+ (hunk) => (hunk.side ?? "new") === "new" && hunk.startLine <= HEADER_LINES ? (hunk.lines ?? []).slice(0, HEADER_LINES - hunk.startLine + 1) : []
3236
+ );
3237
+ }
3238
+ function boilerplateShare(file) {
3239
+ const lines = file.hunks.flatMap((hunk) => hunk.lines ?? []).map((line) => line.trim()).filter(Boolean);
3240
+ if (!lines.length) return void 0;
3241
+ return lines.filter(isBoilerplateLine).length / lines.length;
3242
+ }
3243
+ function currentOverrides(records, now) {
3244
+ const tagged = records.flatMap((record) => {
3245
+ if (record.frontmatter.type !== "fact") return [];
3246
+ const tag = (record.frontmatter.tags ?? []).find(
3247
+ (entry) => OVERRIDE_CLASS.has(entry)
3248
+ );
3249
+ const asserted = tag && OVERRIDE_CLASS.get(tag);
3250
+ return asserted ? [{ record, class: asserted }] : [];
3251
+ });
3252
+ const current = new Set(
3253
+ adjudicate(
3254
+ tagged.map(({ record }) => record),
3255
+ records,
3256
+ now
3257
+ ).filter((entry) => entry.standing === "current").map((entry) => entry.record.conceptId)
3258
+ );
3259
+ return tagged.filter(({ record }) => current.has(record.conceptId)).sort(
3260
+ (left, right) => left.record.conceptId.localeCompare(right.record.conceptId)
3261
+ );
2959
3262
  }
2960
3263
 
2961
3264
  // src/drift/moved.ts
@@ -2976,6 +3279,7 @@ function movedSearch(repoRoot, options = {}) {
2976
3279
  async find(anchor) {
2977
3280
  const stored = anchor.hash;
2978
3281
  if (!stored) return void 0;
3282
+ if (anchor.span) return sameFileWindow(anchor, read, stored);
2979
3283
  const language = languageForFile(anchor.file);
2980
3284
  if (!language) return sameFileWindow(anchor, read, stored);
2981
3285
  const candidates = await filesForLanguage(language);
@@ -3073,6 +3377,11 @@ async function classifyDrift(repoRoot, record, entries, options = {}) {
3073
3377
  );
3074
3378
  const out = [];
3075
3379
  for (const { anchor, entry } of wanted) {
3380
+ if (anchor.side === "old") {
3381
+ const settled2 = entry.class ?? "changed";
3382
+ out.push({ anchor, entry: { ...entry, class: settled2 }, class: settled2 });
3383
+ continue;
3384
+ }
3076
3385
  const movedTo = await search.find(anchor);
3077
3386
  if (movedTo) {
3078
3387
  out.push({
@@ -3146,8 +3455,8 @@ function unifiedDiff(before, after, options = {}) {
3146
3455
  }
3147
3456
  const truncated = body.length > max;
3148
3457
  const shown = truncated ? body.slice(0, max) : body;
3149
- const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
3150
- const lines = [header, ...shown];
3458
+ const header2 = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
3459
+ const lines = [header2, ...shown];
3151
3460
  if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
3152
3461
  return { text: lines.join("\n"), added, removed, truncated };
3153
3462
  }
@@ -3207,12 +3516,12 @@ async function reassessPacket(repoRoot, record, entries, options = {}) {
3207
3516
  ...options.search ? { search: options.search } : {},
3208
3517
  withHistory: options.withDiff !== false
3209
3518
  });
3210
- const open = classified.filter(
3519
+ const open2 = classified.filter(
3211
3520
  (found) => found.class === "changed" || found.class === "gone"
3212
3521
  );
3213
- if (!open.length) return { packet: null, classified };
3214
- const budget = diffBudget(open.length);
3215
- const anchors = open.map(
3522
+ if (!open2.length) return { packet: null, classified };
3523
+ const budget = diffBudget(open2.length);
3524
+ const anchors = open2.map(
3216
3525
  (found) => anchorPacket(found, options.withDiff === true, budget)
3217
3526
  );
3218
3527
  const type = record.frontmatter.type;
@@ -3258,32 +3567,293 @@ function anchorPacket(found, withDiff, maxLines) {
3258
3567
  const rendered = unifiedDiff(found.oldText, found.newText ?? "", {
3259
3568
  maxLines
3260
3569
  });
3261
- return {
3262
- ...base2,
3263
- diff: {
3264
- status: "ok",
3265
- source: found.oldOrigin.kind,
3266
- ref: found.oldOrigin.ref,
3267
- unified: rendered.text,
3268
- added: rendered.added,
3269
- removed: rendered.removed,
3270
- truncated: rendered.truncated
3570
+ return {
3571
+ ...base2,
3572
+ diff: {
3573
+ status: "ok",
3574
+ source: found.oldOrigin.kind,
3575
+ ref: found.oldOrigin.ref,
3576
+ unified: rendered.text,
3577
+ added: rendered.added,
3578
+ removed: rendered.removed,
3579
+ truncated: rendered.truncated
3580
+ }
3581
+ };
3582
+ }
3583
+ function claimOf(record) {
3584
+ const type = record.frontmatter.type;
3585
+ const section = isKbRecordType(type) ? RECORD_TYPES[type].sections[0] : void 0;
3586
+ if (!section) return null;
3587
+ const lines = record.body.replace(/\r\n/g, "\n").split("\n");
3588
+ const start = lines.findIndex(
3589
+ (line) => line.trim().toLowerCase() === `## ${section}`.toLowerCase()
3590
+ );
3591
+ if (start < 0) return null;
3592
+ const rest = lines.slice(start + 1);
3593
+ const end = rest.findIndex((line) => line.startsWith("## "));
3594
+ const text = (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
3595
+ return text ? { section, text } : null;
3596
+ }
3597
+
3598
+ // src/kb-index.ts
3599
+ var INDEX_FILE = "INDEX.md";
3600
+ var HEADING = "# KB Index";
3601
+ function renderIndex(records) {
3602
+ const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map(renderIndexLine);
3603
+ return `${HEADING}
3604
+
3605
+ ${lines.join("\n")}
3606
+ `;
3607
+ }
3608
+ function renderIndexLine(record) {
3609
+ const { frontmatter: fm } = record;
3610
+ const parts = [fm.type, fm.strauss_status];
3611
+ if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
3612
+ if (fm.description) parts.push(fm.description);
3613
+ return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
3614
+ }
3615
+ function indexIsStale(stored, expected) {
3616
+ return stored !== expected;
3617
+ }
3618
+
3619
+ // src/kb-context.ts
3620
+ import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
3621
+ var HEADING2 = "## Knowledge bases (pinned)";
3622
+ var DEFAULT_CONTEXT_BUDGET = 4e3;
3623
+ var CONTEXT_PROFILES = {
3624
+ "session-start": { fullUnderTokens: 1500 },
3625
+ compact: { budgetTokens: 2500 },
3626
+ turn: { budgetTokens: 2500 }
3627
+ };
3628
+ function approxTokens(text) {
3629
+ return Math.ceil(text.length / 4);
3630
+ }
3631
+ function preamble() {
3632
+ return [
3633
+ HEADING2,
3634
+ "",
3635
+ "What follows is an index of this workspace's pinned knowledge bases \u2014",
3636
+ "concept ids, titles and standing only. The record bodies are NOT in this",
3637
+ "context.",
3638
+ "",
3639
+ "Consult records only through the strauss-kb MCP tools: `kb_load` (the",
3640
+ "preferred first call), `kb_query`, and `kb_trace`, passing the",
3641
+ "`bundlePath` listed with each base. Do not read record files directly:",
3642
+ "a raw file read bypasses supersession resolution, and a superseded or",
3643
+ "rejected record file reads exactly like a current one \u2014 only the store",
3644
+ "resolves chains and standing.",
3645
+ "",
3646
+ "KB content loaded earlier in a long session may have been compacted",
3647
+ "away. Before answering a question one of these bases governs, load it",
3648
+ "again at the point of use \u2014 reloading a small base costs a few thousand",
3649
+ "tokens."
3650
+ ].join("\n");
3651
+ }
3652
+ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens, excludeTags) {
3653
+ const bundle = await store.list(absolutePath);
3654
+ if (bundle.length === 0) {
3655
+ return {
3656
+ path,
3657
+ absolutePath,
3658
+ mode: "empty",
3659
+ body: "No readable records yet \u2014 pinned ahead of being populated."
3660
+ };
3661
+ }
3662
+ const fullCap = pinMode === "full" ? budgetTokens : pinMode === "index" ? 0 : fullUnderTokens;
3663
+ let degradedFrom;
3664
+ if (fullCap > 0) {
3665
+ const full = await store.load(absolutePath, {
3666
+ budgetTokens: fullCap,
3667
+ excludeTags
3668
+ });
3669
+ if (!full.loaded && pinMode === "full") {
3670
+ degradedFrom = { approxTokens: full.approxTokens };
3671
+ }
3672
+ if (full.loaded) {
3673
+ const records = full.records.map(
3674
+ (hit) => [
3675
+ `#### ${hit.record.conceptId} \u2014 ${hit.record.frontmatter.title ?? "(untitled)"} (${hit.standing})`,
3676
+ "",
3677
+ hit.record.body.trim()
3678
+ ].join("\n")
3679
+ );
3680
+ const superseded2 = full.superseded.map(
3681
+ (entry) => `- \`${entry.conceptId}\` \u2192 superseded by ${entry.supersededBy.map((id) => `\`${id}\``).join(", ") || "(missing replacement)"}`
3682
+ );
3683
+ return {
3684
+ path,
3685
+ absolutePath,
3686
+ mode: "full",
3687
+ body: [
3688
+ ...records,
3689
+ ...superseded2.length ? [
3690
+ "#### Superseded (bodies withheld \u2014 kb_trace reaches them)",
3691
+ ...superseded2
3692
+ ] : []
3693
+ ].join("\n\n")
3694
+ };
3695
+ }
3696
+ }
3697
+ const adjudicated = adjudicate(bundle, bundle).filter(
3698
+ (hit) => matchesTags(hit.record, { excludeTags })
3699
+ );
3700
+ const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
3701
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
3702
+ (hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
3703
+ );
3704
+ return {
3705
+ path,
3706
+ absolutePath,
3707
+ mode: "index",
3708
+ body: [...lines, ...superseded].join("\n"),
3709
+ ...degradedFrom ? { degradedFrom } : {}
3710
+ };
3711
+ }
3712
+ async function buildContext(store, workspaceDir, options = {}) {
3713
+ const builtin = options.profile ? CONTEXT_PROFILES[options.profile] ?? {} : {};
3714
+ let budgetTokens = options.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
3715
+ let fullUnderTokens = options.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
3716
+ const merged = await readMergedPins(workspaceDir);
3717
+ const fromManifest = mergedContextBudgets(merged, options.profile);
3718
+ budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
3719
+ fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
3720
+ const excludeTags = options.excludeTags ?? fromManifest.excludeTags ?? builtin.excludeTags ?? [];
3721
+ const pins = merged.pins.filter(
3722
+ (pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
3723
+ );
3724
+ if (pins.length === 0) {
3725
+ return {
3726
+ block: "",
3727
+ refused: false,
3728
+ approxTokens: 0,
3729
+ budgetTokens,
3730
+ bases: []
3731
+ };
3732
+ }
3733
+ const sections = await Promise.all(
3734
+ pins.map(async (pin) => ({
3735
+ section: await renderBase(
3736
+ store,
3737
+ pin.path,
3738
+ pin.absolutePath,
3739
+ fullUnderTokens,
3740
+ pin.mode,
3741
+ budgetTokens,
3742
+ excludeTags
3743
+ ),
3744
+ frozen: pin.frozen === true
3745
+ }))
3746
+ );
3747
+ const modeLabel = {
3748
+ index: "index only \u2014 record bodies are not here",
3749
+ full: "full records \u2014 this base arrives whole",
3750
+ empty: "empty"
3751
+ };
3752
+ for (const { section } of sections) {
3753
+ if (section.degradedFrom) {
3754
+ options.warn?.({
3755
+ operation: "kb.context.full-pin-degraded",
3756
+ path: section.path,
3757
+ approxTokens: section.degradedFrom.approxTokens,
3758
+ budgetTokens
3759
+ });
3760
+ }
3761
+ }
3762
+ const rendered = sections.map(({ section, frozen }) => {
3763
+ 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];
3764
+ return [
3765
+ `### ${section.path} (${label}${frozen ? " \xB7 frozen, read-only" : ""})`,
3766
+ "",
3767
+ `bundlePath: \`${section.absolutePath}\``,
3768
+ "",
3769
+ section.body
3770
+ ].join("\n");
3771
+ });
3772
+ const block = [preamble(), "", rendered.join("\n\n"), ""].join("\n");
3773
+ const bases = sections.map(({ section }) => ({
3774
+ path: section.path,
3775
+ absolutePath: section.absolutePath,
3776
+ approxTokens: approxTokens(section.body)
3777
+ }));
3778
+ const total = approxTokens(block);
3779
+ if (total > budgetTokens) {
3780
+ options.warn?.({
3781
+ operation: "kb.context.refused",
3782
+ approxTokens: total,
3783
+ budgetTokens,
3784
+ bases: bases.map((base2) => base2.path)
3785
+ });
3786
+ const refusal = [
3787
+ HEADING2,
3788
+ "",
3789
+ `The pinned index runs to ~${total} tokens, past the ${budgetTokens}-token`,
3790
+ "budget, and was not emitted \u2014 a truncated index is indistinguishable",
3791
+ "from a complete one. The pinned bases:",
3792
+ "",
3793
+ ...bases.map(
3794
+ (base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
3795
+ ),
3796
+ "",
3797
+ "For the question at hand, read what you need now \u2014 `kb_load` a base",
3798
+ "(its own budget is separate), or `kb_index` for one base's shape.",
3799
+ "",
3800
+ "To bring this block back under budget, in order of preference:",
3801
+ "- supersede or resolve stale records \u2014 the base shrinks, the knowledge keeps",
3802
+ "- force a large base to index lines: `strauss-kb pin <path> --mode index`",
3803
+ "- scope a pin to the profiles that need it: `strauss-kb pin <path> --profiles session-start`",
3804
+ "- raise this profile's budget under `context` in .strauss/kb-pins.json",
3805
+ "- unpin what no session actually needs",
3806
+ ""
3807
+ ].join("\n");
3808
+ return {
3809
+ block: refusal,
3810
+ refused: true,
3811
+ approxTokens: total,
3812
+ budgetTokens,
3813
+ bases
3814
+ };
3815
+ }
3816
+ return { block, refused: false, approxTokens: total, budgetTokens, bases };
3817
+ }
3818
+ function toHookJson(block, event) {
3819
+ return JSON.stringify({
3820
+ hookSpecificOutput: {
3821
+ hookEventName: event,
3822
+ additionalContext: block
3271
3823
  }
3272
- };
3824
+ });
3273
3825
  }
3274
- function claimOf(record) {
3275
- const type = record.frontmatter.type;
3276
- const section = isKbRecordType(type) ? RECORD_TYPES[type].sections[0] : void 0;
3277
- if (!section) return null;
3278
- const lines = record.body.replace(/\r\n/g, "\n").split("\n");
3279
- const start = lines.findIndex(
3280
- (line) => line.trim().toLowerCase() === `## ${section}`.toLowerCase()
3826
+ var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
3827
+ var CONTEXT_END = "<!-- strauss-kb:end -->";
3828
+ async function syncInstructions(file, block) {
3829
+ const existing = await readFile5(file, "utf8").catch(() => null);
3830
+ const region = block ? `${CONTEXT_BEGIN}
3831
+ ${block.trim()}
3832
+ ${CONTEXT_END}` : null;
3833
+ if (existing === null) {
3834
+ if (!region) return { file, action: "unchanged" };
3835
+ await writeFile3(file, `${region}
3836
+ `, "utf8");
3837
+ return { file, action: "created" };
3838
+ }
3839
+ const begin = existing.indexOf(CONTEXT_BEGIN);
3840
+ const end = existing.indexOf(CONTEXT_END);
3841
+ if (begin !== -1 && end !== -1 && end >= begin) {
3842
+ const before = existing.slice(0, begin);
3843
+ const after = existing.slice(end + CONTEXT_END.length);
3844
+ const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
3845
+ if (next === existing) return { file, action: "unchanged" };
3846
+ await writeFile3(file, next, "utf8");
3847
+ return { file, action: region ? "replaced" : "removed" };
3848
+ }
3849
+ if (!region) return { file, action: "unchanged" };
3850
+ await writeFile3(
3851
+ file,
3852
+ `${existing.replace(/\n*$/, "\n\n")}${region}
3853
+ `,
3854
+ "utf8"
3281
3855
  );
3282
- if (start < 0) return null;
3283
- const rest = lines.slice(start + 1);
3284
- const end = rest.findIndex((line) => line.startsWith("## "));
3285
- const text = (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
3286
- return text ? { section, text } : null;
3856
+ return { file, action: "appended" };
3287
3857
  }
3288
3858
 
3289
3859
  // src/kb-edges.ts
@@ -3438,6 +4008,34 @@ function validateBundle(records) {
3438
4008
  }
3439
4009
  }
3440
4010
  for (const anchor of fm.strauss_anchors ?? []) {
4011
+ if (anchor.span && anchor.symbol) {
4012
+ report(
4013
+ "anchor_span",
4014
+ conceptId2,
4015
+ `anchor ${anchor.file} names both a symbol and a span \u2014 one or the other`
4016
+ );
4017
+ }
4018
+ if (anchor.span && anchor.span.end < anchor.span.start) {
4019
+ report(
4020
+ "anchor_span",
4021
+ conceptId2,
4022
+ `anchor ${anchor.file} span ${anchor.span.start}-${anchor.span.end} ends before it starts`
4023
+ );
4024
+ }
4025
+ if (anchor.span && anchor.hash_kind === "ast") {
4026
+ report(
4027
+ "anchor_span",
4028
+ conceptId2,
4029
+ `anchor ${anchor.file} is a span with hash_kind: "ast" \u2014 a span is hashed raw`
4030
+ );
4031
+ }
4032
+ if (anchor.side === "old" && !anchor.ref) {
4033
+ report(
4034
+ "anchor_side",
4035
+ conceptId2,
4036
+ `anchor ${anchor.file} is side: "old" with no ref`
4037
+ );
4038
+ }
3441
4039
  if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
3442
4040
  report(
3443
4041
  "anchor_repo",
@@ -3484,14 +4082,19 @@ var DAY_MS = 864e5;
3484
4082
  function anchorResolverCounts(bundle) {
3485
4083
  let treeSitter = 0;
3486
4084
  let regex = 0;
4085
+ let span2 = 0;
4086
+ let oldSide = 0;
3487
4087
  for (const record of bundle) {
3488
4088
  for (const anchor of record.frontmatter.strauss_anchors ?? []) {
3489
- if (!anchor.hash || !anchor.symbol) continue;
3490
- if (anchor.resolver === "tree-sitter") treeSitter += 1;
4089
+ if (!anchor.hash) continue;
4090
+ if (anchor.side === "old") oldSide += 1;
4091
+ if (anchor.span) span2 += 1;
4092
+ else if (!anchor.symbol) continue;
4093
+ else if (anchor.resolver === "tree-sitter") treeSitter += 1;
3491
4094
  else regex += 1;
3492
4095
  }
3493
4096
  }
3494
- return { total: treeSitter + regex, treeSitter, regex };
4097
+ return { total: treeSitter + regex + span2, treeSitter, regex, span: span2, oldSide };
3495
4098
  }
3496
4099
  function doctor(bundle, options = {}) {
3497
4100
  const thresholds = {
@@ -3501,7 +4104,7 @@ function doctor(bundle, options = {}) {
3501
4104
  };
3502
4105
  const now = options.now ?? /* @__PURE__ */ new Date();
3503
4106
  const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
3504
- const standings = new Map(
4107
+ const standings2 = new Map(
3505
4108
  adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
3506
4109
  );
3507
4110
  const inForce = adjudicated.filter(
@@ -3514,7 +4117,7 @@ function doctor(bundle, options = {}) {
3514
4117
  group("aging", aging(inForce, now, thresholds.agingDays)),
3515
4118
  group("orphaned", orphaned(bundle)),
3516
4119
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
3517
- group("superseded-but-cited", supersededButCited(bundle, standings)),
4120
+ group("superseded-but-cited", supersededButCited(bundle, standings2)),
3518
4121
  group("drifted", drifted(inForce)),
3519
4122
  group("unchecked", unchecked(inForce))
3520
4123
  ];
@@ -3589,20 +4192,153 @@ function unverified(hits, now, olderThanDays) {
3589
4192
  findings.push(
3590
4193
  finding(hit.record, `never verified, written ${age} days ago`)
3591
4194
  );
3592
- }
3593
- return findings;
3594
- }
3595
- function aging(hits, now, olderThanDays) {
3596
- const findings = [];
3597
- for (const hit of hits) {
3598
- const status = hit.record.frontmatter.strauss_status;
3599
- if (status !== "open" && status !== "proposed") continue;
3600
- const age = ageInDays(hit.record, now);
3601
- if (age === null || age <= olderThanDays) continue;
4195
+ }
4196
+ return findings;
4197
+ }
4198
+ function aging(hits, now, olderThanDays) {
4199
+ const findings = [];
4200
+ for (const hit of hits) {
4201
+ const status = hit.record.frontmatter.strauss_status;
4202
+ if (status !== "open" && status !== "proposed") continue;
4203
+ const age = ageInDays(hit.record, now);
4204
+ if (age === null || age <= olderThanDays) continue;
4205
+ findings.push(
4206
+ finding(
4207
+ hit.record,
4208
+ status === "open" ? `open for ${age} days` : `proposed ${age} days ago and still unsettled`
4209
+ )
4210
+ );
4211
+ }
4212
+ return findings.sort(
4213
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
4214
+ );
4215
+ }
4216
+ function orphaned(bundle) {
4217
+ const present = new Set(bundle.map((record) => record.conceptId));
4218
+ const referenced = /* @__PURE__ */ new Set();
4219
+ for (const record of bundle) {
4220
+ for (const neighbour of edgeNeighbours(record, bundle, "body-link")) {
4221
+ referenced.add(neighbour.conceptId);
4222
+ }
4223
+ for (const replaced of record.frontmatter.strauss_supersedes ?? []) {
4224
+ referenced.add(replaced);
4225
+ }
4226
+ const replacement = record.frontmatter.strauss_superseded_by;
4227
+ if (replacement && present.has(replacement)) {
4228
+ referenced.add(record.conceptId);
4229
+ }
4230
+ }
4231
+ return bundle.filter((record) => !referenced.has(record.conceptId)).map((record) => finding(record, "no other record links to it"));
4232
+ }
4233
+ var SUPERSESSION_CHECKS = /* @__PURE__ */ new Set([
4234
+ "superseded_by",
4235
+ "supersedes",
4236
+ "backlink"
4237
+ ]);
4238
+ function brokenSupersession(bundle, adjudicated) {
4239
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
4240
+ const findings = [];
4241
+ const seen = /* @__PURE__ */ new Set();
4242
+ const add = (record, note) => {
4243
+ const key2 = `${record.conceptId}\0${note}`;
4244
+ if (seen.has(key2)) return;
4245
+ seen.add(key2);
4246
+ findings.push(finding(record, note));
4247
+ };
4248
+ for (const problem of validateBundle(bundle)) {
4249
+ if (!SUPERSESSION_CHECKS.has(problem.check)) continue;
4250
+ const record = byId.get(problem.conceptId);
4251
+ if (record) add(record, problem.note);
4252
+ }
4253
+ for (const record of bundle) {
4254
+ const replacement = record.frontmatter.strauss_superseded_by;
4255
+ if (!replacement) continue;
4256
+ if (!byId.has(replacement)) {
4257
+ add(record, `replacement ${replacement} is missing`);
4258
+ } else if (record.frontmatter.strauss_status !== "superseded") {
4259
+ add(
4260
+ record,
4261
+ `names ${replacement} as its replacement but is not marked superseded`
4262
+ );
4263
+ }
4264
+ }
4265
+ for (const hit of adjudicated) {
4266
+ for (const warning of hit.warnings) {
4267
+ if (warning.kind === "broken-chain") {
4268
+ add(hit.record, `replacement ${warning.missing} is missing`);
4269
+ } else if (warning.kind === "chain-cycle") {
4270
+ add(
4271
+ hit.record,
4272
+ `supersession chain cycles through ${warning.through.join(" \u2192 ")}`
4273
+ );
4274
+ } else if (warning.kind === "forked-chain") {
4275
+ add(
4276
+ hit.record,
4277
+ `two records claim to replace it: ${warning.heads.join(", ")}`
4278
+ );
4279
+ }
4280
+ }
4281
+ }
4282
+ return findings.sort(
4283
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
4284
+ );
4285
+ }
4286
+ function supersededButCited(bundle, standings2) {
4287
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
4288
+ const findings = [];
4289
+ for (const record of bundle) {
4290
+ const standing = standings2.get(record.conceptId);
4291
+ if (standing === "superseded" || standing === "rejected") continue;
4292
+ for (const target of edgeNeighbours(record, bundle, "body-link")) {
4293
+ const targetStanding = standings2.get(target.conceptId);
4294
+ if (targetStanding !== "superseded" && targetStanding !== "rejected") {
4295
+ continue;
4296
+ }
4297
+ if (replaces(record, target)) continue;
4298
+ const replacement = target.frontmatter.strauss_superseded_by;
4299
+ findings.push(
4300
+ finding(
4301
+ record,
4302
+ `cites ${targetStanding} ${target.conceptId}${targetStanding === "superseded" && replacement && byId.has(replacement) ? ` \u2014 replaced by ${replacement}` : ""}`
4303
+ )
4304
+ );
4305
+ }
4306
+ }
4307
+ return findings;
4308
+ }
4309
+ function drifted(hits) {
4310
+ return anchorFindings(
4311
+ hits,
4312
+ "drifted",
4313
+ (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
4314
+ );
4315
+ }
4316
+ function unchecked(hits) {
4317
+ return anchorFindings(
4318
+ hits,
4319
+ "unchecked",
4320
+ (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
4321
+ );
4322
+ }
4323
+ function anchorFindings(hits, kind, headline) {
4324
+ const findings = [];
4325
+ for (const hit of hits) {
4326
+ const warning = hit.warnings.find(
4327
+ (entry) => entry.kind === kind
4328
+ );
4329
+ if (!warning) continue;
4330
+ const byRepo = /* @__PURE__ */ new Map();
4331
+ for (const anchor of warning.anchors) {
4332
+ const repo = anchor.repo ?? "";
4333
+ byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
4334
+ }
4335
+ const detail = [...byRepo.entries()].map(
4336
+ ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
4337
+ );
3602
4338
  findings.push(
3603
4339
  finding(
3604
4340
  hit.record,
3605
- status === "open" ? `open for ${age} days` : `proposed ${age} days ago and still unsettled`
4341
+ `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
3606
4342
  )
3607
4343
  );
3608
4344
  }
@@ -3610,177 +4346,519 @@ function aging(hits, now, olderThanDays) {
3610
4346
  (left, right) => left.conceptId.localeCompare(right.conceptId)
3611
4347
  );
3612
4348
  }
3613
- function orphaned(bundle) {
3614
- const present = new Set(bundle.map((record) => record.conceptId));
3615
- const referenced = /* @__PURE__ */ new Set();
4349
+ function describeAnchor(anchor) {
4350
+ const at2 = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
4351
+ if (anchor.class === "gone") {
4352
+ return `${at2} gone${anchor.reason ? ` (${anchor.reason})` : ""}`;
4353
+ }
4354
+ if (anchor.reason) return `${at2} (${anchor.reason})`;
4355
+ if (anchor.remoteState === "drifted-on-default") {
4356
+ return `${at2} (matches ref, moved on the default branch)`;
4357
+ }
4358
+ if (anchor.diffSize === null) return `${at2} (changed, size unrecorded)`;
4359
+ return anchor.diffSize === 0 ? `${at2} (content changed, same line count)` : `${at2} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
4360
+ }
4361
+ function replaces(later, earlier) {
4362
+ return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
4363
+ }
4364
+ function finding(record, note) {
4365
+ return {
4366
+ conceptId: record.conceptId,
4367
+ title: record.frontmatter.title ?? null,
4368
+ status: record.frontmatter.strauss_status,
4369
+ note
4370
+ };
4371
+ }
4372
+ function daysBetween(from, to) {
4373
+ return Math.max(0, Math.floor((to - from) / DAY_MS));
4374
+ }
4375
+ function ageInDays(record, now) {
4376
+ const at2 = record.frontmatter.generated?.at;
4377
+ if (!at2) return null;
4378
+ const written = Date.parse(at2);
4379
+ if (Number.isNaN(written)) return null;
4380
+ return daysBetween(written, now.getTime());
4381
+ }
4382
+
4383
+ // src/commands/promote/carry.ts
4384
+ var PROMOTION_SOURCE_ID = "promoted";
4385
+ var CARRIED_STATUS = {
4386
+ draft: "accepted",
4387
+ proposed: "accepted",
4388
+ accepted: "accepted",
4389
+ open: "open",
4390
+ resolved: "resolved",
4391
+ rejected: "rejected",
4392
+ superseded: "superseded"
4393
+ };
4394
+ function carry(record, promoted, source) {
4395
+ const {
4396
+ type: _type,
4397
+ // Both name records in the source base, and supersession in the target is
4398
+ // a separate question from whether this record belongs there at all.
4399
+ strauss_supersedes: _supersedes,
4400
+ strauss_superseded_by: _supersededBy,
4401
+ // A check run against the source repository, which the target never saw.
4402
+ verified: _verified,
4403
+ ...rest
4404
+ } = record.frontmatter;
4405
+ const links = rest.strauss_links ?? [];
4406
+ const kept = links.filter((link2) => promoted.has(link2.target));
4407
+ const dropped = links.filter((link2) => !promoted.has(link2.target));
4408
+ const tags = (rest.tags ?? []).filter((tag) => !isReviewTag(tag));
4409
+ const frontmatter = {
4410
+ ...rest,
4411
+ strauss_status: CARRIED_STATUS[rest.strauss_status]
4412
+ };
4413
+ setOrDrop(frontmatter, "tags", tags);
4414
+ setOrDrop(frontmatter, "strauss_links", kept);
4415
+ let body = withoutLinkSentences(record.body, dropped);
4416
+ if (source) {
4417
+ frontmatter.sources = [
4418
+ ...(rest.sources ?? []).filter(
4419
+ (entry) => entry.id !== PROMOTION_SOURCE_ID
4420
+ ),
4421
+ { id: PROMOTION_SOURCE_ID, resource: source }
4422
+ ];
4423
+ body = `${stripFootnote(body).trimEnd()}
4424
+
4425
+ [^${PROMOTION_SOURCE_ID}]: ${source}
4426
+ `;
4427
+ }
4428
+ return {
4429
+ frontmatter,
4430
+ body,
4431
+ droppedLinks: dropped.map(({ target, rel }) => ({ target, rel }))
4432
+ };
4433
+ }
4434
+ function isReviewTag(tag) {
4435
+ return tag === "review" || tag.startsWith("review:");
4436
+ }
4437
+ function withoutLinkSentences(body, dropped) {
4438
+ const sentences = new Set(
4439
+ dropped.filter((link2) => isKbLinkRel(link2.rel)).map(
4440
+ (link2) => `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
4441
+ )
4442
+ );
4443
+ if (!sentences.size) return body;
4444
+ return body.split("\n\n").filter((block) => !sentences.has(block.trim())).join("\n\n");
4445
+ }
4446
+ function stripFootnote(body) {
4447
+ return body.split("\n").filter((line) => !line.startsWith(`[^${PROMOTION_SOURCE_ID}]: `)).join("\n");
4448
+ }
4449
+ function setOrDrop(frontmatter, key2, value) {
4450
+ if (value.length) frontmatter[key2] = value;
4451
+ else delete frontmatter[key2];
4452
+ }
4453
+
4454
+ // src/kb-links/inbound.ts
4455
+ function inboundIndex(bundle) {
4456
+ const byTarget = /* @__PURE__ */ new Map();
3616
4457
  for (const record of bundle) {
3617
- for (const neighbour of edgeNeighbours(record, bundle, "body-link")) {
3618
- referenced.add(neighbour.conceptId);
3619
- }
3620
- for (const replaced of record.frontmatter.strauss_supersedes ?? []) {
3621
- referenced.add(replaced);
3622
- }
3623
- const replacement = record.frontmatter.strauss_superseded_by;
3624
- if (replacement && present.has(replacement)) {
3625
- referenced.add(record.conceptId);
4458
+ for (const link2 of record.frontmatter.strauss_links ?? []) {
4459
+ if (link2.target === record.conceptId) continue;
4460
+ const edges = byTarget.get(link2.target) ?? [];
4461
+ if (edges.some(
4462
+ (edge) => edge.from === record.conceptId && edge.rel === link2.rel
4463
+ )) {
4464
+ continue;
4465
+ }
4466
+ edges.push({ from: record.conceptId, rel: link2.rel });
4467
+ byTarget.set(link2.target, edges);
3626
4468
  }
3627
4469
  }
3628
- return bundle.filter((record) => !referenced.has(record.conceptId)).map((record) => finding(record, "no other record links to it"));
4470
+ return byTarget;
3629
4471
  }
3630
- var SUPERSESSION_CHECKS = /* @__PURE__ */ new Set([
3631
- "superseded_by",
3632
- "supersedes",
3633
- "backlink"
3634
- ]);
3635
- function brokenSupersession(bundle, adjudicated) {
4472
+
4473
+ // src/kb-links/backlinks.ts
4474
+ function backlinks(targetId, bundle) {
3636
4475
  const byId = new Map(bundle.map((record) => [record.conceptId, record]));
3637
- const findings = [];
3638
- const seen = /* @__PURE__ */ new Set();
3639
- const add = (record, note) => {
3640
- const key = `${record.conceptId}\0${note}`;
3641
- if (seen.has(key)) return;
3642
- seen.add(key);
3643
- findings.push(finding(record, note));
4476
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
4477
+ const standingOf = new Map(
4478
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
4479
+ );
4480
+ const rows = [];
4481
+ for (const edge of inboundIndex(bundle).get(targetId) ?? []) {
4482
+ const record = byId.get(edge.from);
4483
+ if (!record) continue;
4484
+ const hit = standingOf.get(edge.from);
4485
+ rows.push({
4486
+ ...edge,
4487
+ title: record.frontmatter.title ?? null,
4488
+ standing: hit?.standing ?? "unsettled",
4489
+ warnings: hit?.warnings ?? []
4490
+ });
4491
+ }
4492
+ return {
4493
+ target: targetId,
4494
+ backlinks: rows.sort(
4495
+ (left, right) => left.from.localeCompare(right.from) || left.rel.localeCompare(right.rel)
4496
+ )
3644
4497
  };
3645
- for (const problem of validateBundle(bundle)) {
3646
- if (!SUPERSESSION_CHECKS.has(problem.check)) continue;
3647
- const record = byId.get(problem.conceptId);
3648
- if (record) add(record, problem.note);
4498
+ }
4499
+
4500
+ // src/kb-links/impact.ts
4501
+ function impact(targetId, bundle, options = {}) {
4502
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
4503
+ if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
4504
+ const rels = resolveRels(options.rels);
4505
+ const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
4506
+ const inbound = inboundIndex(bundle);
4507
+ const standingOf = new Map(
4508
+ adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
4509
+ );
4510
+ const reached = /* @__PURE__ */ new Map();
4511
+ const stopped = [];
4512
+ let frontier = [targetId];
4513
+ let depth = 0;
4514
+ while (frontier.length && depth < maxDepth) {
4515
+ depth += 1;
4516
+ const next = [];
4517
+ const consider = (dependantId, edge) => {
4518
+ if (dependantId === targetId) return;
4519
+ const existing = reached.get(dependantId);
4520
+ if (existing) {
4521
+ if (!hasEdge(existing.via, edge)) existing.via.push(edge);
4522
+ return;
4523
+ }
4524
+ const record = byId.get(dependantId);
4525
+ if (!record) return;
4526
+ const hit = standingOf.get(dependantId);
4527
+ const entry = {
4528
+ conceptId: dependantId,
4529
+ title: record.frontmatter.title ?? null,
4530
+ standing: hit?.standing ?? "unsettled",
4531
+ warnings: hit?.warnings ?? [],
4532
+ depth,
4533
+ via: [edge]
4534
+ };
4535
+ reached.set(dependantId, entry);
4536
+ if (entry.standing === "superseded" || entry.standing === "rejected") {
4537
+ stopped.push(dependantId);
4538
+ return;
4539
+ }
4540
+ next.push(dependantId);
4541
+ };
4542
+ for (const id of frontier) {
4543
+ for (const edge of inbound.get(id) ?? []) {
4544
+ if (!rels.has(edge.rel)) continue;
4545
+ if (dependantEnd(edge.rel) !== "source") continue;
4546
+ consider(edge.from, { source: edge.from, target: id, rel: edge.rel });
4547
+ }
4548
+ for (const link2 of byId.get(id)?.frontmatter.strauss_links ?? []) {
4549
+ if (!rels.has(link2.rel)) continue;
4550
+ if (dependantEnd(link2.rel) !== "target") continue;
4551
+ if (link2.target === id) continue;
4552
+ consider(link2.target, {
4553
+ source: id,
4554
+ target: link2.target,
4555
+ rel: link2.rel
4556
+ });
4557
+ }
4558
+ }
4559
+ frontier = next;
4560
+ }
4561
+ return {
4562
+ root: targetId,
4563
+ impacted: [...reached.values()].sort(
4564
+ (left, right) => left.depth - right.depth || left.conceptId.localeCompare(right.conceptId)
4565
+ ),
4566
+ stopped: stopped.sort(),
4567
+ truncated: frontier.length > 0,
4568
+ unexpanded: [...frontier].sort()
4569
+ };
4570
+ }
4571
+ function resolveRels(rels) {
4572
+ if (!rels?.length) return new Set(KB_CAUSAL_LINK_RELS);
4573
+ for (const rel of rels) {
4574
+ if (!isKbLinkRel(rel) || LINK_RELS[rel].dependant === null) {
4575
+ throw new KbUnknownLinkRelError(rel, KB_CAUSAL_LINK_RELS);
4576
+ }
4577
+ }
4578
+ return new Set(rels);
4579
+ }
4580
+ function dependantEnd(rel) {
4581
+ return isKbLinkRel(rel) ? LINK_RELS[rel].dependant : null;
4582
+ }
4583
+ function hasEdge(edges, edge) {
4584
+ return edges.some(
4585
+ (existing) => existing.source === edge.source && existing.target === edge.target && existing.rel === edge.rel
4586
+ );
4587
+ }
4588
+
4589
+ // src/commands/promote/standing.ts
4590
+ var WITHDRAWN = ["superseded", "rejected"];
4591
+ function standings(bundle) {
4592
+ return new Map(
4593
+ adjudicate(bundle, bundle).map((hit) => [
4594
+ hit.record.conceptId,
4595
+ hit.standing
4596
+ ])
4597
+ );
4598
+ }
4599
+ function isWithdrawn(standing) {
4600
+ return standing !== void 0 && WITHDRAWN.includes(standing);
4601
+ }
4602
+
4603
+ // src/commands/promote/candidates.ts
4604
+ var REVIEW_TAG = "review";
4605
+ var SETTLED = ["resolved"];
4606
+ function promoteCandidates(bundle) {
4607
+ const inbound = inboundIndex(bundle);
4608
+ const standing = standings(bundle);
4609
+ const rows = [];
4610
+ for (const record of bundle) {
4611
+ if (isWithdrawn(standing.get(record.conceptId))) continue;
4612
+ const why2 = candidateReason(record, inbound.get(record.conceptId) ?? []);
4613
+ if (!why2) continue;
4614
+ rows.push({
4615
+ conceptId: record.conceptId,
4616
+ type: recordType(record.conceptId),
4617
+ title: record.frontmatter.title ?? null,
4618
+ why: why2
4619
+ });
4620
+ }
4621
+ return rows;
4622
+ }
4623
+ function candidateReason(record, inbound) {
4624
+ const { strauss_status: status, tags } = record.frontmatter;
4625
+ switch (recordType(record.conceptId)) {
4626
+ case "decision":
4627
+ if (isNoDecisionRecord(record)) return null;
4628
+ return tags?.includes(REVIEW_TAG) ? null : "decision no longer under review";
4629
+ case "constraint":
4630
+ return status === "proposed" ? "constraint still proposed \u2014 the target base is where it settles" : null;
4631
+ case "contract":
4632
+ return "contract \u2014 it outlives the change that introduced it";
4633
+ case "requirement":
4634
+ return inbound.some((edge) => edge.rel === "satisfies") ? "requirement something in the base satisfies" : null;
4635
+ case "risk":
4636
+ return record.frontmatter.strauss_materiality === "blocking" && !SETTLED.includes(status) ? "blocking risk still open" : null;
4637
+ default:
4638
+ return null;
4639
+ }
4640
+ }
4641
+ function recordType(conceptId2) {
4642
+ return conceptId2.slice(0, conceptId2.indexOf("."));
4643
+ }
4644
+
4645
+ // src/commands/promote/model.ts
4646
+ import { z as z7 } from "zod";
4647
+
4648
+ // src/commands/model.ts
4649
+ import { z as z6 } from "zod";
4650
+ var bundlePath = z6.string().min(1).describe("Absolute path to the knowledge base directory.");
4651
+ var conceptId = z6.string().min(1).describe("e.g. decision.cursor-v2");
4652
+ var TAGS = z6.array(z6.string().min(1)).optional().describe(
4653
+ "Keep only records carrying every one of these frontmatter tags. Matched exactly."
4654
+ );
4655
+ var REPO_ROOT = z6.string().min(1).optional().describe(
4656
+ "Where the anchored source lives, for the drift check. Defaults to the working directory."
4657
+ );
4658
+ function define(command) {
4659
+ return command;
4660
+ }
4661
+ function argvFlag(argv, name) {
4662
+ const joined = argv.find((arg) => arg.startsWith(`${name}=`));
4663
+ if (joined !== void 0) {
4664
+ const value2 = joined.slice(name.length + 1);
4665
+ if (!value2) throw new KbMissingFlagValueError(name);
4666
+ return value2;
3649
4667
  }
3650
- for (const record of bundle) {
3651
- const replacement = record.frontmatter.strauss_superseded_by;
3652
- if (!replacement) continue;
3653
- if (!byId.has(replacement)) {
3654
- add(record, `replacement ${replacement} is missing`);
3655
- } else if (record.frontmatter.strauss_status !== "superseded") {
3656
- add(
3657
- record,
3658
- `names ${replacement} as its replacement but is not marked superseded`
3659
- );
3660
- }
4668
+ const at2 = argv.indexOf(name);
4669
+ if (at2 === -1) return void 0;
4670
+ const value = argv[at2 + 1];
4671
+ if (value === void 0 || value.startsWith("--")) {
4672
+ throw new KbMissingFlagValueError(name);
3661
4673
  }
3662
- for (const hit of adjudicated) {
3663
- for (const warning of hit.warnings) {
3664
- if (warning.kind === "broken-chain") {
3665
- add(hit.record, `replacement ${warning.missing} is missing`);
3666
- } else if (warning.kind === "chain-cycle") {
3667
- add(
3668
- hit.record,
3669
- `supersession chain cycles through ${warning.through.join(" \u2192 ")}`
3670
- );
3671
- } else if (warning.kind === "forked-chain") {
3672
- add(
3673
- hit.record,
3674
- `two records claim to replace it: ${warning.heads.join(", ")}`
3675
- );
4674
+ return value;
4675
+ }
4676
+ function argvFlags(argv, name) {
4677
+ const values = [];
4678
+ for (const [at2, arg] of argv.entries()) {
4679
+ if (arg.startsWith(`${name}=`)) {
4680
+ const value = arg.slice(name.length + 1);
4681
+ if (!value) throw new KbMissingFlagValueError(name);
4682
+ values.push(value);
4683
+ } else if (arg === name) {
4684
+ const value = argv[at2 + 1];
4685
+ if (value === void 0 || value.startsWith("--")) {
4686
+ throw new KbMissingFlagValueError(name);
3676
4687
  }
4688
+ values.push(value);
3677
4689
  }
3678
4690
  }
3679
- return findings.sort(
3680
- (left, right) => left.conceptId.localeCompare(right.conceptId)
3681
- );
4691
+ return values;
3682
4692
  }
3683
- function supersededButCited(bundle, standings) {
3684
- const byId = new Map(bundle.map((record) => [record.conceptId, record]));
3685
- const findings = [];
3686
- for (const record of bundle) {
3687
- const standing = standings.get(record.conceptId);
3688
- if (standing === "superseded" || standing === "rejected") continue;
3689
- for (const target of edgeNeighbours(record, bundle, "body-link")) {
3690
- const targetStanding = standings.get(target.conceptId);
3691
- if (targetStanding !== "superseded" && targetStanding !== "rejected") {
3692
- continue;
3693
- }
3694
- if (replaces(record, target)) continue;
3695
- const replacement = target.frontmatter.strauss_superseded_by;
3696
- findings.push(
3697
- finding(
3698
- record,
3699
- `cites ${targetStanding} ${target.conceptId}${targetStanding === "superseded" && replacement && byId.has(replacement) ? ` \u2014 replaced by ${replacement}` : ""}`
3700
- )
3701
- );
4693
+ function argvWithout(argv, ...names) {
4694
+ const kept = [];
4695
+ for (let at2 = 0; at2 < argv.length; at2 += 1) {
4696
+ const arg = argv[at2];
4697
+ if (names.some((name) => arg.startsWith(`${name}=`))) continue;
4698
+ if (names.includes(arg)) {
4699
+ at2 += 1;
4700
+ continue;
3702
4701
  }
4702
+ kept.push(arg);
3703
4703
  }
3704
- return findings;
3705
- }
3706
- function drifted(hits) {
3707
- return anchorFindings(
3708
- hits,
3709
- "drifted",
3710
- (count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
3711
- );
4704
+ return kept;
3712
4705
  }
3713
- function unchecked(hits) {
3714
- return anchorFindings(
3715
- hits,
3716
- "unchecked",
3717
- (count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
4706
+ function argvPositional(argv, ...names) {
4707
+ return argvWithout(argv.slice(1), ...names).find(
4708
+ (arg) => !arg.startsWith("--")
3718
4709
  );
3719
4710
  }
3720
- function anchorFindings(hits, kind, headline) {
3721
- const findings = [];
3722
- for (const hit of hits) {
3723
- const warning = hit.warnings.find(
3724
- (entry) => entry.kind === kind
3725
- );
3726
- if (!warning) continue;
3727
- const byRepo = /* @__PURE__ */ new Map();
3728
- for (const anchor of warning.anchors) {
3729
- const repo = anchor.repo ?? "";
3730
- byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
4711
+
4712
+ // src/commands/promote/model.ts
4713
+ var promoteInputSchema = z7.object({
4714
+ bundlePath,
4715
+ conceptIds: z7.array(conceptId).max(64).optional().describe("Records to copy into the target base. Omit with `list`."),
4716
+ to: z7.string().min(1).optional().describe("Absolute path to the base being promoted into."),
4717
+ source: z7.string().min(1).optional().describe(
4718
+ "Where the promotion came from, usually the pull request URL. Recorded on each copy as a source."
4719
+ ),
4720
+ force: z7.boolean().optional().describe("Overwrite a record the target base already holds."),
4721
+ list: z7.boolean().optional().describe("List the source base's candidates instead of promoting.")
4722
+ }).refine((input) => input.list === true || input.to !== void 0, {
4723
+ message: "promote needs a target base \u2014 pass --to <bundle>, or --list",
4724
+ path: ["to"]
4725
+ }).refine(
4726
+ (input) => input.list === true || (input.conceptIds?.length ?? 0) > 0,
4727
+ {
4728
+ message: "name at least one concept id to promote, or pass --list",
4729
+ path: ["conceptIds"]
4730
+ }
4731
+ );
4732
+
4733
+ // src/commands/promote/command.ts
4734
+ import { resolve as resolve5 } from "path";
4735
+ var promoteCommand = define({
4736
+ name: "promote",
4737
+ tool: "kb_promote",
4738
+ usage: "promote <concept-id...> --to <bundle> [--source <url>] [--force] | --list",
4739
+ 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.",
4740
+ input: promoteInputSchema,
4741
+ fromArgv: (argv, path) => {
4742
+ const to = argvFlag(argv, "--to");
4743
+ const source = argvFlag(argv, "--source");
4744
+ const words = argv.slice(1);
4745
+ for (const flag of ["--to", "--source"]) {
4746
+ const at2 = words.indexOf(flag);
4747
+ if (at2 !== -1) words.splice(at2, 2);
4748
+ }
4749
+ const conceptIds = words.filter((word) => !word.startsWith("--"));
4750
+ return {
4751
+ bundlePath: path,
4752
+ ...conceptIds.length ? { conceptIds } : {},
4753
+ ...to !== void 0 ? { to } : {},
4754
+ ...source !== void 0 ? { source } : {},
4755
+ ...argv.includes("--force") ? { force: true } : {},
4756
+ ...argv.includes("--list") ? { list: true } : {}
4757
+ };
4758
+ },
4759
+ run: async ({ store, actor }, { bundlePath: path, conceptIds, to, source, force, list }) => {
4760
+ const from = resolve5(path);
4761
+ const bundle = await store.list(from);
4762
+ if (list) {
4763
+ return { mode: "list", candidates: promoteCandidates(bundle) };
4764
+ }
4765
+ const target = resolve5(to);
4766
+ if (target === from) throw new KbPromoteSelfError(target);
4767
+ const named = (conceptIds ?? []).map(namedRecord);
4768
+ const wanted = named.map(({ conceptId: conceptId2, type, slug }) => {
4769
+ const record = bundle.find((entry) => entry.conceptId === conceptId2);
4770
+ if (!record) throw new KbRecordNotFoundError(conceptId2);
4771
+ return { record, type, slug };
4772
+ });
4773
+ await assertBaseNotFrozen(process.cwd(), from);
4774
+ await assertBaseNotFrozen(process.cwd(), target);
4775
+ const standing = standings(bundle);
4776
+ for (const { record } of wanted) {
4777
+ const where = standing.get(record.conceptId);
4778
+ if (isWithdrawn(where)) {
4779
+ throw new KbPromoteStandingError(record.conceptId, where);
4780
+ }
4781
+ if (!force && await store.read(target, record.conceptId)) {
4782
+ throw new KbPromoteCollisionError(record.conceptId, target);
4783
+ }
3731
4784
  }
3732
- const detail = [...byRepo.entries()].map(
3733
- ([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
3734
- );
3735
- findings.push(
3736
- finding(
3737
- hit.record,
3738
- `${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
3739
- )
4785
+ const promotedIds = new Set(wanted.map(({ record }) => record.conceptId));
4786
+ const promoted = [];
4787
+ for (const { record, type, slug } of wanted) {
4788
+ const { frontmatter, body, droppedLinks } = carry(
4789
+ record,
4790
+ promotedIds,
4791
+ source
4792
+ );
4793
+ try {
4794
+ await store.write(
4795
+ target,
4796
+ { type, slug, frontmatter, body, overwrite: force === true },
4797
+ actor
4798
+ );
4799
+ } catch (error) {
4800
+ throw new KbPromoteStoppedError(
4801
+ record.conceptId,
4802
+ promoted.map((entry) => entry.conceptId),
4803
+ error instanceof Error ? error.message : "unknown"
4804
+ );
4805
+ }
4806
+ await store.note(target, {
4807
+ by: actor,
4808
+ operation: "promote-in",
4809
+ conceptId: record.conceptId,
4810
+ target: from
4811
+ });
4812
+ await store.note(from, {
4813
+ by: actor,
4814
+ operation: "promote-out",
4815
+ conceptId: record.conceptId,
4816
+ target
4817
+ });
4818
+ promoted.push({ conceptId: record.conceptId, droppedLinks });
4819
+ }
4820
+ return { mode: "promote", to: target, promoted };
4821
+ },
4822
+ render: (result) => renderPromote(result)
4823
+ });
4824
+ function namedRecord(conceptId2) {
4825
+ const at2 = conceptId2.indexOf(".");
4826
+ const type = at2 === -1 ? conceptId2 : conceptId2.slice(0, at2);
4827
+ const slug = at2 === -1 ? "" : conceptId2.slice(at2 + 1);
4828
+ if (!KB_SLUG_PATTERN.test(type) || !KB_SLUG_PATTERN.test(slug)) {
4829
+ throw new KbInvalidConceptIdError(
4830
+ "concept id must be <type>.<slug>, both kebab-case",
4831
+ { conceptId: conceptId2 }
3740
4832
  );
3741
4833
  }
3742
- return findings.sort(
3743
- (left, right) => left.conceptId.localeCompare(right.conceptId)
3744
- );
4834
+ return { conceptId: conceptId2, type, slug };
3745
4835
  }
3746
- function describeAnchor(anchor) {
3747
- const at2 = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
3748
- if (anchor.class === "gone") {
3749
- return `${at2} gone${anchor.reason ? ` (${anchor.reason})` : ""}`;
4836
+ function renderPromote(result) {
4837
+ if (result.mode === "list") {
4838
+ if (!result.candidates.length) return "No promotion candidates.";
4839
+ return result.candidates.flatMap((candidate) => [
4840
+ `${candidate.conceptId} [${candidate.type}]${candidate.title ? ` \u2014 ${candidate.title}` : ""}`,
4841
+ ` ${candidate.why}`
4842
+ ]).join("\n");
3750
4843
  }
3751
- if (anchor.reason) return `${at2} (${anchor.reason})`;
3752
- if (anchor.remoteState === "drifted-on-default") {
3753
- return `${at2} (matches ref, moved on the default branch)`;
4844
+ const lines = [
4845
+ `Promoted ${result.promoted.length} record${result.promoted.length === 1 ? "" : "s"} into ${result.to}.`
4846
+ ];
4847
+ for (const entry of result.promoted) {
4848
+ lines.push(`- ${entry.conceptId}`);
4849
+ for (const link2 of entry.droppedLinks) {
4850
+ lines.push(
4851
+ ` dropped ${link2.rel} \u2192 ${link2.target} (not promoted in this run)`
4852
+ );
4853
+ }
3754
4854
  }
3755
- if (anchor.diffSize === null) return `${at2} (changed, size unrecorded)`;
3756
- return anchor.diffSize === 0 ? `${at2} (content changed, same line count)` : `${at2} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
3757
- }
3758
- function replaces(later, earlier) {
3759
- return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
3760
- }
3761
- function finding(record, note) {
3762
- return {
3763
- conceptId: record.conceptId,
3764
- title: record.frontmatter.title ?? null,
3765
- status: record.frontmatter.strauss_status,
3766
- note
3767
- };
3768
- }
3769
- function daysBetween(from, to) {
3770
- return Math.max(0, Math.floor((to - from) / DAY_MS));
3771
- }
3772
- function ageInDays(record, now) {
3773
- const at2 = record.frontmatter.generated?.at;
3774
- if (!at2) return null;
3775
- const written = Date.parse(at2);
3776
- if (Number.isNaN(written)) return null;
3777
- return daysBetween(written, now.getTime());
4855
+ return lines.join("\n");
3778
4856
  }
3779
4857
 
3780
4858
  // src/kb-log.ts
3781
- import { z as z6 } from "zod";
4859
+ import { z as z8 } from "zod";
3782
4860
  var LOG_FILE = "log.jsonl";
3783
- var kbLogEntrySchema = z6.object({
4861
+ var kbLogEntrySchema = z8.object({
3784
4862
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
3785
4863
  // below), and a value that isn't actually chronological — a Unix
3786
4864
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -3789,23 +4867,32 @@ var kbLogEntrySchema = z6.object({
3789
4867
  // and rejects everything else, including a non-`Z` offset — so a
3790
4868
  // malformed `at` is reported the same way a malformed line already is,
3791
4869
  // rather than silently sorting into the wrong place.
3792
- at: z6.iso.datetime(),
3793
- by: z6.string().min(1),
3794
- operation: z6.string().min(1),
3795
- conceptId: z6.string().min(1),
3796
- /** Second concept id, where the operation relates two — supersession. */
3797
- target: z6.string().min(1).optional()
4870
+ at: z8.iso.datetime(),
4871
+ by: z8.string().min(1),
4872
+ operation: z8.string().min(1),
4873
+ conceptId: z8.string().min(1),
4874
+ /**
4875
+ * The operation's other end, where it has one: a second concept id for
4876
+ * supersession, the other base's path for promotion.
4877
+ */
4878
+ target: z8.string().min(1).optional()
3798
4879
  }).strict();
3799
4880
  function renderLogEntry(entry) {
3800
4881
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
3801
4882
  `;
3802
4883
  }
4884
+ var CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})/;
3803
4885
  function parseLog(raw) {
3804
4886
  const entries = [];
3805
4887
  const malformed = [];
3806
4888
  const seen = /* @__PURE__ */ new Set();
4889
+ let conflicted = false;
3807
4890
  raw.split("\n").forEach((text, index2) => {
3808
4891
  if (!text.trim()) return;
4892
+ if (CONFLICT_MARKER.test(text)) {
4893
+ conflicted = true;
4894
+ return;
4895
+ }
3809
4896
  let value;
3810
4897
  try {
3811
4898
  value = JSON.parse(text);
@@ -3818,26 +4905,26 @@ function parseLog(raw) {
3818
4905
  malformed.push({ line: index2 + 1, text });
3819
4906
  return;
3820
4907
  }
3821
- const key = JSON.stringify(parsed.data);
3822
- if (seen.has(key)) return;
3823
- seen.add(key);
4908
+ const key2 = JSON.stringify(parsed.data);
4909
+ if (seen.has(key2)) return;
4910
+ seen.add(key2);
3824
4911
  entries.push(parsed.data);
3825
4912
  });
3826
4913
  entries.sort(
3827
4914
  (left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
3828
4915
  );
3829
- return { entries, malformed };
4916
+ return { entries, malformed, conflicted };
3830
4917
  }
3831
4918
 
3832
4919
  // src/json-schema.ts
3833
- import { z as z7 } from "zod";
4920
+ import { z as z9 } from "zod";
3834
4921
  function kbJsonSchemas() {
3835
4922
  return {
3836
- recordFrontmatter: z7.toJSONSchema(kbRecordFrontmatterSchema, {
4923
+ recordFrontmatter: z9.toJSONSchema(kbRecordFrontmatterSchema, {
3837
4924
  io: "input"
3838
4925
  }),
3839
- composeInput: z7.toJSONSchema(composeInputSchema, { io: "input" }),
3840
- logEntry: z7.toJSONSchema(kbLogEntrySchema, { io: "input" })
4926
+ composeInput: z9.toJSONSchema(composeInputSchema, { io: "input" }),
4927
+ logEntry: z9.toJSONSchema(kbLogEntrySchema, { io: "input" })
3841
4928
  };
3842
4929
  }
3843
4930
 
@@ -3890,73 +4977,7 @@ function byGeneratedAt(left, right) {
3890
4977
  }
3891
4978
 
3892
4979
  // src/commands/anchor-resolve.ts
3893
- import { z as z9 } from "zod";
3894
-
3895
- // src/commands/model.ts
3896
- import { z as z8 } from "zod";
3897
- var bundlePath = z8.string().min(1).describe("Absolute path to the knowledge base directory.");
3898
- var conceptId = z8.string().min(1).describe("e.g. decision.cursor-v2");
3899
- var TAGS = z8.array(z8.string().min(1)).optional().describe(
3900
- "Keep only records carrying every one of these frontmatter tags. Matched exactly."
3901
- );
3902
- var REPO_ROOT = z8.string().min(1).optional().describe(
3903
- "Where the anchored source lives, for the drift check. Defaults to the working directory."
3904
- );
3905
- function define(command) {
3906
- return command;
3907
- }
3908
- function argvFlag(argv, name) {
3909
- const joined = argv.find((arg) => arg.startsWith(`${name}=`));
3910
- if (joined !== void 0) {
3911
- const value2 = joined.slice(name.length + 1);
3912
- if (!value2) throw new KbMissingFlagValueError(name);
3913
- return value2;
3914
- }
3915
- const at2 = argv.indexOf(name);
3916
- if (at2 === -1) return void 0;
3917
- const value = argv[at2 + 1];
3918
- if (value === void 0 || value.startsWith("--")) {
3919
- throw new KbMissingFlagValueError(name);
3920
- }
3921
- return value;
3922
- }
3923
- function argvFlags(argv, name) {
3924
- const values = [];
3925
- for (const [at2, arg] of argv.entries()) {
3926
- if (arg.startsWith(`${name}=`)) {
3927
- const value = arg.slice(name.length + 1);
3928
- if (!value) throw new KbMissingFlagValueError(name);
3929
- values.push(value);
3930
- } else if (arg === name) {
3931
- const value = argv[at2 + 1];
3932
- if (value === void 0 || value.startsWith("--")) {
3933
- throw new KbMissingFlagValueError(name);
3934
- }
3935
- values.push(value);
3936
- }
3937
- }
3938
- return values;
3939
- }
3940
- function argvWithout(argv, ...names) {
3941
- const kept = [];
3942
- for (let at2 = 0; at2 < argv.length; at2 += 1) {
3943
- const arg = argv[at2];
3944
- if (names.some((name) => arg.startsWith(`${name}=`))) continue;
3945
- if (names.includes(arg)) {
3946
- at2 += 1;
3947
- continue;
3948
- }
3949
- kept.push(arg);
3950
- }
3951
- return kept;
3952
- }
3953
- function argvPositional(argv, ...names) {
3954
- return argvWithout(argv.slice(1), ...names).find(
3955
- (arg) => !arg.startsWith("--")
3956
- );
3957
- }
3958
-
3959
- // src/commands/anchor-resolve.ts
4980
+ import { z as z10 } from "zod";
3960
4981
  function resolverSummary(results) {
3961
4982
  const names = [
3962
4983
  ...new Set(
@@ -3970,17 +4991,17 @@ var anchorResolveCommand = define({
3970
4991
  tool: "kb_anchor_resolve",
3971
4992
  usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
3972
4993
  description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
3973
- input: z9.object({
4994
+ input: z10.object({
3974
4995
  bundlePath,
3975
4996
  conceptId,
3976
- repoRoot: z9.string().min(1).optional(),
3977
- offline: z9.boolean().optional().describe(
4997
+ repoRoot: z10.string().min(1).optional(),
4998
+ offline: z10.boolean().optional().describe(
3978
4999
  "Resolve foreign anchors from the local repo cache only, never fetching."
3979
5000
  ),
3980
- rebaseline: z9.boolean().optional().describe(
5001
+ rebaseline: z10.boolean().optional().describe(
3981
5002
  "Accept the current code as the new baseline for anchors that drifted."
3982
5003
  ),
3983
- restamp: z9.boolean().optional().describe(
5004
+ restamp: z10.boolean().optional().describe(
3984
5005
  "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
3985
5006
  )
3986
5007
  }),
@@ -4018,6 +5039,7 @@ var anchorResolveCommand = define({
4018
5039
  const base2 = {
4019
5040
  file: anchor.file,
4020
5041
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
5042
+ ...anchor.side === "old" ? { side: "old" } : {},
4021
5043
  // Carried onto unresolved findings too: an anchor that once hashed
4022
5044
  // and now resolves to nothing is a broken anchor, and the exit code
4023
5045
  // has to be able to tell it from one nobody ever stamped.
@@ -4194,12 +5216,18 @@ async function readSources(anchors, root, offline) {
4194
5216
  const foreign = new Map(
4195
5217
  anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
4196
5218
  );
4197
- const local = anchors.filter((anchor) => !foreign.get(anchor));
5219
+ const local = anchors.filter(
5220
+ (anchor) => !foreign.get(anchor) && anchor.side !== "old"
5221
+ );
5222
+ const committed = anchors.filter(
5223
+ (anchor) => !foreign.get(anchor) && anchor.side === "old"
5224
+ );
4198
5225
  const remote = anchors.filter((anchor) => foreign.get(anchor));
4199
5226
  const reads = await readAnchorFiles(
4200
5227
  local.map((anchor) => anchor.file),
4201
5228
  anchorFileReader(root)
4202
5229
  );
5230
+ const atRef = await readCommitted(root, committed);
4203
5231
  const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
4204
5232
  offline
4205
5233
  });
@@ -4211,11 +5239,18 @@ async function readSources(anchors, root, offline) {
4211
5239
  read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
4212
5240
  );
4213
5241
  }
5242
+ for (const anchor of committed) {
5243
+ const read = atRef.get(atRefKey(anchor));
5244
+ sources.set(
5245
+ anchor,
5246
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
5247
+ );
5248
+ }
4214
5249
  for (const anchor of remote) {
4215
5250
  const repo = anchor.repo;
4216
- const key = normalizeRepoUrl(repo);
4217
- const atDefault = blobs.get(wantKey(key, void 0, anchor.file));
4218
- const primary = anchor.ref ? blobs.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
5251
+ const key2 = normalizeRepoUrl(repo);
5252
+ const atDefault = blobs.get(wantKey(key2, void 0, anchor.file));
5253
+ const primary = anchor.ref ? blobs.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
4219
5254
  if (!primary?.ok) {
4220
5255
  sources.set(anchor, {
4221
5256
  ok: false,
@@ -4235,13 +5270,13 @@ async function readSources(anchors, root, offline) {
4235
5270
  }
4236
5271
 
4237
5272
  // src/commands/answer.ts
4238
- import { z as z10 } from "zod";
5273
+ import { z as z11 } from "zod";
4239
5274
  var answerCommand = define({
4240
5275
  name: "answer",
4241
5276
  tool: "kb_answer",
4242
5277
  usage: "answer <concept-id> <answer...>",
4243
5278
  description: "Resolve an open question: set status, stamp who and when, append an Answer section. If the answer overturns a decision or assumption, supersede that record explicitly.",
4244
- input: z10.object({ bundlePath, conceptId, answer: z10.string().min(1) }),
5279
+ input: z11.object({ bundlePath, conceptId, answer: z11.string().min(1) }),
4245
5280
  fromArgv: (argv, path) => ({
4246
5281
  bundlePath: path,
4247
5282
  conceptId: argv[1],
@@ -4255,27 +5290,27 @@ var answerCommand = define({
4255
5290
  });
4256
5291
 
4257
5292
  // src/commands/backlinks.ts
4258
- import { z as z11 } from "zod";
5293
+ import { z as z12 } from "zod";
4259
5294
  var backlinksCommand = define({
4260
5295
  name: "backlinks",
4261
5296
  tool: "kb_backlinks",
4262
5297
  usage: "backlinks <concept-id>",
4263
5298
  description: "Who points at this record: every inbound typed causal link (`strauss_links`), one hop, every rel including `related_to`, each with its rel and the standing of the record that made it. Use it when you need the exact edges \u2014 reviewing or renaming a record.",
4264
- input: z11.object({ bundlePath, conceptId }),
5299
+ input: z12.object({ bundlePath, conceptId }),
4265
5300
  fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
4266
5301
  run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
4267
5302
  });
4268
5303
 
4269
5304
  // src/commands/catalog.ts
4270
- import { z as z12 } from "zod";
5305
+ import { z as z13 } from "zod";
4271
5306
  var catalogCommand = define({
4272
5307
  name: "catalog",
4273
5308
  tool: "kb_catalog",
4274
5309
  usage: "catalog [type] [--tag T]...",
4275
5310
  description: "Lists every record as one line \u2014 concept id, type, title, standing, and a stale flag \u2014 at roughly thirty tokens each. Pick this over kb_load once kb_load refuses: kb_catalog never refuses. Superseded records show only their replacement; fetch bodies with kb_load, kb_pack, kb_query, or kb_trace.",
4276
- input: z12.object({
5311
+ input: z13.object({
4277
5312
  bundlePath,
4278
- type: z12.enum(KB_RECORD_TYPES).optional(),
5313
+ type: z13.enum(KB_RECORD_TYPES).optional(),
4279
5314
  tags: TAGS
4280
5315
  }),
4281
5316
  fromArgv: (argv, path) => {
@@ -4343,30 +5378,559 @@ function count(value, noun) {
4343
5378
  return `${value} ${value === 1 ? noun : `${noun}s`}`;
4344
5379
  }
4345
5380
 
5381
+ // src/commands/classify.ts
5382
+ import { Buffer } from "buffer";
5383
+ import { open } from "fs/promises";
5384
+ import { join as join6 } from "path";
5385
+ import { z as z16 } from "zod";
5386
+
5387
+ // src/commands/match/command.ts
5388
+ import { z as z15 } from "zod";
5389
+
5390
+ // src/commands/match/errors.ts
5391
+ var KbMatchInputError = class extends BaseError {
5392
+ constructor(reason) {
5393
+ super({
5394
+ message: `match: ${reason}`,
5395
+ errorType: "KbMatchInput" /* KbMatchInput */,
5396
+ code: 400,
5397
+ fault: "User" /* User */,
5398
+ retriable: false,
5399
+ reportToUser: true,
5400
+ details: { reason }
5401
+ });
5402
+ this.reason = reason;
5403
+ }
5404
+ reason;
5405
+ };
5406
+
5407
+ // src/commands/match/model.ts
5408
+ import { z as z14 } from "zod";
5409
+ var diffHunkSchema = z14.object({
5410
+ startLine: z14.number().int().positive(),
5411
+ endLine: z14.number().int().positive(),
5412
+ side: z14.enum(["old", "new"]).optional()
5413
+ }).passthrough();
5414
+ var diffFileSchema = z14.object({
5415
+ filePath: z14.string().min(1).describe("Repo-relative, spelled the way anchors are."),
5416
+ hunks: z14.array(diffHunkSchema)
5417
+ });
5418
+ var symbolRangeSchema = z14.object({
5419
+ file: z14.string().min(1),
5420
+ symbol: z14.string().min(1),
5421
+ startLine: z14.number().int().positive(),
5422
+ endLine: z14.number().int().positive()
5423
+ });
5424
+
5425
+ // src/commands/match/parse-unified-diff.ts
5426
+ var FILE_HEADER = /^diff --git (.+)$/;
5427
+ var HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
5428
+ var SIMILARITY = /^similarity index (\d+)%$/;
5429
+ var KNOWN_PREFIX = /^[ab]\//;
5430
+ function parseUnifiedDiff(patch, options = {}) {
5431
+ const files = [];
5432
+ let current;
5433
+ let listed = false;
5434
+ let shared;
5435
+ let oldPath;
5436
+ let rename4 = {};
5437
+ let inHeader = false;
5438
+ let added;
5439
+ let removed;
5440
+ const list = () => {
5441
+ if (!current || listed) return;
5442
+ files.push(current);
5443
+ listed = true;
5444
+ };
5445
+ const open2 = (filePath) => {
5446
+ current = { filePath, hunks: [], ...rename4 };
5447
+ listed = false;
5448
+ };
5449
+ const amend = () => {
5450
+ if (current) Object.assign(current, rename4);
5451
+ };
5452
+ const close = () => {
5453
+ if (options.keepEmpty) list();
5454
+ current = void 0;
5455
+ listed = false;
5456
+ added = void 0;
5457
+ removed = void 0;
5458
+ };
5459
+ for (const raw of patch.split("\n")) {
5460
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
5461
+ const start = FILE_HEADER.exec(line);
5462
+ if (start) {
5463
+ close();
5464
+ oldPath = void 0;
5465
+ rename4 = {};
5466
+ shared = sharedHeaderPath(start[1]);
5467
+ inHeader = true;
5468
+ if (shared) open2(shared);
5469
+ continue;
5470
+ }
5471
+ if (inHeader) {
5472
+ const similarity = SIMILARITY.exec(line);
5473
+ if (similarity) {
5474
+ rename4.similarity = Number(similarity[1]);
5475
+ amend();
5476
+ continue;
5477
+ }
5478
+ if (line.startsWith("rename from ")) {
5479
+ rename4.renamedFrom = unquote(line.slice(12).trim());
5480
+ amend();
5481
+ continue;
5482
+ }
5483
+ if (line.startsWith("rename to ")) {
5484
+ open2(unquote(line.slice(10).trim()));
5485
+ continue;
5486
+ }
5487
+ if (line.startsWith("--- ")) {
5488
+ oldPath = sidePath(line.slice(4), shared);
5489
+ continue;
5490
+ }
5491
+ if (line.startsWith("+++ ")) {
5492
+ const path = sidePath(line.slice(4), shared) ?? oldPath;
5493
+ if (path) open2(path);
5494
+ else current = void 0;
5495
+ continue;
5496
+ }
5497
+ }
5498
+ const hunk = HUNK.exec(line);
5499
+ if (!hunk) {
5500
+ if (!options.withLines || inHeader) continue;
5501
+ if (line.startsWith("+")) added?.lines?.push(line.slice(1));
5502
+ else if (line.startsWith("-")) removed?.lines?.push(line.slice(1));
5503
+ continue;
5504
+ }
5505
+ inHeader = false;
5506
+ added = void 0;
5507
+ removed = void 0;
5508
+ if (!current) continue;
5509
+ const [next, before] = hunksOf(hunk, options.withLines === true);
5510
+ added = next;
5511
+ removed = before;
5512
+ list();
5513
+ current.hunks.push(...before ? [next, before] : [next]);
5514
+ }
5515
+ close();
5516
+ return files;
5517
+ }
5518
+ function hunksOf(hunk, withLines) {
5519
+ const oldStart = Number(hunk[1]);
5520
+ const oldCount = hunk[2] === void 0 ? 1 : Number(hunk[2]);
5521
+ const newStart = Number(hunk[3]);
5522
+ const newCount = hunk[4] === void 0 ? 1 : Number(hunk[4]);
5523
+ const lines = withLines ? { lines: [] } : {};
5524
+ const added = newCount === 0 ? { ...point(newStart), ...lines } : { startLine: newStart, endLine: newStart + newCount - 1, ...lines };
5525
+ if (oldCount === 0) return [added];
5526
+ return [
5527
+ added,
5528
+ {
5529
+ startLine: oldStart,
5530
+ endLine: oldStart + oldCount - 1,
5531
+ side: "old",
5532
+ ...withLines ? { lines: [] } : {}
5533
+ }
5534
+ ];
5535
+ }
5536
+ function point(start) {
5537
+ const at2 = Math.max(1, start);
5538
+ return { startLine: at2, endLine: at2 };
5539
+ }
5540
+ function sidePath(raw, shared) {
5541
+ const text = unquote(raw.replace(/\t.*$/, "").trim());
5542
+ if (text === "/dev/null") return void 0;
5543
+ if (KNOWN_PREFIX.test(text)) return text.slice(2);
5544
+ return shared ?? text;
5545
+ }
5546
+ function sharedHeaderPath(rest) {
5547
+ const pair = splitHeaderPair(rest);
5548
+ if (!pair || pair[0] === pair[1]) return void 0;
5549
+ const from = unquote(pair[0]).split("/");
5550
+ const to = unquote(pair[1]).split("/");
5551
+ const shared = [];
5552
+ while (from.length > 1 && to.length > 1 && from.at(-1) === to.at(-1)) {
5553
+ shared.unshift(from.pop());
5554
+ to.pop();
5555
+ }
5556
+ return shared.length ? shared.join("/") : void 0;
5557
+ }
5558
+ function splitHeaderPair(rest) {
5559
+ if (rest.startsWith('"')) {
5560
+ const end = endOfQuoted(rest);
5561
+ if (end < 0 || rest[end + 1] !== " ") return void 0;
5562
+ return [rest.slice(0, end + 1), rest.slice(end + 2)];
5563
+ }
5564
+ const mid = (rest.length - 1) / 2;
5565
+ if (Number.isInteger(mid) && rest[mid] === " ") {
5566
+ return [rest.slice(0, mid), rest.slice(mid + 1)];
5567
+ }
5568
+ const at2 = rest.indexOf(" ");
5569
+ return at2 === -1 ? void 0 : [rest.slice(0, at2), rest.slice(at2 + 1)];
5570
+ }
5571
+ function endOfQuoted(text) {
5572
+ for (let at2 = 1; at2 < text.length; at2 += 1) {
5573
+ if (text[at2] === "\\") {
5574
+ at2 += 1;
5575
+ continue;
5576
+ }
5577
+ if (text[at2] === '"') return at2;
5578
+ }
5579
+ return -1;
5580
+ }
5581
+ var ESCAPES = {
5582
+ a: 7,
5583
+ b: 8,
5584
+ f: 12,
5585
+ n: 10,
5586
+ r: 13,
5587
+ t: 9,
5588
+ v: 11,
5589
+ '"': 34,
5590
+ "\\": 92
5591
+ };
5592
+ var OCTAL = /^[0-7]{3}/;
5593
+ var utf8 = new TextEncoder();
5594
+ function unquote(text) {
5595
+ if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) {
5596
+ return text;
5597
+ }
5598
+ const body = text.slice(1, -1);
5599
+ const bytes = [];
5600
+ for (let at2 = 0; at2 < body.length; ) {
5601
+ const slash = body.indexOf("\\", at2);
5602
+ if (slash < 0) {
5603
+ bytes.push(...utf8.encode(body.slice(at2)));
5604
+ break;
5605
+ }
5606
+ if (slash > at2) bytes.push(...utf8.encode(body.slice(at2, slash)));
5607
+ const octal = OCTAL.exec(body.slice(slash + 1, slash + 4));
5608
+ if (octal) {
5609
+ bytes.push(Number.parseInt(octal[0], 8));
5610
+ at2 = slash + 4;
5611
+ continue;
5612
+ }
5613
+ const next = body[slash + 1];
5614
+ if (next === void 0) {
5615
+ bytes.push(ESCAPES["\\"]);
5616
+ break;
5617
+ }
5618
+ const mapped = ESCAPES[next];
5619
+ if (mapped === void 0) bytes.push(...utf8.encode(next));
5620
+ else bytes.push(mapped);
5621
+ at2 = slash + 2;
5622
+ }
5623
+ return new TextDecoder().decode(Uint8Array.from(bytes));
5624
+ }
5625
+
5626
+ // src/commands/match/symbol-ranges.ts
5627
+ async function resolveSymbolRanges(repoRoot, files, records, offline = false) {
5628
+ const changed = new Set(files.map((file) => strip(file.filePath)));
5629
+ const wanted = [];
5630
+ const seen = /* @__PURE__ */ new Set();
5631
+ for (const record of records) {
5632
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
5633
+ if (!anchor.symbol || anchor.repo) continue;
5634
+ if (!changed.has(strip(anchor.file))) continue;
5635
+ const key2 = `${strip(anchor.file)}#${anchor.symbol}`;
5636
+ if (seen.has(key2)) continue;
5637
+ seen.add(key2);
5638
+ wanted.push(anchor);
5639
+ }
5640
+ }
5641
+ if (!wanted.length) return [];
5642
+ const paths = [...new Set(wanted.map((anchor) => anchor.file))];
5643
+ const sources = await readAnchorFiles(paths, anchorFileReader(repoRoot));
5644
+ const resolvers = defaultAnchorResolvers({ offline });
5645
+ await prepareResolvers(resolvers, paths);
5646
+ const ranges = [];
5647
+ for (const anchor of wanted) {
5648
+ const read = sources.get(anchor.file);
5649
+ if (!read?.ok) continue;
5650
+ const outcome = resolveAnchorSpan(read.source, anchor, resolvers);
5651
+ if (!outcome.ok) continue;
5652
+ ranges.push({
5653
+ file: anchor.file,
5654
+ symbol: anchor.symbol,
5655
+ startLine: outcome.span.startLine,
5656
+ endLine: outcome.span.endLine
5657
+ });
5658
+ }
5659
+ return ranges;
5660
+ }
5661
+ function strip(path) {
5662
+ return path.replace(/^\.\//, "");
5663
+ }
5664
+
5665
+ // src/commands/match/command.ts
5666
+ var matchCommand = define({
5667
+ name: "match",
5668
+ tool: "kb_match",
5669
+ usage: "match --git <base>..<head> | --stdin [--repo-root <path>] [--offline] [--include-non-current]",
5670
+ 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.",
5671
+ input: z15.object({
5672
+ bundlePath,
5673
+ files: z15.array(diffFileSchema).describe("The changed files, each with its post-change line ranges."),
5674
+ symbolRanges: z15.array(symbolRangeSchema).optional().describe(
5675
+ "Symbol spans the caller already has. Resolved from repoRoot when omitted."
5676
+ ),
5677
+ repoRoot: REPO_ROOT,
5678
+ offline: z15.boolean().optional().describe(
5679
+ "Resolve symbol ranges from what is already on disk, never fetching a grammar."
5680
+ ),
5681
+ includeNonCurrent: z15.boolean().optional().describe(
5682
+ "Return superseded, rejected and unsettled records too, each carrying its standing."
5683
+ )
5684
+ }),
5685
+ fromArgv: async (argv, path, stdin) => {
5686
+ const repoRoot = argvFlag(argv, "--repo-root");
5687
+ const range = argvFlag(argv, "--git");
5688
+ const base2 = {
5689
+ bundlePath: path,
5690
+ ...repoRoot !== void 0 ? { repoRoot } : {},
5691
+ ...argv.includes("--offline") ? { offline: true } : {},
5692
+ ...argv.includes("--include-non-current") ? { includeNonCurrent: true } : {}
5693
+ };
5694
+ if (range !== void 0) {
5695
+ const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
5696
+ if (!diff.ok) {
5697
+ throw new KbMatchInputError(`--git ${range} ${REFUSED[diff.reason]}`);
5698
+ }
5699
+ return { ...base2, files: parseUnifiedDiff(diff.text) };
5700
+ }
5701
+ if (!argv.includes("--stdin")) {
5702
+ throw new KbMatchInputError(
5703
+ "pass --git <base>..<head>, or --stdin with { files } as JSON"
5704
+ );
5705
+ }
5706
+ return { ...base2, ...fromStdin(await stdin()) };
5707
+ },
5708
+ run: async ({ store }, {
5709
+ bundlePath: path,
5710
+ files,
5711
+ symbolRanges,
5712
+ repoRoot,
5713
+ offline,
5714
+ includeNonCurrent
5715
+ }) => {
5716
+ const records = await store.list(path);
5717
+ const ranges = symbolRanges ?? await resolveSymbolRanges(
5718
+ repoRoot ?? process.cwd(),
5719
+ files,
5720
+ records,
5721
+ offline === true
5722
+ );
5723
+ const index2 = symbolRangeIndex(ranges);
5724
+ return matchToDiff(files, records, { symbolRanges: ranges }).flatMap(
5725
+ (match) => project(match, index2, includeNonCurrent === true)
5726
+ );
5727
+ }
5728
+ });
5729
+ var REFUSED = {
5730
+ "bad-range": "is not a range git could read here \u2014 both halves of <base>..<head> are required",
5731
+ "too-large": "diffs to a patch past the output cap \u2014 narrow the range",
5732
+ timeout: "took longer to diff than the runner allows \u2014 narrow the range",
5733
+ "git-missing": "needs git on PATH, and there is none"
5734
+ };
5735
+ function fromStdin(text) {
5736
+ let payload;
5737
+ try {
5738
+ payload = JSON.parse(text);
5739
+ } catch {
5740
+ throw new KbMatchInputError("stdin is not JSON");
5741
+ }
5742
+ if (!Array.isArray(payload?.files)) {
5743
+ throw new KbMatchInputError("stdin needs a files array");
5744
+ }
5745
+ return {
5746
+ files: payload.files,
5747
+ ...payload.symbolRanges !== void 0 ? { symbolRanges: payload.symbolRanges } : {}
5748
+ };
5749
+ }
5750
+ function project(match, ranges, all) {
5751
+ const kept = all ? match.records : match.records.filter((hit) => hit.standing === "current");
5752
+ if (!kept.length) return [];
5753
+ const placed = kept.map((hit) => ({
5754
+ hit,
5755
+ at: placeOnHunk(hit.record, match.filePath, match.hunk, ranges)
5756
+ }));
5757
+ return [
5758
+ {
5759
+ filePath: match.filePath,
5760
+ hunk: match.hunk,
5761
+ // Over the records returned, not the ones matched: a hunk holding only
5762
+ // symbol-placed records is not `file` because a dropped one was.
5763
+ precision: placed.every(({ at: at2 }) => at2.kind === "symbol") ? "symbol" : "file",
5764
+ records: placed.map(({ hit, at: { anchor } }) => {
5765
+ const { frontmatter } = hit.record;
5766
+ return {
5767
+ conceptId: hit.record.conceptId,
5768
+ type: frontmatter.type,
5769
+ title: frontmatter.title ?? null,
5770
+ standing: hit.standing,
5771
+ status: frontmatter.strauss_status,
5772
+ supersededBy: hit.heads.map((head) => head.conceptId),
5773
+ ...frontmatter.strauss_materiality ? { materiality: frontmatter.strauss_materiality } : {},
5774
+ ...frontmatter.strauss_confidence ? { confidence: frontmatter.strauss_confidence } : {},
5775
+ ...frontmatter.tags?.length ? { tags: frontmatter.tags } : {},
5776
+ ...anchor ? { anchor } : {}
5777
+ };
5778
+ })
5779
+ }
5780
+ ];
5781
+ }
5782
+
5783
+ // src/commands/classify.ts
5784
+ var classifyFileSchema = diffFileSchema.extend({
5785
+ hunks: z16.array(
5786
+ diffHunkSchema.extend({ lines: z16.array(z16.string()).optional() })
5787
+ ),
5788
+ renamedFrom: z16.string().min(1).optional().describe("Where `git diff -M` says the path came from."),
5789
+ similarity: z16.number().min(0).max(100).optional()
5790
+ });
5791
+ var classifyCommand = define({
5792
+ name: "classify",
5793
+ tool: "kb_classify",
5794
+ usage: "classify --git <base>..<head> | --stdin [--repo-root <path>] [--offline]",
5795
+ 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.",
5796
+ input: z16.object({
5797
+ bundlePath,
5798
+ files: z16.array(classifyFileSchema).describe("The changed files, each with its line ranges."),
5799
+ repoRoot: REPO_ROOT,
5800
+ offline: z16.boolean().optional().describe(
5801
+ "Resolve symbol ranges from what is already on disk, never fetching a grammar."
5802
+ )
5803
+ }),
5804
+ fromArgv: async (argv, path, stdin) => {
5805
+ const repoRoot = argvFlag(argv, "--repo-root");
5806
+ const range = argvFlag(argv, "--git");
5807
+ const base2 = {
5808
+ bundlePath: path,
5809
+ ...repoRoot !== void 0 ? { repoRoot } : {},
5810
+ ...argv.includes("--offline") ? { offline: true } : {}
5811
+ };
5812
+ if (range !== void 0) {
5813
+ const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
5814
+ if (!diff.ok) {
5815
+ throw new KbClassifyInputError(
5816
+ `--git ${range} ${REFUSED2[diff.reason]}`
5817
+ );
5818
+ }
5819
+ return {
5820
+ ...base2,
5821
+ files: parseUnifiedDiff(diff.text, {
5822
+ keepEmpty: true,
5823
+ withLines: true
5824
+ })
5825
+ };
5826
+ }
5827
+ if (!argv.includes("--stdin")) {
5828
+ throw new KbClassifyInputError(
5829
+ "pass --git <base>..<head>, or --stdin with { files } as JSON"
5830
+ );
5831
+ }
5832
+ return { ...base2, files: fromStdin2(await stdin()) };
5833
+ },
5834
+ run: async ({ store }, { bundlePath: path, files, repoRoot, offline }) => {
5835
+ const records = await store.list(path);
5836
+ const root = repoRoot ?? process.cwd();
5837
+ const withHeaders = await mapLimit2(files, READERS, async (file) => ({
5838
+ ...file,
5839
+ header: await header(root, file)
5840
+ }));
5841
+ const symbolRanges = await resolveSymbolRanges(
5842
+ root,
5843
+ files,
5844
+ records,
5845
+ offline === true
5846
+ );
5847
+ return { files: classifyDiff(withHeaders, { records, symbolRanges }) };
5848
+ },
5849
+ render: (result) => renderClassify(result)
5850
+ });
5851
+ var HEADER_BYTES = 65536;
5852
+ var READERS = 16;
5853
+ async function header(root, file) {
5854
+ if (!filePathIsSafe(file.filePath)) return void 0;
5855
+ let handle;
5856
+ try {
5857
+ handle = await open(join6(root, file.filePath), "r");
5858
+ const buffer = Buffer.alloc(HEADER_BYTES);
5859
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_BYTES, 0);
5860
+ return buffer.toString("utf8", 0, bytesRead).split("\n").slice(0, HEADER_LINES);
5861
+ } catch {
5862
+ return void 0;
5863
+ } finally {
5864
+ await handle?.close();
5865
+ }
5866
+ }
5867
+ async function mapLimit2(items, limit, run) {
5868
+ const out = Array.from({ length: items.length });
5869
+ let next = 0;
5870
+ const worker = async () => {
5871
+ while (next < items.length) {
5872
+ const at2 = next;
5873
+ next += 1;
5874
+ out[at2] = await run(items[at2]);
5875
+ }
5876
+ };
5877
+ await Promise.all(
5878
+ Array.from({ length: Math.min(limit, items.length) }, () => worker())
5879
+ );
5880
+ return out;
5881
+ }
5882
+ var REFUSED2 = {
5883
+ "bad-range": "is not a range git could read here \u2014 both halves of <base>..<head> are required",
5884
+ "too-large": "diffs to a patch past the output cap \u2014 narrow the range",
5885
+ timeout: "took longer to diff than the runner allows \u2014 narrow the range",
5886
+ "git-missing": "needs git on PATH, and there is none"
5887
+ };
5888
+ function fromStdin2(text) {
5889
+ let payload;
5890
+ try {
5891
+ payload = JSON.parse(text);
5892
+ } catch {
5893
+ throw new KbClassifyInputError("stdin is not JSON");
5894
+ }
5895
+ if (!Array.isArray(payload?.files)) {
5896
+ throw new KbClassifyInputError("stdin needs a files array");
5897
+ }
5898
+ return payload.files;
5899
+ }
5900
+ function renderClassify(result) {
5901
+ const width2 = Math.max(
5902
+ 0,
5903
+ ...result.files.map((file) => file.class.length)
5904
+ );
5905
+ return result.files.map(
5906
+ (file) => `${file.class.padEnd(width2)} ${file.filePath} (${file.reason})`
5907
+ ).join("\n");
5908
+ }
5909
+
4346
5910
  // src/commands/context.ts
4347
- import { z as z13 } from "zod";
5911
+ import { z as z17 } from "zod";
4348
5912
  var contextCommand = define({
4349
5913
  name: "context",
4350
5914
  tool: "kb_context",
4351
5915
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
4352
5916
  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.",
4353
- input: z13.object({
4354
- budgetTokens: z13.number().int().positive().optional().describe(
5917
+ input: z17.object({
5918
+ budgetTokens: z17.number().int().positive().optional().describe(
4355
5919
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
4356
5920
  ),
4357
- fullUnderTokens: z13.number().int().positive().optional().describe(
5921
+ fullUnderTokens: z17.number().int().positive().optional().describe(
4358
5922
  "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."
4359
5923
  ),
4360
- profile: z13.string().optional().describe(
5924
+ profile: z17.string().optional().describe(
4361
5925
  "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."
4362
5926
  ),
4363
- excludeTags: z13.array(z13.string().min(1)).optional().describe(
5927
+ excludeTags: z17.array(z17.string().min(1)).optional().describe(
4364
5928
  "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
4365
5929
  ),
4366
- format: z13.enum(["markdown", "json"]).optional().describe(
5930
+ format: z17.enum(["markdown", "json"]).optional().describe(
4367
5931
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
4368
5932
  ),
4369
- event: z13.string().optional().describe(
5933
+ event: z17.string().optional().describe(
4370
5934
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
4371
5935
  )
4372
5936
  }),
@@ -4405,20 +5969,20 @@ var contextCommand = define({
4405
5969
  });
4406
5970
 
4407
5971
  // src/commands/doctor.ts
4408
- import { z as z15 } from "zod";
5972
+ import { z as z19 } from "zod";
4409
5973
 
4410
5974
  // src/commands/reassess.ts
4411
- import { z as z14 } from "zod";
5975
+ import { z as z18 } from "zod";
4412
5976
  var reassessCommand = define({
4413
5977
  name: "reassess",
4414
5978
  tool: "kb_reassess",
4415
5979
  usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
4416
5980
  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.",
4417
- input: z14.object({
5981
+ input: z18.object({
4418
5982
  bundlePath,
4419
5983
  conceptId,
4420
5984
  repoRoot: REPO_ROOT,
4421
- withDiff: z14.boolean().optional().describe(
5985
+ withDiff: z18.boolean().optional().describe(
4422
5986
  "Recover each anchor's committed span and render the diff. Reads git history."
4423
5987
  )
4424
5988
  }),
@@ -4461,7 +6025,10 @@ var reassessCommand = define({
4461
6025
  relocated.set(found.anchor, {
4462
6026
  ...found.anchor,
4463
6027
  file: to.file,
4464
- ...to.symbol ? { symbol: to.symbol } : {}
6028
+ ...to.symbol ? { symbol: to.symbol } : {},
6029
+ // A span is the anchor's whole address, so relocating it means
6030
+ // moving the line range the same code now occupies.
6031
+ ...found.anchor.span ? { span: { start: to.startLine, end: to.endLine } } : {}
4465
6032
  });
4466
6033
  rebaselined.push({
4467
6034
  file: found.anchor.file,
@@ -4560,13 +6127,13 @@ function at(file, symbol) {
4560
6127
  }
4561
6128
 
4562
6129
  // src/commands/doctor.ts
4563
- var days = (what, fallback) => z15.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
6130
+ var days = (what, fallback) => z19.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
4564
6131
  var doctorCommand = define({
4565
6132
  name: "doctor",
4566
6133
  tool: "kb_doctor",
4567
6134
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
4568
6135
  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.",
4569
- input: z15.object({
6136
+ input: z19.object({
4570
6137
  bundlePath,
4571
6138
  repoRoot: REPO_ROOT,
4572
6139
  expiringDays: days(
@@ -4581,16 +6148,16 @@ var doctorCommand = define({
4581
6148
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
4582
6149
  DEFAULT_AGING_DAYS
4583
6150
  ),
4584
- offline: z15.boolean().optional().describe(
6151
+ offline: z19.boolean().optional().describe(
4585
6152
  "Read foreign anchors from the local repo cache only, never fetching."
4586
6153
  ),
4587
- strict: z15.boolean().optional().describe(
6154
+ strict: z19.boolean().optional().describe(
4588
6155
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
4589
6156
  ),
4590
- drifted: z15.boolean().optional().describe(
6157
+ drifted: z19.boolean().optional().describe(
4591
6158
  "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
4592
6159
  ),
4593
- withDiff: z15.boolean().optional().describe(
6160
+ withDiff: z19.boolean().optional().describe(
4594
6161
  "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
4595
6162
  )
4596
6163
  }),
@@ -4646,7 +6213,7 @@ var doctorCommand = define({
4646
6213
  ...hints.length ? { hints } : {}
4647
6214
  };
4648
6215
  }
4649
- const standings = new Map(
6216
+ const standings2 = new Map(
4650
6217
  adjudicate(records, records, new Date(checkedAt)).map((hit) => [
4651
6218
  hit.record.conceptId,
4652
6219
  hit.standing
@@ -4660,7 +6227,7 @@ var doctorCommand = define({
4660
6227
  (entry) => entry.conceptId === found.conceptId
4661
6228
  );
4662
6229
  if (!record) continue;
4663
- const standing = standings.get(record.conceptId);
6230
+ const standing = standings2.get(record.conceptId);
4664
6231
  const built = await reassessPacket(
4665
6232
  repoRoot ?? process.cwd(),
4666
6233
  record,
@@ -4697,15 +6264,16 @@ var doctorCommand = define({
4697
6264
  });
4698
6265
  function render2(result) {
4699
6266
  if (result.packets) return renderPackets(result);
4700
- const { thresholds } = result;
6267
+ const { thresholds, anchorResolvers: counts } = result;
4701
6268
  const lines = [
4702
6269
  `# KB Doctor \u2014 ${result.bundlePath}`,
4703
6270
  `records: ${result.recordCount}`,
4704
6271
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
4705
6272
  `checked: ${result.checkedAt}`,
4706
- ...result.anchorResolvers.total ? [
4707
- `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
4708
- ] : [],
6273
+ ...counts.total ? [anchorLine(counts)] : [],
6274
+ // Its own line: an old-side anchor may name a whole file, which no
6275
+ // resolver bucket and no `total` counts.
6276
+ ...counts.oldSide ? [`old-side anchors: ${counts.oldSide}`] : [],
4709
6277
  ""
4710
6278
  ];
4711
6279
  const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
@@ -4730,6 +6298,14 @@ function render2(result) {
4730
6298
  );
4731
6299
  return lines.join("\n");
4732
6300
  }
6301
+ function anchorLine(counts) {
6302
+ const parts = [
6303
+ `${counts.treeSitter} tree-sitter`,
6304
+ `${counts.regex} regex`,
6305
+ ...counts.span ? [`${counts.span} span`] : []
6306
+ ];
6307
+ return `anchors: ${counts.total} hashed \u2014 ${parts.join(", ")}`;
6308
+ }
4733
6309
  function renderPackets(result) {
4734
6310
  const packets = result.packets ?? [];
4735
6311
  const lines = [
@@ -4755,20 +6331,162 @@ function renderPackets(result) {
4755
6331
  return lines.join("\n");
4756
6332
  }
4757
6333
 
6334
+ // src/commands/export.ts
6335
+ import {
6336
+ mkdir as mkdir4,
6337
+ readFile as readFile6,
6338
+ readdir,
6339
+ rename as rename2,
6340
+ unlink,
6341
+ writeFile as writeFile4
6342
+ } from "fs/promises";
6343
+ import { join as join7 } from "path";
6344
+ import { z as z20 } from "zod";
6345
+ var NUMBERED = /^(\d{4})-(.+)\.md$/;
6346
+ var MARKER = "<!-- strauss-kb export: ";
6347
+ var exportCommand = define({
6348
+ name: "export",
6349
+ tool: "kb_export",
6350
+ usage: "export --format madr --to <dir>",
6351
+ 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.",
6352
+ input: z20.object({
6353
+ bundlePath,
6354
+ format: z20.enum(["madr"]).describe("Output layout. `madr` is the only one so far."),
6355
+ to: z20.string().min(1).describe("Directory the ADR files are written into.")
6356
+ }),
6357
+ fromArgv: (argv, path) => ({
6358
+ bundlePath: path,
6359
+ format: argvFlag(argv, "--format"),
6360
+ to: argvFlag(argv, "--to")
6361
+ }),
6362
+ run: async ({ store }, { bundlePath: path, to }) => {
6363
+ const bundle = await store.list(path);
6364
+ const decisions = selectDecisions(bundle).sort(
6365
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
6366
+ );
6367
+ const adjudicated = new Map(
6368
+ adjudicate(decisions, bundle).map((hit) => [hit.record.conceptId, hit])
6369
+ );
6370
+ await mkdir4(to, { recursive: true });
6371
+ const taken = await existingFiles(to);
6372
+ let next = Math.max(0, ...[...taken.values()].map((row) => row.number)) + 1;
6373
+ const exported = [];
6374
+ const foreign = [];
6375
+ for (const record of decisions) {
6376
+ const slug = record.conceptId.slice(record.conceptId.indexOf(".") + 1);
6377
+ const held = taken.get(slug);
6378
+ if (held && !held.ours) {
6379
+ foreign.push({ conceptId: record.conceptId, file: held.file });
6380
+ continue;
6381
+ }
6382
+ const number = held?.number ?? next++;
6383
+ const file = `${String(number).padStart(4, "0")}-${slug}.md`;
6384
+ const status = statusLine(adjudicated.get(record.conceptId));
6385
+ await publish(join7(to, file), renderMadr(record, status));
6386
+ exported.push({ conceptId: record.conceptId, file, status });
6387
+ }
6388
+ return { to, format: "madr", exported, foreign };
6389
+ },
6390
+ render: (result) => {
6391
+ const { exported, foreign, to } = result;
6392
+ return [
6393
+ `Wrote ${exported.length} MADR file${exported.length === 1 ? "" : "s"} to ${to}.`,
6394
+ ...exported.map(
6395
+ (entry) => `- ${entry.file} ${entry.conceptId} [${entry.status}]`
6396
+ ),
6397
+ ...foreign.map(
6398
+ (entry) => `- skipped ${entry.conceptId}: ${entry.file} was not written by export`
6399
+ )
6400
+ ].join("\n");
6401
+ }
6402
+ });
6403
+ async function publish(target, contents) {
6404
+ const staging = `${target}.${process.pid}.tmp`;
6405
+ await writeFile4(staging, contents, "utf8");
6406
+ try {
6407
+ await rename2(staging, target);
6408
+ } catch (error) {
6409
+ await unlink(staging).catch(() => void 0);
6410
+ throw error;
6411
+ }
6412
+ }
6413
+ async function existingFiles(to) {
6414
+ const names = await readdir(to).catch(() => []);
6415
+ const taken = /* @__PURE__ */ new Map();
6416
+ for (const name of names.sort()) {
6417
+ const [, number, slug] = NUMBERED.exec(name) ?? [];
6418
+ if (!number || !slug) continue;
6419
+ const text = await readFile6(join7(to, name), "utf8").catch(() => "");
6420
+ taken.set(slug, {
6421
+ file: name,
6422
+ number: Number(number),
6423
+ ours: text.includes(MARKER)
6424
+ });
6425
+ }
6426
+ return taken;
6427
+ }
6428
+ function statusLine(hit) {
6429
+ const status = hit?.record.frontmatter.strauss_status ?? "draft";
6430
+ if (status !== "superseded") return status;
6431
+ const by = (hit?.heads ?? []).map((head) => head.conceptId);
6432
+ return by.length ? `superseded by ${by.join(", ")}` : "superseded";
6433
+ }
6434
+ function renderMadr(record, status) {
6435
+ const sections = bodySections(record.body);
6436
+ const blocks = [
6437
+ `# ${record.frontmatter.title ?? record.conceptId}`,
6438
+ "## Status",
6439
+ status
6440
+ ];
6441
+ push(blocks, "Context and Problem Statement", record.frontmatter.description);
6442
+ push(blocks, "Considered Options", sections.get("Rejected"));
6443
+ push(blocks, "Decision Outcome", sections.get("Decision"));
6444
+ push(blocks, "Consequences", sections.get("Impact"));
6445
+ blocks.push(`${MARKER}${record.conceptId} -->`);
6446
+ return `${blocks.join("\n\n")}
6447
+ `;
6448
+ }
6449
+ function push(blocks, heading, text) {
6450
+ if (text?.trim()) blocks.push(`## ${heading}`, text.trim());
6451
+ }
6452
+ function bodySections(body) {
6453
+ const generated = new RegExp(
6454
+ `^(?:(?:${Object.values(LINK_RELS).map((spec) => spec.phrase).join("|")}) \\[[^\\]]+\\]\\([^)]+\\.md\\)\\.|\\[\\^[^\\]]+\\]: .*)$`
6455
+ );
6456
+ const sections = /* @__PURE__ */ new Map();
6457
+ let heading = null;
6458
+ let lines = [];
6459
+ const flush = () => {
6460
+ if (heading) sections.set(heading, lines.join("\n").trim());
6461
+ };
6462
+ for (const line of body.split("\n")) {
6463
+ const match = /^## (.+?)\s*$/.exec(line);
6464
+ if (match) {
6465
+ flush();
6466
+ heading = match[1] ?? null;
6467
+ lines = [];
6468
+ } else if (heading && !generated.test(line)) {
6469
+ lines.push(line);
6470
+ }
6471
+ }
6472
+ flush();
6473
+ return sections;
6474
+ }
6475
+
4758
6476
  // src/commands/impact.ts
4759
- import { z as z16 } from "zod";
6477
+ import { z as z21 } from "zod";
4760
6478
  var impactCommand = define({
4761
6479
  name: "impact",
4762
6480
  tool: "kb_impact",
4763
6481
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
4764
6482
  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.",
4765
- input: z16.object({
6483
+ input: z21.object({
4766
6484
  bundlePath,
4767
6485
  conceptId,
4768
- depth: z16.number().int().positive().optional().describe(
6486
+ depth: z21.number().int().positive().optional().describe(
4769
6487
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
4770
6488
  ),
4771
- rels: z16.array(z16.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
6489
+ rels: z21.array(z21.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
4772
6490
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
4773
6491
  )
4774
6492
  }),
@@ -4789,15 +6507,15 @@ var impactCommand = define({
4789
6507
  });
4790
6508
 
4791
6509
  // src/commands/list.ts
4792
- import { z as z17 } from "zod";
6510
+ import { z as z22 } from "zod";
4793
6511
  var listCommand = define({
4794
6512
  name: "list",
4795
6513
  tool: "kb_list",
4796
6514
  usage: "list [type] [--tag T]...",
4797
6515
  description: "Every record, optionally one type or tag. For enumerating; use kb_query for a question.",
4798
- input: z17.object({
6516
+ input: z22.object({
4799
6517
  bundlePath,
4800
- type: z17.enum(KB_RECORD_TYPES).optional(),
6518
+ type: z22.enum(KB_RECORD_TYPES).optional(),
4801
6519
  tags: TAGS
4802
6520
  }),
4803
6521
  fromArgv: (argv, path) => {
@@ -4821,17 +6539,17 @@ var listCommand = define({
4821
6539
  });
4822
6540
 
4823
6541
  // src/commands/load.ts
4824
- import { z as z18 } from "zod";
6542
+ import { z as z23 } from "zod";
4825
6543
  var loadCommand = define({
4826
6544
  name: "load",
4827
6545
  tool: "kb_load",
4828
6546
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
4829
6547
  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.",
4830
- input: z18.object({
6548
+ input: z23.object({
4831
6549
  bundlePath,
4832
- type: z18.enum(KB_RECORD_TYPES).optional(),
4833
- budgetTokens: z18.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
4834
- all: z18.boolean().optional().describe(
6550
+ type: z23.enum(KB_RECORD_TYPES).optional(),
6551
+ budgetTokens: z23.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6552
+ all: z23.boolean().optional().describe(
4835
6553
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
4836
6554
  ),
4837
6555
  repoRoot: REPO_ROOT
@@ -4873,25 +6591,25 @@ var loadCommand = define({
4873
6591
  });
4874
6592
 
4875
6593
  // src/commands/log.ts
4876
- import { z as z19 } from "zod";
6594
+ import { z as z24 } from "zod";
4877
6595
  var logCommand = define({
4878
6596
  name: "log",
4879
6597
  tool: "kb_log",
4880
6598
  usage: "log",
4881
6599
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
4882
- input: z19.object({ bundlePath }),
6600
+ input: z24.object({ bundlePath }),
4883
6601
  fromArgv: (_argv, path) => ({ bundlePath: path }),
4884
6602
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
4885
6603
  });
4886
6604
 
4887
6605
  // src/commands/no-decision.ts
4888
- import { z as z20 } from "zod";
6606
+ import { z as z25 } from "zod";
4889
6607
  var noDecisionCommand = define({
4890
6608
  name: "no-decision",
4891
6609
  tool: "kb_no_decision",
4892
6610
  usage: "no-decision <reason...>",
4893
6611
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
4894
- input: z20.object({ bundlePath, reason: z20.string().min(1) }),
6612
+ input: z25.object({ bundlePath, reason: z25.string().min(1) }),
4895
6613
  fromArgv: (argv, path) => ({
4896
6614
  bundlePath: path,
4897
6615
  reason: argv.slice(1).join(" ").trim()
@@ -4908,20 +6626,20 @@ var noDecisionCommand = define({
4908
6626
  });
4909
6627
 
4910
6628
  // src/commands/pack.ts
4911
- import { z as z21 } from "zod";
6629
+ import { z as z26 } from "zod";
4912
6630
  var packCommand = define({
4913
6631
  name: "pack",
4914
6632
  tool: "kb_pack",
4915
6633
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
4916
6634
  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.",
4917
- input: z21.object({
6635
+ input: z26.object({
4918
6636
  bundlePath,
4919
6637
  conceptId,
4920
- hops: z21.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
4921
- maxNodes: z21.number().int().positive().optional().describe(
6638
+ hops: z26.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6639
+ maxNodes: z26.number().int().positive().optional().describe(
4922
6640
  "How many records the pack may hold, root included. Defaults to 20."
4923
6641
  ),
4924
- budgetTokens: z21.number().int().positive().optional().describe(
6642
+ budgetTokens: z26.number().int().positive().optional().describe(
4925
6643
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
4926
6644
  )
4927
6645
  }),
@@ -5008,22 +6726,22 @@ function warningLabel(warning) {
5008
6726
  }
5009
6727
 
5010
6728
  // src/commands/pin.ts
5011
- import { z as z22 } from "zod";
6729
+ import { z as z27 } from "zod";
5012
6730
  var pinCommand = define({
5013
6731
  name: "pin",
5014
6732
  tool: "kb_pin",
5015
6733
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
5016
6734
  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.",
5017
- input: z22.object({
6735
+ input: z27.object({
5018
6736
  bundlePath,
5019
- mode: z22.enum(["full", "index"]).optional().describe(
6737
+ mode: z27.enum(["full", "index"]).optional().describe(
5020
6738
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
5021
6739
  ),
5022
- profiles: z22.array(z22.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
5023
- layer: z22.enum(["project", "local", "user"]).optional().describe(
6740
+ profiles: z27.array(z27.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6741
+ layer: z27.enum(["project", "local", "user"]).optional().describe(
5024
6742
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
5025
6743
  ),
5026
- frozen: z22.boolean().optional().describe(
6744
+ frozen: z27.boolean().optional().describe(
5027
6745
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
5028
6746
  )
5029
6747
  }),
@@ -5052,29 +6770,29 @@ var pinCommand = define({
5052
6770
  });
5053
6771
 
5054
6772
  // src/commands/pins.ts
5055
- import { z as z23 } from "zod";
6773
+ import { z as z28 } from "zod";
5056
6774
  var pinsCommand = define({
5057
6775
  name: "pins",
5058
6776
  tool: "kb_pins",
5059
6777
  usage: "pins",
5060
6778
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
5061
- input: z23.object({}),
6779
+ input: z28.object({}),
5062
6780
  fromArgv: () => ({}),
5063
6781
  run: ({ store }) => listPins(store, process.cwd())
5064
6782
  });
5065
6783
 
5066
6784
  // src/commands/query.ts
5067
- import { z as z24 } from "zod";
6785
+ import { z as z29 } from "zod";
5068
6786
  var queryCommand = define({
5069
6787
  name: "query",
5070
6788
  tool: "kb_query",
5071
6789
  usage: "query <text...> [--tag T]... [--repo-root PATH]",
5072
6790
  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.",
5073
- input: z24.object({
6791
+ input: z29.object({
5074
6792
  bundlePath,
5075
- text: z24.string().optional(),
5076
- type: z24.enum(KB_RECORD_TYPES).optional(),
5077
- includeNonCurrent: z24.boolean().optional(),
6793
+ text: z29.string().optional(),
6794
+ type: z29.enum(KB_RECORD_TYPES).optional(),
6795
+ includeNonCurrent: z29.boolean().optional(),
5078
6796
  tags: TAGS,
5079
6797
  repoRoot: REPO_ROOT
5080
6798
  }),
@@ -5108,43 +6826,43 @@ var queryCommand = define({
5108
6826
  });
5109
6827
 
5110
6828
  // src/commands/read-index.ts
5111
- import { z as z25 } from "zod";
6829
+ import { z as z30 } from "zod";
5112
6830
  var readIndexCommand = define({
5113
6831
  name: "index",
5114
6832
  tool: "kb_index",
5115
6833
  usage: "index",
5116
6834
  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.",
5117
- input: z25.object({ bundlePath }),
6835
+ input: z30.object({ bundlePath }),
5118
6836
  fromArgv: (_argv, path) => ({ bundlePath: path }),
5119
6837
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
5120
6838
  });
5121
6839
 
5122
6840
  // src/commands/schema.ts
5123
- import { z as z26 } from "zod";
6841
+ import { z as z31 } from "zod";
5124
6842
  var schemaCommand = define({
5125
6843
  name: "schema",
5126
6844
  tool: "kb_schema",
5127
6845
  usage: "schema",
5128
6846
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
5129
- input: z26.object({}),
6847
+ input: z31.object({}),
5130
6848
  fromArgv: () => ({}),
5131
6849
  run: () => Promise.resolve(kbJsonSchemas())
5132
6850
  });
5133
6851
 
5134
6852
  // src/commands/stamp.ts
5135
- import { readFile as readFile6 } from "fs/promises";
5136
- import { z as z27 } from "zod";
6853
+ import { readFile as readFile7 } from "fs/promises";
6854
+ import { z as z32 } from "zod";
5137
6855
  var DIGEST = /^[0-9a-f]{64}$/;
5138
6856
  var stampCommand = define({
5139
6857
  name: "stamp",
5140
6858
  tool: "kb_stamp",
5141
6859
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
5142
6860
  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.",
5143
- input: z27.object({
5144
- bundlePath: z27.string().min(1).optional().describe(
6861
+ input: z32.object({
6862
+ bundlePath: z32.string().min(1).optional().describe(
5145
6863
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
5146
6864
  ),
5147
- since: z27.string().min(1).optional().describe(
6865
+ since: z32.string().min(1).optional().describe(
5148
6866
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
5149
6867
  )
5150
6868
  }),
@@ -5206,7 +6924,7 @@ async function readBaseline(since) {
5206
6924
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
5207
6925
  let parsed;
5208
6926
  try {
5209
- parsed = JSON.parse(await readFile6(since, "utf8"));
6927
+ parsed = JSON.parse(await readFile7(since, "utf8"));
5210
6928
  } catch {
5211
6929
  throw new KbStampBaselineError(since);
5212
6930
  }
@@ -5230,16 +6948,16 @@ async function readBaseline(since) {
5230
6948
  }
5231
6949
 
5232
6950
  // src/commands/status.ts
5233
- import { z as z28 } from "zod";
6951
+ import { z as z33 } from "zod";
5234
6952
  var statusCommand = define({
5235
6953
  name: "status",
5236
6954
  tool: "kb_status",
5237
6955
  usage: "status <concept-id> <status>",
5238
6956
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
5239
- input: z28.object({
6957
+ input: z33.object({
5240
6958
  bundlePath,
5241
6959
  conceptId,
5242
- status: z28.enum(KB_RECORD_STATUSES)
6960
+ status: z33.enum(KB_RECORD_STATUSES)
5243
6961
  }),
5244
6962
  fromArgv: (argv, path) => ({
5245
6963
  bundlePath: path,
@@ -5254,13 +6972,13 @@ var statusCommand = define({
5254
6972
  });
5255
6973
 
5256
6974
  // src/commands/supersede.ts
5257
- import { z as z29 } from "zod";
6975
+ import { z as z34 } from "zod";
5258
6976
  var supersedeCommand = define({
5259
6977
  name: "supersede",
5260
6978
  tool: "kb_supersede",
5261
6979
  usage: "supersede <concept-id> <replacement-id>",
5262
6980
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
5263
- input: z29.object({ bundlePath, conceptId, replacementId: conceptId }),
6981
+ input: z34.object({ bundlePath, conceptId, replacementId: conceptId }),
5264
6982
  fromArgv: (argv, path) => ({
5265
6983
  bundlePath: path,
5266
6984
  conceptId: argv[1],
@@ -5273,17 +6991,153 @@ var supersedeCommand = define({
5273
6991
  }
5274
6992
  });
5275
6993
 
6994
+ // src/commands/sweep.ts
6995
+ import { z as z35 } from "zod";
6996
+ var TERMINAL = [
6997
+ "resolved",
6998
+ "rejected",
6999
+ "superseded"
7000
+ ];
7001
+ var sweepCommand = define({
7002
+ name: "sweep",
7003
+ tool: "kb_sweep",
7004
+ usage: "sweep --tag <tag> --terminal [--dry-run]",
7005
+ 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.",
7006
+ input: z35.object({
7007
+ bundlePath,
7008
+ tag: z35.string({ error: "sweep needs --tag: it never sweeps a whole base" }).min(1).describe("Only records carrying this tag are considered."),
7009
+ terminal: z35.literal(true, {
7010
+ error: "sweep needs --terminal: it deletes only settled records"
7011
+ }).describe(
7012
+ "Required. Names the only scope sweep deletes: resolved, rejected and superseded records."
7013
+ ),
7014
+ dryRun: z35.boolean().optional().describe("Report what would go, and delete nothing.")
7015
+ }),
7016
+ fromArgv: (argv, path) => ({
7017
+ bundlePath: path,
7018
+ tag: argvFlag(argv, "--tag"),
7019
+ ...argv.includes("--terminal") ? { terminal: true } : {},
7020
+ ...argv.includes("--dry-run") ? { dryRun: true } : {}
7021
+ }),
7022
+ run: async ({ store, actor }, { bundlePath: path, tag, dryRun }) => {
7023
+ const bundle = await store.list(path);
7024
+ const held = holderIndex(bundle);
7025
+ const candidates = adjudicate(bundle, bundle).filter(
7026
+ (hit) => sweepable(hit, tag)
7027
+ );
7028
+ const doomed = new Set(candidates.map((hit) => hit.record.conceptId));
7029
+ let changed = true;
7030
+ while (changed) {
7031
+ changed = false;
7032
+ for (const conceptId2 of [...doomed]) {
7033
+ if (survivorsHolding(conceptId2, held, doomed).length === 0) continue;
7034
+ doomed.delete(conceptId2);
7035
+ changed = true;
7036
+ }
7037
+ }
7038
+ const skipped = candidates.filter((hit) => !doomed.has(hit.record.conceptId)).map((hit) => ({
7039
+ conceptId: hit.record.conceptId,
7040
+ heldBy: survivorsHolding(hit.record.conceptId, held, doomed)
7041
+ }));
7042
+ const ordered = [...doomed].sort();
7043
+ if (dryRun) {
7044
+ return {
7045
+ tag,
7046
+ dryRun: true,
7047
+ deleted: [],
7048
+ candidates: ordered,
7049
+ skipped,
7050
+ failed: []
7051
+ };
7052
+ }
7053
+ await assertBaseNotFrozen(process.cwd(), path);
7054
+ const deleted = [];
7055
+ const failed = [];
7056
+ try {
7057
+ for (const conceptId2 of ordered) {
7058
+ try {
7059
+ const outcome = await store.deleteRecord(
7060
+ path,
7061
+ conceptId2,
7062
+ { tag, statuses: TERMINAL },
7063
+ actor
7064
+ );
7065
+ if (outcome === "deleted") deleted.push(conceptId2);
7066
+ else failed.push({ conceptId: conceptId2, reason: outcome });
7067
+ } catch (error) {
7068
+ failed.push({
7069
+ conceptId: conceptId2,
7070
+ reason: error instanceof Error ? error.message : "unknown"
7071
+ });
7072
+ }
7073
+ }
7074
+ } finally {
7075
+ await store.readIndex(path);
7076
+ await store.dropSearchIndex(path);
7077
+ }
7078
+ return {
7079
+ tag,
7080
+ dryRun: false,
7081
+ deleted,
7082
+ candidates: ordered,
7083
+ skipped,
7084
+ failed
7085
+ };
7086
+ },
7087
+ render: (result) => renderSweep(result)
7088
+ });
7089
+ function sweepable(hit, tag) {
7090
+ const { tags, strauss_status } = hit.record.frontmatter;
7091
+ return (tags ?? []).includes(tag) && // Supersession is a standing, settled against the whole base; the other
7092
+ // two are the record's own word for itself.
7093
+ (hit.standing === "superseded" || strauss_status === "resolved" || strauss_status === "rejected");
7094
+ }
7095
+ function holderIndex(bundle) {
7096
+ const byTarget = /* @__PURE__ */ new Map();
7097
+ const hold = (target, from) => {
7098
+ if (target === from) return;
7099
+ const holders = byTarget.get(target) ?? /* @__PURE__ */ new Set();
7100
+ holders.add(from);
7101
+ byTarget.set(target, holders);
7102
+ };
7103
+ for (const [target, edges] of inboundIndex(bundle)) {
7104
+ for (const edge of edges) hold(target, edge.from);
7105
+ }
7106
+ for (const record of bundle) {
7107
+ const { strauss_supersedes, strauss_superseded_by } = record.frontmatter;
7108
+ for (const old of strauss_supersedes ?? []) hold(old, record.conceptId);
7109
+ if (strauss_superseded_by) hold(strauss_superseded_by, record.conceptId);
7110
+ }
7111
+ return byTarget;
7112
+ }
7113
+ function survivorsHolding(conceptId2, held, doomed) {
7114
+ return [...held.get(conceptId2) ?? []].filter((from) => !doomed.has(from)).sort();
7115
+ }
7116
+ function renderSweep(result) {
7117
+ const shown = result.dryRun ? result.candidates : result.deleted;
7118
+ const verb = result.dryRun ? "would delete" : "deleted";
7119
+ const lines = [`${verb} ${shown.length} (tag: ${result.tag})`];
7120
+ for (const conceptId2 of shown) lines.push(`- ${conceptId2}`);
7121
+ for (const skip of result.skipped) {
7122
+ lines.push(`kept ${skip.conceptId} \u2014 held by ${skip.heldBy.join(", ")}`);
7123
+ }
7124
+ for (const failure of result.failed) {
7125
+ lines.push(`failed ${failure.conceptId} \u2014 ${failure.reason}`);
7126
+ }
7127
+ return lines.join("\n");
7128
+ }
7129
+
5276
7130
  // src/commands/sync-instructions.ts
5277
- import { z as z30 } from "zod";
7131
+ import { z as z36 } from "zod";
5278
7132
  var syncInstructionsCommand = define({
5279
7133
  name: "sync-instructions",
5280
7134
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
5281
7135
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
5282
- input: z30.object({
5283
- file: z30.string().min(1).describe("The instruction file to edit in place."),
5284
- budgetTokens: z30.number().int().positive().optional(),
5285
- fullUnderTokens: z30.number().int().positive().optional(),
5286
- profile: z30.string().optional()
7136
+ input: z36.object({
7137
+ file: z36.string().min(1).describe("The instruction file to edit in place."),
7138
+ budgetTokens: z36.number().int().positive().optional(),
7139
+ fullUnderTokens: z36.number().int().positive().optional(),
7140
+ profile: z36.string().optional()
5287
7141
  }),
5288
7142
  fromArgv: (argv) => {
5289
7143
  const budget = argvFlag(argv, "--budget");
@@ -5309,17 +7163,17 @@ var syncInstructionsCommand = define({
5309
7163
  });
5310
7164
 
5311
7165
  // src/commands/trace.ts
5312
- import { z as z31 } from "zod";
7166
+ import { z as z37 } from "zod";
5313
7167
  var traceCommand = define({
5314
7168
  name: "trace",
5315
7169
  tool: "kb_trace",
5316
7170
  usage: "trace <concept-id> [edges...]",
5317
7171
  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".',
5318
- input: z31.object({
7172
+ input: z37.object({
5319
7173
  bundlePath,
5320
7174
  conceptId,
5321
- edges: z31.array(z31.enum(TRACE_EDGES)).optional(),
5322
- depth: z31.number().int().positive().optional()
7175
+ edges: z37.array(z37.enum(TRACE_EDGES)).optional(),
7176
+ depth: z37.number().int().positive().optional()
5323
7177
  }),
5324
7178
  fromArgv: (argv, path) => ({
5325
7179
  bundlePath: path,
@@ -5341,37 +7195,37 @@ var traceCommand = define({
5341
7195
  });
5342
7196
 
5343
7197
  // src/commands/types.ts
5344
- import { z as z32 } from "zod";
7198
+ import { z as z38 } from "zod";
5345
7199
  var typesCommand = define({
5346
7200
  name: "types",
5347
7201
  tool: "kb_types",
5348
7202
  usage: "types",
5349
7203
  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.",
5350
- input: z32.object({}),
7204
+ input: z38.object({}),
5351
7205
  fromArgv: () => ({}),
5352
7206
  run: () => Promise.resolve(RECORD_TYPES)
5353
7207
  });
5354
7208
 
5355
7209
  // src/commands/unpin.ts
5356
- import { z as z33 } from "zod";
7210
+ import { z as z39 } from "zod";
5357
7211
  var unpinCommand = define({
5358
7212
  name: "unpin",
5359
7213
  tool: "kb_unpin",
5360
7214
  usage: "unpin [bundle-path]",
5361
7215
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
5362
- input: z33.object({ bundlePath }),
7216
+ input: z39.object({ bundlePath }),
5363
7217
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
5364
7218
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
5365
7219
  });
5366
7220
 
5367
7221
  // src/commands/validate.ts
5368
- import { z as z34 } from "zod";
7222
+ import { z as z40 } from "zod";
5369
7223
  var validateCommand = define({
5370
7224
  name: "validate",
5371
7225
  tool: "kb_validate",
5372
7226
  usage: "validate",
5373
7227
  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.",
5374
- input: z34.object({ bundlePath }),
7228
+ input: z40.object({ bundlePath }),
5375
7229
  fromArgv: (_argv, path) => ({ bundlePath: path }),
5376
7230
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
5377
7231
  // Warnings never fail the exit code; every other severity does.
@@ -5381,16 +7235,16 @@ var validateCommand = define({
5381
7235
  });
5382
7236
 
5383
7237
  // src/commands/verify.ts
5384
- import { z as z35 } from "zod";
7238
+ import { z as z41 } from "zod";
5385
7239
  var verifyCommand = define({
5386
7240
  name: "verify",
5387
7241
  tool: "kb_verify",
5388
7242
  usage: "verify <concept-id> --note <text>",
5389
7243
  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.",
5390
- input: z35.object({
7244
+ input: z41.object({
5391
7245
  bundlePath,
5392
7246
  conceptId,
5393
- note: z35.string().refine((s) => s.trim().length > 0, {
7247
+ note: z41.string().refine((s) => s.trim().length > 0, {
5394
7248
  message: "note must say what the check found"
5395
7249
  })
5396
7250
  }),
@@ -5410,15 +7264,15 @@ var verifyCommand = define({
5410
7264
  });
5411
7265
 
5412
7266
  // src/commands/write.ts
5413
- import { z as z36 } from "zod";
7267
+ import { z as z42 } from "zod";
5414
7268
  var writeCommand = define({
5415
7269
  name: "write",
5416
7270
  tool: "kb_write",
5417
7271
  usage: "write <type> < record.json",
5418
7272
  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.",
5419
- input: z36.object({
7273
+ input: z42.object({
5420
7274
  bundlePath,
5421
- type: z36.enum(KB_RECORD_TYPES),
7275
+ type: z42.enum(KB_RECORD_TYPES),
5422
7276
  input: composeInputSchema
5423
7277
  }),
5424
7278
  fromArgv: async (argv, path, stdin) => ({
@@ -5442,13 +7296,13 @@ var writeCommand = define({
5442
7296
  });
5443
7297
 
5444
7298
  // src/commands/write-decision.ts
5445
- import { z as z37 } from "zod";
7299
+ import { z as z43 } from "zod";
5446
7300
  var writeDecisionCommand = define({
5447
7301
  name: "write-decision",
5448
7302
  tool: "kb_write_decision",
5449
7303
  usage: "write-decision < decision.json",
5450
7304
  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.",
5451
- input: z37.object({ bundlePath, input: decisionInputSchema }),
7305
+ input: z43.object({ bundlePath, input: decisionInputSchema }),
5452
7306
  fromArgv: async (_argv, path, stdin) => ({
5453
7307
  bundlePath: path,
5454
7308
  input: JSON.parse(await stdin())
@@ -5479,19 +7333,24 @@ var KB_COMMANDS = [
5479
7333
  verifyCommand,
5480
7334
  anchorResolveCommand,
5481
7335
  reassessCommand,
7336
+ promoteCommand,
5482
7337
  loadCommand,
5483
7338
  catalogCommand,
5484
7339
  packCommand,
7340
+ exportCommand,
5485
7341
  queryCommand,
5486
7342
  traceCommand,
5487
7343
  impactCommand,
5488
7344
  backlinksCommand,
7345
+ matchCommand,
7346
+ classifyCommand,
5489
7347
  listCommand,
5490
7348
  readIndexCommand,
5491
7349
  logCommand,
5492
7350
  stampCommand,
5493
7351
  validateCommand,
5494
7352
  doctorCommand,
7353
+ sweepCommand,
5495
7354
  schemaCommand,
5496
7355
  pinCommand,
5497
7356
  unpinCommand,
@@ -5531,7 +7390,7 @@ function parseMarkdownWithFrontmatter(text, schema) {
5531
7390
 
5532
7391
  // src/search-index.ts
5533
7392
  import { stat as stat3 } from "fs/promises";
5534
- import { join as join6 } from "path";
7393
+ import { join as join8 } from "path";
5535
7394
  var SEARCH_INDEX_FILE = ".index.sqlite";
5536
7395
  var COLLECTION = "kb";
5537
7396
  async function searchBase(bundlePath2, query, options = {}) {
@@ -5540,7 +7399,7 @@ async function searchBase(bundlePath2, query, options = {}) {
5540
7399
  let store = null;
5541
7400
  try {
5542
7401
  store = await qmd.createStore({
5543
- dbPath: join6(bundlePath2, SEARCH_INDEX_FILE),
7402
+ dbPath: join8(bundlePath2, SEARCH_INDEX_FILE),
5544
7403
  config: {
5545
7404
  collections: {
5546
7405
  [COLLECTION]: {
@@ -5575,16 +7434,16 @@ async function searchBase(bundlePath2, query, options = {}) {
5575
7434
  }
5576
7435
  }
5577
7436
  async function isStale(bundlePath2) {
5578
- const indexAt = await stat3(join6(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
7437
+ const indexAt = await stat3(join8(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
5579
7438
  if (!indexAt) return true;
5580
- const { readdir: readdir2 } = await import("fs/promises");
5581
- const names = (await readdir2(bundlePath2).catch(() => [])).filter(
7439
+ const { readdir: readdir3 } = await import("fs/promises");
7440
+ const names = (await readdir3(bundlePath2).catch(() => [])).filter(
5582
7441
  (name) => name.endsWith(".md") && name !== INDEX_FILE
5583
7442
  );
5584
7443
  let stale = false;
5585
7444
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
5586
7445
  if (stale) return;
5587
- const at2 = await stat3(join6(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
7446
+ const at2 = await stat3(join8(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
5588
7447
  if (at2 > indexAt) stale = true;
5589
7448
  });
5590
7449
  return stale;
@@ -5622,14 +7481,14 @@ async function loadQmd(logger) {
5622
7481
  import {
5623
7482
  appendFile,
5624
7483
  link,
5625
- mkdir as mkdir4,
5626
- readdir,
5627
- readFile as readFile7,
5628
- rename as rename2,
5629
- unlink,
5630
- writeFile as writeFile4
7484
+ mkdir as mkdir5,
7485
+ readdir as readdir2,
7486
+ readFile as readFile8,
7487
+ rename as rename3,
7488
+ unlink as unlink2,
7489
+ writeFile as writeFile5
5631
7490
  } from "fs/promises";
5632
- import { join as join7, resolve as resolve5, sep as sep3 } from "path";
7491
+ import { join as join9, resolve as resolve6, sep as sep3 } from "path";
5633
7492
 
5634
7493
  // src/kb-stamp.ts
5635
7494
  import { createHash as createHash4 } from "crypto";
@@ -5663,144 +7522,13 @@ function bundleDigest(records, superseded) {
5663
7522
  return bundleStamp(records, superseded).digest;
5664
7523
  }
5665
7524
 
5666
- // src/kb-links/inbound.ts
5667
- function inboundIndex(bundle) {
5668
- const byTarget = /* @__PURE__ */ new Map();
5669
- for (const record of bundle) {
5670
- for (const link2 of record.frontmatter.strauss_links ?? []) {
5671
- if (link2.target === record.conceptId) continue;
5672
- const edges = byTarget.get(link2.target) ?? [];
5673
- if (edges.some(
5674
- (edge) => edge.from === record.conceptId && edge.rel === link2.rel
5675
- )) {
5676
- continue;
5677
- }
5678
- edges.push({ from: record.conceptId, rel: link2.rel });
5679
- byTarget.set(link2.target, edges);
5680
- }
5681
- }
5682
- return byTarget;
5683
- }
5684
-
5685
- // src/kb-links/backlinks.ts
5686
- function backlinks(targetId, bundle) {
5687
- const byId = new Map(bundle.map((record) => [record.conceptId, record]));
5688
- if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
5689
- const standingOf = new Map(
5690
- adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
5691
- );
5692
- const rows = [];
5693
- for (const edge of inboundIndex(bundle).get(targetId) ?? []) {
5694
- const record = byId.get(edge.from);
5695
- if (!record) continue;
5696
- const hit = standingOf.get(edge.from);
5697
- rows.push({
5698
- ...edge,
5699
- title: record.frontmatter.title ?? null,
5700
- standing: hit?.standing ?? "unsettled",
5701
- warnings: hit?.warnings ?? []
5702
- });
5703
- }
5704
- return {
5705
- target: targetId,
5706
- backlinks: rows.sort(
5707
- (left, right) => left.from.localeCompare(right.from) || left.rel.localeCompare(right.rel)
5708
- )
5709
- };
5710
- }
5711
-
5712
- // src/kb-links/impact.ts
5713
- function impact(targetId, bundle, options = {}) {
5714
- const byId = new Map(bundle.map((record) => [record.conceptId, record]));
5715
- if (!byId.has(targetId)) throw new KbRecordNotFoundError(targetId);
5716
- const rels = resolveRels(options.rels);
5717
- const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
5718
- const inbound = inboundIndex(bundle);
5719
- const standingOf = new Map(
5720
- adjudicate(bundle, bundle).map((hit) => [hit.record.conceptId, hit])
5721
- );
5722
- const reached = /* @__PURE__ */ new Map();
5723
- const stopped = [];
5724
- let frontier = [targetId];
5725
- let depth = 0;
5726
- while (frontier.length && depth < maxDepth) {
5727
- depth += 1;
5728
- const next = [];
5729
- const consider = (dependantId, edge) => {
5730
- if (dependantId === targetId) return;
5731
- const existing = reached.get(dependantId);
5732
- if (existing) {
5733
- if (!hasEdge(existing.via, edge)) existing.via.push(edge);
5734
- return;
5735
- }
5736
- const record = byId.get(dependantId);
5737
- if (!record) return;
5738
- const hit = standingOf.get(dependantId);
5739
- const entry = {
5740
- conceptId: dependantId,
5741
- title: record.frontmatter.title ?? null,
5742
- standing: hit?.standing ?? "unsettled",
5743
- warnings: hit?.warnings ?? [],
5744
- depth,
5745
- via: [edge]
5746
- };
5747
- reached.set(dependantId, entry);
5748
- if (entry.standing === "superseded" || entry.standing === "rejected") {
5749
- stopped.push(dependantId);
5750
- return;
5751
- }
5752
- next.push(dependantId);
5753
- };
5754
- for (const id of frontier) {
5755
- for (const edge of inbound.get(id) ?? []) {
5756
- if (!rels.has(edge.rel)) continue;
5757
- if (dependantEnd(edge.rel) !== "source") continue;
5758
- consider(edge.from, { source: edge.from, target: id, rel: edge.rel });
5759
- }
5760
- for (const link2 of byId.get(id)?.frontmatter.strauss_links ?? []) {
5761
- if (!rels.has(link2.rel)) continue;
5762
- if (dependantEnd(link2.rel) !== "target") continue;
5763
- if (link2.target === id) continue;
5764
- consider(link2.target, {
5765
- source: id,
5766
- target: link2.target,
5767
- rel: link2.rel
5768
- });
5769
- }
5770
- }
5771
- frontier = next;
5772
- }
5773
- return {
5774
- root: targetId,
5775
- impacted: [...reached.values()].sort(
5776
- (left, right) => left.depth - right.depth || left.conceptId.localeCompare(right.conceptId)
5777
- ),
5778
- stopped: stopped.sort(),
5779
- truncated: frontier.length > 0,
5780
- unexpanded: [...frontier].sort()
5781
- };
5782
- }
5783
- function resolveRels(rels) {
5784
- if (!rels?.length) return new Set(KB_CAUSAL_LINK_RELS);
5785
- for (const rel of rels) {
5786
- if (!isKbLinkRel(rel) || LINK_RELS[rel].dependant === null) {
5787
- throw new KbUnknownLinkRelError(rel, KB_CAUSAL_LINK_RELS);
5788
- }
5789
- }
5790
- return new Set(rels);
5791
- }
5792
- function dependantEnd(rel) {
5793
- return isKbLinkRel(rel) ? LINK_RELS[rel].dependant : null;
5794
- }
5795
- function hasEdge(edges, edge) {
5796
- return edges.some(
5797
- (existing) => existing.source === edge.source && existing.target === edge.target && existing.rel === edge.rel
5798
- );
5799
- }
7525
+ // src/kb-files.ts
7526
+ var STORE_OWNED_FILES = [INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE];
5800
7527
 
5801
7528
  // src/kb-gitattributes.ts
5802
7529
  var GITATTRIBUTES_FILE = ".gitattributes";
5803
- var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
7530
+ var GENERATED = "linguist-generated";
7531
+ var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union ${GENERATED}=true`;
5804
7532
  function parseLine(line) {
5805
7533
  const trimmed = line.trim();
5806
7534
  if (!trimmed || trimmed.startsWith("#")) return null;
@@ -5808,23 +7536,39 @@ function parseLine(line) {
5808
7536
  return pattern === void 0 ? null : { pattern, attrs };
5809
7537
  }
5810
7538
  function hasMergeDeclaration(contents) {
7539
+ return declares(contents, LOG_FILE, "merge");
7540
+ }
7541
+ function declares(contents, pattern, attribute) {
5811
7542
  return contents.split("\n").some((line) => {
5812
7543
  const parsed = parseLine(line);
5813
- if (!parsed || parsed.pattern !== LOG_FILE) return false;
7544
+ if (!parsed || parsed.pattern !== pattern) return false;
5814
7545
  return parsed.attrs.some(
5815
- (attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
7546
+ (attr) => attr === attribute || attr === `-${attribute}` || attr.startsWith(`${attribute}=`)
5816
7547
  );
5817
7548
  });
5818
7549
  }
5819
- function appendUnionMergeLine(contents) {
7550
+ function missingGitattributesLines(contents) {
7551
+ const needsMerge = !hasMergeDeclaration(contents);
7552
+ const generated = STORE_OWNED_FILES.filter(
7553
+ // The union-merge line carries the log's `linguist-generated` too, so the
7554
+ // log needs its own line only where that line is already there without it.
7555
+ (file) => !(needsMerge && file === LOG_FILE)
7556
+ ).filter((file) => !declares(contents, file, GENERATED)).map((file) => `${file} ${GENERATED}=true`);
7557
+ return needsMerge ? [UNION_MERGE_LINE, ...generated] : generated;
7558
+ }
7559
+ var GITATTRIBUTES_BLOCK = `${missingGitattributesLines("").join("\n")}
7560
+ `;
7561
+ function appendGitattributesLines(contents) {
7562
+ const lines = missingGitattributesLines(contents);
7563
+ if (lines.length === 0) return "";
5820
7564
  const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
5821
- return `${separator}${UNION_MERGE_LINE}
7565
+ return `${separator}${lines.join("\n")}
5822
7566
  `;
5823
7567
  }
5824
7568
 
5825
7569
  // src/kb-store.ts
5826
- var KB_DIR = join7(".strauss", "kb");
5827
- var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
7570
+ var KB_DIR = join9(".strauss", "kb");
7571
+ var STORE_OWNED = new Set(STORE_OWNED_FILES);
5828
7572
  var DEFAULT_LOAD_BUDGET = 25e3;
5829
7573
  var KbStore = class {
5830
7574
  constructor(logger = {}) {
@@ -5854,7 +7598,7 @@ var KbStore = class {
5854
7598
  const conceptId2 = `${input.type}.${input.slug}`;
5855
7599
  const root = this.root(bundlePath2);
5856
7600
  const target = this.recordPath(bundlePath2, conceptId2);
5857
- await mkdir4(root, { recursive: true });
7601
+ await mkdir5(root, { recursive: true });
5858
7602
  await this.publish(
5859
7603
  target,
5860
7604
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -5893,7 +7637,7 @@ var KbStore = class {
5893
7637
  const target = this.recordPath(bundlePath2, conceptId2);
5894
7638
  let raw;
5895
7639
  try {
5896
- raw = await readFile7(target, "utf8");
7640
+ raw = await readFile8(target, "utf8");
5897
7641
  } catch {
5898
7642
  return null;
5899
7643
  }
@@ -5913,7 +7657,7 @@ var KbStore = class {
5913
7657
  const root = this.root(bundlePath2);
5914
7658
  let names;
5915
7659
  try {
5916
- names = await readdir(root);
7660
+ names = await readdir2(root);
5917
7661
  } catch {
5918
7662
  return [];
5919
7663
  }
@@ -5921,7 +7665,7 @@ var KbStore = class {
5921
7665
  const records = await mapLimit(
5922
7666
  wanted,
5923
7667
  DEFAULT_IO_CONCURRENCY,
5924
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile7(join7(root, name), "utf8"))
7668
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile8(join9(root, name), "utf8"))
5925
7669
  );
5926
7670
  return records.filter(
5927
7671
  (record) => record !== null && matchesTags(record, filter)
@@ -5951,12 +7695,16 @@ var KbStore = class {
5951
7695
  * Wholesale rather than merged: the caller just resolved the anchors it is
5952
7696
  * writing, so it holds the complete current set, and a merge would keep
5953
7697
  * stale entries the resolution pass deliberately dropped.
7698
+ *
7699
+ * Through the write schema: this is a write, and a defect a hand-edit put in
7700
+ * the frontmatter must not be published back out under an actor stamp.
5954
7701
  */
5955
7702
  async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
7703
+ const checked = anchors.map((anchor) => kbAnchorWriteSchema.parse(anchor));
5956
7704
  return this.mutate(
5957
7705
  bundlePath2,
5958
7706
  conceptId2,
5959
- (frontmatter) => ({ ...frontmatter, strauss_anchors: anchors }),
7707
+ (frontmatter) => ({ ...frontmatter, strauss_anchors: checked }),
5960
7708
  { operation: "anchor-resolve", by: actor }
5961
7709
  );
5962
7710
  }
@@ -6042,6 +7790,35 @@ ${answer}
6042
7790
  `
6043
7791
  );
6044
7792
  }
7793
+ /**
7794
+ * Removes one record, logged as `sweep`. The only path in this store that
7795
+ * deletes — see the specification for the scope that makes it safe.
7796
+ *
7797
+ * `expected` is re-read and re-checked immediately before the unlink, the
7798
+ * compare-and-swap `mutate` makes: a record retagged or moved out of a
7799
+ * terminal status since the caller listed it is reported, not removed.
7800
+ */
7801
+ async deleteRecord(bundlePath2, conceptId2, expected, actor = "unknown") {
7802
+ const target = this.recordPath(bundlePath2, conceptId2);
7803
+ const witness = await this.read(bundlePath2, conceptId2);
7804
+ if (!witness) throw new KbRecordNotFoundError(conceptId2);
7805
+ const { tags, strauss_status } = witness.frontmatter;
7806
+ if (!(tags ?? []).includes(expected.tag) || !expected.statuses.includes(strauss_status)) {
7807
+ return "changed-since-listing";
7808
+ }
7809
+ try {
7810
+ await unlink2(target);
7811
+ } catch (error) {
7812
+ if (error.code !== "ENOENT") throw error;
7813
+ throw new KbRecordNotFoundError(conceptId2);
7814
+ }
7815
+ await this.record(this.root(bundlePath2), {
7816
+ operation: "sweep",
7817
+ by: actor,
7818
+ conceptId: conceptId2
7819
+ });
7820
+ return "deleted";
7821
+ }
6045
7822
  /**
6046
7823
  * Records matching a text query, each carrying its standing.
6047
7824
  *
@@ -6264,11 +8041,11 @@ ${answer}
6264
8041
  async readIndex(bundlePath2) {
6265
8042
  const root = this.root(bundlePath2);
6266
8043
  const expected = renderIndex(await this.list(bundlePath2));
6267
- const stored = await readFile7(join7(root, INDEX_FILE), "utf8").catch(
8044
+ const stored = await readFile8(join9(root, INDEX_FILE), "utf8").catch(
6268
8045
  () => null
6269
8046
  );
6270
8047
  if (indexIsStale(stored, expected)) {
6271
- await this.publish(join7(root, INDEX_FILE), expected, true, INDEX_FILE);
8048
+ await this.publish(join9(root, INDEX_FILE), expected, true, INDEX_FILE);
6272
8049
  this.logger.info?.({
6273
8050
  operation: "kb.index.repair",
6274
8051
  bundlePath: root,
@@ -6277,19 +8054,39 @@ ${answer}
6277
8054
  }
6278
8055
  return expected;
6279
8056
  }
8057
+ /**
8058
+ * Drops the derived search index, so the next search rebuilds it.
8059
+ *
8060
+ * `searchBase` re-indexes when a record is newer than the index, which no
8061
+ * deletion makes true — a swept record would stay findable until some other
8062
+ * record was written.
8063
+ */
8064
+ async dropSearchIndex(bundlePath2) {
8065
+ await unlink2(join9(this.root(bundlePath2), SEARCH_INDEX_FILE)).catch(
8066
+ () => void 0
8067
+ );
8068
+ }
6280
8069
  /**
6281
8070
  * The log, with unparseable lines reported rather than repaired.
6282
8071
  *
6283
8072
  * The log is the bundle's only artifact that cannot be reconstructed — the
6284
8073
  * records rebuild the index, and the code outlives both, but nothing else
6285
8074
  * knows which agent touched what. So a bad line is surfaced and left alone.
8075
+ * Conflict markers are read past rather than reported per line.
6286
8076
  */
6287
8077
  async readLog(bundlePath2) {
6288
- const raw = await readFile7(
6289
- join7(this.root(bundlePath2), LOG_FILE),
8078
+ const raw = await readFile8(
8079
+ join9(this.root(bundlePath2), LOG_FILE),
6290
8080
  "utf8"
6291
8081
  ).catch(() => "");
6292
8082
  const result = parseLog(raw);
8083
+ if (result.conflicted) {
8084
+ this.logger.warn?.({
8085
+ operation: "kb.log.parse",
8086
+ bundlePath: this.root(bundlePath2),
8087
+ outcome: "conflicted"
8088
+ });
8089
+ }
6293
8090
  for (const bad of result.malformed) {
6294
8091
  this.logger.warn?.({
6295
8092
  operation: "kb.log.parse",
@@ -6299,6 +8096,14 @@ ${answer}
6299
8096
  }
6300
8097
  return result;
6301
8098
  }
8099
+ /**
8100
+ * Appends one log entry for a move the store cannot see from one base.
8101
+ * Promotion writes into a target base and has to be legible from the source
8102
+ * base too, where nothing was written.
8103
+ */
8104
+ async note(bundlePath2, entry) {
8105
+ await this.record(this.root(bundlePath2), entry);
8106
+ }
6302
8107
  /**
6303
8108
  * `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
6304
8109
  * a missing target (a broken link, legal per compose.ts) or a CAS conflict
@@ -6337,14 +8142,14 @@ ${answer}
6337
8142
  }
6338
8143
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
6339
8144
  const target = this.recordPath(bundlePath2, conceptId2);
6340
- const before = await readFile7(target, "utf8").catch(() => null);
8145
+ const before = await readFile8(target, "utf8").catch(() => null);
6341
8146
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
6342
8147
  const parsed = this.parse(conceptId2, before);
6343
8148
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
6344
8149
  const frontmatter = change(parsed.frontmatter);
6345
8150
  const body = changeBody(parsed.body);
6346
8151
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
6347
- const witness = await readFile7(target, "utf8").catch(() => null);
8152
+ const witness = await readFile8(target, "utf8").catch(() => null);
6348
8153
  if (witness === null || sha2563(witness) !== sha2563(before)) {
6349
8154
  throw new KbWriteConflictError(conceptId2);
6350
8155
  }
@@ -6370,10 +8175,10 @@ ${answer}
6370
8175
  */
6371
8176
  async publish(target, contents, overwrite, conceptId2) {
6372
8177
  const staging = `${target}.${process.pid}.tmp`;
6373
- await writeFile4(staging, contents, "utf8");
8178
+ await writeFile5(staging, contents, "utf8");
6374
8179
  try {
6375
8180
  if (overwrite) {
6376
- await rename2(staging, target);
8181
+ await rename3(staging, target);
6377
8182
  return;
6378
8183
  }
6379
8184
  await link(staging, target);
@@ -6383,13 +8188,14 @@ ${answer}
6383
8188
  }
6384
8189
  throw error;
6385
8190
  } finally {
6386
- await unlink(staging).catch(() => void 0);
8191
+ await unlink2(staging).catch(() => void 0);
6387
8192
  }
6388
8193
  }
6389
8194
  /**
6390
8195
  * Declares union merge for the log, so two worktrees writing the same
6391
8196
  * bundle interleave their `log.jsonl` lines on merge rather than one
6392
- * side's appends silently losing to git's ordinary line-level merge.
8197
+ * side's appends silently losing to git's ordinary line-level merge — and
8198
+ * marks every store-owned file generated, so GitHub collapses it in a diff.
6393
8199
  *
6394
8200
  * Called from `record` — every path that appends a log line, not just
6395
8201
  * `write` — so a bundle only ever mutated through `setStatus`/`verify`/
@@ -6402,10 +8208,9 @@ ${answer}
6402
8208
  * race and created the file between the `readFile` below and this call,
6403
8209
  * `wx` fails instead of truncating what that writer just wrote, and the
6404
8210
  * failure is swallowed by the catch below same as any other best-effort
6405
- * miss. A file that exists but declares no merge strategy for the log
6406
- * gets the line appended, never a wholesale rewrite; one that already
6407
- * declares any merge strategy — this one or a user's own — is left alone
6408
- * entirely (see `hasMergeDeclaration`).
8211
+ * miss. A file that exists gets only the lines it lacks appended, never a
8212
+ * wholesale rewrite; an attribute it already sets this one's value or a
8213
+ * user's own — is left alone (see `missingGitattributesLines`).
6409
8214
  *
6410
8215
  * `readFile` failing is `existing === null` only for `ENOENT` — genuinely
6411
8216
  * missing. Any other error (a permission problem, a transient `EMFILE`,
@@ -6416,29 +8221,29 @@ ${answer}
6416
8221
  * therefore left untouched and reported as a failure like any other.
6417
8222
  *
6418
8223
  * Two processes racing the append branch — both read a file without the
6419
- * line, both append it — is possible and left unguarded: `appendFile` is
6420
- * `O_APPEND`, so the result is two copies of the same line rather than a
6421
- * torn write, and `hasMergeDeclaration` sees a duplicate declaration as
6422
- * "already declared" on the next call. A cheap-to-detect, harmless-to-
6423
- * leave residue, not a reason to add a cross-process lock (see
6424
- * `ARCHITECTURE.md`'s rejection of one for the same trade on records).
8224
+ * lines, both append them — is possible and left unguarded: `appendFile` is
8225
+ * `O_APPEND`, so the result is two copies of the same lines rather than a
8226
+ * torn write, and the next call sees a duplicate declaration as "already
8227
+ * declared". A cheap-to-detect, harmless-to-leave residue, not a reason to
8228
+ * add a cross-process lock (see `ARCHITECTURE.md`'s rejection of one for
8229
+ * the same trade on records).
6425
8230
  *
6426
8231
  * Best-effort, like the log append it precedes: failing to write this
6427
8232
  * file must not fail the mutation it guards.
6428
8233
  */
6429
8234
  async ensureGitattributes(root) {
6430
- const target = join7(root, GITATTRIBUTES_FILE);
8235
+ const target = join9(root, GITATTRIBUTES_FILE);
6431
8236
  try {
6432
8237
  let existing;
6433
8238
  try {
6434
- existing = await readFile7(target, "utf8");
8239
+ existing = await readFile8(target, "utf8");
6435
8240
  } catch (error) {
6436
8241
  if (error.code !== "ENOENT") throw error;
6437
8242
  existing = null;
6438
8243
  }
6439
8244
  if (existing === null) {
6440
8245
  try {
6441
- await writeFile4(target, appendUnionMergeLine(""), {
8246
+ await writeFile5(target, appendGitattributesLines(""), {
6442
8247
  encoding: "utf8",
6443
8248
  flag: "wx"
6444
8249
  });
@@ -6458,8 +8263,9 @@ ${answer}
6458
8263
  });
6459
8264
  return;
6460
8265
  }
6461
- if (!hasMergeDeclaration(existing)) {
6462
- await appendFile(target, appendUnionMergeLine(existing), "utf8");
8266
+ const addition = appendGitattributesLines(existing);
8267
+ if (addition) {
8268
+ await appendFile(target, addition, "utf8");
6463
8269
  this.logger.info?.({
6464
8270
  operation: "kb.gitattributes.ensure",
6465
8271
  bundlePath: root,
@@ -6478,7 +8284,7 @@ ${answer}
6478
8284
  async record(root, entry) {
6479
8285
  await this.ensureGitattributes(root);
6480
8286
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
6481
- await appendFile(join7(root, LOG_FILE), line, "utf8").catch((error) => {
8287
+ await appendFile(join9(root, LOG_FILE), line, "utf8").catch((error) => {
6482
8288
  this.logger.warn?.({
6483
8289
  operation: "kb.log.append",
6484
8290
  outcome: "failed",
@@ -6504,7 +8310,7 @@ ${answer}
6504
8310
  };
6505
8311
  }
6506
8312
  root(bundlePath2) {
6507
- return resolve5(bundlePath2);
8313
+ return resolve6(bundlePath2);
6508
8314
  }
6509
8315
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
6510
8316
  // bundle root; anything carrying a separator would escape it.
@@ -6515,7 +8321,7 @@ ${answer}
6515
8321
  { conceptId: conceptId2 }
6516
8322
  );
6517
8323
  }
6518
- return join7(this.root(bundlePath2), `${conceptId2}.md`);
8324
+ return join9(this.root(bundlePath2), `${conceptId2}.md`);
6519
8325
  }
6520
8326
  };
6521
8327
  function estimateTokens(record) {
@@ -6639,13 +8445,15 @@ function typeRank(record) {
6639
8445
  }
6640
8446
 
6641
8447
  // src/version.ts
6642
- var VERSION = true ? "0.1.19" : "0.0.0-dev";
8448
+ var VERSION = true ? "0.1.20" : "0.0.0-dev";
6643
8449
 
6644
8450
  export {
6645
8451
  kbSourceSchema,
6646
8452
  kbActorStampSchema,
6647
8453
  kbVerifiedEventSchema,
8454
+ kbAnchorSpanSchema,
6648
8455
  kbAnchorSchema,
8456
+ kbAnchorWriteSchema,
6649
8457
  kbLinkSchema,
6650
8458
  KB_RECORD_TYPES,
6651
8459
  KB_SLUG_PATTERN,
@@ -6699,6 +8507,11 @@ export {
6699
8507
  KbPackBudgetExceededError,
6700
8508
  KbUnknownLinkRelError,
6701
8509
  KbMissingFlagValueError,
8510
+ KbClassifyInputError,
8511
+ KbPromoteCollisionError,
8512
+ KbPromoteStandingError,
8513
+ KbPromoteSelfError,
8514
+ KbPromoteStoppedError,
6702
8515
  KbInvalidConceptIdError,
6703
8516
  contextProfileBudgets,
6704
8517
  mergedContextBudgets,
@@ -6719,6 +8532,15 @@ export {
6719
8532
  matchesTags,
6720
8533
  catalog,
6721
8534
  renderCatalogLine,
8535
+ matchToDiff,
8536
+ anchorOnHunk,
8537
+ symbolRangeIndex,
8538
+ KB_CLASSES,
8539
+ DEFAULT_THRESHOLDS,
8540
+ classifyDiff,
8541
+ classifyDrift,
8542
+ unifiedDiff,
8543
+ reassessPacket,
6722
8544
  INDEX_FILE,
6723
8545
  renderIndex,
6724
8546
  renderIndexLine,
@@ -6729,9 +8551,6 @@ export {
6729
8551
  CONTEXT_BEGIN,
6730
8552
  CONTEXT_END,
6731
8553
  syncInstructions,
6732
- classifyDrift,
6733
- unifiedDiff,
6734
- reassessPacket,
6735
8554
  KB_EDGE_KINDS,
6736
8555
  DEFAULT_TYPED_LINK_RELS,
6737
8556
  neighbours,
@@ -6742,6 +8561,14 @@ export {
6742
8561
  DEFAULT_AGING_DAYS,
6743
8562
  KB_DOCTOR_CHECKS,
6744
8563
  doctor,
8564
+ PROMOTION_SOURCE_ID,
8565
+ carry,
8566
+ isReviewTag,
8567
+ inboundIndex,
8568
+ backlinks,
8569
+ impact,
8570
+ promoteCandidates,
8571
+ promoteInputSchema,
6745
8572
  LOG_FILE,
6746
8573
  kbLogEntrySchema,
6747
8574
  renderLogEntry,
@@ -6761,12 +8588,9 @@ export {
6761
8588
  DEFAULT_PACK_HOPS,
6762
8589
  DEFAULT_PACK_MAX_NODES,
6763
8590
  pack,
6764
- inboundIndex,
6765
- backlinks,
6766
- impact,
6767
8591
  KB_DIR,
6768
8592
  DEFAULT_LOAD_BUDGET,
6769
8593
  KbStore,
6770
8594
  VERSION
6771
8595
  };
6772
- //# sourceMappingURL=chunk-MNQNHYWL.js.map
8596
+ //# sourceMappingURL=chunk-QQLPJO4R.js.map