@saasontools/strauss-kb 0.1.19 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +5 -5
- package/README.md +11 -4
- package/dist/{chunk-ZWSCLHG6.js → chunk-EXZQZJU2.js} +2 -2
- package/dist/{chunk-MNQNHYWL.js → chunk-QQLPJO4R.js} +2781 -957
- package/dist/chunk-QQLPJO4R.js.map +1 -0
- package/dist/{chunk-CQBLH7CE.js → chunk-XDH6J6CH.js} +2 -2
- package/dist/cli-main.cjs +2783 -994
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +2149 -394
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +470 -96
- package/dist/index.d.ts +470 -96
- package/dist/index.js +38 -83
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +2779 -990
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-MNQNHYWL.js.map +0 -1
- /package/dist/{chunk-ZWSCLHG6.js.map → chunk-EXZQZJU2.js.map} +0 -0
- /package/dist/{chunk-CQBLH7CE.js.map → chunk-XDH6J6CH.js.map} +0 -0
|
@@ -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(
|
|
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
|
|
698
|
-
const group2 = byRepo.get(
|
|
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(
|
|
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 [
|
|
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((
|
|
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
|
|
1148
|
-
const existing = inFlight.get(
|
|
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(
|
|
1225
|
+
inFlight.set(key2, pending);
|
|
1173
1226
|
const result = await pending;
|
|
1174
|
-
if (result === null) inFlight.delete(
|
|
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
|
|
1473
|
-
const cached2 = this.trees.get(
|
|
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(
|
|
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
|
|
1659
|
-
const indent =
|
|
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
|
|
1681
|
-
(name
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
const
|
|
1695
|
-
const
|
|
1696
|
-
|
|
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
|
|
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")
|
|
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 (
|
|
1827
|
-
else
|
|
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
|
-
|
|
1847
|
-
|
|
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
|
|
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
|
|
1932
|
-
const atDefault = remote.get(wantKey(
|
|
1933
|
-
const primary = anchor.ref ? remote.get(wantKey(
|
|
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 = (
|
|
2169
|
-
const raw2 = table2[
|
|
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
|
|
2602
|
-
for (const entry of entries)
|
|
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:
|
|
2608
|
-
supersededCount:
|
|
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/
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
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
|
|
2640
|
-
const
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
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
|
|
2647
|
-
return
|
|
3024
|
+
function asIndex(ranges) {
|
|
3025
|
+
return isIndex(ranges) ? ranges : symbolRangeIndex(ranges);
|
|
2648
3026
|
}
|
|
2649
|
-
|
|
2650
|
-
|
|
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
|
|
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
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
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
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
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
|
|
3086
|
+
return byKey;
|
|
2848
3087
|
}
|
|
2849
|
-
function
|
|
2850
|
-
return
|
|
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
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
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
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
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
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
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/
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
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
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
3209
|
+
function verdictOf(override) {
|
|
3210
|
+
return {
|
|
3211
|
+
class: override.class,
|
|
3212
|
+
reason: `kb-override ${override.record.conceptId}`
|
|
3213
|
+
};
|
|
2916
3214
|
}
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
]);
|
|
2958
|
-
|
|
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
|
|
3150
|
-
const lines = [
|
|
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
|
|
3519
|
+
const open2 = classified.filter(
|
|
3211
3520
|
(found) => found.class === "changed" || found.class === "gone"
|
|
3212
3521
|
);
|
|
3213
|
-
if (!
|
|
3214
|
-
const budget = diffBudget(
|
|
3215
|
-
const anchors =
|
|
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
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
const
|
|
3279
|
-
|
|
3280
|
-
|
|
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
|
-
|
|
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
|
|
3490
|
-
if (anchor.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
3614
|
-
const
|
|
3615
|
-
|
|
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
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
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
|
|
4470
|
+
return byTarget;
|
|
3629
4471
|
}
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
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
|
-
|
|
3638
|
-
const
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
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
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
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
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
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
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
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
|
|
3680
|
-
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
3681
|
-
);
|
|
4691
|
+
return values;
|
|
3682
4692
|
}
|
|
3683
|
-
function
|
|
3684
|
-
const
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
if (
|
|
3689
|
-
|
|
3690
|
-
|
|
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
|
|
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
|
|
3714
|
-
return
|
|
3715
|
-
|
|
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
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
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
|
|
3733
|
-
|
|
3734
|
-
)
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
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
|
|
3743
|
-
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
3744
|
-
);
|
|
4834
|
+
return { conceptId: conceptId2, type, slug };
|
|
3745
4835
|
}
|
|
3746
|
-
function
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
return
|
|
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
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
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
|
-
|
|
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
|
|
4859
|
+
import { z as z8 } from "zod";
|
|
3782
4860
|
var LOG_FILE = "log.jsonl";
|
|
3783
|
-
var kbLogEntrySchema =
|
|
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:
|
|
3793
|
-
by:
|
|
3794
|
-
operation:
|
|
3795
|
-
conceptId:
|
|
3796
|
-
/**
|
|
3797
|
-
|
|
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
|
|
3822
|
-
if (seen.has(
|
|
3823
|
-
seen.add(
|
|
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
|
|
4920
|
+
import { z as z9 } from "zod";
|
|
3834
4921
|
function kbJsonSchemas() {
|
|
3835
4922
|
return {
|
|
3836
|
-
recordFrontmatter:
|
|
4923
|
+
recordFrontmatter: z9.toJSONSchema(kbRecordFrontmatterSchema, {
|
|
3837
4924
|
io: "input"
|
|
3838
4925
|
}),
|
|
3839
|
-
composeInput:
|
|
3840
|
-
logEntry:
|
|
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
|
|
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:
|
|
4994
|
+
input: z10.object({
|
|
3974
4995
|
bundlePath,
|
|
3975
4996
|
conceptId,
|
|
3976
|
-
repoRoot:
|
|
3977
|
-
offline:
|
|
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:
|
|
5001
|
+
rebaseline: z10.boolean().optional().describe(
|
|
3981
5002
|
"Accept the current code as the new baseline for anchors that drifted."
|
|
3982
5003
|
),
|
|
3983
|
-
restamp:
|
|
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(
|
|
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
|
|
4217
|
-
const atDefault = blobs.get(wantKey(
|
|
4218
|
-
const primary = anchor.ref ? blobs.get(wantKey(
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
5311
|
+
input: z13.object({
|
|
4277
5312
|
bundlePath,
|
|
4278
|
-
type:
|
|
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
|
|
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:
|
|
4354
|
-
budgetTokens:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
5972
|
+
import { z as z19 } from "zod";
|
|
4409
5973
|
|
|
4410
5974
|
// src/commands/reassess.ts
|
|
4411
|
-
import { z as
|
|
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:
|
|
5981
|
+
input: z18.object({
|
|
4418
5982
|
bundlePath,
|
|
4419
5983
|
conceptId,
|
|
4420
5984
|
repoRoot: REPO_ROOT,
|
|
4421
|
-
withDiff:
|
|
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) =>
|
|
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:
|
|
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:
|
|
6151
|
+
offline: z19.boolean().optional().describe(
|
|
4585
6152
|
"Read foreign anchors from the local repo cache only, never fetching."
|
|
4586
6153
|
),
|
|
4587
|
-
strict:
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
-
...
|
|
4707
|
-
|
|
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
|
|
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:
|
|
6483
|
+
input: z21.object({
|
|
4766
6484
|
bundlePath,
|
|
4767
6485
|
conceptId,
|
|
4768
|
-
depth:
|
|
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:
|
|
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
|
|
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:
|
|
6516
|
+
input: z22.object({
|
|
4799
6517
|
bundlePath,
|
|
4800
|
-
type:
|
|
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
|
|
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:
|
|
6548
|
+
input: z23.object({
|
|
4831
6549
|
bundlePath,
|
|
4832
|
-
type:
|
|
4833
|
-
budgetTokens:
|
|
4834
|
-
all:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
6635
|
+
input: z26.object({
|
|
4918
6636
|
bundlePath,
|
|
4919
6637
|
conceptId,
|
|
4920
|
-
hops:
|
|
4921
|
-
maxNodes:
|
|
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:
|
|
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
|
|
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:
|
|
6735
|
+
input: z27.object({
|
|
5018
6736
|
bundlePath,
|
|
5019
|
-
mode:
|
|
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:
|
|
5023
|
-
layer:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
6791
|
+
input: z29.object({
|
|
5074
6792
|
bundlePath,
|
|
5075
|
-
text:
|
|
5076
|
-
type:
|
|
5077
|
-
includeNonCurrent:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
5136
|
-
import { z as
|
|
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:
|
|
5144
|
-
bundlePath:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
6957
|
+
input: z33.object({
|
|
5240
6958
|
bundlePath,
|
|
5241
6959
|
conceptId,
|
|
5242
|
-
status:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
5283
|
-
file:
|
|
5284
|
-
budgetTokens:
|
|
5285
|
-
fullUnderTokens:
|
|
5286
|
-
profile:
|
|
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
|
|
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:
|
|
7172
|
+
input: z37.object({
|
|
5319
7173
|
bundlePath,
|
|
5320
7174
|
conceptId,
|
|
5321
|
-
edges:
|
|
5322
|
-
depth:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
7244
|
+
input: z41.object({
|
|
5391
7245
|
bundlePath,
|
|
5392
7246
|
conceptId,
|
|
5393
|
-
note:
|
|
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
|
|
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:
|
|
7273
|
+
input: z42.object({
|
|
5420
7274
|
bundlePath,
|
|
5421
|
-
type:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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(
|
|
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:
|
|
5581
|
-
const names = (await
|
|
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(
|
|
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
|
|
5626
|
-
readdir,
|
|
5627
|
-
readFile as
|
|
5628
|
-
rename as
|
|
5629
|
-
unlink,
|
|
5630
|
-
writeFile as
|
|
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
|
|
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-
|
|
5667
|
-
|
|
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
|
|
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 !==
|
|
7544
|
+
if (!parsed || parsed.pattern !== pattern) return false;
|
|
5814
7545
|
return parsed.attrs.some(
|
|
5815
|
-
(attr) => attr ===
|
|
7546
|
+
(attr) => attr === attribute || attr === `-${attribute}` || attr.startsWith(`${attribute}=`)
|
|
5816
7547
|
);
|
|
5817
7548
|
});
|
|
5818
7549
|
}
|
|
5819
|
-
function
|
|
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}${
|
|
7565
|
+
return `${separator}${lines.join("\n")}
|
|
5822
7566
|
`;
|
|
5823
7567
|
}
|
|
5824
7568
|
|
|
5825
7569
|
// src/kb-store.ts
|
|
5826
|
-
var KB_DIR =
|
|
5827
|
-
var STORE_OWNED =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
6289
|
-
|
|
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
|
|
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
|
|
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
|
|
8178
|
+
await writeFile5(staging, contents, "utf8");
|
|
6374
8179
|
try {
|
|
6375
8180
|
if (overwrite) {
|
|
6376
|
-
await
|
|
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
|
|
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
|
|
6406
|
-
*
|
|
6407
|
-
*
|
|
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
|
-
*
|
|
6420
|
-
* `O_APPEND`, so the result is two copies of the same
|
|
6421
|
-
* torn write, and
|
|
6422
|
-
*
|
|
6423
|
-
*
|
|
6424
|
-
*
|
|
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 =
|
|
8235
|
+
const target = join9(root, GITATTRIBUTES_FILE);
|
|
6431
8236
|
try {
|
|
6432
8237
|
let existing;
|
|
6433
8238
|
try {
|
|
6434
|
-
existing = await
|
|
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
|
|
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
|
-
|
|
6462
|
-
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
8596
|
+
//# sourceMappingURL=chunk-QQLPJO4R.js.map
|