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