@saasontools/strauss-kb 0.1.14 → 0.1.16
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 +20 -2
- package/README.md +44 -28
- package/dist/{chunk-43KALLFU.js → chunk-H5W53NVU.js} +1090 -388
- package/dist/chunk-H5W53NVU.js.map +1 -0
- package/dist/{chunk-PYA5E7FL.js → chunk-MEZCF646.js} +2 -2
- package/dist/{chunk-MBXNCZ4V.js → chunk-RINBOAQZ.js} +15 -6
- package/dist/chunk-RINBOAQZ.js.map +1 -0
- package/dist/cli-main.cjs +1111 -404
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +1152 -434
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +174 -69
- package/dist/index.d.ts +174 -69
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +1100 -402
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +2 -2
- package/dist/chunk-43KALLFU.js.map +0 -1
- package/dist/chunk-MBXNCZ4V.js.map +0 -1
- /package/dist/{chunk-PYA5E7FL.js.map → chunk-MEZCF646.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -99,6 +99,7 @@ __export(index_exports, {
|
|
|
99
99
|
impact: () => impact,
|
|
100
100
|
inboundIndex: () => inboundIndex,
|
|
101
101
|
indexIsStale: () => indexIsStale,
|
|
102
|
+
isCanonicalRepoUrl: () => isCanonicalRepoUrl,
|
|
102
103
|
isKbLinkRel: () => isKbLinkRel,
|
|
103
104
|
isKbRecordType: () => isKbRecordType,
|
|
104
105
|
isNoDecisionRecord: () => isNoDecisionRecord,
|
|
@@ -122,11 +123,13 @@ __export(index_exports, {
|
|
|
122
123
|
pinBase: () => pinBase,
|
|
123
124
|
readMergedPins: () => readMergedPins,
|
|
124
125
|
readPinsLayer: () => readPinsLayer,
|
|
126
|
+
readRemoteAnchors: () => readRemoteAnchors,
|
|
125
127
|
regexResolver: () => regexResolver,
|
|
126
128
|
renderCatalogLine: () => renderCatalogLine,
|
|
127
129
|
renderIndex: () => renderIndex,
|
|
128
130
|
renderIndexLine: () => renderIndexLine,
|
|
129
131
|
renderLogEntry: () => renderLogEntry,
|
|
132
|
+
repoCacheDir: () => repoCacheDir,
|
|
130
133
|
resolveAnchor: () => resolveAnchor,
|
|
131
134
|
resolveHeads: () => resolveHeads,
|
|
132
135
|
resolveHits: () => resolveHits,
|
|
@@ -146,9 +149,8 @@ __export(index_exports, {
|
|
|
146
149
|
module.exports = __toCommonJS(index_exports);
|
|
147
150
|
|
|
148
151
|
// src/kb-store.ts
|
|
149
|
-
var
|
|
150
|
-
var
|
|
151
|
-
var import_node_path3 = require("path");
|
|
152
|
+
var import_promises4 = require("fs/promises");
|
|
153
|
+
var import_node_path4 = require("path");
|
|
152
154
|
|
|
153
155
|
// src/concurrency.ts
|
|
154
156
|
var DEFAULT_IO_CONCURRENCY = 16;
|
|
@@ -230,16 +232,17 @@ var kbAnchorSchema = import_zod.z.object({
|
|
|
230
232
|
* (`https://github.com/org/name`) or a short name. Absent means the base's
|
|
231
233
|
* own repository, which is what nearly every anchor means.
|
|
232
234
|
*
|
|
233
|
-
* Unvalidated beyond not-blank: one repository has many spellings
|
|
234
|
-
*
|
|
235
|
+
* Unvalidated beyond not-blank: one repository has many spellings, matched
|
|
236
|
+
* after normalisation. Only a full URL can be fetched from, so `validate`
|
|
237
|
+
* warns on a short one; see ARCHITECTURE.
|
|
235
238
|
*/
|
|
236
239
|
repo: import_zod.z.string().trim().min(1).optional(),
|
|
237
240
|
/**
|
|
238
241
|
* The git rev the evidence was taken at. Prefer a commit SHA: a branch
|
|
239
242
|
* name is a moving pointer, so an anchor pinned to one says the evidence
|
|
240
243
|
* came from wherever that branch happens to be now, which is not a
|
|
241
|
-
* baseline.
|
|
242
|
-
*
|
|
244
|
+
* baseline. A foreign anchor is checked at this rev, and compared against
|
|
245
|
+
* the remote's default branch on top of it.
|
|
243
246
|
*/
|
|
244
247
|
ref: import_zod.z.string().trim().min(1).optional(),
|
|
245
248
|
hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
|
|
@@ -341,6 +344,8 @@ var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
|
|
|
341
344
|
ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
|
|
342
345
|
ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
|
|
343
346
|
ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
|
|
347
|
+
ErrorTypes2["KbStampBaselineUnreadable"] = "KbStampBaselineUnreadable";
|
|
348
|
+
ErrorTypes2["KbStampDigestBaselineAmbiguous"] = "KbStampDigestBaselineAmbiguous";
|
|
344
349
|
ErrorTypes2["KbUnknownLinkRel"] = "KbUnknownLinkRel";
|
|
345
350
|
ErrorTypes2["KbWriteConflict"] = "KbWriteConflict";
|
|
346
351
|
return ErrorTypes2;
|
|
@@ -495,6 +500,36 @@ var KbInvalidConceptIdError = class extends BaseError {
|
|
|
495
500
|
});
|
|
496
501
|
}
|
|
497
502
|
};
|
|
503
|
+
var KbStampBaselineError = class extends BaseError {
|
|
504
|
+
constructor(since) {
|
|
505
|
+
super({
|
|
506
|
+
message: `kb: --since ${since} is neither a 64-character digest nor a readable stamp file`,
|
|
507
|
+
errorType: "KbStampBaselineUnreadable" /* KbStampBaselineUnreadable */,
|
|
508
|
+
code: 400,
|
|
509
|
+
fault: "User" /* User */,
|
|
510
|
+
retriable: false,
|
|
511
|
+
reportToUser: true,
|
|
512
|
+
details: { since }
|
|
513
|
+
});
|
|
514
|
+
this.since = since;
|
|
515
|
+
}
|
|
516
|
+
since;
|
|
517
|
+
};
|
|
518
|
+
var KbStampDigestBaselineError = class extends BaseError {
|
|
519
|
+
constructor(since) {
|
|
520
|
+
super({
|
|
521
|
+
message: `kb: --since ${since} is a digest, which needs --bundle (one base) \u2014 a file baseline works for many`,
|
|
522
|
+
errorType: "KbStampDigestBaselineAmbiguous" /* KbStampDigestBaselineAmbiguous */,
|
|
523
|
+
code: 400,
|
|
524
|
+
fault: "User" /* User */,
|
|
525
|
+
retriable: false,
|
|
526
|
+
reportToUser: true,
|
|
527
|
+
details: { since }
|
|
528
|
+
});
|
|
529
|
+
this.since = since;
|
|
530
|
+
}
|
|
531
|
+
since;
|
|
532
|
+
};
|
|
498
533
|
|
|
499
534
|
// src/kb-index.ts
|
|
500
535
|
var INDEX_FILE = "INDEX.md";
|
|
@@ -517,6 +552,436 @@ function indexIsStale(stored, expected) {
|
|
|
517
552
|
return stored !== expected;
|
|
518
553
|
}
|
|
519
554
|
|
|
555
|
+
// src/remote-repo/cache.ts
|
|
556
|
+
var import_node_os = require("os");
|
|
557
|
+
var import_node_path = require("path");
|
|
558
|
+
|
|
559
|
+
// src/anchor-resolver/repo-identity.ts
|
|
560
|
+
var import_node_child_process = require("child_process");
|
|
561
|
+
var import_node_util = require("util");
|
|
562
|
+
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
563
|
+
function normalizeRepoUrl(value) {
|
|
564
|
+
let url = value.trim().replace(/^git\+/, "");
|
|
565
|
+
const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
|
|
566
|
+
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
567
|
+
url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
|
|
568
|
+
url = trimTrailingSlashes(url);
|
|
569
|
+
if (url.endsWith(".git")) url = url.slice(0, -4);
|
|
570
|
+
return trimTrailingSlashes(url).toLowerCase();
|
|
571
|
+
}
|
|
572
|
+
function trimTrailingSlashes(value) {
|
|
573
|
+
let end = value.length;
|
|
574
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
575
|
+
return value.slice(0, end);
|
|
576
|
+
}
|
|
577
|
+
function isCanonicalRepoUrl(value) {
|
|
578
|
+
return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
|
|
579
|
+
}
|
|
580
|
+
function repoPath(normalized) {
|
|
581
|
+
const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
|
|
582
|
+
const segments = withoutScheme.split("/").filter(Boolean);
|
|
583
|
+
return segments.length > 1 ? segments.slice(1).join("/") : "";
|
|
584
|
+
}
|
|
585
|
+
function repoIdentifies(declared, originUrl) {
|
|
586
|
+
if (!originUrl) return false;
|
|
587
|
+
const origin = normalizeRepoUrl(originUrl);
|
|
588
|
+
const want = normalizeRepoUrl(declared);
|
|
589
|
+
if (!want || !origin) return false;
|
|
590
|
+
if (want === origin) return true;
|
|
591
|
+
const path = repoPath(origin);
|
|
592
|
+
if (!path) return false;
|
|
593
|
+
return want === path || want === (path.split("/").pop() ?? "");
|
|
594
|
+
}
|
|
595
|
+
async function repoOriginUrl(repoRoot) {
|
|
596
|
+
try {
|
|
597
|
+
const { stdout } = await execFileAsync(
|
|
598
|
+
"git",
|
|
599
|
+
["-C", repoRoot, "config", "--get", "remote.origin.url"],
|
|
600
|
+
{ timeout: 5e3 }
|
|
601
|
+
);
|
|
602
|
+
return stdout.trim() || null;
|
|
603
|
+
} catch {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
var LazyOrigin = class {
|
|
608
|
+
constructor(repoRoot) {
|
|
609
|
+
this.repoRoot = repoRoot;
|
|
610
|
+
}
|
|
611
|
+
repoRoot;
|
|
612
|
+
url = null;
|
|
613
|
+
asked = false;
|
|
614
|
+
/** Asks git once, so later `isForeign` calls need no await. */
|
|
615
|
+
async prime() {
|
|
616
|
+
if (this.asked) return;
|
|
617
|
+
this.url = await repoOriginUrl(this.repoRoot);
|
|
618
|
+
this.asked = true;
|
|
619
|
+
}
|
|
620
|
+
/** Only meaningful after `prime`; an unprimed origin identifies nothing. */
|
|
621
|
+
isForeign(anchor) {
|
|
622
|
+
if (!anchor.repo) return false;
|
|
623
|
+
return !repoIdentifies(anchor.repo, this.url);
|
|
624
|
+
}
|
|
625
|
+
async foreign(anchor) {
|
|
626
|
+
if (!anchor.repo) return false;
|
|
627
|
+
await this.prime();
|
|
628
|
+
return this.isForeign(anchor);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// src/remote-repo/cache.ts
|
|
633
|
+
function repoCacheDir(override) {
|
|
634
|
+
return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".strauss", "repo-cache");
|
|
635
|
+
}
|
|
636
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
|
|
637
|
+
function fetchTimeoutMs(override) {
|
|
638
|
+
if (override !== void 0) return override;
|
|
639
|
+
const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
|
|
640
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
|
|
641
|
+
}
|
|
642
|
+
function cachePathFor(repo, cacheDir) {
|
|
643
|
+
const normalized = normalizeRepoUrl(repo);
|
|
644
|
+
const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
|
|
645
|
+
if (!scheme) return null;
|
|
646
|
+
const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
|
|
647
|
+
if (segments.length < 2 || segments.some((segment) => segment === null)) {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
const path = segments;
|
|
651
|
+
return (0, import_node_path.join)(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
|
|
652
|
+
}
|
|
653
|
+
function safeSegment(value) {
|
|
654
|
+
return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
|
|
655
|
+
}
|
|
656
|
+
function revRef(rev) {
|
|
657
|
+
const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
|
|
658
|
+
let hash = 5381;
|
|
659
|
+
for (let at = 0; at < rev.length; at++) {
|
|
660
|
+
hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
|
|
661
|
+
}
|
|
662
|
+
return `refs/strauss/${safe}-${hash.toString(16)}`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/remote-repo/model.ts
|
|
666
|
+
var UNCHECKED_REASONS = [
|
|
667
|
+
"remote-unreachable",
|
|
668
|
+
"repo-unauthorized",
|
|
669
|
+
"default-branch-unknown"
|
|
670
|
+
];
|
|
671
|
+
function isUncheckedReason(reason) {
|
|
672
|
+
return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
|
|
673
|
+
}
|
|
674
|
+
function wantKey(repo, ref, file) {
|
|
675
|
+
return `${repo}\0${ref ?? ""}\0${file}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/remote-repo/read.ts
|
|
679
|
+
var import_promises = require("fs/promises");
|
|
680
|
+
|
|
681
|
+
// src/remote-repo/git.ts
|
|
682
|
+
var import_node_child_process2 = require("child_process");
|
|
683
|
+
var import_node_util2 = require("util");
|
|
684
|
+
|
|
685
|
+
// src/anchor-resolver/model.ts
|
|
686
|
+
var MAX_ANCHOR_FILE_BYTES = 1048576;
|
|
687
|
+
|
|
688
|
+
// src/remote-repo/git.ts
|
|
689
|
+
var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
690
|
+
function childEnv() {
|
|
691
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
692
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
|
|
693
|
+
delete env[name];
|
|
694
|
+
}
|
|
695
|
+
return env;
|
|
696
|
+
}
|
|
697
|
+
async function git(args, options = {}) {
|
|
698
|
+
try {
|
|
699
|
+
const { stdout, stderr } = await execFileAsync2("git", args, {
|
|
700
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
701
|
+
timeout: options.timeoutMs ?? 3e4,
|
|
702
|
+
maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
|
|
703
|
+
encoding: "utf8",
|
|
704
|
+
windowsHide: true,
|
|
705
|
+
env: childEnv()
|
|
706
|
+
});
|
|
707
|
+
return { ok: true, stdout, stderr, overflowed: false };
|
|
708
|
+
} catch (error) {
|
|
709
|
+
const failure = error;
|
|
710
|
+
return {
|
|
711
|
+
ok: false,
|
|
712
|
+
stdout: failure.stdout ?? "",
|
|
713
|
+
stderr: failure.stderr ?? "",
|
|
714
|
+
overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function transportReason(stderr) {
|
|
719
|
+
const text = stderr.toLowerCase();
|
|
720
|
+
if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
|
|
721
|
+
return "repo-unauthorized";
|
|
722
|
+
}
|
|
723
|
+
if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
|
|
724
|
+
return "ref-not-found";
|
|
725
|
+
}
|
|
726
|
+
return "remote-unreachable";
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/remote-repo/validate.ts
|
|
730
|
+
var MAX_REF_LENGTH = 200;
|
|
731
|
+
var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
732
|
+
function refShapeIsSafe(ref) {
|
|
733
|
+
if (!ref || ref.length > MAX_REF_LENGTH) return false;
|
|
734
|
+
if (ref.includes("..")) return false;
|
|
735
|
+
return REF_SHAPE.test(ref);
|
|
736
|
+
}
|
|
737
|
+
async function refIsWellFormed(ref) {
|
|
738
|
+
if (!refShapeIsSafe(ref)) return false;
|
|
739
|
+
const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
|
|
740
|
+
return checked.ok;
|
|
741
|
+
}
|
|
742
|
+
function filePathIsSafe(file) {
|
|
743
|
+
const path = file.replace(/^\.\//, "");
|
|
744
|
+
if (!path || path.startsWith("-") || path.includes("\0")) return false;
|
|
745
|
+
return !path.split("/").includes("..");
|
|
746
|
+
}
|
|
747
|
+
var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
|
|
748
|
+
function allowedProtocols() {
|
|
749
|
+
const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
|
|
750
|
+
if (raw === void 0) return [...DEFAULT_PROTOCOLS];
|
|
751
|
+
const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
752
|
+
return listed.length ? listed : [...DEFAULT_PROTOCOLS];
|
|
753
|
+
}
|
|
754
|
+
function isShortRepoName(repo) {
|
|
755
|
+
return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
|
|
756
|
+
}
|
|
757
|
+
var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
|
|
758
|
+
var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
|
|
759
|
+
var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
760
|
+
function repoUrlIsSafe(repo) {
|
|
761
|
+
const url = repo.trim();
|
|
762
|
+
if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
|
|
763
|
+
const allowed = allowedProtocols();
|
|
764
|
+
if (SCP_LIKE.test(url)) return allowed.includes("ssh");
|
|
765
|
+
const scheme = URL_SCHEME.exec(url);
|
|
766
|
+
if (!scheme?.[1]) return false;
|
|
767
|
+
if (!allowed.includes(scheme[1].toLowerCase())) return false;
|
|
768
|
+
const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
|
|
769
|
+
const at = authority.lastIndexOf("@");
|
|
770
|
+
return at < 0 || !authority.slice(0, at).includes(":");
|
|
771
|
+
}
|
|
772
|
+
function protocolArgs() {
|
|
773
|
+
const allowed = allowedProtocols();
|
|
774
|
+
return [
|
|
775
|
+
"-c",
|
|
776
|
+
"protocol.ext.allow=never",
|
|
777
|
+
"-c",
|
|
778
|
+
`protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
|
|
779
|
+
];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/remote-repo/read.ts
|
|
783
|
+
var DEFAULT_REPO_CONCURRENCY = 4;
|
|
784
|
+
var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
|
|
785
|
+
async function readRemoteAnchors(wants, options = {}) {
|
|
786
|
+
const out = /* @__PURE__ */ new Map();
|
|
787
|
+
if (!wants.length) return out;
|
|
788
|
+
const cacheDir = repoCacheDir(options.cacheDir);
|
|
789
|
+
const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
|
|
790
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
791
|
+
for (const want of wants) {
|
|
792
|
+
const key2 = normalizeRepoUrl(want.repo);
|
|
793
|
+
const group2 = byRepo.get(key2) ?? { url: want.repo.trim(), wants: [] };
|
|
794
|
+
group2.wants.push(want);
|
|
795
|
+
byRepo.set(key2, group2);
|
|
796
|
+
}
|
|
797
|
+
const groups = [...byRepo.entries()];
|
|
798
|
+
const results = await mapLimit(
|
|
799
|
+
groups,
|
|
800
|
+
Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
|
|
801
|
+
([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
|
|
802
|
+
cacheDir,
|
|
803
|
+
timeoutMs,
|
|
804
|
+
offline: options.offline === true
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
for (const result of results) {
|
|
808
|
+
for (const [key2, read] of result) out.set(key2, read);
|
|
809
|
+
}
|
|
810
|
+
return out;
|
|
811
|
+
}
|
|
812
|
+
async function readOneRepo(repo, url, declared, context) {
|
|
813
|
+
let wants = declared;
|
|
814
|
+
const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
|
|
815
|
+
if (isShortRepoName(url)) {
|
|
816
|
+
return all({ ok: false, reason: "remote-unreachable" });
|
|
817
|
+
}
|
|
818
|
+
if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
|
|
819
|
+
const cache = cachePathFor(repo, context.cacheDir);
|
|
820
|
+
if (!cache) return all({ ok: false, reason: "remote-unreachable" });
|
|
821
|
+
const rejected = /* @__PURE__ */ new Map();
|
|
822
|
+
const usable = [];
|
|
823
|
+
for (const want of wants) {
|
|
824
|
+
const reason = wantReason(want);
|
|
825
|
+
if (reason)
|
|
826
|
+
rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
|
|
827
|
+
else usable.push(want);
|
|
828
|
+
}
|
|
829
|
+
if (!usable.length) return rejected;
|
|
830
|
+
wants = usable;
|
|
831
|
+
const opened = await openCache(cache, url, context);
|
|
832
|
+
if (opened) return new Map([...rejected, ...all(opened)]);
|
|
833
|
+
const wantsDefault = wants.some((want) => want.ref === void 0);
|
|
834
|
+
const branch = wantsDefault ? await defaultBranch(cache, context) : {};
|
|
835
|
+
const revs = /* @__PURE__ */ new Map();
|
|
836
|
+
for (const rev of distinctRevs(wants, branch.name)) {
|
|
837
|
+
revs.set(
|
|
838
|
+
rev,
|
|
839
|
+
await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
const reads = await mapLimit(
|
|
843
|
+
wants,
|
|
844
|
+
DEFAULT_IO_CONCURRENCY,
|
|
845
|
+
async (want) => {
|
|
846
|
+
const rev = want.ref ?? branch.name;
|
|
847
|
+
if (rev === void 0) {
|
|
848
|
+
return {
|
|
849
|
+
ok: false,
|
|
850
|
+
reason: branch.reason ?? "default-branch-unknown"
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
const failed = revs.get(rev);
|
|
854
|
+
if (failed) return failed;
|
|
855
|
+
return readBlob(cache, rev, want.file, context);
|
|
856
|
+
}
|
|
857
|
+
);
|
|
858
|
+
return new Map([
|
|
859
|
+
...rejected,
|
|
860
|
+
...wants.map(
|
|
861
|
+
(want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
|
|
862
|
+
)
|
|
863
|
+
]);
|
|
864
|
+
}
|
|
865
|
+
function wantReason(want) {
|
|
866
|
+
if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
|
|
867
|
+
return filePathIsSafe(want.file) ? void 0 : "outside-repo";
|
|
868
|
+
}
|
|
869
|
+
function distinctRevs(wants, branch) {
|
|
870
|
+
const revs = /* @__PURE__ */ new Set();
|
|
871
|
+
for (const want of wants) {
|
|
872
|
+
if (want.ref !== void 0) revs.add(want.ref);
|
|
873
|
+
else if (branch) revs.add(branch);
|
|
874
|
+
}
|
|
875
|
+
return [...revs];
|
|
876
|
+
}
|
|
877
|
+
async function openCache(cache, url, context) {
|
|
878
|
+
try {
|
|
879
|
+
await (0, import_promises.mkdir)(cache, { recursive: true });
|
|
880
|
+
} catch {
|
|
881
|
+
return { ok: false, reason: "remote-unreachable" };
|
|
882
|
+
}
|
|
883
|
+
const init = await git(["init", "--bare", "--quiet", cache], {
|
|
884
|
+
timeoutMs: context.timeoutMs
|
|
885
|
+
});
|
|
886
|
+
if (!init.ok) return { ok: false, reason: "remote-unreachable" };
|
|
887
|
+
const remote = await git(["config", "remote.origin.url", url], {
|
|
888
|
+
cwd: cache,
|
|
889
|
+
timeoutMs: context.timeoutMs
|
|
890
|
+
});
|
|
891
|
+
return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
|
|
892
|
+
}
|
|
893
|
+
async function defaultBranch(cache, context) {
|
|
894
|
+
if (!context.offline) {
|
|
895
|
+
const listed = await git(
|
|
896
|
+
[...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
|
|
897
|
+
{
|
|
898
|
+
cwd: cache,
|
|
899
|
+
timeoutMs: context.timeoutMs
|
|
900
|
+
}
|
|
901
|
+
);
|
|
902
|
+
const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
|
|
903
|
+
if (listed.ok && found?.[1]) {
|
|
904
|
+
const name = found[1];
|
|
905
|
+
await git(["config", "strauss.defaultBranch", name], { cwd: cache });
|
|
906
|
+
return { name };
|
|
907
|
+
}
|
|
908
|
+
if (!listed.ok) {
|
|
909
|
+
const reason = transportReason(listed.stderr);
|
|
910
|
+
if (reason !== "ref-not-found") {
|
|
911
|
+
const cached2 = await cachedBranch(cache);
|
|
912
|
+
return cached2 ? { name: cached2 } : { reason };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const cached = await cachedBranch(cache);
|
|
917
|
+
if (cached) return { name: cached };
|
|
918
|
+
return {
|
|
919
|
+
reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
async function cachedBranch(cache) {
|
|
923
|
+
const stored = await git(["config", "--get", "strauss.defaultBranch"], {
|
|
924
|
+
cwd: cache
|
|
925
|
+
});
|
|
926
|
+
if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
|
|
927
|
+
const head = await git(
|
|
928
|
+
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
929
|
+
{
|
|
930
|
+
cwd: cache
|
|
931
|
+
}
|
|
932
|
+
);
|
|
933
|
+
const name = head.stdout.trim().replace(/^origin\//, "");
|
|
934
|
+
return head.ok && name ? name : void 0;
|
|
935
|
+
}
|
|
936
|
+
async function ensureRev(cache, rev, context) {
|
|
937
|
+
const ref = revRef(rev);
|
|
938
|
+
const have = await git(
|
|
939
|
+
["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
|
|
940
|
+
{
|
|
941
|
+
cwd: cache
|
|
942
|
+
}
|
|
943
|
+
);
|
|
944
|
+
const cached = have.ok && have.stdout.trim().length > 0;
|
|
945
|
+
if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
|
|
946
|
+
if (context.offline) return { ok: false, reason: "remote-unreachable" };
|
|
947
|
+
const fetched = await git(
|
|
948
|
+
[
|
|
949
|
+
...protocolArgs(),
|
|
950
|
+
"fetch",
|
|
951
|
+
"--depth",
|
|
952
|
+
"1",
|
|
953
|
+
"origin",
|
|
954
|
+
"--end-of-options",
|
|
955
|
+
rev
|
|
956
|
+
],
|
|
957
|
+
{ cwd: cache, timeoutMs: context.timeoutMs }
|
|
958
|
+
);
|
|
959
|
+
if (!fetched.ok) {
|
|
960
|
+
const reason = transportReason(fetched.stderr);
|
|
961
|
+
if (cached && reason !== "ref-not-found") return void 0;
|
|
962
|
+
return { ok: false, reason };
|
|
963
|
+
}
|
|
964
|
+
const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
|
|
965
|
+
const sha = head.stdout.trim();
|
|
966
|
+
if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
|
|
967
|
+
const updated = await git(["update-ref", ref, sha], { cwd: cache });
|
|
968
|
+
return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
|
|
969
|
+
}
|
|
970
|
+
async function readBlob(cache, rev, file, context) {
|
|
971
|
+
const path = file.replace(/^\.\//, "");
|
|
972
|
+
const blob = await git(
|
|
973
|
+
["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
|
|
974
|
+
{
|
|
975
|
+
cwd: cache,
|
|
976
|
+
timeoutMs: context.timeoutMs
|
|
977
|
+
}
|
|
978
|
+
);
|
|
979
|
+
if (blob.ok) return { ok: true, source: blob.stdout };
|
|
980
|
+
if (blob.overflowed) return { ok: false, reason: "file-too-large" };
|
|
981
|
+
const text = blob.stderr.toLowerCase();
|
|
982
|
+
return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
|
|
983
|
+
}
|
|
984
|
+
|
|
520
985
|
// src/adjudicate.ts
|
|
521
986
|
var STANDING = {
|
|
522
987
|
accepted: "current",
|
|
@@ -557,23 +1022,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
|
|
|
557
1022
|
if (!record.frontmatter.verified?.length) {
|
|
558
1023
|
warnings.push({ kind: "unverified" });
|
|
559
1024
|
}
|
|
560
|
-
const
|
|
561
|
-
(entry) => entry.state !== "match"
|
|
1025
|
+
const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
|
|
1026
|
+
(entry) => entry.state !== "match"
|
|
562
1027
|
);
|
|
1028
|
+
const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
|
|
1029
|
+
const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
|
|
563
1030
|
if (moved.length) {
|
|
1031
|
+
warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
|
|
1032
|
+
}
|
|
1033
|
+
if (unchecked2.length) {
|
|
564
1034
|
warnings.push({
|
|
565
|
-
kind: "
|
|
566
|
-
anchors:
|
|
567
|
-
file,
|
|
568
|
-
...symbol !== void 0 ? { symbol } : {},
|
|
569
|
-
diffSize,
|
|
570
|
-
...reason !== void 0 ? { reason } : {}
|
|
571
|
-
}))
|
|
1035
|
+
kind: "unchecked",
|
|
1036
|
+
anchors: unchecked2.map(warningAnchor)
|
|
572
1037
|
});
|
|
573
1038
|
}
|
|
574
1039
|
return { record, standing: STANDING[status], heads, warnings };
|
|
575
1040
|
});
|
|
576
1041
|
}
|
|
1042
|
+
function warningAnchor(entry) {
|
|
1043
|
+
const { file, symbol, diffSize, reason, repo, remoteState } = entry;
|
|
1044
|
+
return {
|
|
1045
|
+
file,
|
|
1046
|
+
...symbol !== void 0 ? { symbol } : {},
|
|
1047
|
+
diffSize,
|
|
1048
|
+
...reason !== void 0 ? { reason } : {},
|
|
1049
|
+
...repo !== void 0 ? { repo } : {},
|
|
1050
|
+
...remoteState !== void 0 ? { remoteState } : {}
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
577
1053
|
function resolveHeads(from, byId) {
|
|
578
1054
|
const warnings = [];
|
|
579
1055
|
const heads = /* @__PURE__ */ new Map();
|
|
@@ -623,14 +1099,130 @@ function successors(record, byId) {
|
|
|
623
1099
|
return { records, missing };
|
|
624
1100
|
}
|
|
625
1101
|
|
|
626
|
-
// src/
|
|
627
|
-
var import_node_child_process = require("child_process");
|
|
1102
|
+
// src/kb-stamp.ts
|
|
628
1103
|
var import_node_crypto = require("crypto");
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1104
|
+
function sha256(contents) {
|
|
1105
|
+
return (0, import_node_crypto.createHash)("sha256").update(contents).digest("hex");
|
|
1106
|
+
}
|
|
1107
|
+
function bundleStamp(records, superseded) {
|
|
1108
|
+
const entries = [
|
|
1109
|
+
...records.map((hit) => ({
|
|
1110
|
+
conceptId: hit.record.conceptId,
|
|
1111
|
+
digest: `current:${sha256(
|
|
1112
|
+
stringifyMarkdownWithFrontmatter(
|
|
1113
|
+
hit.record.body,
|
|
1114
|
+
hit.record.frontmatter
|
|
1115
|
+
)
|
|
1116
|
+
)}`
|
|
1117
|
+
})),
|
|
1118
|
+
...superseded.map((entry) => ({
|
|
1119
|
+
conceptId: entry.conceptId,
|
|
1120
|
+
digest: `superseded:${sha256(JSON.stringify(entry))}`
|
|
1121
|
+
}))
|
|
1122
|
+
].sort((a, b) => a.conceptId < b.conceptId ? -1 : 1);
|
|
1123
|
+
return {
|
|
1124
|
+
digest: sha256(
|
|
1125
|
+
entries.map((entry) => `${entry.conceptId}:${entry.digest}`).join("\n")
|
|
1126
|
+
),
|
|
1127
|
+
records: entries
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
function bundleDigest(records, superseded) {
|
|
1131
|
+
return bundleStamp(records, superseded).digest;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// src/anchor-resolver/read.ts
|
|
1135
|
+
var import_promises2 = require("fs/promises");
|
|
1136
|
+
var import_node_path2 = require("path");
|
|
1137
|
+
function anchorFilePath(repoRoot, file) {
|
|
1138
|
+
const path = (0, import_node_path2.resolve)(repoRoot, file.replace(/^\.\//, ""));
|
|
1139
|
+
const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(repoRoot), path);
|
|
1140
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path2.sep}`) || (0, import_node_path2.isAbsolute)(rel)) {
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
return path;
|
|
1144
|
+
}
|
|
1145
|
+
function contains(root, path) {
|
|
1146
|
+
const rel = (0, import_node_path2.relative)(root, path);
|
|
1147
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path2.sep}`) && !(0, import_node_path2.isAbsolute)(rel);
|
|
1148
|
+
}
|
|
1149
|
+
function errorCode(error) {
|
|
1150
|
+
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
|
|
1151
|
+
}
|
|
1152
|
+
function anchorFileReader(repoRoot) {
|
|
1153
|
+
let rootOnce;
|
|
1154
|
+
const realRoot = () => {
|
|
1155
|
+
rootOnce ??= (0, import_promises2.realpath)((0, import_node_path2.resolve)(repoRoot)).catch((error) => {
|
|
1156
|
+
rootOnce = void 0;
|
|
1157
|
+
throw error;
|
|
1158
|
+
});
|
|
1159
|
+
return rootOnce;
|
|
1160
|
+
};
|
|
1161
|
+
return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
|
|
1162
|
+
}
|
|
1163
|
+
async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
|
|
1164
|
+
const lexical = anchorFilePath(repoRoot, file);
|
|
1165
|
+
if (lexical === null) return { ok: false, reason: "outside-repo" };
|
|
1166
|
+
let root;
|
|
1167
|
+
let path;
|
|
1168
|
+
try {
|
|
1169
|
+
root = await realRoot();
|
|
1170
|
+
path = await (0, import_promises2.realpath)(lexical);
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
const code = errorCode(error);
|
|
1173
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
1174
|
+
return { ok: false, reason: "file-missing" };
|
|
1175
|
+
}
|
|
1176
|
+
return { ok: false, reason: "file-unreadable" };
|
|
1177
|
+
}
|
|
1178
|
+
if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
|
|
1179
|
+
try {
|
|
1180
|
+
const stats = await (0, import_promises2.stat)(path);
|
|
1181
|
+
if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
|
|
1182
|
+
if (stats.size > MAX_ANCHOR_FILE_BYTES) {
|
|
1183
|
+
return { ok: false, reason: "file-too-large" };
|
|
1184
|
+
}
|
|
1185
|
+
return { ok: true, source: await (0, import_promises2.readFile)(path, "utf8") };
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
const code = errorCode(error);
|
|
1188
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
1189
|
+
return { ok: false, reason: "file-missing" };
|
|
1190
|
+
}
|
|
1191
|
+
return { ok: false, reason: "file-unreadable" };
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
function looksLikeWrongRepoRoot(drift) {
|
|
1195
|
+
let checked = 0;
|
|
1196
|
+
for (const entries of drift.values()) {
|
|
1197
|
+
for (const entry of entries) {
|
|
1198
|
+
if (entry.repo !== void 0) continue;
|
|
1199
|
+
checked += 1;
|
|
1200
|
+
if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
|
|
1201
|
+
return false;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return checked > 0;
|
|
1206
|
+
}
|
|
1207
|
+
async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
|
|
1208
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
1209
|
+
throw new RangeError(
|
|
1210
|
+
`readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
const wanted = [...new Set(files)];
|
|
1214
|
+
const results = await mapLimit(wanted, concurrency, async (file) => {
|
|
1215
|
+
try {
|
|
1216
|
+
return await read(file);
|
|
1217
|
+
} catch {
|
|
1218
|
+
return { ok: false, reason: "file-unreadable" };
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
return new Map(wanted.map((file, at) => [file, results[at]]));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/anchor-resolver/resolver.ts
|
|
1225
|
+
var import_node_crypto2 = require("crypto");
|
|
634
1226
|
var PARENT_SCOPE_LINES = 50;
|
|
635
1227
|
var CLEAN_STATE = { blockComment: false, template: false };
|
|
636
1228
|
function stripLine(line, state) {
|
|
@@ -761,202 +1353,53 @@ var regexResolver = {
|
|
|
761
1353
|
const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
|
|
762
1354
|
const lines = source.split("\n");
|
|
763
1355
|
for (const tier of TIERS) {
|
|
764
|
-
const pattern = tier(escaped);
|
|
765
|
-
let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
|
|
766
|
-
if (!candidates.length) continue;
|
|
767
|
-
if (parentPattern && candidates.length > 1) {
|
|
768
|
-
const distances = candidates.map(
|
|
769
|
-
(index) => distanceToParent(lines, index, parentPattern)
|
|
770
|
-
);
|
|
771
|
-
const nearest = Math.min(...distances);
|
|
772
|
-
if (Number.isFinite(nearest)) {
|
|
773
|
-
candidates = candidates.filter((_, at) => distances[at] === nearest);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
if (candidates.length !== 1) return null;
|
|
777
|
-
const matchLine = candidates[0];
|
|
778
|
-
return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
|
|
779
|
-
}
|
|
780
|
-
return null;
|
|
781
|
-
}
|
|
782
|
-
};
|
|
783
|
-
function escapeRegExp(value) {
|
|
784
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
785
|
-
}
|
|
786
|
-
function distanceToParent(lines, index, parent) {
|
|
787
|
-
const floor = Math.max(0, index - PARENT_SCOPE_LINES);
|
|
788
|
-
for (let at = index; at >= floor; at--) {
|
|
789
|
-
if (parent.test(lines[at] ?? "")) return index - at;
|
|
790
|
-
}
|
|
791
|
-
return Number.POSITIVE_INFINITY;
|
|
792
|
-
}
|
|
793
|
-
function hashAnchorText(text) {
|
|
794
|
-
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
|
|
795
|
-
}
|
|
796
|
-
function resolveAnchor(source, anchor, resolver = regexResolver) {
|
|
797
|
-
const normalized = source.replace(/\r\n/g, "\n");
|
|
798
|
-
if (!anchor.symbol) {
|
|
799
|
-
const lines = normalized.split("\n");
|
|
800
|
-
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
801
|
-
return {
|
|
802
|
-
text: normalized,
|
|
803
|
-
startLine: 1,
|
|
804
|
-
endLine: Math.max(1, lines.length)
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
return resolver.resolve(normalized, anchor.symbol);
|
|
808
|
-
}
|
|
809
|
-
function anchorFilePath(repoRoot, file) {
|
|
810
|
-
const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
|
|
811
|
-
const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
|
|
812
|
-
if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
|
|
813
|
-
return null;
|
|
814
|
-
}
|
|
815
|
-
return path;
|
|
816
|
-
}
|
|
817
|
-
function contains(root, path) {
|
|
818
|
-
const rel = (0, import_node_path.relative)(root, path);
|
|
819
|
-
return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
|
|
820
|
-
}
|
|
821
|
-
function normalizeRepoUrl(value) {
|
|
822
|
-
let url = value.trim().replace(/^git\+/, "");
|
|
823
|
-
const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
|
|
824
|
-
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
825
|
-
url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
|
|
826
|
-
url = trimTrailingSlashes(url);
|
|
827
|
-
if (url.endsWith(".git")) url = url.slice(0, -4);
|
|
828
|
-
return trimTrailingSlashes(url).toLowerCase();
|
|
829
|
-
}
|
|
830
|
-
function trimTrailingSlashes(value) {
|
|
831
|
-
let end = value.length;
|
|
832
|
-
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
833
|
-
return value.slice(0, end);
|
|
834
|
-
}
|
|
835
|
-
function repoPath(normalized) {
|
|
836
|
-
const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
|
|
837
|
-
const segments = withoutScheme.split("/").filter(Boolean);
|
|
838
|
-
return segments.length > 1 ? segments.slice(1).join("/") : "";
|
|
839
|
-
}
|
|
840
|
-
function repoIdentifies(declared, originUrl) {
|
|
841
|
-
if (!originUrl) return false;
|
|
842
|
-
const origin = normalizeRepoUrl(originUrl);
|
|
843
|
-
const want = normalizeRepoUrl(declared);
|
|
844
|
-
if (!want || !origin) return false;
|
|
845
|
-
if (want === origin) return true;
|
|
846
|
-
const path = repoPath(origin);
|
|
847
|
-
if (!path) return false;
|
|
848
|
-
return want === path || want === (path.split("/").pop() ?? "");
|
|
849
|
-
}
|
|
850
|
-
async function repoOriginUrl(repoRoot) {
|
|
851
|
-
try {
|
|
852
|
-
const { stdout } = await execFileAsync(
|
|
853
|
-
"git",
|
|
854
|
-
["-C", repoRoot, "config", "--get", "remote.origin.url"],
|
|
855
|
-
{ timeout: 5e3 }
|
|
856
|
-
);
|
|
857
|
-
return stdout.trim() || null;
|
|
858
|
-
} catch {
|
|
859
|
-
return null;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
var LazyOrigin = class {
|
|
863
|
-
constructor(repoRoot) {
|
|
864
|
-
this.repoRoot = repoRoot;
|
|
865
|
-
}
|
|
866
|
-
repoRoot;
|
|
867
|
-
url = null;
|
|
868
|
-
asked = false;
|
|
869
|
-
/** Asks git once, so later `isForeign` calls need no await. */
|
|
870
|
-
async prime() {
|
|
871
|
-
if (this.asked) return;
|
|
872
|
-
this.url = await repoOriginUrl(this.repoRoot);
|
|
873
|
-
this.asked = true;
|
|
874
|
-
}
|
|
875
|
-
/** Only meaningful after `prime`; an unprimed origin identifies nothing. */
|
|
876
|
-
isForeign(anchor) {
|
|
877
|
-
if (!anchor.repo) return false;
|
|
878
|
-
return !repoIdentifies(anchor.repo, this.url);
|
|
879
|
-
}
|
|
880
|
-
async foreign(anchor) {
|
|
881
|
-
if (!anchor.repo) return false;
|
|
882
|
-
await this.prime();
|
|
883
|
-
return this.isForeign(anchor);
|
|
884
|
-
}
|
|
885
|
-
};
|
|
886
|
-
function errorCode(error) {
|
|
887
|
-
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
|
|
888
|
-
}
|
|
889
|
-
function anchorFileReader(repoRoot) {
|
|
890
|
-
let rootOnce;
|
|
891
|
-
const realRoot = () => {
|
|
892
|
-
rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
|
|
893
|
-
rootOnce = void 0;
|
|
894
|
-
throw error;
|
|
895
|
-
});
|
|
896
|
-
return rootOnce;
|
|
897
|
-
};
|
|
898
|
-
return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
|
|
899
|
-
}
|
|
900
|
-
async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
|
|
901
|
-
const lexical = anchorFilePath(repoRoot, file);
|
|
902
|
-
if (lexical === null) return { ok: false, reason: "outside-repo" };
|
|
903
|
-
let root;
|
|
904
|
-
let path;
|
|
905
|
-
try {
|
|
906
|
-
root = await realRoot();
|
|
907
|
-
path = await (0, import_promises.realpath)(lexical);
|
|
908
|
-
} catch (error) {
|
|
909
|
-
const code = errorCode(error);
|
|
910
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
911
|
-
return { ok: false, reason: "file-missing" };
|
|
912
|
-
}
|
|
913
|
-
return { ok: false, reason: "file-unreadable" };
|
|
914
|
-
}
|
|
915
|
-
if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
|
|
916
|
-
try {
|
|
917
|
-
const stats = await (0, import_promises.stat)(path);
|
|
918
|
-
if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
|
|
919
|
-
if (stats.size > MAX_ANCHOR_FILE_BYTES) {
|
|
920
|
-
return { ok: false, reason: "file-too-large" };
|
|
921
|
-
}
|
|
922
|
-
return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
|
|
923
|
-
} catch (error) {
|
|
924
|
-
const code = errorCode(error);
|
|
925
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
926
|
-
return { ok: false, reason: "file-missing" };
|
|
1356
|
+
const pattern = tier(escaped);
|
|
1357
|
+
let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
|
|
1358
|
+
if (!candidates.length) continue;
|
|
1359
|
+
if (parentPattern && candidates.length > 1) {
|
|
1360
|
+
const distances = candidates.map(
|
|
1361
|
+
(index) => distanceToParent(lines, index, parentPattern)
|
|
1362
|
+
);
|
|
1363
|
+
const nearest = Math.min(...distances);
|
|
1364
|
+
if (Number.isFinite(nearest)) {
|
|
1365
|
+
candidates = candidates.filter((_, at) => distances[at] === nearest);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
if (candidates.length !== 1) return null;
|
|
1369
|
+
const matchLine = candidates[0];
|
|
1370
|
+
return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
|
|
927
1371
|
}
|
|
928
|
-
return
|
|
1372
|
+
return null;
|
|
929
1373
|
}
|
|
1374
|
+
};
|
|
1375
|
+
function escapeRegExp(value) {
|
|
1376
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
930
1377
|
}
|
|
931
|
-
function
|
|
932
|
-
|
|
933
|
-
for (
|
|
934
|
-
|
|
935
|
-
if (entry.reason === "foreign-repo") continue;
|
|
936
|
-
checked += 1;
|
|
937
|
-
if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
|
|
938
|
-
return false;
|
|
939
|
-
}
|
|
940
|
-
}
|
|
1378
|
+
function distanceToParent(lines, index, parent) {
|
|
1379
|
+
const floor = Math.max(0, index - PARENT_SCOPE_LINES);
|
|
1380
|
+
for (let at = index; at >= floor; at--) {
|
|
1381
|
+
if (parent.test(lines[at] ?? "")) return index - at;
|
|
941
1382
|
}
|
|
942
|
-
return
|
|
1383
|
+
return Number.POSITIVE_INFINITY;
|
|
943
1384
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1385
|
+
function hashAnchorText(text) {
|
|
1386
|
+
return `sha256:${(0, import_node_crypto2.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
|
|
1387
|
+
}
|
|
1388
|
+
function resolveAnchor(source, anchor, resolver = regexResolver) {
|
|
1389
|
+
const normalized = source.replace(/\r\n/g, "\n");
|
|
1390
|
+
if (!anchor.symbol) {
|
|
1391
|
+
const lines = normalized.split("\n");
|
|
1392
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
1393
|
+
return {
|
|
1394
|
+
text: normalized,
|
|
1395
|
+
startLine: 1,
|
|
1396
|
+
endLine: Math.max(1, lines.length)
|
|
1397
|
+
};
|
|
949
1398
|
}
|
|
950
|
-
|
|
951
|
-
const results = await mapLimit(wanted, concurrency, async (file) => {
|
|
952
|
-
try {
|
|
953
|
-
return await read(file);
|
|
954
|
-
} catch {
|
|
955
|
-
return { ok: false, reason: "file-unreadable" };
|
|
956
|
-
}
|
|
957
|
-
});
|
|
958
|
-
return new Map(wanted.map((file, at) => [file, results[at]]));
|
|
1399
|
+
return resolver.resolve(normalized, anchor.symbol);
|
|
959
1400
|
}
|
|
1401
|
+
|
|
1402
|
+
// src/anchor-resolver/drift.ts
|
|
960
1403
|
async function detectAnchorDrift(records, options = {}) {
|
|
961
1404
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
962
1405
|
const resolver = options.resolver ?? regexResolver;
|
|
@@ -982,71 +1425,101 @@ async function detectAnchorDrift(records, options = {}) {
|
|
|
982
1425
|
}
|
|
983
1426
|
}
|
|
984
1427
|
const files = [];
|
|
1428
|
+
const wants = [];
|
|
985
1429
|
for (const entries of planned.values()) {
|
|
986
|
-
for (const
|
|
987
|
-
if (!
|
|
1430
|
+
for (const { anchor, foreign } of entries) {
|
|
1431
|
+
if (!foreign) files.push(anchor.file);
|
|
1432
|
+
else wants.push(...remoteWants(anchor));
|
|
988
1433
|
}
|
|
989
1434
|
}
|
|
990
|
-
const reads = await
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1435
|
+
const [reads, remote] = await Promise.all([
|
|
1436
|
+
readAnchorFiles(
|
|
1437
|
+
files,
|
|
1438
|
+
options.reader ?? anchorFileReader(repoRoot),
|
|
1439
|
+
options.concurrency ?? DEFAULT_IO_CONCURRENCY
|
|
1440
|
+
),
|
|
1441
|
+
(options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
|
|
1442
|
+
]);
|
|
995
1443
|
const drift = /* @__PURE__ */ new Map();
|
|
996
1444
|
for (const record of records) {
|
|
997
1445
|
const entries = [];
|
|
998
1446
|
for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
storedHash: anchor.hash
|
|
1003
|
-
};
|
|
1004
|
-
if (foreign) {
|
|
1005
|
-
entries.push({
|
|
1006
|
-
...base,
|
|
1007
|
-
state: "unresolved",
|
|
1008
|
-
diffSize: null,
|
|
1009
|
-
reason: "foreign-repo"
|
|
1010
|
-
});
|
|
1011
|
-
continue;
|
|
1012
|
-
}
|
|
1013
|
-
const read = reads.get(anchor.file);
|
|
1014
|
-
if (!read.ok) {
|
|
1015
|
-
entries.push({
|
|
1016
|
-
...base,
|
|
1017
|
-
state: "unresolved",
|
|
1018
|
-
diffSize: null,
|
|
1019
|
-
reason: read.reason
|
|
1020
|
-
});
|
|
1021
|
-
continue;
|
|
1022
|
-
}
|
|
1023
|
-
const resolved = resolveAnchor(read.source, anchor, resolver);
|
|
1024
|
-
if (!resolved) {
|
|
1025
|
-
entries.push({
|
|
1026
|
-
...base,
|
|
1027
|
-
state: "unresolved",
|
|
1028
|
-
diffSize: null,
|
|
1029
|
-
reason: "symbol-not-found"
|
|
1030
|
-
});
|
|
1031
|
-
continue;
|
|
1032
|
-
}
|
|
1033
|
-
const currentHash = hashAnchorText(resolved.text);
|
|
1034
|
-
const currentLines = resolved.endLine - resolved.startLine + 1;
|
|
1035
|
-
entries.push({
|
|
1036
|
-
...base,
|
|
1037
|
-
state: currentHash === anchor.hash ? "match" : "drifted",
|
|
1038
|
-
currentHash,
|
|
1039
|
-
diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
|
|
1040
|
-
});
|
|
1447
|
+
entries.push(
|
|
1448
|
+
foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
|
|
1449
|
+
);
|
|
1041
1450
|
}
|
|
1042
1451
|
if (entries.length) drift.set(record.conceptId, entries);
|
|
1043
1452
|
}
|
|
1044
1453
|
return drift;
|
|
1045
1454
|
}
|
|
1455
|
+
function remoteWants(anchor) {
|
|
1456
|
+
const repo = anchor.repo;
|
|
1457
|
+
const wants = [{ repo, file: anchor.file }];
|
|
1458
|
+
if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
|
|
1459
|
+
return wants;
|
|
1460
|
+
}
|
|
1461
|
+
function base(anchor) {
|
|
1462
|
+
return {
|
|
1463
|
+
file: anchor.file,
|
|
1464
|
+
...anchor.symbol ? { symbol: anchor.symbol } : {},
|
|
1465
|
+
storedHash: anchor.hash
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
function unresolved(anchor, reason, repo) {
|
|
1469
|
+
return {
|
|
1470
|
+
...base(anchor),
|
|
1471
|
+
state: "unresolved",
|
|
1472
|
+
diffSize: null,
|
|
1473
|
+
...reason ? { reason } : {},
|
|
1474
|
+
...repo ? { repo } : {}
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
function hashIn(source, anchor, resolver) {
|
|
1478
|
+
const resolved = resolveAnchor(source, anchor, resolver);
|
|
1479
|
+
if (!resolved) return null;
|
|
1480
|
+
return {
|
|
1481
|
+
hash: hashAnchorText(resolved.text),
|
|
1482
|
+
lines: resolved.endLine - resolved.startLine + 1
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
function compared(anchor, current, extra = {}) {
|
|
1486
|
+
return {
|
|
1487
|
+
...base(anchor),
|
|
1488
|
+
state: current.hash === anchor.hash ? "match" : "drifted",
|
|
1489
|
+
currentHash: current.hash,
|
|
1490
|
+
diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
|
|
1491
|
+
...extra
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
function localEntry(anchor, read, resolver) {
|
|
1495
|
+
if (!read.ok) return unresolved(anchor, read.reason);
|
|
1496
|
+
const current = hashIn(read.source, anchor, resolver);
|
|
1497
|
+
return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
|
|
1498
|
+
}
|
|
1499
|
+
function remoteEntry(anchor, remote, resolver) {
|
|
1500
|
+
const repo = anchor.repo;
|
|
1501
|
+
const key2 = normalizeRepoUrl(repo);
|
|
1502
|
+
const atDefault = remote.get(wantKey(key2, void 0, anchor.file));
|
|
1503
|
+
const primary = anchor.ref ? remote.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
|
|
1504
|
+
if (!primary) return unresolved(anchor, "remote-unreachable", repo);
|
|
1505
|
+
if (!primary.ok) return unresolved(anchor, primary.reason, repo);
|
|
1506
|
+
const current = hashIn(primary.source, anchor, resolver);
|
|
1507
|
+
if (!current) return unresolved(anchor, "symbol-not-found", repo);
|
|
1508
|
+
if (!anchor.ref) return compared(anchor, current, { repo });
|
|
1509
|
+
if (current.hash !== anchor.hash) {
|
|
1510
|
+
return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
|
|
1511
|
+
}
|
|
1512
|
+
const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
|
|
1513
|
+
return head && head.hash !== anchor.hash ? {
|
|
1514
|
+
...compared(anchor, head, { repo }),
|
|
1515
|
+
state: "drifted",
|
|
1516
|
+
remoteState: "drifted-on-default"
|
|
1517
|
+
} : compared(anchor, current, { repo, remoteState: "matches-ref" });
|
|
1518
|
+
}
|
|
1046
1519
|
|
|
1047
1520
|
// src/search-index.ts
|
|
1048
|
-
var
|
|
1049
|
-
var
|
|
1521
|
+
var import_promises3 = require("fs/promises");
|
|
1522
|
+
var import_node_path3 = require("path");
|
|
1050
1523
|
|
|
1051
1524
|
// src/kb-log.ts
|
|
1052
1525
|
var import_zod2 = require("zod");
|
|
@@ -1109,7 +1582,7 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
1109
1582
|
let store = null;
|
|
1110
1583
|
try {
|
|
1111
1584
|
store = await qmd.createStore({
|
|
1112
|
-
dbPath: (0,
|
|
1585
|
+
dbPath: (0, import_node_path3.join)(bundlePath2, SEARCH_INDEX_FILE),
|
|
1113
1586
|
config: {
|
|
1114
1587
|
collections: {
|
|
1115
1588
|
[COLLECTION]: {
|
|
@@ -1144,7 +1617,7 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
1144
1617
|
}
|
|
1145
1618
|
}
|
|
1146
1619
|
async function isStale(bundlePath2) {
|
|
1147
|
-
const indexAt = await (0,
|
|
1620
|
+
const indexAt = await (0, import_promises3.stat)((0, import_node_path3.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
|
|
1148
1621
|
if (!indexAt) return true;
|
|
1149
1622
|
const { readdir: readdir2 } = await import("fs/promises");
|
|
1150
1623
|
const names = (await readdir2(bundlePath2).catch(() => [])).filter(
|
|
@@ -1153,7 +1626,7 @@ async function isStale(bundlePath2) {
|
|
|
1153
1626
|
let stale = false;
|
|
1154
1627
|
await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
|
|
1155
1628
|
if (stale) return;
|
|
1156
|
-
const at = await (0,
|
|
1629
|
+
const at = await (0, import_promises3.stat)((0, import_node_path3.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
|
|
1157
1630
|
if (at > indexAt) stale = true;
|
|
1158
1631
|
});
|
|
1159
1632
|
return stale;
|
|
@@ -1743,7 +2216,7 @@ function appendUnionMergeLine(contents) {
|
|
|
1743
2216
|
}
|
|
1744
2217
|
|
|
1745
2218
|
// src/kb-store.ts
|
|
1746
|
-
var KB_DIR = (0,
|
|
2219
|
+
var KB_DIR = (0, import_node_path4.join)(".strauss", "kb");
|
|
1747
2220
|
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
1748
2221
|
var DEFAULT_LOAD_BUDGET = 25e3;
|
|
1749
2222
|
var KbStore = class {
|
|
@@ -1774,7 +2247,7 @@ var KbStore = class {
|
|
|
1774
2247
|
const conceptId2 = `${input.type}.${input.slug}`;
|
|
1775
2248
|
const root = this.root(bundlePath2);
|
|
1776
2249
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
1777
|
-
await (0,
|
|
2250
|
+
await (0, import_promises4.mkdir)(root, { recursive: true });
|
|
1778
2251
|
await this.publish(
|
|
1779
2252
|
target,
|
|
1780
2253
|
stringifyMarkdownWithFrontmatter(input.body, frontmatter),
|
|
@@ -1813,7 +2286,7 @@ var KbStore = class {
|
|
|
1813
2286
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
1814
2287
|
let raw;
|
|
1815
2288
|
try {
|
|
1816
|
-
raw = await (0,
|
|
2289
|
+
raw = await (0, import_promises4.readFile)(target, "utf8");
|
|
1817
2290
|
} catch {
|
|
1818
2291
|
return null;
|
|
1819
2292
|
}
|
|
@@ -1830,7 +2303,7 @@ var KbStore = class {
|
|
|
1830
2303
|
const root = this.root(bundlePath2);
|
|
1831
2304
|
let names;
|
|
1832
2305
|
try {
|
|
1833
|
-
names = await (0,
|
|
2306
|
+
names = await (0, import_promises4.readdir)(root);
|
|
1834
2307
|
} catch {
|
|
1835
2308
|
return [];
|
|
1836
2309
|
}
|
|
@@ -1838,7 +2311,7 @@ var KbStore = class {
|
|
|
1838
2311
|
const records = await mapLimit(
|
|
1839
2312
|
wanted,
|
|
1840
2313
|
DEFAULT_IO_CONCURRENCY,
|
|
1841
|
-
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0,
|
|
2314
|
+
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises4.readFile)((0, import_node_path4.join)(root, name), "utf8"))
|
|
1842
2315
|
);
|
|
1843
2316
|
return records.filter((record) => record !== null);
|
|
1844
2317
|
}
|
|
@@ -2006,7 +2479,8 @@ ${answer}
|
|
|
2006
2479
|
* at the repo root, and the MCP server's cwd is the workspace.
|
|
2007
2480
|
*
|
|
2008
2481
|
* Public because `doctor` needs the same map with the same degradation: a
|
|
2009
|
-
* sweep that failed to read the tree should report no drift, not fail
|
|
2482
|
+
* sweep that failed to read the tree should report no drift, not fail — and
|
|
2483
|
+
* `offline: false` there, because a sweep is worth a fetch.
|
|
2010
2484
|
*
|
|
2011
2485
|
* When no root was given and not one anchored file was found, the finding is
|
|
2012
2486
|
* discarded. A base read from somewhere other than the tree it describes
|
|
@@ -2018,10 +2492,13 @@ ${answer}
|
|
|
2018
2492
|
* plausible, and the misses become findings again; an explicit `repoRoot` is
|
|
2019
2493
|
* taken at its word either way.
|
|
2020
2494
|
*/
|
|
2021
|
-
async detectDrift(records, repoRoot) {
|
|
2495
|
+
async detectDrift(records, repoRoot, options = {}) {
|
|
2022
2496
|
try {
|
|
2023
2497
|
const drift = await detectAnchorDrift(records, {
|
|
2024
|
-
repoRoot: repoRoot ?? process.cwd()
|
|
2498
|
+
repoRoot: repoRoot ?? process.cwd(),
|
|
2499
|
+
// Offline by default: a read path must never spend a network fetch per
|
|
2500
|
+
// call. `doctor` and `anchor-resolve` are the verbs that go get it.
|
|
2501
|
+
remote: { offline: options.offline !== false }
|
|
2025
2502
|
});
|
|
2026
2503
|
if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
|
|
2027
2504
|
this.logger.warn?.({
|
|
@@ -2109,6 +2586,28 @@ ${answer}
|
|
|
2109
2586
|
digest: bundleDigestValue
|
|
2110
2587
|
};
|
|
2111
2588
|
}
|
|
2589
|
+
/**
|
|
2590
|
+
* `load`'s digest without `load`'s bodies — the same records, adjudicated
|
|
2591
|
+
* the same way, handed back as a stamp. Skips the anchor drift pass, which
|
|
2592
|
+
* reads source files and only ever adds warnings: no warning reaches the
|
|
2593
|
+
* digest, so the value is identical to the one `load` returns.
|
|
2594
|
+
*/
|
|
2595
|
+
async stamp(bundlePath2) {
|
|
2596
|
+
const bundle = await this.list(bundlePath2);
|
|
2597
|
+
const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
|
|
2598
|
+
const current = adjudicated.filter((hit) => hit.standing !== "superseded");
|
|
2599
|
+
const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
|
|
2600
|
+
const stamped = bundleStamp(current, superseded);
|
|
2601
|
+
const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
|
|
2602
|
+
return {
|
|
2603
|
+
path: bundlePath2,
|
|
2604
|
+
digest: stamped.digest,
|
|
2605
|
+
recordCount: bundle.length,
|
|
2606
|
+
superseded: superseded.length,
|
|
2607
|
+
newestAt: dates.at(-1) ?? null,
|
|
2608
|
+
records: stamped.records
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2112
2611
|
/** How a position was arrived at, as a timeline. See `trace.ts`. */
|
|
2113
2612
|
async trace(bundlePath2, seedId, options = {}) {
|
|
2114
2613
|
return trace(seedId, await this.list(bundlePath2), options);
|
|
@@ -2139,11 +2638,11 @@ ${answer}
|
|
|
2139
2638
|
async readIndex(bundlePath2) {
|
|
2140
2639
|
const root = this.root(bundlePath2);
|
|
2141
2640
|
const expected = renderIndex(await this.list(bundlePath2));
|
|
2142
|
-
const stored = await (0,
|
|
2641
|
+
const stored = await (0, import_promises4.readFile)((0, import_node_path4.join)(root, INDEX_FILE), "utf8").catch(
|
|
2143
2642
|
() => null
|
|
2144
2643
|
);
|
|
2145
2644
|
if (indexIsStale(stored, expected)) {
|
|
2146
|
-
await this.publish((0,
|
|
2645
|
+
await this.publish((0, import_node_path4.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
|
|
2147
2646
|
this.logger.info?.({
|
|
2148
2647
|
operation: "kb.index.repair",
|
|
2149
2648
|
bundlePath: root,
|
|
@@ -2160,8 +2659,8 @@ ${answer}
|
|
|
2160
2659
|
* knows which agent touched what. So a bad line is surfaced and left alone.
|
|
2161
2660
|
*/
|
|
2162
2661
|
async readLog(bundlePath2) {
|
|
2163
|
-
const raw = await (0,
|
|
2164
|
-
(0,
|
|
2662
|
+
const raw = await (0, import_promises4.readFile)(
|
|
2663
|
+
(0, import_node_path4.join)(this.root(bundlePath2), LOG_FILE),
|
|
2165
2664
|
"utf8"
|
|
2166
2665
|
).catch(() => "");
|
|
2167
2666
|
const result = parseLog(raw);
|
|
@@ -2212,15 +2711,15 @@ ${answer}
|
|
|
2212
2711
|
}
|
|
2213
2712
|
async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
|
|
2214
2713
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
2215
|
-
const before = await (0,
|
|
2714
|
+
const before = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
|
|
2216
2715
|
if (before === null) throw new KbRecordNotFoundError(conceptId2);
|
|
2217
2716
|
const parsed = this.parse(conceptId2, before);
|
|
2218
2717
|
if (!parsed) throw new KbRecordNotFoundError(conceptId2);
|
|
2219
2718
|
const frontmatter = change(parsed.frontmatter);
|
|
2220
2719
|
const body = changeBody(parsed.body);
|
|
2221
2720
|
const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
|
|
2222
|
-
const witness = await (0,
|
|
2223
|
-
if (witness === null ||
|
|
2721
|
+
const witness = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
|
|
2722
|
+
if (witness === null || sha256(witness) !== sha256(before)) {
|
|
2224
2723
|
throw new KbWriteConflictError(conceptId2);
|
|
2225
2724
|
}
|
|
2226
2725
|
await this.publish(target, contents, true, conceptId2);
|
|
@@ -2245,20 +2744,20 @@ ${answer}
|
|
|
2245
2744
|
*/
|
|
2246
2745
|
async publish(target, contents, overwrite, conceptId2) {
|
|
2247
2746
|
const staging = `${target}.${process.pid}.tmp`;
|
|
2248
|
-
await (0,
|
|
2747
|
+
await (0, import_promises4.writeFile)(staging, contents, "utf8");
|
|
2249
2748
|
try {
|
|
2250
2749
|
if (overwrite) {
|
|
2251
|
-
await (0,
|
|
2750
|
+
await (0, import_promises4.rename)(staging, target);
|
|
2252
2751
|
return;
|
|
2253
2752
|
}
|
|
2254
|
-
await (0,
|
|
2753
|
+
await (0, import_promises4.link)(staging, target);
|
|
2255
2754
|
} catch (error) {
|
|
2256
2755
|
if (error.code === "EEXIST") {
|
|
2257
2756
|
throw new KbRecordAlreadyExistsError(conceptId2);
|
|
2258
2757
|
}
|
|
2259
2758
|
throw error;
|
|
2260
2759
|
} finally {
|
|
2261
|
-
await (0,
|
|
2760
|
+
await (0, import_promises4.unlink)(staging).catch(() => void 0);
|
|
2262
2761
|
}
|
|
2263
2762
|
}
|
|
2264
2763
|
/**
|
|
@@ -2302,18 +2801,18 @@ ${answer}
|
|
|
2302
2801
|
* file must not fail the mutation it guards.
|
|
2303
2802
|
*/
|
|
2304
2803
|
async ensureGitattributes(root) {
|
|
2305
|
-
const target = (0,
|
|
2804
|
+
const target = (0, import_node_path4.join)(root, GITATTRIBUTES_FILE);
|
|
2306
2805
|
try {
|
|
2307
2806
|
let existing;
|
|
2308
2807
|
try {
|
|
2309
|
-
existing = await (0,
|
|
2808
|
+
existing = await (0, import_promises4.readFile)(target, "utf8");
|
|
2310
2809
|
} catch (error) {
|
|
2311
2810
|
if (error.code !== "ENOENT") throw error;
|
|
2312
2811
|
existing = null;
|
|
2313
2812
|
}
|
|
2314
2813
|
if (existing === null) {
|
|
2315
2814
|
try {
|
|
2316
|
-
await (0,
|
|
2815
|
+
await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
|
|
2317
2816
|
encoding: "utf8",
|
|
2318
2817
|
flag: "wx"
|
|
2319
2818
|
});
|
|
@@ -2334,7 +2833,7 @@ ${answer}
|
|
|
2334
2833
|
return;
|
|
2335
2834
|
}
|
|
2336
2835
|
if (!hasMergeDeclaration(existing)) {
|
|
2337
|
-
await (0,
|
|
2836
|
+
await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
|
|
2338
2837
|
this.logger.info?.({
|
|
2339
2838
|
operation: "kb.gitattributes.ensure",
|
|
2340
2839
|
bundlePath: root,
|
|
@@ -2353,7 +2852,7 @@ ${answer}
|
|
|
2353
2852
|
async record(root, entry) {
|
|
2354
2853
|
await this.ensureGitattributes(root);
|
|
2355
2854
|
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
2356
|
-
await (0,
|
|
2855
|
+
await (0, import_promises4.appendFile)((0, import_node_path4.join)(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
2357
2856
|
this.logger.warn?.({
|
|
2358
2857
|
operation: "kb.log.append",
|
|
2359
2858
|
outcome: "failed",
|
|
@@ -2379,18 +2878,18 @@ ${answer}
|
|
|
2379
2878
|
};
|
|
2380
2879
|
}
|
|
2381
2880
|
root(bundlePath2) {
|
|
2382
|
-
return (0,
|
|
2881
|
+
return (0, import_node_path4.resolve)(bundlePath2);
|
|
2383
2882
|
}
|
|
2384
2883
|
// Concept ids are `<type>.<slug>` and map to a single file directly under the
|
|
2385
2884
|
// bundle root; anything carrying a separator would escape it.
|
|
2386
2885
|
recordPath(bundlePath2, conceptId2) {
|
|
2387
|
-
if (conceptId2.includes(
|
|
2886
|
+
if (conceptId2.includes(import_node_path4.sep) || conceptId2.includes("/")) {
|
|
2388
2887
|
throw new KbInvalidConceptIdError(
|
|
2389
2888
|
"concept id must not contain a path separator",
|
|
2390
2889
|
{ conceptId: conceptId2 }
|
|
2391
2890
|
);
|
|
2392
2891
|
}
|
|
2393
|
-
return (0,
|
|
2892
|
+
return (0, import_node_path4.join)(this.root(bundlePath2), `${conceptId2}.md`);
|
|
2394
2893
|
}
|
|
2395
2894
|
};
|
|
2396
2895
|
function estimateTokens(record) {
|
|
@@ -2428,25 +2927,6 @@ function normalizeActor(id) {
|
|
|
2428
2927
|
if (colon === -1) return id.toLowerCase();
|
|
2429
2928
|
return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
|
|
2430
2929
|
}
|
|
2431
|
-
function digest(contents) {
|
|
2432
|
-
return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
|
|
2433
|
-
}
|
|
2434
|
-
function bundleDigest(records, superseded) {
|
|
2435
|
-
const entries = [
|
|
2436
|
-
...records.map(
|
|
2437
|
-
(hit) => `${hit.record.conceptId}:current:${digest(
|
|
2438
|
-
stringifyMarkdownWithFrontmatter(
|
|
2439
|
-
hit.record.body,
|
|
2440
|
-
hit.record.frontmatter
|
|
2441
|
-
)
|
|
2442
|
-
)}`
|
|
2443
|
-
),
|
|
2444
|
-
...superseded.map(
|
|
2445
|
-
(entry) => `${entry.conceptId}:superseded:${digest(JSON.stringify(entry))}`
|
|
2446
|
-
)
|
|
2447
|
-
].sort();
|
|
2448
|
-
return digest(entries.join("\n"));
|
|
2449
|
-
}
|
|
2450
2930
|
|
|
2451
2931
|
// src/compose.ts
|
|
2452
2932
|
var import_zod3 = require("zod");
|
|
@@ -2619,18 +3099,18 @@ var KbBaseFrozenError = class extends Error {
|
|
|
2619
3099
|
};
|
|
2620
3100
|
|
|
2621
3101
|
// src/kb-pins/frozen.ts
|
|
2622
|
-
var
|
|
3102
|
+
var import_node_path7 = require("path");
|
|
2623
3103
|
|
|
2624
3104
|
// src/kb-pins/layers.ts
|
|
2625
|
-
var
|
|
2626
|
-
var
|
|
2627
|
-
var
|
|
3105
|
+
var import_promises5 = require("fs/promises");
|
|
3106
|
+
var import_node_os2 = require("os");
|
|
3107
|
+
var import_node_path6 = require("path");
|
|
2628
3108
|
|
|
2629
3109
|
// src/kb-pins/model.ts
|
|
2630
|
-
var
|
|
3110
|
+
var import_node_path5 = require("path");
|
|
2631
3111
|
var import_zod4 = require("zod");
|
|
2632
|
-
var PINS_FILE = (0,
|
|
2633
|
-
var PINS_LOCAL_FILE = (0,
|
|
3112
|
+
var PINS_FILE = (0, import_node_path5.join)(".strauss", "kb-pins.json");
|
|
3113
|
+
var PINS_LOCAL_FILE = (0, import_node_path5.join)(".strauss", "kb-pins.local.json");
|
|
2634
3114
|
var PIN_LAYERS = ["project", "local", "user"];
|
|
2635
3115
|
var pinSchema = import_zod4.z.object({
|
|
2636
3116
|
/** Relative to the manifest's root, so the file is committable. */
|
|
@@ -2677,13 +3157,13 @@ var pinsManifestSchema = import_zod4.z.object({
|
|
|
2677
3157
|
|
|
2678
3158
|
// src/kb-pins/layers.ts
|
|
2679
3159
|
function userRoot() {
|
|
2680
|
-
return process.env.STRAUSS_KB_USER_ROOT || (0,
|
|
3160
|
+
return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os2.homedir)();
|
|
2681
3161
|
}
|
|
2682
3162
|
function layerRoot(workspaceDir, layer) {
|
|
2683
|
-
return layer === "user" ? userRoot() : (0,
|
|
3163
|
+
return layer === "user" ? userRoot() : (0, import_node_path6.resolve)(workspaceDir);
|
|
2684
3164
|
}
|
|
2685
3165
|
function layerFile(workspaceDir, layer) {
|
|
2686
|
-
return (0,
|
|
3166
|
+
return (0, import_node_path6.join)(
|
|
2687
3167
|
layerRoot(workspaceDir, layer),
|
|
2688
3168
|
layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
|
|
2689
3169
|
);
|
|
@@ -2692,7 +3172,7 @@ async function readPinsLayer(workspaceDir, layer) {
|
|
|
2692
3172
|
const file = layerFile(workspaceDir, layer);
|
|
2693
3173
|
let raw;
|
|
2694
3174
|
try {
|
|
2695
|
-
raw = await (0,
|
|
3175
|
+
raw = await (0, import_promises5.readFile)(file, "utf8");
|
|
2696
3176
|
} catch {
|
|
2697
3177
|
return { pins: [] };
|
|
2698
3178
|
}
|
|
@@ -2716,16 +3196,16 @@ async function readPinsLayer(workspaceDir, layer) {
|
|
|
2716
3196
|
}
|
|
2717
3197
|
async function writePinsLayer(workspaceDir, layer, manifest) {
|
|
2718
3198
|
const file = layerFile(workspaceDir, layer);
|
|
2719
|
-
await (0,
|
|
2720
|
-
await (0,
|
|
3199
|
+
await (0, import_promises5.mkdir)((0, import_node_path6.dirname)(file), { recursive: true });
|
|
3200
|
+
await (0, import_promises5.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
|
|
2721
3201
|
`, "utf8");
|
|
2722
3202
|
}
|
|
2723
3203
|
function resolvePinPath(rootDir, path) {
|
|
2724
|
-
return (0,
|
|
3204
|
+
return (0, import_node_path6.isAbsolute)(path) ? (0, import_node_path6.resolve)(path) : (0, import_node_path6.resolve)(rootDir, path.split("/").join(import_node_path6.sep));
|
|
2725
3205
|
}
|
|
2726
3206
|
function storablePath(rootDir, bundlePath2) {
|
|
2727
|
-
const rel = (0,
|
|
2728
|
-
return (rel === "" ? "." : rel).split(
|
|
3207
|
+
const rel = (0, import_node_path6.relative)((0, import_node_path6.resolve)(rootDir), (0, import_node_path6.resolve)(bundlePath2));
|
|
3208
|
+
return (rel === "" ? "." : rel).split(import_node_path6.sep).join("/");
|
|
2729
3209
|
}
|
|
2730
3210
|
async function readMergedPins(workspaceDir) {
|
|
2731
3211
|
const manifests = {};
|
|
@@ -2753,7 +3233,7 @@ async function readMergedPins(workspaceDir) {
|
|
|
2753
3233
|
// src/kb-pins/frozen.ts
|
|
2754
3234
|
async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
|
|
2755
3235
|
const merged = await readMergedPins(workspaceDir);
|
|
2756
|
-
const absolute = (0,
|
|
3236
|
+
const absolute = (0, import_node_path7.resolve)(bundlePath2);
|
|
2757
3237
|
const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
|
|
2758
3238
|
if (pin?.frozen === true) {
|
|
2759
3239
|
throw new KbBaseFrozenError(pin.path, pin.layer);
|
|
@@ -2838,7 +3318,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
|
|
|
2838
3318
|
}
|
|
2839
3319
|
|
|
2840
3320
|
// src/kb-pins/unpin.ts
|
|
2841
|
-
var
|
|
3321
|
+
var import_node_path8 = require("path");
|
|
2842
3322
|
async function unpinBase(workspaceDir, bundlePath2) {
|
|
2843
3323
|
const layers = [];
|
|
2844
3324
|
for (const layer of PIN_LAYERS) {
|
|
@@ -2859,14 +3339,14 @@ async function unpinBase(workspaceDir, bundlePath2) {
|
|
|
2859
3339
|
}
|
|
2860
3340
|
}
|
|
2861
3341
|
return {
|
|
2862
|
-
path: storablePath((0,
|
|
3342
|
+
path: storablePath((0, import_node_path8.resolve)(workspaceDir), bundlePath2),
|
|
2863
3343
|
removed: layers.length > 0,
|
|
2864
3344
|
layers
|
|
2865
3345
|
};
|
|
2866
3346
|
}
|
|
2867
3347
|
|
|
2868
3348
|
// src/kb-context.ts
|
|
2869
|
-
var
|
|
3349
|
+
var import_promises6 = require("fs/promises");
|
|
2870
3350
|
var HEADING2 = "## Knowledge bases (pinned)";
|
|
2871
3351
|
var DEFAULT_CONTEXT_BUDGET = 4e3;
|
|
2872
3352
|
var CONTEXT_PROFILES = {
|
|
@@ -3025,7 +3505,7 @@ async function buildContext(store, workspaceDir, options = {}) {
|
|
|
3025
3505
|
operation: "kb.context.refused",
|
|
3026
3506
|
approxTokens: total,
|
|
3027
3507
|
budgetTokens,
|
|
3028
|
-
bases: bases.map((
|
|
3508
|
+
bases: bases.map((base2) => base2.path)
|
|
3029
3509
|
});
|
|
3030
3510
|
const refusal = [
|
|
3031
3511
|
HEADING2,
|
|
@@ -3035,7 +3515,7 @@ async function buildContext(store, workspaceDir, options = {}) {
|
|
|
3035
3515
|
"from a complete one. The pinned bases:",
|
|
3036
3516
|
"",
|
|
3037
3517
|
...bases.map(
|
|
3038
|
-
(
|
|
3518
|
+
(base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
|
|
3039
3519
|
),
|
|
3040
3520
|
"",
|
|
3041
3521
|
"For the question at hand, read what you need now \u2014 `kb_load` a base",
|
|
@@ -3070,13 +3550,13 @@ function toHookJson(block, event) {
|
|
|
3070
3550
|
var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
|
|
3071
3551
|
var CONTEXT_END = "<!-- strauss-kb:end -->";
|
|
3072
3552
|
async function syncInstructions(file, block) {
|
|
3073
|
-
const existing = await (0,
|
|
3553
|
+
const existing = await (0, import_promises6.readFile)(file, "utf8").catch(() => null);
|
|
3074
3554
|
const region = block ? `${CONTEXT_BEGIN}
|
|
3075
3555
|
${block.trim()}
|
|
3076
3556
|
${CONTEXT_END}` : null;
|
|
3077
3557
|
if (existing === null) {
|
|
3078
3558
|
if (!region) return { file, action: "unchanged" };
|
|
3079
|
-
await (0,
|
|
3559
|
+
await (0, import_promises6.writeFile)(file, `${region}
|
|
3080
3560
|
`, "utf8");
|
|
3081
3561
|
return { file, action: "created" };
|
|
3082
3562
|
}
|
|
@@ -3087,11 +3567,11 @@ ${CONTEXT_END}` : null;
|
|
|
3087
3567
|
const after = existing.slice(end + CONTEXT_END.length);
|
|
3088
3568
|
const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
|
|
3089
3569
|
if (next === existing) return { file, action: "unchanged" };
|
|
3090
|
-
await (0,
|
|
3570
|
+
await (0, import_promises6.writeFile)(file, next, "utf8");
|
|
3091
3571
|
return { file, action: region ? "replaced" : "removed" };
|
|
3092
3572
|
}
|
|
3093
3573
|
if (!region) return { file, action: "unchanged" };
|
|
3094
|
-
await (0,
|
|
3574
|
+
await (0, import_promises6.writeFile)(
|
|
3095
3575
|
file,
|
|
3096
3576
|
`${existing.replace(/\n*$/, "\n\n")}${region}
|
|
3097
3577
|
`,
|
|
@@ -3250,6 +3730,16 @@ function validateBundle(records) {
|
|
|
3250
3730
|
);
|
|
3251
3731
|
}
|
|
3252
3732
|
}
|
|
3733
|
+
for (const anchor of fm.strauss_anchors ?? []) {
|
|
3734
|
+
if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
|
|
3735
|
+
report(
|
|
3736
|
+
"anchor_repo",
|
|
3737
|
+
conceptId2,
|
|
3738
|
+
`anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
|
|
3739
|
+
"warning"
|
|
3740
|
+
);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3253
3743
|
if (fm.strauss_assumption && fm.sources?.length) {
|
|
3254
3744
|
report("assumption", conceptId2, "marked an assumption but cites sources");
|
|
3255
3745
|
}
|
|
@@ -3269,7 +3759,8 @@ var KB_DOCTOR_CHECKS = [
|
|
|
3269
3759
|
"orphaned",
|
|
3270
3760
|
"broken-supersession",
|
|
3271
3761
|
"superseded-but-cited",
|
|
3272
|
-
"drifted"
|
|
3762
|
+
"drifted",
|
|
3763
|
+
"unchecked"
|
|
3273
3764
|
];
|
|
3274
3765
|
var CHECK_HEADLINES = {
|
|
3275
3766
|
expired: "past its stale_after date",
|
|
@@ -3279,7 +3770,8 @@ var CHECK_HEADLINES = {
|
|
|
3279
3770
|
orphaned: "no other record links to it",
|
|
3280
3771
|
"broken-supersession": "the supersession pointers do not resolve",
|
|
3281
3772
|
"superseded-but-cited": "a live record's body links to one that no longer holds",
|
|
3282
|
-
drifted: "the code an anchor points at moved out from under its hash"
|
|
3773
|
+
drifted: "the code an anchor points at moved out from under its hash",
|
|
3774
|
+
unchecked: "an anchor in another repository nothing could reach"
|
|
3283
3775
|
};
|
|
3284
3776
|
var DAY_MS = 864e5;
|
|
3285
3777
|
function doctor(bundle, options = {}) {
|
|
@@ -3304,7 +3796,8 @@ function doctor(bundle, options = {}) {
|
|
|
3304
3796
|
group("orphaned", orphaned(bundle)),
|
|
3305
3797
|
group("broken-supersession", brokenSupersession(bundle, adjudicated)),
|
|
3306
3798
|
group("superseded-but-cited", supersededButCited(bundle, standings)),
|
|
3307
|
-
group("drifted", drifted(inForce))
|
|
3799
|
+
group("drifted", drifted(inForce)),
|
|
3800
|
+
group("unchecked", unchecked(inForce))
|
|
3308
3801
|
];
|
|
3309
3802
|
const counts = Object.fromEntries(
|
|
3310
3803
|
groups.map((entry) => [entry.check, entry.count])
|
|
@@ -3491,21 +3984,38 @@ function supersededButCited(bundle, standings) {
|
|
|
3491
3984
|
return findings;
|
|
3492
3985
|
}
|
|
3493
3986
|
function drifted(hits) {
|
|
3987
|
+
return anchorFindings(
|
|
3988
|
+
hits,
|
|
3989
|
+
"drifted",
|
|
3990
|
+
(count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
|
|
3991
|
+
);
|
|
3992
|
+
}
|
|
3993
|
+
function unchecked(hits) {
|
|
3994
|
+
return anchorFindings(
|
|
3995
|
+
hits,
|
|
3996
|
+
"unchecked",
|
|
3997
|
+
(count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
|
|
3998
|
+
);
|
|
3999
|
+
}
|
|
4000
|
+
function anchorFindings(hits, kind, headline) {
|
|
3494
4001
|
const findings = [];
|
|
3495
4002
|
for (const hit of hits) {
|
|
3496
|
-
const warning = hit.warnings.find(
|
|
4003
|
+
const warning = hit.warnings.find(
|
|
4004
|
+
(entry) => entry.kind === kind
|
|
4005
|
+
);
|
|
3497
4006
|
if (!warning) continue;
|
|
4007
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
4008
|
+
for (const anchor of warning.anchors) {
|
|
4009
|
+
const repo = anchor.repo ?? "";
|
|
4010
|
+
byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
|
|
4011
|
+
}
|
|
4012
|
+
const detail = [...byRepo.entries()].map(
|
|
4013
|
+
([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
|
|
4014
|
+
);
|
|
3498
4015
|
findings.push(
|
|
3499
4016
|
finding(
|
|
3500
4017
|
hit.record,
|
|
3501
|
-
`${warning.anchors.length} ${warning.anchors.length
|
|
3502
|
-
const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
|
|
3503
|
-
if (anchor.reason) return `${at} (${anchor.reason})`;
|
|
3504
|
-
if (anchor.diffSize === null) {
|
|
3505
|
-
return `${at} (changed, size unrecorded)`;
|
|
3506
|
-
}
|
|
3507
|
-
return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
|
|
3508
|
-
}).join(", ")}`
|
|
4018
|
+
`${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
|
|
3509
4019
|
)
|
|
3510
4020
|
);
|
|
3511
4021
|
}
|
|
@@ -3513,6 +4023,15 @@ function drifted(hits) {
|
|
|
3513
4023
|
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
3514
4024
|
);
|
|
3515
4025
|
}
|
|
4026
|
+
function describeAnchor(anchor) {
|
|
4027
|
+
const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
|
|
4028
|
+
if (anchor.reason) return `${at} (${anchor.reason})`;
|
|
4029
|
+
if (anchor.remoteState === "drifted-on-default") {
|
|
4030
|
+
return `${at} (matches ref, moved on the default branch)`;
|
|
4031
|
+
}
|
|
4032
|
+
if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
|
|
4033
|
+
return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
|
|
4034
|
+
}
|
|
3516
4035
|
function replaces(later, earlier) {
|
|
3517
4036
|
return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
|
|
3518
4037
|
}
|
|
@@ -3615,12 +4134,15 @@ function argvFlag(argv, name) {
|
|
|
3615
4134
|
var anchorResolveCommand = define({
|
|
3616
4135
|
name: "anchor-resolve",
|
|
3617
4136
|
tool: "kb_anchor_resolve",
|
|
3618
|
-
usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
|
|
3619
|
-
description: "Resolve a record's anchors
|
|
4137
|
+
usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
|
|
4138
|
+
description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
|
|
3620
4139
|
input: import_zod8.z.object({
|
|
3621
4140
|
bundlePath,
|
|
3622
4141
|
conceptId,
|
|
3623
4142
|
repoRoot: import_zod8.z.string().min(1).optional(),
|
|
4143
|
+
offline: import_zod8.z.boolean().optional().describe(
|
|
4144
|
+
"Resolve foreign anchors from the local repo cache only, never fetching."
|
|
4145
|
+
),
|
|
3624
4146
|
rebaseline: import_zod8.z.boolean().optional().describe(
|
|
3625
4147
|
"Accept the current code as the new baseline for anchors that drifted."
|
|
3626
4148
|
),
|
|
@@ -3632,10 +4154,11 @@ var anchorResolveCommand = define({
|
|
|
3632
4154
|
bundlePath: path,
|
|
3633
4155
|
conceptId: argv[1],
|
|
3634
4156
|
repoRoot: argvFlag(argv, "--repo-root"),
|
|
4157
|
+
offline: argv.includes("--offline"),
|
|
3635
4158
|
rebaseline: argv.includes("--rebaseline"),
|
|
3636
4159
|
restamp: argv.includes("--restamp")
|
|
3637
4160
|
}),
|
|
3638
|
-
run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
|
|
4161
|
+
run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
|
|
3639
4162
|
const root = repoRoot ?? process.cwd();
|
|
3640
4163
|
const record = await store.read(path, id);
|
|
3641
4164
|
if (!record) throw new KbRecordNotFoundError(id);
|
|
@@ -3650,18 +4173,10 @@ var anchorResolveCommand = define({
|
|
|
3650
4173
|
}
|
|
3651
4174
|
const results = [];
|
|
3652
4175
|
const updated = [];
|
|
3653
|
-
const origin = new LazyOrigin(root);
|
|
3654
4176
|
let dirty = false;
|
|
3655
|
-
|
|
3656
|
-
const foreign = new Map(
|
|
3657
|
-
anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
|
|
3658
|
-
);
|
|
3659
|
-
const reads = await readAnchorFiles(
|
|
3660
|
-
anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
|
|
3661
|
-
anchorFileReader(root)
|
|
3662
|
-
);
|
|
4177
|
+
const sources = await readSources(anchors, root, offline === true);
|
|
3663
4178
|
for (const anchor of anchors) {
|
|
3664
|
-
const
|
|
4179
|
+
const base2 = {
|
|
3665
4180
|
file: anchor.file,
|
|
3666
4181
|
...anchor.symbol ? { symbol: anchor.symbol } : {},
|
|
3667
4182
|
// Carried onto unresolved findings too: an anchor that once hashed
|
|
@@ -3669,21 +4184,17 @@ var anchorResolveCommand = define({
|
|
|
3669
4184
|
// has to be able to tell it from one nobody ever stamped.
|
|
3670
4185
|
...anchor.hash ? { storedHash: anchor.hash } : {}
|
|
3671
4186
|
};
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
}
|
|
3677
|
-
const fileRead = reads.get(anchor.file);
|
|
3678
|
-
if (!fileRead.ok) {
|
|
3679
|
-
results.push({ ...base, state: "unresolved", reason: fileRead.reason });
|
|
4187
|
+
const source = sources.get(anchor);
|
|
4188
|
+
if (source.repo) base2.repo = source.repo;
|
|
4189
|
+
if (!source.ok) {
|
|
4190
|
+
results.push({ ...base2, state: "unresolved", reason: source.reason });
|
|
3680
4191
|
updated.push(anchor);
|
|
3681
4192
|
continue;
|
|
3682
4193
|
}
|
|
3683
|
-
const resolved = resolveAnchor(
|
|
4194
|
+
const resolved = resolveAnchor(source.source, anchor);
|
|
3684
4195
|
if (!resolved) {
|
|
3685
4196
|
results.push({
|
|
3686
|
-
...
|
|
4197
|
+
...base2,
|
|
3687
4198
|
state: "unresolved",
|
|
3688
4199
|
reason: "symbol-not-found"
|
|
3689
4200
|
});
|
|
@@ -3698,30 +4209,47 @@ var anchorResolveCommand = define({
|
|
|
3698
4209
|
lines: currentLines,
|
|
3699
4210
|
resolved_at: now()
|
|
3700
4211
|
};
|
|
4212
|
+
const pinned = anchor.ref !== void 0 && source.repo !== void 0;
|
|
3701
4213
|
if (!anchor.hash) {
|
|
3702
|
-
results.push({ ...
|
|
4214
|
+
results.push({ ...base2, state: "stamped", currentHash });
|
|
3703
4215
|
updated.push(stamped);
|
|
3704
4216
|
dirty = true;
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
state: "match",
|
|
3709
|
-
currentHash
|
|
3710
|
-
});
|
|
3711
|
-
const refresh = restamp || anchor.resolved_at === void 0;
|
|
3712
|
-
updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
|
|
3713
|
-
if (refresh) dirty = true;
|
|
3714
|
-
} else {
|
|
4217
|
+
continue;
|
|
4218
|
+
}
|
|
4219
|
+
if (anchor.hash !== currentHash) {
|
|
3715
4220
|
results.push({
|
|
3716
|
-
...
|
|
4221
|
+
...base2,
|
|
3717
4222
|
state: "drifted",
|
|
3718
4223
|
currentHash,
|
|
3719
|
-
diffSize: anchor
|
|
4224
|
+
diffSize: lineDelta(anchor, currentLines),
|
|
4225
|
+
...pinned ? { remoteState: "drifted-from-ref" } : {},
|
|
3720
4226
|
...rebaseline ? { rebaselined: true } : {}
|
|
3721
4227
|
});
|
|
3722
4228
|
updated.push(rebaseline ? stamped : anchor);
|
|
3723
4229
|
if (rebaseline) dirty = true;
|
|
4230
|
+
continue;
|
|
4231
|
+
}
|
|
4232
|
+
const onDefault = pinned ? headHash(source, anchor) : void 0;
|
|
4233
|
+
if (onDefault && onDefault.hash !== anchor.hash) {
|
|
4234
|
+
results.push({
|
|
4235
|
+
...base2,
|
|
4236
|
+
state: "drifted",
|
|
4237
|
+
currentHash: onDefault.hash,
|
|
4238
|
+
diffSize: lineDelta(anchor, onDefault.lines),
|
|
4239
|
+
remoteState: "drifted-on-default"
|
|
4240
|
+
});
|
|
4241
|
+
updated.push(anchor);
|
|
4242
|
+
continue;
|
|
3724
4243
|
}
|
|
4244
|
+
results.push({
|
|
4245
|
+
...base2,
|
|
4246
|
+
state: "match",
|
|
4247
|
+
currentHash,
|
|
4248
|
+
...pinned ? { remoteState: "matches-ref" } : {}
|
|
4249
|
+
});
|
|
4250
|
+
const refresh = restamp || anchor.resolved_at === void 0;
|
|
4251
|
+
updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
|
|
4252
|
+
if (refresh) dirty = true;
|
|
3725
4253
|
}
|
|
3726
4254
|
let frozen = false;
|
|
3727
4255
|
if (dirty) {
|
|
@@ -3734,16 +4262,19 @@ var anchorResolveCommand = define({
|
|
|
3734
4262
|
if (!frozen) await store.updateAnchors(path, id, updated, actor);
|
|
3735
4263
|
}
|
|
3736
4264
|
const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
|
|
3737
|
-
const
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
const
|
|
4265
|
+
const unreachable = results.filter(
|
|
4266
|
+
(entry) => isUncheckedReason(entry.reason)
|
|
4267
|
+
).length;
|
|
4268
|
+
const checked = results.length - unreachable;
|
|
4269
|
+
const matches2 = results.filter((entry) => entry.state === "match").length;
|
|
4270
|
+
const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
|
|
4271
|
+
const clean = checked > 0 && matches2 === checked && unreachable === 0;
|
|
3741
4272
|
if (clean) {
|
|
3742
4273
|
try {
|
|
3743
4274
|
await store.verify(
|
|
3744
4275
|
path,
|
|
3745
4276
|
id,
|
|
3746
|
-
`anchor-resolve: ${
|
|
4277
|
+
`anchor-resolve: ${note} (regex resolver)`,
|
|
3747
4278
|
actor,
|
|
3748
4279
|
now()
|
|
3749
4280
|
);
|
|
@@ -3759,18 +4290,81 @@ var anchorResolveCommand = define({
|
|
|
3759
4290
|
}
|
|
3760
4291
|
return { conceptId: id, results, verified: true, ...frozenNote };
|
|
3761
4292
|
}
|
|
3762
|
-
return {
|
|
4293
|
+
return {
|
|
4294
|
+
conceptId: id,
|
|
4295
|
+
results,
|
|
4296
|
+
verified: false,
|
|
4297
|
+
...unreachable ? { note } : {},
|
|
4298
|
+
...frozenNote
|
|
4299
|
+
};
|
|
3763
4300
|
},
|
|
3764
4301
|
// A stored hash that no longer resolves is a broken anchor, not an absence:
|
|
3765
4302
|
// the file was deleted or the symbol renamed, and exiting zero on it would
|
|
3766
4303
|
// let the one edit that destroys an anchor pass the gate that exists to
|
|
3767
4304
|
// catch it. An anchor nobody ever stamped is still just unstamped, and one
|
|
3768
|
-
//
|
|
3769
|
-
//
|
|
4305
|
+
// whose remote nothing could reach was never checked — failing CI on either
|
|
4306
|
+
// would gate on work this command did not do.
|
|
3770
4307
|
failsWhen: (result) => result.results.some(
|
|
3771
|
-
(entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason
|
|
4308
|
+
(entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
|
|
3772
4309
|
)
|
|
3773
4310
|
});
|
|
4311
|
+
function lineDelta(anchor, current) {
|
|
4312
|
+
return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
|
|
4313
|
+
}
|
|
4314
|
+
function headHash(source, anchor) {
|
|
4315
|
+
if (source.head === void 0) return void 0;
|
|
4316
|
+
const resolved = resolveAnchor(source.head, anchor);
|
|
4317
|
+
if (!resolved) return void 0;
|
|
4318
|
+
return {
|
|
4319
|
+
hash: hashAnchorText(resolved.text),
|
|
4320
|
+
lines: resolved.endLine - resolved.startLine + 1
|
|
4321
|
+
};
|
|
4322
|
+
}
|
|
4323
|
+
async function readSources(anchors, root, offline) {
|
|
4324
|
+
const origin = new LazyOrigin(root);
|
|
4325
|
+
if (anchors.some((anchor) => anchor.repo)) await origin.prime();
|
|
4326
|
+
const foreign = new Map(
|
|
4327
|
+
anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
|
|
4328
|
+
);
|
|
4329
|
+
const local = anchors.filter((anchor) => !foreign.get(anchor));
|
|
4330
|
+
const remote = anchors.filter((anchor) => foreign.get(anchor));
|
|
4331
|
+
const reads = await readAnchorFiles(
|
|
4332
|
+
local.map((anchor) => anchor.file),
|
|
4333
|
+
anchorFileReader(root)
|
|
4334
|
+
);
|
|
4335
|
+
const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
|
|
4336
|
+
offline
|
|
4337
|
+
});
|
|
4338
|
+
const sources = /* @__PURE__ */ new Map();
|
|
4339
|
+
for (const anchor of local) {
|
|
4340
|
+
const read = reads.get(anchor.file);
|
|
4341
|
+
sources.set(
|
|
4342
|
+
anchor,
|
|
4343
|
+
read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
|
|
4344
|
+
);
|
|
4345
|
+
}
|
|
4346
|
+
for (const anchor of remote) {
|
|
4347
|
+
const repo = anchor.repo;
|
|
4348
|
+
const key2 = normalizeRepoUrl(repo);
|
|
4349
|
+
const atDefault = blobs.get(wantKey(key2, void 0, anchor.file));
|
|
4350
|
+
const primary = anchor.ref ? blobs.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
|
|
4351
|
+
if (!primary?.ok) {
|
|
4352
|
+
sources.set(anchor, {
|
|
4353
|
+
ok: false,
|
|
4354
|
+
reason: primary?.ok === false ? primary.reason : "remote-unreachable",
|
|
4355
|
+
repo
|
|
4356
|
+
});
|
|
4357
|
+
continue;
|
|
4358
|
+
}
|
|
4359
|
+
sources.set(anchor, {
|
|
4360
|
+
ok: true,
|
|
4361
|
+
source: primary.source,
|
|
4362
|
+
repo,
|
|
4363
|
+
...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
|
|
4364
|
+
});
|
|
4365
|
+
}
|
|
4366
|
+
return sources;
|
|
4367
|
+
}
|
|
3774
4368
|
|
|
3775
4369
|
// src/commands/answer.ts
|
|
3776
4370
|
var import_zod9 = require("zod");
|
|
@@ -3928,8 +4522,8 @@ var days = (what, fallback) => import_zod13.z.number().int().positive().optional
|
|
|
3928
4522
|
var doctorCommand = define({
|
|
3929
4523
|
name: "doctor",
|
|
3930
4524
|
tool: "kb_doctor",
|
|
3931
|
-
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
|
|
3932
|
-
description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
|
|
4525
|
+
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
|
|
4526
|
+
description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
|
|
3933
4527
|
input: import_zod13.z.object({
|
|
3934
4528
|
bundlePath,
|
|
3935
4529
|
repoRoot: REPO_ROOT,
|
|
@@ -3945,6 +4539,9 @@ var doctorCommand = define({
|
|
|
3945
4539
|
"How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
|
|
3946
4540
|
DEFAULT_AGING_DAYS
|
|
3947
4541
|
),
|
|
4542
|
+
offline: import_zod13.z.boolean().optional().describe(
|
|
4543
|
+
"Read foreign anchors from the local repo cache only, never fetching."
|
|
4544
|
+
),
|
|
3948
4545
|
strict: import_zod13.z.boolean().optional().describe(
|
|
3949
4546
|
"Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
|
|
3950
4547
|
)
|
|
@@ -3964,13 +4561,23 @@ var doctorCommand = define({
|
|
|
3964
4561
|
...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
|
|
3965
4562
|
...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
|
|
3966
4563
|
...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
|
|
4564
|
+
...argv.includes("--offline") ? { offline: true } : {},
|
|
3967
4565
|
...argv.includes("--strict") ? { strict: true } : {}
|
|
3968
4566
|
};
|
|
3969
4567
|
},
|
|
3970
|
-
run: async ({ store, now }, {
|
|
4568
|
+
run: async ({ store, now }, {
|
|
4569
|
+
bundlePath: path,
|
|
4570
|
+
expiringDays,
|
|
4571
|
+
unverifiedDays,
|
|
4572
|
+
agingDays,
|
|
4573
|
+
repoRoot,
|
|
4574
|
+
offline
|
|
4575
|
+
}) => {
|
|
3971
4576
|
const checkedAt = now();
|
|
3972
4577
|
const records = await store.list(path);
|
|
3973
|
-
const anchorDrift = await store.detectDrift(records, repoRoot
|
|
4578
|
+
const anchorDrift = await store.detectDrift(records, repoRoot, {
|
|
4579
|
+
offline: offline === true
|
|
4580
|
+
});
|
|
3974
4581
|
const report = doctor(records, {
|
|
3975
4582
|
...expiringDays !== void 0 ? { expiringDays } : {},
|
|
3976
4583
|
...unverifiedDays !== void 0 ? { unverifiedDays } : {},
|
|
@@ -4380,17 +4987,115 @@ var schemaCommand = define({
|
|
|
4380
4987
|
run: () => Promise.resolve(kbJsonSchemas())
|
|
4381
4988
|
});
|
|
4382
4989
|
|
|
4383
|
-
// src/commands/
|
|
4990
|
+
// src/commands/stamp.ts
|
|
4991
|
+
var import_promises7 = require("fs/promises");
|
|
4384
4992
|
var import_zod25 = require("zod");
|
|
4993
|
+
var DIGEST = /^[0-9a-f]{64}$/;
|
|
4994
|
+
var stampCommand = define({
|
|
4995
|
+
name: "stamp",
|
|
4996
|
+
tool: "kb_stamp",
|
|
4997
|
+
usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
|
|
4998
|
+
description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids when the baseline is a prior stamp; silent when nothing changed. Reads, never writes.",
|
|
4999
|
+
input: import_zod25.z.object({
|
|
5000
|
+
bundlePath: import_zod25.z.string().min(1).optional().describe(
|
|
5001
|
+
"Absolute path to one knowledge base. Omit to stamp every pinned base."
|
|
5002
|
+
),
|
|
5003
|
+
since: import_zod25.z.string().min(1).optional().describe(
|
|
5004
|
+
"Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
|
|
5005
|
+
)
|
|
5006
|
+
}),
|
|
5007
|
+
fromArgv: (argv, path, _stdin, bundleExplicit) => {
|
|
5008
|
+
const since = argvFlag(argv, "--since");
|
|
5009
|
+
return {
|
|
5010
|
+
...bundleExplicit ? { bundlePath: path } : {},
|
|
5011
|
+
...since !== void 0 ? { since } : {}
|
|
5012
|
+
};
|
|
5013
|
+
},
|
|
5014
|
+
run: async ({ store }, { bundlePath: bundlePath2, since }) => {
|
|
5015
|
+
const targets = bundlePath2 ? [bundlePath2] : (await readMergedPins(process.cwd())).pins.map(
|
|
5016
|
+
(pin) => pin.absolutePath
|
|
5017
|
+
);
|
|
5018
|
+
if (since !== void 0 && DIGEST.test(since) && targets.length > 1) {
|
|
5019
|
+
throw new KbStampDigestBaselineError(since);
|
|
5020
|
+
}
|
|
5021
|
+
const stamps = await Promise.all(
|
|
5022
|
+
targets.map((target) => store.stamp(target))
|
|
5023
|
+
);
|
|
5024
|
+
if (since === void 0) {
|
|
5025
|
+
return stamps.map((stamp) => ({ ...stamp, changed: null }));
|
|
5026
|
+
}
|
|
5027
|
+
const baseline = await readBaseline(since);
|
|
5028
|
+
const reports = [];
|
|
5029
|
+
for (const stamp of stamps) {
|
|
5030
|
+
const before = baseline.byPath.get(stamp.path);
|
|
5031
|
+
if (baseline.digest !== null) {
|
|
5032
|
+
if (baseline.digest === stamp.digest) continue;
|
|
5033
|
+
reports.push({ ...stamp, changed: null });
|
|
5034
|
+
continue;
|
|
5035
|
+
}
|
|
5036
|
+
if (before && before.digest === stamp.digest) continue;
|
|
5037
|
+
reports.push({ ...stamp, changed: changedIds(before?.records, stamp) });
|
|
5038
|
+
}
|
|
5039
|
+
return reports;
|
|
5040
|
+
},
|
|
5041
|
+
render: (result) => result.map((report) => {
|
|
5042
|
+
const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
|
|
5043
|
+
const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
|
|
5044
|
+
return report.changed?.length ? `${head}
|
|
5045
|
+
changed: ${report.changed.join(", ")}` : head;
|
|
5046
|
+
}).join("\n")
|
|
5047
|
+
});
|
|
5048
|
+
function changedIds(before, stamp) {
|
|
5049
|
+
const now = new Map(
|
|
5050
|
+
stamp.records.map((record) => [record.conceptId, record.digest])
|
|
5051
|
+
);
|
|
5052
|
+
const ids = /* @__PURE__ */ new Set();
|
|
5053
|
+
for (const [conceptId2, digest] of now) {
|
|
5054
|
+
if (before?.get(conceptId2) !== digest) ids.add(conceptId2);
|
|
5055
|
+
}
|
|
5056
|
+
for (const conceptId2 of before?.keys() ?? []) {
|
|
5057
|
+
if (!now.has(conceptId2)) ids.add(conceptId2);
|
|
5058
|
+
}
|
|
5059
|
+
return [...ids].sort();
|
|
5060
|
+
}
|
|
5061
|
+
async function readBaseline(since) {
|
|
5062
|
+
if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
|
|
5063
|
+
let parsed;
|
|
5064
|
+
try {
|
|
5065
|
+
parsed = JSON.parse(await (0, import_promises7.readFile)(since, "utf8"));
|
|
5066
|
+
} catch {
|
|
5067
|
+
throw new KbStampBaselineError(since);
|
|
5068
|
+
}
|
|
5069
|
+
const entries = Array.isArray(parsed) ? parsed : parsed?.stamps ?? [];
|
|
5070
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
5071
|
+
for (const entry of entries) {
|
|
5072
|
+
if (typeof entry?.path !== "string" || typeof entry?.digest !== "string") {
|
|
5073
|
+
continue;
|
|
5074
|
+
}
|
|
5075
|
+
byPath.set(entry.path, {
|
|
5076
|
+
digest: entry.digest,
|
|
5077
|
+
records: new Map(
|
|
5078
|
+
(entry.records ?? []).map((record) => [
|
|
5079
|
+
record.conceptId,
|
|
5080
|
+
record.digest
|
|
5081
|
+
])
|
|
5082
|
+
)
|
|
5083
|
+
});
|
|
5084
|
+
}
|
|
5085
|
+
return { digest: null, byPath };
|
|
5086
|
+
}
|
|
5087
|
+
|
|
5088
|
+
// src/commands/status.ts
|
|
5089
|
+
var import_zod26 = require("zod");
|
|
4385
5090
|
var statusCommand = define({
|
|
4386
5091
|
name: "status",
|
|
4387
5092
|
tool: "kb_status",
|
|
4388
5093
|
usage: "status <concept-id> <status>",
|
|
4389
5094
|
description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
|
|
4390
|
-
input:
|
|
5095
|
+
input: import_zod26.z.object({
|
|
4391
5096
|
bundlePath,
|
|
4392
5097
|
conceptId,
|
|
4393
|
-
status:
|
|
5098
|
+
status: import_zod26.z.enum(KB_RECORD_STATUSES)
|
|
4394
5099
|
}),
|
|
4395
5100
|
fromArgv: (argv, path) => ({
|
|
4396
5101
|
bundlePath: path,
|
|
@@ -4405,13 +5110,13 @@ var statusCommand = define({
|
|
|
4405
5110
|
});
|
|
4406
5111
|
|
|
4407
5112
|
// src/commands/supersede.ts
|
|
4408
|
-
var
|
|
5113
|
+
var import_zod27 = require("zod");
|
|
4409
5114
|
var supersedeCommand = define({
|
|
4410
5115
|
name: "supersede",
|
|
4411
5116
|
tool: "kb_supersede",
|
|
4412
5117
|
usage: "supersede <concept-id> <replacement-id>",
|
|
4413
5118
|
description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
|
|
4414
|
-
input:
|
|
5119
|
+
input: import_zod27.z.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
4415
5120
|
fromArgv: (argv, path) => ({
|
|
4416
5121
|
bundlePath: path,
|
|
4417
5122
|
conceptId: argv[1],
|
|
@@ -4425,16 +5130,16 @@ var supersedeCommand = define({
|
|
|
4425
5130
|
});
|
|
4426
5131
|
|
|
4427
5132
|
// src/commands/sync-instructions.ts
|
|
4428
|
-
var
|
|
5133
|
+
var import_zod28 = require("zod");
|
|
4429
5134
|
var syncInstructionsCommand = define({
|
|
4430
5135
|
name: "sync-instructions",
|
|
4431
5136
|
usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
|
|
4432
5137
|
description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
|
|
4433
|
-
input:
|
|
4434
|
-
file:
|
|
4435
|
-
budgetTokens:
|
|
4436
|
-
fullUnderTokens:
|
|
4437
|
-
profile:
|
|
5138
|
+
input: import_zod28.z.object({
|
|
5139
|
+
file: import_zod28.z.string().min(1).describe("The instruction file to edit in place."),
|
|
5140
|
+
budgetTokens: import_zod28.z.number().int().positive().optional(),
|
|
5141
|
+
fullUnderTokens: import_zod28.z.number().int().positive().optional(),
|
|
5142
|
+
profile: import_zod28.z.string().optional()
|
|
4438
5143
|
}),
|
|
4439
5144
|
fromArgv: (argv) => {
|
|
4440
5145
|
const budget = argvFlag(argv, "--budget");
|
|
@@ -4460,17 +5165,17 @@ var syncInstructionsCommand = define({
|
|
|
4460
5165
|
});
|
|
4461
5166
|
|
|
4462
5167
|
// src/commands/trace.ts
|
|
4463
|
-
var
|
|
5168
|
+
var import_zod29 = require("zod");
|
|
4464
5169
|
var traceCommand = define({
|
|
4465
5170
|
name: "trace",
|
|
4466
5171
|
tool: "kb_trace",
|
|
4467
5172
|
usage: "trace <concept-id> [edges...]",
|
|
4468
5173
|
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".',
|
|
4469
|
-
input:
|
|
5174
|
+
input: import_zod29.z.object({
|
|
4470
5175
|
bundlePath,
|
|
4471
5176
|
conceptId,
|
|
4472
|
-
edges:
|
|
4473
|
-
depth:
|
|
5177
|
+
edges: import_zod29.z.array(import_zod29.z.enum(TRACE_EDGES)).optional(),
|
|
5178
|
+
depth: import_zod29.z.number().int().positive().optional()
|
|
4474
5179
|
}),
|
|
4475
5180
|
fromArgv: (argv, path) => ({
|
|
4476
5181
|
bundlePath: path,
|
|
@@ -4492,37 +5197,37 @@ var traceCommand = define({
|
|
|
4492
5197
|
});
|
|
4493
5198
|
|
|
4494
5199
|
// src/commands/types.ts
|
|
4495
|
-
var
|
|
5200
|
+
var import_zod30 = require("zod");
|
|
4496
5201
|
var typesCommand = define({
|
|
4497
5202
|
name: "types",
|
|
4498
5203
|
tool: "kb_types",
|
|
4499
5204
|
usage: "types",
|
|
4500
5205
|
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.",
|
|
4501
|
-
input:
|
|
5206
|
+
input: import_zod30.z.object({}),
|
|
4502
5207
|
fromArgv: () => ({}),
|
|
4503
5208
|
run: () => Promise.resolve(RECORD_TYPES)
|
|
4504
5209
|
});
|
|
4505
5210
|
|
|
4506
5211
|
// src/commands/unpin.ts
|
|
4507
|
-
var
|
|
5212
|
+
var import_zod31 = require("zod");
|
|
4508
5213
|
var unpinCommand = define({
|
|
4509
5214
|
name: "unpin",
|
|
4510
5215
|
tool: "kb_unpin",
|
|
4511
5216
|
usage: "unpin [bundle-path]",
|
|
4512
5217
|
description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
|
|
4513
|
-
input:
|
|
5218
|
+
input: import_zod31.z.object({ bundlePath }),
|
|
4514
5219
|
fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
|
|
4515
5220
|
run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
|
|
4516
5221
|
});
|
|
4517
5222
|
|
|
4518
5223
|
// src/commands/validate.ts
|
|
4519
|
-
var
|
|
5224
|
+
var import_zod32 = require("zod");
|
|
4520
5225
|
var validateCommand = define({
|
|
4521
5226
|
name: "validate",
|
|
4522
5227
|
tool: "kb_validate",
|
|
4523
5228
|
usage: "validate",
|
|
4524
5229
|
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.",
|
|
4525
|
-
input:
|
|
5230
|
+
input: import_zod32.z.object({ bundlePath }),
|
|
4526
5231
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
4527
5232
|
run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
|
|
4528
5233
|
// Warnings never fail the exit code; every other severity does.
|
|
@@ -4532,16 +5237,16 @@ var validateCommand = define({
|
|
|
4532
5237
|
});
|
|
4533
5238
|
|
|
4534
5239
|
// src/commands/verify.ts
|
|
4535
|
-
var
|
|
5240
|
+
var import_zod33 = require("zod");
|
|
4536
5241
|
var verifyCommand = define({
|
|
4537
5242
|
name: "verify",
|
|
4538
5243
|
tool: "kb_verify",
|
|
4539
5244
|
usage: "verify <concept-id> --note <text>",
|
|
4540
5245
|
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.",
|
|
4541
|
-
input:
|
|
5246
|
+
input: import_zod33.z.object({
|
|
4542
5247
|
bundlePath,
|
|
4543
5248
|
conceptId,
|
|
4544
|
-
note:
|
|
5249
|
+
note: import_zod33.z.string().refine((s) => s.trim().length > 0, {
|
|
4545
5250
|
message: "note must say what the check found"
|
|
4546
5251
|
})
|
|
4547
5252
|
}),
|
|
@@ -4561,15 +5266,15 @@ var verifyCommand = define({
|
|
|
4561
5266
|
});
|
|
4562
5267
|
|
|
4563
5268
|
// src/commands/write.ts
|
|
4564
|
-
var
|
|
5269
|
+
var import_zod34 = require("zod");
|
|
4565
5270
|
var writeCommand = define({
|
|
4566
5271
|
name: "write",
|
|
4567
5272
|
tool: "kb_write",
|
|
4568
5273
|
usage: "write <type> < record.json",
|
|
4569
5274
|
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.",
|
|
4570
|
-
input:
|
|
5275
|
+
input: import_zod34.z.object({
|
|
4571
5276
|
bundlePath,
|
|
4572
|
-
type:
|
|
5277
|
+
type: import_zod34.z.enum(KB_RECORD_TYPES),
|
|
4573
5278
|
input: composeInputSchema
|
|
4574
5279
|
}),
|
|
4575
5280
|
fromArgv: async (argv, path, stdin) => ({
|
|
@@ -4593,13 +5298,13 @@ var writeCommand = define({
|
|
|
4593
5298
|
});
|
|
4594
5299
|
|
|
4595
5300
|
// src/commands/write-decision.ts
|
|
4596
|
-
var
|
|
5301
|
+
var import_zod35 = require("zod");
|
|
4597
5302
|
var writeDecisionCommand = define({
|
|
4598
5303
|
name: "write-decision",
|
|
4599
5304
|
tool: "kb_write_decision",
|
|
4600
5305
|
usage: "write-decision < decision.json",
|
|
4601
5306
|
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.",
|
|
4602
|
-
input:
|
|
5307
|
+
input: import_zod35.z.object({ bundlePath, input: decisionInputSchema }),
|
|
4603
5308
|
fromArgv: async (_argv, path, stdin) => ({
|
|
4604
5309
|
bundlePath: path,
|
|
4605
5310
|
input: JSON.parse(await stdin())
|
|
@@ -4639,6 +5344,7 @@ var KB_COMMANDS = [
|
|
|
4639
5344
|
listCommand,
|
|
4640
5345
|
readIndexCommand,
|
|
4641
5346
|
logCommand,
|
|
5347
|
+
stampCommand,
|
|
4642
5348
|
validateCommand,
|
|
4643
5349
|
doctorCommand,
|
|
4644
5350
|
schemaCommand,
|
|
@@ -4658,7 +5364,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
4658
5364
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
4659
5365
|
|
|
4660
5366
|
// src/version.ts
|
|
4661
|
-
var VERSION = true ? "0.1.
|
|
5367
|
+
var VERSION = true ? "0.1.16" : "0.0.0-dev";
|
|
4662
5368
|
|
|
4663
5369
|
// src/mcp.ts
|
|
4664
5370
|
function createKbMcpServer() {
|
|
@@ -4697,10 +5403,14 @@ async function runKbMcpServer() {
|
|
|
4697
5403
|
}
|
|
4698
5404
|
|
|
4699
5405
|
// src/cli.ts
|
|
4700
|
-
var
|
|
5406
|
+
var import_node_path9 = require("path");
|
|
4701
5407
|
async function runKbCli(argv) {
|
|
4702
5408
|
const { flags, literal } = takeLiteral(argv);
|
|
4703
|
-
const {
|
|
5409
|
+
const {
|
|
5410
|
+
bundle,
|
|
5411
|
+
explicit: bundleExplicit,
|
|
5412
|
+
rest: withFlags
|
|
5413
|
+
} = takeBundle(flags);
|
|
4704
5414
|
const name = withFlags[0] ?? "";
|
|
4705
5415
|
if (!name || name === "-h" || name === "--help") {
|
|
4706
5416
|
process.stdout.write(usage());
|
|
@@ -4721,7 +5431,7 @@ async function runKbCli(argv) {
|
|
|
4721
5431
|
...json ? withFlags.filter((argument) => argument !== "--json") : withFlags,
|
|
4722
5432
|
...literal
|
|
4723
5433
|
];
|
|
4724
|
-
const raw = await command.fromArgv(rest, bundle, readStdin);
|
|
5434
|
+
const raw = await command.fromArgv(rest, bundle, readStdin, bundleExplicit);
|
|
4725
5435
|
const parsed = command.input.safeParse(raw);
|
|
4726
5436
|
if (!parsed.success) {
|
|
4727
5437
|
die(
|
|
@@ -4743,6 +5453,7 @@ async function runKbCli(argv) {
|
|
|
4743
5453
|
if (command.failsWhen?.(result, parsed.data)) process.exitCode = 1;
|
|
4744
5454
|
if (result === "") return;
|
|
4745
5455
|
const text = command.render && !json ? command.render(result) : typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
5456
|
+
if (text === "") return;
|
|
4746
5457
|
process.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
4747
5458
|
`);
|
|
4748
5459
|
}
|
|
@@ -4754,11 +5465,15 @@ function takeLiteral(argv) {
|
|
|
4754
5465
|
function takeBundle(argv) {
|
|
4755
5466
|
const at = argv.indexOf("--bundle");
|
|
4756
5467
|
if (at === -1) {
|
|
4757
|
-
return { bundle: (0,
|
|
5468
|
+
return { bundle: (0, import_node_path9.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
|
|
4758
5469
|
}
|
|
4759
5470
|
const bundle = argv[at + 1];
|
|
4760
5471
|
if (!bundle) die("--bundle requires a path");
|
|
4761
|
-
return {
|
|
5472
|
+
return {
|
|
5473
|
+
bundle,
|
|
5474
|
+
explicit: true,
|
|
5475
|
+
rest: [...argv.slice(0, at), ...argv.slice(at + 2)]
|
|
5476
|
+
};
|
|
4762
5477
|
}
|
|
4763
5478
|
function readStdin() {
|
|
4764
5479
|
return new Promise((resolve6, reject) => {
|
|
@@ -4869,6 +5584,7 @@ function usage() {
|
|
|
4869
5584
|
impact,
|
|
4870
5585
|
inboundIndex,
|
|
4871
5586
|
indexIsStale,
|
|
5587
|
+
isCanonicalRepoUrl,
|
|
4872
5588
|
isKbLinkRel,
|
|
4873
5589
|
isKbRecordType,
|
|
4874
5590
|
isNoDecisionRecord,
|
|
@@ -4892,11 +5608,13 @@ function usage() {
|
|
|
4892
5608
|
pinBase,
|
|
4893
5609
|
readMergedPins,
|
|
4894
5610
|
readPinsLayer,
|
|
5611
|
+
readRemoteAnchors,
|
|
4895
5612
|
regexResolver,
|
|
4896
5613
|
renderCatalogLine,
|
|
4897
5614
|
renderIndex,
|
|
4898
5615
|
renderIndexLine,
|
|
4899
5616
|
renderLogEntry,
|
|
5617
|
+
repoCacheDir,
|
|
4900
5618
|
resolveAnchor,
|
|
4901
5619
|
resolveHeads,
|
|
4902
5620
|
resolveHits,
|