@saasontools/strauss-kb 0.1.13 → 0.1.15
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 +59 -52
- package/README.md +163 -438
- package/dist/{chunk-XALWG3EZ.js → chunk-KNIUBCZY.js} +868 -346
- package/dist/chunk-KNIUBCZY.js.map +1 -0
- package/dist/{chunk-WZODZNR6.js → chunk-LACCRB2Y.js} +2 -2
- package/dist/{chunk-ZICKDZGY.js → chunk-MK7GU4DX.js} +2 -2
- package/dist/cli-main.cjs +973 -453
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +906 -377
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +144 -67
- package/dist/index.d.ts +144 -67
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +895 -375
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +2 -2
- package/dist/chunk-XALWG3EZ.js.map +0 -1
- /package/dist/{chunk-WZODZNR6.js.map → chunk-LACCRB2Y.js.map} +0 -0
- /package/dist/{chunk-ZICKDZGY.js.map → chunk-MK7GU4DX.js.map} +0 -0
|
@@ -24,16 +24,17 @@ var kbAnchorSchema = z.object({
|
|
|
24
24
|
* (`https://github.com/org/name`) or a short name. Absent means the base's
|
|
25
25
|
* own repository, which is what nearly every anchor means.
|
|
26
26
|
*
|
|
27
|
-
* Unvalidated beyond not-blank: one repository has many spellings
|
|
28
|
-
*
|
|
27
|
+
* Unvalidated beyond not-blank: one repository has many spellings, matched
|
|
28
|
+
* after normalisation. Only a full URL can be fetched from, so `validate`
|
|
29
|
+
* warns on a short one; see ARCHITECTURE.
|
|
29
30
|
*/
|
|
30
31
|
repo: z.string().trim().min(1).optional(),
|
|
31
32
|
/**
|
|
32
33
|
* The git rev the evidence was taken at. Prefer a commit SHA: a branch
|
|
33
34
|
* name is a moving pointer, so an anchor pinned to one says the evidence
|
|
34
35
|
* came from wherever that branch happens to be now, which is not a
|
|
35
|
-
* baseline.
|
|
36
|
-
*
|
|
36
|
+
* baseline. A foreign anchor is checked at this rev, and compared against
|
|
37
|
+
* the remote's default branch on top of it.
|
|
37
38
|
*/
|
|
38
39
|
ref: z.string().trim().min(1).optional(),
|
|
39
40
|
hash: z.string().regex(/^sha256:[0-9a-f]{64}$/, {
|
|
@@ -415,12 +416,116 @@ function selectDecisions(records) {
|
|
|
415
416
|
);
|
|
416
417
|
}
|
|
417
418
|
|
|
418
|
-
// src/anchor-resolver.ts
|
|
419
|
+
// src/anchor-resolver/repo-identity.ts
|
|
419
420
|
import { execFile } from "child_process";
|
|
420
|
-
import { createHash } from "crypto";
|
|
421
|
-
import { readFile, realpath, stat } from "fs/promises";
|
|
422
|
-
import { isAbsolute, relative, resolve, sep } from "path";
|
|
423
421
|
import { promisify } from "util";
|
|
422
|
+
var execFileAsync = promisify(execFile);
|
|
423
|
+
function normalizeRepoUrl(value) {
|
|
424
|
+
let url = value.trim().replace(/^git\+/, "");
|
|
425
|
+
const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
|
|
426
|
+
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
427
|
+
url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
|
|
428
|
+
url = trimTrailingSlashes(url);
|
|
429
|
+
if (url.endsWith(".git")) url = url.slice(0, -4);
|
|
430
|
+
return trimTrailingSlashes(url).toLowerCase();
|
|
431
|
+
}
|
|
432
|
+
function trimTrailingSlashes(value) {
|
|
433
|
+
let end = value.length;
|
|
434
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
435
|
+
return value.slice(0, end);
|
|
436
|
+
}
|
|
437
|
+
function isCanonicalRepoUrl(value) {
|
|
438
|
+
return /^[a-z0-9+.-]+:\/\//.test(normalizeRepoUrl(value));
|
|
439
|
+
}
|
|
440
|
+
function repoPath(normalized) {
|
|
441
|
+
const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
|
|
442
|
+
const segments = withoutScheme.split("/").filter(Boolean);
|
|
443
|
+
return segments.length > 1 ? segments.slice(1).join("/") : "";
|
|
444
|
+
}
|
|
445
|
+
function repoIdentifies(declared, originUrl) {
|
|
446
|
+
if (!originUrl) return false;
|
|
447
|
+
const origin = normalizeRepoUrl(originUrl);
|
|
448
|
+
const want = normalizeRepoUrl(declared);
|
|
449
|
+
if (!want || !origin) return false;
|
|
450
|
+
if (want === origin) return true;
|
|
451
|
+
const path = repoPath(origin);
|
|
452
|
+
if (!path) return false;
|
|
453
|
+
return want === path || want === (path.split("/").pop() ?? "");
|
|
454
|
+
}
|
|
455
|
+
async function repoOriginUrl(repoRoot) {
|
|
456
|
+
try {
|
|
457
|
+
const { stdout } = await execFileAsync(
|
|
458
|
+
"git",
|
|
459
|
+
["-C", repoRoot, "config", "--get", "remote.origin.url"],
|
|
460
|
+
{ timeout: 5e3 }
|
|
461
|
+
);
|
|
462
|
+
return stdout.trim() || null;
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
var LazyOrigin = class {
|
|
468
|
+
constructor(repoRoot) {
|
|
469
|
+
this.repoRoot = repoRoot;
|
|
470
|
+
}
|
|
471
|
+
repoRoot;
|
|
472
|
+
url = null;
|
|
473
|
+
asked = false;
|
|
474
|
+
/** Asks git once, so later `isForeign` calls need no await. */
|
|
475
|
+
async prime() {
|
|
476
|
+
if (this.asked) return;
|
|
477
|
+
this.url = await repoOriginUrl(this.repoRoot);
|
|
478
|
+
this.asked = true;
|
|
479
|
+
}
|
|
480
|
+
/** Only meaningful after `prime`; an unprimed origin identifies nothing. */
|
|
481
|
+
isForeign(anchor) {
|
|
482
|
+
if (!anchor.repo) return false;
|
|
483
|
+
return !repoIdentifies(anchor.repo, this.url);
|
|
484
|
+
}
|
|
485
|
+
async foreign(anchor) {
|
|
486
|
+
if (!anchor.repo) return false;
|
|
487
|
+
await this.prime();
|
|
488
|
+
return this.isForeign(anchor);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// src/remote-repo/cache.ts
|
|
493
|
+
import { homedir } from "os";
|
|
494
|
+
import { join } from "path";
|
|
495
|
+
function repoCacheDir(override) {
|
|
496
|
+
return override ?? process.env["STRAUSS_KB_REPO_CACHE"] ?? join(homedir(), ".strauss", "repo-cache");
|
|
497
|
+
}
|
|
498
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 3e4;
|
|
499
|
+
function fetchTimeoutMs(override) {
|
|
500
|
+
if (override !== void 0) return override;
|
|
501
|
+
const fromEnv = Number(process.env["STRAUSS_KB_FETCH_TIMEOUT_MS"]);
|
|
502
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_FETCH_TIMEOUT_MS;
|
|
503
|
+
}
|
|
504
|
+
function cachePathFor(repo, cacheDir) {
|
|
505
|
+
const normalized = normalizeRepoUrl(repo);
|
|
506
|
+
const scheme = /^[a-z0-9+.-]+:\/\//.exec(normalized);
|
|
507
|
+
if (!scheme) return null;
|
|
508
|
+
const segments = normalized.slice(scheme[0].length).split("/").filter(Boolean).map((segment) => safeSegment(segment));
|
|
509
|
+
if (segments.length < 2 || segments.some((segment) => segment === null)) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
const path = segments;
|
|
513
|
+
return join(cacheDir, ...path.slice(0, -1), `${path[path.length - 1]}.git`);
|
|
514
|
+
}
|
|
515
|
+
function safeSegment(value) {
|
|
516
|
+
return value === "." || value === ".." || value.includes("\0") ? null : value.replace(/[/\\:]/g, "-");
|
|
517
|
+
}
|
|
518
|
+
function revRef(rev) {
|
|
519
|
+
const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
|
|
520
|
+
let hash = 5381;
|
|
521
|
+
for (let at = 0; at < rev.length; at++) {
|
|
522
|
+
hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
|
|
523
|
+
}
|
|
524
|
+
return `refs/strauss/${safe}-${hash.toString(16)}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/remote-repo/read.ts
|
|
528
|
+
import { mkdir } from "fs/promises";
|
|
424
529
|
|
|
425
530
|
// src/concurrency.ts
|
|
426
531
|
var DEFAULT_IO_CONCURRENCY = 16;
|
|
@@ -451,9 +556,415 @@ async function mapLimit(items, limit, fn) {
|
|
|
451
556
|
return out;
|
|
452
557
|
}
|
|
453
558
|
|
|
454
|
-
// src/
|
|
455
|
-
|
|
559
|
+
// src/remote-repo/git.ts
|
|
560
|
+
import { execFile as execFile2 } from "child_process";
|
|
561
|
+
import { promisify as promisify2 } from "util";
|
|
562
|
+
|
|
563
|
+
// src/anchor-resolver/model.ts
|
|
456
564
|
var MAX_ANCHOR_FILE_BYTES = 1048576;
|
|
565
|
+
|
|
566
|
+
// src/remote-repo/git.ts
|
|
567
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
568
|
+
function childEnv() {
|
|
569
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
570
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
|
|
571
|
+
delete env[name];
|
|
572
|
+
}
|
|
573
|
+
return env;
|
|
574
|
+
}
|
|
575
|
+
async function git(args, options = {}) {
|
|
576
|
+
try {
|
|
577
|
+
const { stdout, stderr } = await execFileAsync2("git", args, {
|
|
578
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
579
|
+
timeout: options.timeoutMs ?? 3e4,
|
|
580
|
+
maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
|
|
581
|
+
encoding: "utf8",
|
|
582
|
+
windowsHide: true,
|
|
583
|
+
env: childEnv()
|
|
584
|
+
});
|
|
585
|
+
return { ok: true, stdout, stderr, overflowed: false };
|
|
586
|
+
} catch (error) {
|
|
587
|
+
const failure = error;
|
|
588
|
+
return {
|
|
589
|
+
ok: false,
|
|
590
|
+
stdout: failure.stdout ?? "",
|
|
591
|
+
stderr: failure.stderr ?? "",
|
|
592
|
+
overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function transportReason(stderr) {
|
|
597
|
+
const text = stderr.toLowerCase();
|
|
598
|
+
if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
|
|
599
|
+
return "repo-unauthorized";
|
|
600
|
+
}
|
|
601
|
+
if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
|
|
602
|
+
return "ref-not-found";
|
|
603
|
+
}
|
|
604
|
+
return "remote-unreachable";
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/remote-repo/model.ts
|
|
608
|
+
var UNCHECKED_REASONS = [
|
|
609
|
+
"remote-unreachable",
|
|
610
|
+
"repo-unauthorized",
|
|
611
|
+
"default-branch-unknown"
|
|
612
|
+
];
|
|
613
|
+
function isUncheckedReason(reason) {
|
|
614
|
+
return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
|
|
615
|
+
}
|
|
616
|
+
function wantKey(repo, ref, file) {
|
|
617
|
+
return `${repo}\0${ref ?? ""}\0${file}`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/remote-repo/validate.ts
|
|
621
|
+
var MAX_REF_LENGTH = 200;
|
|
622
|
+
var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
623
|
+
function refShapeIsSafe(ref) {
|
|
624
|
+
if (!ref || ref.length > MAX_REF_LENGTH) return false;
|
|
625
|
+
if (ref.includes("..")) return false;
|
|
626
|
+
return REF_SHAPE.test(ref);
|
|
627
|
+
}
|
|
628
|
+
async function refIsWellFormed(ref) {
|
|
629
|
+
if (!refShapeIsSafe(ref)) return false;
|
|
630
|
+
const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
|
|
631
|
+
return checked.ok;
|
|
632
|
+
}
|
|
633
|
+
function filePathIsSafe(file) {
|
|
634
|
+
const path = file.replace(/^\.\//, "");
|
|
635
|
+
if (!path || path.startsWith("-") || path.includes("\0")) return false;
|
|
636
|
+
return !path.split("/").includes("..");
|
|
637
|
+
}
|
|
638
|
+
var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
|
|
639
|
+
function allowedProtocols() {
|
|
640
|
+
const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
|
|
641
|
+
if (raw === void 0) return [...DEFAULT_PROTOCOLS];
|
|
642
|
+
const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
643
|
+
return listed.length ? listed : [...DEFAULT_PROTOCOLS];
|
|
644
|
+
}
|
|
645
|
+
function isShortRepoName(repo) {
|
|
646
|
+
return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
|
|
647
|
+
}
|
|
648
|
+
var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
|
|
649
|
+
var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
|
|
650
|
+
var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
651
|
+
function repoUrlIsSafe(repo) {
|
|
652
|
+
const url = repo.trim();
|
|
653
|
+
if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
|
|
654
|
+
const allowed = allowedProtocols();
|
|
655
|
+
if (SCP_LIKE.test(url)) return allowed.includes("ssh");
|
|
656
|
+
const scheme = URL_SCHEME.exec(url);
|
|
657
|
+
if (!scheme?.[1]) return false;
|
|
658
|
+
if (!allowed.includes(scheme[1].toLowerCase())) return false;
|
|
659
|
+
const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
|
|
660
|
+
const at = authority.lastIndexOf("@");
|
|
661
|
+
return at < 0 || !authority.slice(0, at).includes(":");
|
|
662
|
+
}
|
|
663
|
+
function protocolArgs() {
|
|
664
|
+
const allowed = allowedProtocols();
|
|
665
|
+
return [
|
|
666
|
+
"-c",
|
|
667
|
+
"protocol.ext.allow=never",
|
|
668
|
+
"-c",
|
|
669
|
+
`protocol.file.allow=${allowed.includes("file") ? "user" : "never"}`
|
|
670
|
+
];
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/remote-repo/read.ts
|
|
674
|
+
var DEFAULT_REPO_CONCURRENCY = 4;
|
|
675
|
+
var IMMUTABLE_REV = /^[0-9a-f]{40}$/;
|
|
676
|
+
async function readRemoteAnchors(wants, options = {}) {
|
|
677
|
+
const out = /* @__PURE__ */ new Map();
|
|
678
|
+
if (!wants.length) return out;
|
|
679
|
+
const cacheDir = repoCacheDir(options.cacheDir);
|
|
680
|
+
const timeoutMs = fetchTimeoutMs(options.fetchTimeoutMs);
|
|
681
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
682
|
+
for (const want of wants) {
|
|
683
|
+
const key = normalizeRepoUrl(want.repo);
|
|
684
|
+
const group2 = byRepo.get(key) ?? { url: want.repo.trim(), wants: [] };
|
|
685
|
+
group2.wants.push(want);
|
|
686
|
+
byRepo.set(key, group2);
|
|
687
|
+
}
|
|
688
|
+
const groups = [...byRepo.entries()];
|
|
689
|
+
const results = await mapLimit(
|
|
690
|
+
groups,
|
|
691
|
+
Math.max(1, options.concurrency ?? DEFAULT_REPO_CONCURRENCY),
|
|
692
|
+
([repo, group2]) => readOneRepo(repo, group2.url, group2.wants, {
|
|
693
|
+
cacheDir,
|
|
694
|
+
timeoutMs,
|
|
695
|
+
offline: options.offline === true
|
|
696
|
+
})
|
|
697
|
+
);
|
|
698
|
+
for (const result of results) {
|
|
699
|
+
for (const [key, read] of result) out.set(key, read);
|
|
700
|
+
}
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
async function readOneRepo(repo, url, declared, context) {
|
|
704
|
+
let wants = declared;
|
|
705
|
+
const all = (read) => new Map(wants.map((want) => [wantKey(repo, want.ref, want.file), read]));
|
|
706
|
+
if (isShortRepoName(url)) {
|
|
707
|
+
return all({ ok: false, reason: "remote-unreachable" });
|
|
708
|
+
}
|
|
709
|
+
if (!repoUrlIsSafe(url)) return all({ ok: false, reason: "repo-invalid" });
|
|
710
|
+
const cache = cachePathFor(repo, context.cacheDir);
|
|
711
|
+
if (!cache) return all({ ok: false, reason: "remote-unreachable" });
|
|
712
|
+
const rejected = /* @__PURE__ */ new Map();
|
|
713
|
+
const usable = [];
|
|
714
|
+
for (const want of wants) {
|
|
715
|
+
const reason = wantReason(want);
|
|
716
|
+
if (reason)
|
|
717
|
+
rejected.set(wantKey(repo, want.ref, want.file), { ok: false, reason });
|
|
718
|
+
else usable.push(want);
|
|
719
|
+
}
|
|
720
|
+
if (!usable.length) return rejected;
|
|
721
|
+
wants = usable;
|
|
722
|
+
const opened = await openCache(cache, url, context);
|
|
723
|
+
if (opened) return new Map([...rejected, ...all(opened)]);
|
|
724
|
+
const wantsDefault = wants.some((want) => want.ref === void 0);
|
|
725
|
+
const branch = wantsDefault ? await defaultBranch(cache, context) : {};
|
|
726
|
+
const revs = /* @__PURE__ */ new Map();
|
|
727
|
+
for (const rev of distinctRevs(wants, branch.name)) {
|
|
728
|
+
revs.set(
|
|
729
|
+
rev,
|
|
730
|
+
await refIsWellFormed(rev) ? await ensureRev(cache, rev, context) : { ok: false, reason: "ref-invalid" }
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
const reads = await mapLimit(
|
|
734
|
+
wants,
|
|
735
|
+
DEFAULT_IO_CONCURRENCY,
|
|
736
|
+
async (want) => {
|
|
737
|
+
const rev = want.ref ?? branch.name;
|
|
738
|
+
if (rev === void 0) {
|
|
739
|
+
return {
|
|
740
|
+
ok: false,
|
|
741
|
+
reason: branch.reason ?? "default-branch-unknown"
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
const failed = revs.get(rev);
|
|
745
|
+
if (failed) return failed;
|
|
746
|
+
return readBlob(cache, rev, want.file, context);
|
|
747
|
+
}
|
|
748
|
+
);
|
|
749
|
+
return new Map([
|
|
750
|
+
...rejected,
|
|
751
|
+
...wants.map(
|
|
752
|
+
(want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
|
|
753
|
+
)
|
|
754
|
+
]);
|
|
755
|
+
}
|
|
756
|
+
function wantReason(want) {
|
|
757
|
+
if (want.ref !== void 0 && !refShapeIsSafe(want.ref)) return "ref-invalid";
|
|
758
|
+
return filePathIsSafe(want.file) ? void 0 : "outside-repo";
|
|
759
|
+
}
|
|
760
|
+
function distinctRevs(wants, branch) {
|
|
761
|
+
const revs = /* @__PURE__ */ new Set();
|
|
762
|
+
for (const want of wants) {
|
|
763
|
+
if (want.ref !== void 0) revs.add(want.ref);
|
|
764
|
+
else if (branch) revs.add(branch);
|
|
765
|
+
}
|
|
766
|
+
return [...revs];
|
|
767
|
+
}
|
|
768
|
+
async function openCache(cache, url, context) {
|
|
769
|
+
try {
|
|
770
|
+
await mkdir(cache, { recursive: true });
|
|
771
|
+
} catch {
|
|
772
|
+
return { ok: false, reason: "remote-unreachable" };
|
|
773
|
+
}
|
|
774
|
+
const init = await git(["init", "--bare", "--quiet", cache], {
|
|
775
|
+
timeoutMs: context.timeoutMs
|
|
776
|
+
});
|
|
777
|
+
if (!init.ok) return { ok: false, reason: "remote-unreachable" };
|
|
778
|
+
const remote = await git(["config", "remote.origin.url", url], {
|
|
779
|
+
cwd: cache,
|
|
780
|
+
timeoutMs: context.timeoutMs
|
|
781
|
+
});
|
|
782
|
+
return remote.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
|
|
783
|
+
}
|
|
784
|
+
async function defaultBranch(cache, context) {
|
|
785
|
+
if (!context.offline) {
|
|
786
|
+
const listed = await git(
|
|
787
|
+
[...protocolArgs(), "ls-remote", "--symref", "origin", "HEAD"],
|
|
788
|
+
{
|
|
789
|
+
cwd: cache,
|
|
790
|
+
timeoutMs: context.timeoutMs
|
|
791
|
+
}
|
|
792
|
+
);
|
|
793
|
+
const found = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(listed.stdout);
|
|
794
|
+
if (listed.ok && found?.[1]) {
|
|
795
|
+
const name = found[1];
|
|
796
|
+
await git(["config", "strauss.defaultBranch", name], { cwd: cache });
|
|
797
|
+
return { name };
|
|
798
|
+
}
|
|
799
|
+
if (!listed.ok) {
|
|
800
|
+
const reason = transportReason(listed.stderr);
|
|
801
|
+
if (reason !== "ref-not-found") {
|
|
802
|
+
const cached2 = await cachedBranch(cache);
|
|
803
|
+
return cached2 ? { name: cached2 } : { reason };
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const cached = await cachedBranch(cache);
|
|
808
|
+
if (cached) return { name: cached };
|
|
809
|
+
return {
|
|
810
|
+
reason: context.offline ? "remote-unreachable" : "default-branch-unknown"
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
async function cachedBranch(cache) {
|
|
814
|
+
const stored = await git(["config", "--get", "strauss.defaultBranch"], {
|
|
815
|
+
cwd: cache
|
|
816
|
+
});
|
|
817
|
+
if (stored.ok && stored.stdout.trim()) return stored.stdout.trim();
|
|
818
|
+
const head = await git(
|
|
819
|
+
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
820
|
+
{
|
|
821
|
+
cwd: cache
|
|
822
|
+
}
|
|
823
|
+
);
|
|
824
|
+
const name = head.stdout.trim().replace(/^origin\//, "");
|
|
825
|
+
return head.ok && name ? name : void 0;
|
|
826
|
+
}
|
|
827
|
+
async function ensureRev(cache, rev, context) {
|
|
828
|
+
const ref = revRef(rev);
|
|
829
|
+
const have = await git(
|
|
830
|
+
["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
|
|
831
|
+
{
|
|
832
|
+
cwd: cache
|
|
833
|
+
}
|
|
834
|
+
);
|
|
835
|
+
const cached = have.ok && have.stdout.trim().length > 0;
|
|
836
|
+
if (cached && (context.offline || IMMUTABLE_REV.test(rev))) return void 0;
|
|
837
|
+
if (context.offline) return { ok: false, reason: "remote-unreachable" };
|
|
838
|
+
const fetched = await git(
|
|
839
|
+
[
|
|
840
|
+
...protocolArgs(),
|
|
841
|
+
"fetch",
|
|
842
|
+
"--depth",
|
|
843
|
+
"1",
|
|
844
|
+
"origin",
|
|
845
|
+
"--end-of-options",
|
|
846
|
+
rev
|
|
847
|
+
],
|
|
848
|
+
{ cwd: cache, timeoutMs: context.timeoutMs }
|
|
849
|
+
);
|
|
850
|
+
if (!fetched.ok) {
|
|
851
|
+
const reason = transportReason(fetched.stderr);
|
|
852
|
+
if (cached && reason !== "ref-not-found") return void 0;
|
|
853
|
+
return { ok: false, reason };
|
|
854
|
+
}
|
|
855
|
+
const head = await git(["rev-parse", "FETCH_HEAD"], { cwd: cache });
|
|
856
|
+
const sha = head.stdout.trim();
|
|
857
|
+
if (!head.ok || !sha) return { ok: false, reason: "remote-unreachable" };
|
|
858
|
+
const updated = await git(["update-ref", ref, sha], { cwd: cache });
|
|
859
|
+
return updated.ok ? void 0 : { ok: false, reason: "remote-unreachable" };
|
|
860
|
+
}
|
|
861
|
+
async function readBlob(cache, rev, file, context) {
|
|
862
|
+
const path = file.replace(/^\.\//, "");
|
|
863
|
+
const blob = await git(
|
|
864
|
+
["cat-file", "blob", "--end-of-options", `${revRef(rev)}:${path}`],
|
|
865
|
+
{
|
|
866
|
+
cwd: cache,
|
|
867
|
+
timeoutMs: context.timeoutMs
|
|
868
|
+
}
|
|
869
|
+
);
|
|
870
|
+
if (blob.ok) return { ok: true, source: blob.stdout };
|
|
871
|
+
if (blob.overflowed) return { ok: false, reason: "file-too-large" };
|
|
872
|
+
const text = blob.stderr.toLowerCase();
|
|
873
|
+
return text.includes("does not exist") || text.includes("not a valid object") ? { ok: false, reason: "file-missing" } : { ok: false, reason: "file-unreadable" };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// src/anchor-resolver/read.ts
|
|
877
|
+
import { readFile, realpath, stat } from "fs/promises";
|
|
878
|
+
import { isAbsolute, relative, resolve, sep } from "path";
|
|
879
|
+
function anchorFilePath(repoRoot, file) {
|
|
880
|
+
const path = resolve(repoRoot, file.replace(/^\.\//, ""));
|
|
881
|
+
const rel = relative(resolve(repoRoot), path);
|
|
882
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
return path;
|
|
886
|
+
}
|
|
887
|
+
function contains(root, path) {
|
|
888
|
+
const rel = relative(root, path);
|
|
889
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
890
|
+
}
|
|
891
|
+
function errorCode(error) {
|
|
892
|
+
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
|
|
893
|
+
}
|
|
894
|
+
function anchorFileReader(repoRoot) {
|
|
895
|
+
let rootOnce;
|
|
896
|
+
const realRoot = () => {
|
|
897
|
+
rootOnce ??= realpath(resolve(repoRoot)).catch((error) => {
|
|
898
|
+
rootOnce = void 0;
|
|
899
|
+
throw error;
|
|
900
|
+
});
|
|
901
|
+
return rootOnce;
|
|
902
|
+
};
|
|
903
|
+
return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
|
|
904
|
+
}
|
|
905
|
+
async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
|
|
906
|
+
const lexical = anchorFilePath(repoRoot, file);
|
|
907
|
+
if (lexical === null) return { ok: false, reason: "outside-repo" };
|
|
908
|
+
let root;
|
|
909
|
+
let path;
|
|
910
|
+
try {
|
|
911
|
+
root = await realRoot();
|
|
912
|
+
path = await realpath(lexical);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
const code = errorCode(error);
|
|
915
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
916
|
+
return { ok: false, reason: "file-missing" };
|
|
917
|
+
}
|
|
918
|
+
return { ok: false, reason: "file-unreadable" };
|
|
919
|
+
}
|
|
920
|
+
if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
|
|
921
|
+
try {
|
|
922
|
+
const stats = await stat(path);
|
|
923
|
+
if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
|
|
924
|
+
if (stats.size > MAX_ANCHOR_FILE_BYTES) {
|
|
925
|
+
return { ok: false, reason: "file-too-large" };
|
|
926
|
+
}
|
|
927
|
+
return { ok: true, source: await readFile(path, "utf8") };
|
|
928
|
+
} catch (error) {
|
|
929
|
+
const code = errorCode(error);
|
|
930
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
931
|
+
return { ok: false, reason: "file-missing" };
|
|
932
|
+
}
|
|
933
|
+
return { ok: false, reason: "file-unreadable" };
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function looksLikeWrongRepoRoot(drift) {
|
|
937
|
+
let checked = 0;
|
|
938
|
+
for (const entries of drift.values()) {
|
|
939
|
+
for (const entry of entries) {
|
|
940
|
+
if (entry.repo !== void 0) continue;
|
|
941
|
+
checked += 1;
|
|
942
|
+
if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return checked > 0;
|
|
948
|
+
}
|
|
949
|
+
async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
|
|
950
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
951
|
+
throw new RangeError(
|
|
952
|
+
`readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
const wanted = [...new Set(files)];
|
|
956
|
+
const results = await mapLimit(wanted, concurrency, async (file) => {
|
|
957
|
+
try {
|
|
958
|
+
return await read(file);
|
|
959
|
+
} catch {
|
|
960
|
+
return { ok: false, reason: "file-unreadable" };
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
return new Map(wanted.map((file, at) => [file, results[at]]));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// src/anchor-resolver/resolver.ts
|
|
967
|
+
import { createHash } from "crypto";
|
|
457
968
|
var PARENT_SCOPE_LINES = 50;
|
|
458
969
|
var CLEAN_STATE = { blockComment: false, template: false };
|
|
459
970
|
function stripLine(line, state) {
|
|
@@ -629,157 +1140,8 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
|
|
|
629
1140
|
}
|
|
630
1141
|
return resolver.resolve(normalized, anchor.symbol);
|
|
631
1142
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const rel = relative(resolve(repoRoot), path);
|
|
635
|
-
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
636
|
-
return null;
|
|
637
|
-
}
|
|
638
|
-
return path;
|
|
639
|
-
}
|
|
640
|
-
function contains(root, path) {
|
|
641
|
-
const rel = relative(root, path);
|
|
642
|
-
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
643
|
-
}
|
|
644
|
-
function normalizeRepoUrl(value) {
|
|
645
|
-
let url = value.trim().replace(/^git\+/, "");
|
|
646
|
-
const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
|
|
647
|
-
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
648
|
-
url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
|
|
649
|
-
url = trimTrailingSlashes(url);
|
|
650
|
-
if (url.endsWith(".git")) url = url.slice(0, -4);
|
|
651
|
-
return trimTrailingSlashes(url).toLowerCase();
|
|
652
|
-
}
|
|
653
|
-
function trimTrailingSlashes(value) {
|
|
654
|
-
let end = value.length;
|
|
655
|
-
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
656
|
-
return value.slice(0, end);
|
|
657
|
-
}
|
|
658
|
-
function repoPath(normalized) {
|
|
659
|
-
const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
|
|
660
|
-
const segments = withoutScheme.split("/").filter(Boolean);
|
|
661
|
-
return segments.length > 1 ? segments.slice(1).join("/") : "";
|
|
662
|
-
}
|
|
663
|
-
function repoIdentifies(declared, originUrl) {
|
|
664
|
-
if (!originUrl) return false;
|
|
665
|
-
const origin = normalizeRepoUrl(originUrl);
|
|
666
|
-
const want = normalizeRepoUrl(declared);
|
|
667
|
-
if (!want || !origin) return false;
|
|
668
|
-
if (want === origin) return true;
|
|
669
|
-
const path = repoPath(origin);
|
|
670
|
-
if (!path) return false;
|
|
671
|
-
return want === path || want === (path.split("/").pop() ?? "");
|
|
672
|
-
}
|
|
673
|
-
async function repoOriginUrl(repoRoot) {
|
|
674
|
-
try {
|
|
675
|
-
const { stdout } = await execFileAsync(
|
|
676
|
-
"git",
|
|
677
|
-
["-C", repoRoot, "config", "--get", "remote.origin.url"],
|
|
678
|
-
{ timeout: 5e3 }
|
|
679
|
-
);
|
|
680
|
-
return stdout.trim() || null;
|
|
681
|
-
} catch {
|
|
682
|
-
return null;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
var LazyOrigin = class {
|
|
686
|
-
constructor(repoRoot) {
|
|
687
|
-
this.repoRoot = repoRoot;
|
|
688
|
-
}
|
|
689
|
-
repoRoot;
|
|
690
|
-
url = null;
|
|
691
|
-
asked = false;
|
|
692
|
-
/** Asks git once, so later `isForeign` calls need no await. */
|
|
693
|
-
async prime() {
|
|
694
|
-
if (this.asked) return;
|
|
695
|
-
this.url = await repoOriginUrl(this.repoRoot);
|
|
696
|
-
this.asked = true;
|
|
697
|
-
}
|
|
698
|
-
/** Only meaningful after `prime`; an unprimed origin identifies nothing. */
|
|
699
|
-
isForeign(anchor) {
|
|
700
|
-
if (!anchor.repo) return false;
|
|
701
|
-
return !repoIdentifies(anchor.repo, this.url);
|
|
702
|
-
}
|
|
703
|
-
async foreign(anchor) {
|
|
704
|
-
if (!anchor.repo) return false;
|
|
705
|
-
await this.prime();
|
|
706
|
-
return this.isForeign(anchor);
|
|
707
|
-
}
|
|
708
|
-
};
|
|
709
|
-
function errorCode(error) {
|
|
710
|
-
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
|
|
711
|
-
}
|
|
712
|
-
function anchorFileReader(repoRoot) {
|
|
713
|
-
let rootOnce;
|
|
714
|
-
const realRoot = () => {
|
|
715
|
-
rootOnce ??= realpath(resolve(repoRoot)).catch((error) => {
|
|
716
|
-
rootOnce = void 0;
|
|
717
|
-
throw error;
|
|
718
|
-
});
|
|
719
|
-
return rootOnce;
|
|
720
|
-
};
|
|
721
|
-
return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
|
|
722
|
-
}
|
|
723
|
-
async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
|
|
724
|
-
const lexical = anchorFilePath(repoRoot, file);
|
|
725
|
-
if (lexical === null) return { ok: false, reason: "outside-repo" };
|
|
726
|
-
let root;
|
|
727
|
-
let path;
|
|
728
|
-
try {
|
|
729
|
-
root = await realRoot();
|
|
730
|
-
path = await realpath(lexical);
|
|
731
|
-
} catch (error) {
|
|
732
|
-
const code = errorCode(error);
|
|
733
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
734
|
-
return { ok: false, reason: "file-missing" };
|
|
735
|
-
}
|
|
736
|
-
return { ok: false, reason: "file-unreadable" };
|
|
737
|
-
}
|
|
738
|
-
if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
|
|
739
|
-
try {
|
|
740
|
-
const stats = await stat(path);
|
|
741
|
-
if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
|
|
742
|
-
if (stats.size > MAX_ANCHOR_FILE_BYTES) {
|
|
743
|
-
return { ok: false, reason: "file-too-large" };
|
|
744
|
-
}
|
|
745
|
-
return { ok: true, source: await readFile(path, "utf8") };
|
|
746
|
-
} catch (error) {
|
|
747
|
-
const code = errorCode(error);
|
|
748
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
749
|
-
return { ok: false, reason: "file-missing" };
|
|
750
|
-
}
|
|
751
|
-
return { ok: false, reason: "file-unreadable" };
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
function looksLikeWrongRepoRoot(drift) {
|
|
755
|
-
let checked = 0;
|
|
756
|
-
for (const entries of drift.values()) {
|
|
757
|
-
for (const entry of entries) {
|
|
758
|
-
if (entry.reason === "foreign-repo") continue;
|
|
759
|
-
checked += 1;
|
|
760
|
-
if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
|
|
761
|
-
return false;
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
return checked > 0;
|
|
766
|
-
}
|
|
767
|
-
async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
|
|
768
|
-
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
769
|
-
throw new RangeError(
|
|
770
|
-
`readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
|
|
771
|
-
);
|
|
772
|
-
}
|
|
773
|
-
const wanted = [...new Set(files)];
|
|
774
|
-
const results = await mapLimit(wanted, concurrency, async (file) => {
|
|
775
|
-
try {
|
|
776
|
-
return await read(file);
|
|
777
|
-
} catch {
|
|
778
|
-
return { ok: false, reason: "file-unreadable" };
|
|
779
|
-
}
|
|
780
|
-
});
|
|
781
|
-
return new Map(wanted.map((file, at) => [file, results[at]]));
|
|
782
|
-
}
|
|
1143
|
+
|
|
1144
|
+
// src/anchor-resolver/drift.ts
|
|
783
1145
|
async function detectAnchorDrift(records, options = {}) {
|
|
784
1146
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
785
1147
|
const resolver = options.resolver ?? regexResolver;
|
|
@@ -805,67 +1167,97 @@ async function detectAnchorDrift(records, options = {}) {
|
|
|
805
1167
|
}
|
|
806
1168
|
}
|
|
807
1169
|
const files = [];
|
|
1170
|
+
const wants = [];
|
|
808
1171
|
for (const entries of planned.values()) {
|
|
809
|
-
for (const
|
|
810
|
-
if (!
|
|
1172
|
+
for (const { anchor, foreign } of entries) {
|
|
1173
|
+
if (!foreign) files.push(anchor.file);
|
|
1174
|
+
else wants.push(...remoteWants(anchor));
|
|
811
1175
|
}
|
|
812
1176
|
}
|
|
813
|
-
const reads = await
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1177
|
+
const [reads, remote] = await Promise.all([
|
|
1178
|
+
readAnchorFiles(
|
|
1179
|
+
files,
|
|
1180
|
+
options.reader ?? anchorFileReader(repoRoot),
|
|
1181
|
+
options.concurrency ?? DEFAULT_IO_CONCURRENCY
|
|
1182
|
+
),
|
|
1183
|
+
(options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
|
|
1184
|
+
]);
|
|
818
1185
|
const drift = /* @__PURE__ */ new Map();
|
|
819
1186
|
for (const record of records) {
|
|
820
1187
|
const entries = [];
|
|
821
1188
|
for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
storedHash: anchor.hash
|
|
826
|
-
};
|
|
827
|
-
if (foreign) {
|
|
828
|
-
entries.push({
|
|
829
|
-
...base,
|
|
830
|
-
state: "unresolved",
|
|
831
|
-
diffSize: null,
|
|
832
|
-
reason: "foreign-repo"
|
|
833
|
-
});
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
|
-
const read = reads.get(anchor.file);
|
|
837
|
-
if (!read.ok) {
|
|
838
|
-
entries.push({
|
|
839
|
-
...base,
|
|
840
|
-
state: "unresolved",
|
|
841
|
-
diffSize: null,
|
|
842
|
-
reason: read.reason
|
|
843
|
-
});
|
|
844
|
-
continue;
|
|
845
|
-
}
|
|
846
|
-
const resolved = resolveAnchor(read.source, anchor, resolver);
|
|
847
|
-
if (!resolved) {
|
|
848
|
-
entries.push({
|
|
849
|
-
...base,
|
|
850
|
-
state: "unresolved",
|
|
851
|
-
diffSize: null,
|
|
852
|
-
reason: "symbol-not-found"
|
|
853
|
-
});
|
|
854
|
-
continue;
|
|
855
|
-
}
|
|
856
|
-
const currentHash = hashAnchorText(resolved.text);
|
|
857
|
-
const currentLines = resolved.endLine - resolved.startLine + 1;
|
|
858
|
-
entries.push({
|
|
859
|
-
...base,
|
|
860
|
-
state: currentHash === anchor.hash ? "match" : "drifted",
|
|
861
|
-
currentHash,
|
|
862
|
-
diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
|
|
863
|
-
});
|
|
1189
|
+
entries.push(
|
|
1190
|
+
foreign ? remoteEntry(anchor, remote, resolver) : localEntry(anchor, reads.get(anchor.file), resolver)
|
|
1191
|
+
);
|
|
864
1192
|
}
|
|
865
1193
|
if (entries.length) drift.set(record.conceptId, entries);
|
|
866
1194
|
}
|
|
867
1195
|
return drift;
|
|
868
1196
|
}
|
|
1197
|
+
function remoteWants(anchor) {
|
|
1198
|
+
const repo = anchor.repo;
|
|
1199
|
+
const wants = [{ repo, file: anchor.file }];
|
|
1200
|
+
if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
|
|
1201
|
+
return wants;
|
|
1202
|
+
}
|
|
1203
|
+
function base(anchor) {
|
|
1204
|
+
return {
|
|
1205
|
+
file: anchor.file,
|
|
1206
|
+
...anchor.symbol ? { symbol: anchor.symbol } : {},
|
|
1207
|
+
storedHash: anchor.hash
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
function unresolved(anchor, reason, repo) {
|
|
1211
|
+
return {
|
|
1212
|
+
...base(anchor),
|
|
1213
|
+
state: "unresolved",
|
|
1214
|
+
diffSize: null,
|
|
1215
|
+
...reason ? { reason } : {},
|
|
1216
|
+
...repo ? { repo } : {}
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
function hashIn(source, anchor, resolver) {
|
|
1220
|
+
const resolved = resolveAnchor(source, anchor, resolver);
|
|
1221
|
+
if (!resolved) return null;
|
|
1222
|
+
return {
|
|
1223
|
+
hash: hashAnchorText(resolved.text),
|
|
1224
|
+
lines: resolved.endLine - resolved.startLine + 1
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
function compared(anchor, current, extra = {}) {
|
|
1228
|
+
return {
|
|
1229
|
+
...base(anchor),
|
|
1230
|
+
state: current.hash === anchor.hash ? "match" : "drifted",
|
|
1231
|
+
currentHash: current.hash,
|
|
1232
|
+
diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
|
|
1233
|
+
...extra
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
function localEntry(anchor, read, resolver) {
|
|
1237
|
+
if (!read.ok) return unresolved(anchor, read.reason);
|
|
1238
|
+
const current = hashIn(read.source, anchor, resolver);
|
|
1239
|
+
return current ? compared(anchor, current) : unresolved(anchor, "symbol-not-found");
|
|
1240
|
+
}
|
|
1241
|
+
function remoteEntry(anchor, remote, resolver) {
|
|
1242
|
+
const repo = anchor.repo;
|
|
1243
|
+
const key = normalizeRepoUrl(repo);
|
|
1244
|
+
const atDefault = remote.get(wantKey(key, void 0, anchor.file));
|
|
1245
|
+
const primary = anchor.ref ? remote.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
|
|
1246
|
+
if (!primary) return unresolved(anchor, "remote-unreachable", repo);
|
|
1247
|
+
if (!primary.ok) return unresolved(anchor, primary.reason, repo);
|
|
1248
|
+
const current = hashIn(primary.source, anchor, resolver);
|
|
1249
|
+
if (!current) return unresolved(anchor, "symbol-not-found", repo);
|
|
1250
|
+
if (!anchor.ref) return compared(anchor, current, { repo });
|
|
1251
|
+
if (current.hash !== anchor.hash) {
|
|
1252
|
+
return compared(anchor, current, { repo, remoteState: "drifted-from-ref" });
|
|
1253
|
+
}
|
|
1254
|
+
const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolver) : null;
|
|
1255
|
+
return head && head.hash !== anchor.hash ? {
|
|
1256
|
+
...compared(anchor, head, { repo }),
|
|
1257
|
+
state: "drifted",
|
|
1258
|
+
remoteState: "drifted-on-default"
|
|
1259
|
+
} : compared(anchor, current, { repo, remoteState: "matches-ref" });
|
|
1260
|
+
}
|
|
869
1261
|
|
|
870
1262
|
// src/errors.ts
|
|
871
1263
|
var Fault = /* @__PURE__ */ ((Fault2) => {
|
|
@@ -1085,10 +1477,10 @@ var KbBaseFrozenError = class extends Error {
|
|
|
1085
1477
|
};
|
|
1086
1478
|
|
|
1087
1479
|
// src/kb-pins/model.ts
|
|
1088
|
-
import { join } from "path";
|
|
1480
|
+
import { join as join2 } from "path";
|
|
1089
1481
|
import { z as z4 } from "zod";
|
|
1090
|
-
var PINS_FILE =
|
|
1091
|
-
var PINS_LOCAL_FILE =
|
|
1482
|
+
var PINS_FILE = join2(".strauss", "kb-pins.json");
|
|
1483
|
+
var PINS_LOCAL_FILE = join2(".strauss", "kb-pins.local.json");
|
|
1092
1484
|
var PIN_LAYERS = ["project", "local", "user"];
|
|
1093
1485
|
var pinSchema = z4.object({
|
|
1094
1486
|
/** Relative to the manifest's root, so the file is committable. */
|
|
@@ -1134,17 +1526,17 @@ var pinsManifestSchema = z4.object({
|
|
|
1134
1526
|
}).passthrough();
|
|
1135
1527
|
|
|
1136
1528
|
// src/kb-pins/layers.ts
|
|
1137
|
-
import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
1138
|
-
import { homedir } from "os";
|
|
1139
|
-
import { dirname, isAbsolute as isAbsolute2, join as
|
|
1529
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
|
|
1530
|
+
import { homedir as homedir2 } from "os";
|
|
1531
|
+
import { dirname, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
|
|
1140
1532
|
function userRoot() {
|
|
1141
|
-
return process.env.STRAUSS_KB_USER_ROOT ||
|
|
1533
|
+
return process.env.STRAUSS_KB_USER_ROOT || homedir2();
|
|
1142
1534
|
}
|
|
1143
1535
|
function layerRoot(workspaceDir, layer) {
|
|
1144
1536
|
return layer === "user" ? userRoot() : resolve2(workspaceDir);
|
|
1145
1537
|
}
|
|
1146
1538
|
function layerFile(workspaceDir, layer) {
|
|
1147
|
-
return
|
|
1539
|
+
return join3(
|
|
1148
1540
|
layerRoot(workspaceDir, layer),
|
|
1149
1541
|
layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
|
|
1150
1542
|
);
|
|
@@ -1177,7 +1569,7 @@ async function readPinsLayer(workspaceDir, layer) {
|
|
|
1177
1569
|
}
|
|
1178
1570
|
async function writePinsLayer(workspaceDir, layer, manifest) {
|
|
1179
1571
|
const file = layerFile(workspaceDir, layer);
|
|
1180
|
-
await
|
|
1572
|
+
await mkdir2(dirname(file), { recursive: true });
|
|
1181
1573
|
await writeFile(file, `${JSON.stringify(manifest, null, 2)}
|
|
1182
1574
|
`, "utf8");
|
|
1183
1575
|
}
|
|
@@ -1367,23 +1759,34 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift)
|
|
|
1367
1759
|
if (!record.frontmatter.verified?.length) {
|
|
1368
1760
|
warnings.push({ kind: "unverified" });
|
|
1369
1761
|
}
|
|
1370
|
-
const
|
|
1371
|
-
(entry) => entry.state !== "match"
|
|
1762
|
+
const found = (anchorDrift?.get(record.conceptId) ?? []).filter(
|
|
1763
|
+
(entry) => entry.state !== "match"
|
|
1372
1764
|
);
|
|
1765
|
+
const unchecked2 = found.filter((entry) => isUncheckedReason(entry.reason));
|
|
1766
|
+
const moved = found.filter((entry) => !isUncheckedReason(entry.reason));
|
|
1373
1767
|
if (moved.length) {
|
|
1768
|
+
warnings.push({ kind: "drifted", anchors: moved.map(warningAnchor) });
|
|
1769
|
+
}
|
|
1770
|
+
if (unchecked2.length) {
|
|
1374
1771
|
warnings.push({
|
|
1375
|
-
kind: "
|
|
1376
|
-
anchors:
|
|
1377
|
-
file,
|
|
1378
|
-
...symbol !== void 0 ? { symbol } : {},
|
|
1379
|
-
diffSize,
|
|
1380
|
-
...reason !== void 0 ? { reason } : {}
|
|
1381
|
-
}))
|
|
1772
|
+
kind: "unchecked",
|
|
1773
|
+
anchors: unchecked2.map(warningAnchor)
|
|
1382
1774
|
});
|
|
1383
1775
|
}
|
|
1384
1776
|
return { record, standing: STANDING[status], heads, warnings };
|
|
1385
1777
|
});
|
|
1386
1778
|
}
|
|
1779
|
+
function warningAnchor(entry) {
|
|
1780
|
+
const { file, symbol, diffSize, reason, repo, remoteState } = entry;
|
|
1781
|
+
return {
|
|
1782
|
+
file,
|
|
1783
|
+
...symbol !== void 0 ? { symbol } : {},
|
|
1784
|
+
diffSize,
|
|
1785
|
+
...reason !== void 0 ? { reason } : {},
|
|
1786
|
+
...repo !== void 0 ? { repo } : {},
|
|
1787
|
+
...remoteState !== void 0 ? { remoteState } : {}
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1387
1790
|
function resolveHeads(from, byId) {
|
|
1388
1791
|
const warnings = [];
|
|
1389
1792
|
const heads = /* @__PURE__ */ new Map();
|
|
@@ -1660,7 +2063,7 @@ async function buildContext(store, workspaceDir, options = {}) {
|
|
|
1660
2063
|
operation: "kb.context.refused",
|
|
1661
2064
|
approxTokens: total,
|
|
1662
2065
|
budgetTokens,
|
|
1663
|
-
bases: bases.map((
|
|
2066
|
+
bases: bases.map((base2) => base2.path)
|
|
1664
2067
|
});
|
|
1665
2068
|
const refusal = [
|
|
1666
2069
|
HEADING2,
|
|
@@ -1670,7 +2073,7 @@ async function buildContext(store, workspaceDir, options = {}) {
|
|
|
1670
2073
|
"from a complete one. The pinned bases:",
|
|
1671
2074
|
"",
|
|
1672
2075
|
...bases.map(
|
|
1673
|
-
(
|
|
2076
|
+
(base2) => `- ${base2.path} \u2014 ~${base2.approxTokens} tokens (bundlePath: \`${base2.absolutePath}\`)`
|
|
1674
2077
|
),
|
|
1675
2078
|
"",
|
|
1676
2079
|
"For the question at hand, read what you need now \u2014 `kb_load` a base",
|
|
@@ -1886,6 +2289,16 @@ function validateBundle(records) {
|
|
|
1886
2289
|
);
|
|
1887
2290
|
}
|
|
1888
2291
|
}
|
|
2292
|
+
for (const anchor of fm.strauss_anchors ?? []) {
|
|
2293
|
+
if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
|
|
2294
|
+
report(
|
|
2295
|
+
"anchor_repo",
|
|
2296
|
+
conceptId2,
|
|
2297
|
+
`anchor repo "${anchor.repo}" is not a full remote URL, so it cannot be resolved against a remote`,
|
|
2298
|
+
"warning"
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
1889
2302
|
if (fm.strauss_assumption && fm.sources?.length) {
|
|
1890
2303
|
report("assumption", conceptId2, "marked an assumption but cites sources");
|
|
1891
2304
|
}
|
|
@@ -1905,7 +2318,8 @@ var KB_DOCTOR_CHECKS = [
|
|
|
1905
2318
|
"orphaned",
|
|
1906
2319
|
"broken-supersession",
|
|
1907
2320
|
"superseded-but-cited",
|
|
1908
|
-
"drifted"
|
|
2321
|
+
"drifted",
|
|
2322
|
+
"unchecked"
|
|
1909
2323
|
];
|
|
1910
2324
|
var CHECK_HEADLINES = {
|
|
1911
2325
|
expired: "past its stale_after date",
|
|
@@ -1915,7 +2329,8 @@ var CHECK_HEADLINES = {
|
|
|
1915
2329
|
orphaned: "no other record links to it",
|
|
1916
2330
|
"broken-supersession": "the supersession pointers do not resolve",
|
|
1917
2331
|
"superseded-but-cited": "a live record's body links to one that no longer holds",
|
|
1918
|
-
drifted: "the code an anchor points at moved out from under its hash"
|
|
2332
|
+
drifted: "the code an anchor points at moved out from under its hash",
|
|
2333
|
+
unchecked: "an anchor in another repository nothing could reach"
|
|
1919
2334
|
};
|
|
1920
2335
|
var DAY_MS = 864e5;
|
|
1921
2336
|
function doctor(bundle, options = {}) {
|
|
@@ -1940,7 +2355,8 @@ function doctor(bundle, options = {}) {
|
|
|
1940
2355
|
group("orphaned", orphaned(bundle)),
|
|
1941
2356
|
group("broken-supersession", brokenSupersession(bundle, adjudicated)),
|
|
1942
2357
|
group("superseded-but-cited", supersededButCited(bundle, standings)),
|
|
1943
|
-
group("drifted", drifted(inForce))
|
|
2358
|
+
group("drifted", drifted(inForce)),
|
|
2359
|
+
group("unchecked", unchecked(inForce))
|
|
1944
2360
|
];
|
|
1945
2361
|
const counts = Object.fromEntries(
|
|
1946
2362
|
groups.map((entry) => [entry.check, entry.count])
|
|
@@ -2127,21 +2543,38 @@ function supersededButCited(bundle, standings) {
|
|
|
2127
2543
|
return findings;
|
|
2128
2544
|
}
|
|
2129
2545
|
function drifted(hits) {
|
|
2546
|
+
return anchorFindings(
|
|
2547
|
+
hits,
|
|
2548
|
+
"drifted",
|
|
2549
|
+
(count2) => count2 === 1 ? "anchor no longer matches" : "anchors no longer match"
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
function unchecked(hits) {
|
|
2553
|
+
return anchorFindings(
|
|
2554
|
+
hits,
|
|
2555
|
+
"unchecked",
|
|
2556
|
+
(count2) => count2 === 1 ? "anchor was not checked" : "anchors were not checked"
|
|
2557
|
+
);
|
|
2558
|
+
}
|
|
2559
|
+
function anchorFindings(hits, kind, headline) {
|
|
2130
2560
|
const findings = [];
|
|
2131
2561
|
for (const hit of hits) {
|
|
2132
|
-
const warning = hit.warnings.find(
|
|
2562
|
+
const warning = hit.warnings.find(
|
|
2563
|
+
(entry) => entry.kind === kind
|
|
2564
|
+
);
|
|
2133
2565
|
if (!warning) continue;
|
|
2566
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
2567
|
+
for (const anchor of warning.anchors) {
|
|
2568
|
+
const repo = anchor.repo ?? "";
|
|
2569
|
+
byRepo.set(repo, [...byRepo.get(repo) ?? [], describeAnchor(anchor)]);
|
|
2570
|
+
}
|
|
2571
|
+
const detail = [...byRepo.entries()].map(
|
|
2572
|
+
([repo, entries]) => repo ? `${repo}: ${entries.join(", ")}` : entries.join(", ")
|
|
2573
|
+
);
|
|
2134
2574
|
findings.push(
|
|
2135
2575
|
finding(
|
|
2136
2576
|
hit.record,
|
|
2137
|
-
`${warning.anchors.length} ${warning.anchors.length
|
|
2138
|
-
const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
|
|
2139
|
-
if (anchor.reason) return `${at} (${anchor.reason})`;
|
|
2140
|
-
if (anchor.diffSize === null) {
|
|
2141
|
-
return `${at} (changed, size unrecorded)`;
|
|
2142
|
-
}
|
|
2143
|
-
return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
|
|
2144
|
-
}).join(", ")}`
|
|
2577
|
+
`${warning.anchors.length} ${headline(warning.anchors.length)}: ${detail.join("; ")}`
|
|
2145
2578
|
)
|
|
2146
2579
|
);
|
|
2147
2580
|
}
|
|
@@ -2149,6 +2582,15 @@ function drifted(hits) {
|
|
|
2149
2582
|
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
2150
2583
|
);
|
|
2151
2584
|
}
|
|
2585
|
+
function describeAnchor(anchor) {
|
|
2586
|
+
const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
|
|
2587
|
+
if (anchor.reason) return `${at} (${anchor.reason})`;
|
|
2588
|
+
if (anchor.remoteState === "drifted-on-default") {
|
|
2589
|
+
return `${at} (matches ref, moved on the default branch)`;
|
|
2590
|
+
}
|
|
2591
|
+
if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
|
|
2592
|
+
return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
|
|
2593
|
+
}
|
|
2152
2594
|
function replaces(later, earlier) {
|
|
2153
2595
|
return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
|
|
2154
2596
|
}
|
|
@@ -2316,12 +2758,15 @@ function argvFlag(argv, name) {
|
|
|
2316
2758
|
var anchorResolveCommand = define({
|
|
2317
2759
|
name: "anchor-resolve",
|
|
2318
2760
|
tool: "kb_anchor_resolve",
|
|
2319
|
-
usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
|
|
2320
|
-
description: "Resolve a record's anchors
|
|
2761
|
+
usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
|
|
2762
|
+
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.",
|
|
2321
2763
|
input: z8.object({
|
|
2322
2764
|
bundlePath,
|
|
2323
2765
|
conceptId,
|
|
2324
2766
|
repoRoot: z8.string().min(1).optional(),
|
|
2767
|
+
offline: z8.boolean().optional().describe(
|
|
2768
|
+
"Resolve foreign anchors from the local repo cache only, never fetching."
|
|
2769
|
+
),
|
|
2325
2770
|
rebaseline: z8.boolean().optional().describe(
|
|
2326
2771
|
"Accept the current code as the new baseline for anchors that drifted."
|
|
2327
2772
|
),
|
|
@@ -2333,10 +2778,11 @@ var anchorResolveCommand = define({
|
|
|
2333
2778
|
bundlePath: path,
|
|
2334
2779
|
conceptId: argv[1],
|
|
2335
2780
|
repoRoot: argvFlag(argv, "--repo-root"),
|
|
2781
|
+
offline: argv.includes("--offline"),
|
|
2336
2782
|
rebaseline: argv.includes("--rebaseline"),
|
|
2337
2783
|
restamp: argv.includes("--restamp")
|
|
2338
2784
|
}),
|
|
2339
|
-
run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
|
|
2785
|
+
run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
|
|
2340
2786
|
const root = repoRoot ?? process.cwd();
|
|
2341
2787
|
const record = await store.read(path, id);
|
|
2342
2788
|
if (!record) throw new KbRecordNotFoundError(id);
|
|
@@ -2351,18 +2797,10 @@ var anchorResolveCommand = define({
|
|
|
2351
2797
|
}
|
|
2352
2798
|
const results = [];
|
|
2353
2799
|
const updated = [];
|
|
2354
|
-
const origin = new LazyOrigin(root);
|
|
2355
2800
|
let dirty = false;
|
|
2356
|
-
|
|
2357
|
-
const foreign = new Map(
|
|
2358
|
-
anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
|
|
2359
|
-
);
|
|
2360
|
-
const reads = await readAnchorFiles(
|
|
2361
|
-
anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
|
|
2362
|
-
anchorFileReader(root)
|
|
2363
|
-
);
|
|
2801
|
+
const sources = await readSources(anchors, root, offline === true);
|
|
2364
2802
|
for (const anchor of anchors) {
|
|
2365
|
-
const
|
|
2803
|
+
const base2 = {
|
|
2366
2804
|
file: anchor.file,
|
|
2367
2805
|
...anchor.symbol ? { symbol: anchor.symbol } : {},
|
|
2368
2806
|
// Carried onto unresolved findings too: an anchor that once hashed
|
|
@@ -2370,21 +2808,17 @@ var anchorResolveCommand = define({
|
|
|
2370
2808
|
// has to be able to tell it from one nobody ever stamped.
|
|
2371
2809
|
...anchor.hash ? { storedHash: anchor.hash } : {}
|
|
2372
2810
|
};
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
}
|
|
2378
|
-
const fileRead = reads.get(anchor.file);
|
|
2379
|
-
if (!fileRead.ok) {
|
|
2380
|
-
results.push({ ...base, state: "unresolved", reason: fileRead.reason });
|
|
2811
|
+
const source = sources.get(anchor);
|
|
2812
|
+
if (source.repo) base2.repo = source.repo;
|
|
2813
|
+
if (!source.ok) {
|
|
2814
|
+
results.push({ ...base2, state: "unresolved", reason: source.reason });
|
|
2381
2815
|
updated.push(anchor);
|
|
2382
2816
|
continue;
|
|
2383
2817
|
}
|
|
2384
|
-
const resolved = resolveAnchor(
|
|
2818
|
+
const resolved = resolveAnchor(source.source, anchor);
|
|
2385
2819
|
if (!resolved) {
|
|
2386
2820
|
results.push({
|
|
2387
|
-
...
|
|
2821
|
+
...base2,
|
|
2388
2822
|
state: "unresolved",
|
|
2389
2823
|
reason: "symbol-not-found"
|
|
2390
2824
|
});
|
|
@@ -2399,30 +2833,47 @@ var anchorResolveCommand = define({
|
|
|
2399
2833
|
lines: currentLines,
|
|
2400
2834
|
resolved_at: now()
|
|
2401
2835
|
};
|
|
2836
|
+
const pinned = anchor.ref !== void 0 && source.repo !== void 0;
|
|
2402
2837
|
if (!anchor.hash) {
|
|
2403
|
-
results.push({ ...
|
|
2838
|
+
results.push({ ...base2, state: "stamped", currentHash });
|
|
2404
2839
|
updated.push(stamped);
|
|
2405
2840
|
dirty = true;
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
state: "match",
|
|
2410
|
-
currentHash
|
|
2411
|
-
});
|
|
2412
|
-
const refresh = restamp || anchor.resolved_at === void 0;
|
|
2413
|
-
updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
|
|
2414
|
-
if (refresh) dirty = true;
|
|
2415
|
-
} else {
|
|
2841
|
+
continue;
|
|
2842
|
+
}
|
|
2843
|
+
if (anchor.hash !== currentHash) {
|
|
2416
2844
|
results.push({
|
|
2417
|
-
...
|
|
2845
|
+
...base2,
|
|
2418
2846
|
state: "drifted",
|
|
2419
2847
|
currentHash,
|
|
2420
|
-
diffSize: anchor
|
|
2848
|
+
diffSize: lineDelta(anchor, currentLines),
|
|
2849
|
+
...pinned ? { remoteState: "drifted-from-ref" } : {},
|
|
2421
2850
|
...rebaseline ? { rebaselined: true } : {}
|
|
2422
2851
|
});
|
|
2423
2852
|
updated.push(rebaseline ? stamped : anchor);
|
|
2424
2853
|
if (rebaseline) dirty = true;
|
|
2854
|
+
continue;
|
|
2425
2855
|
}
|
|
2856
|
+
const onDefault = pinned ? headHash(source, anchor) : void 0;
|
|
2857
|
+
if (onDefault && onDefault.hash !== anchor.hash) {
|
|
2858
|
+
results.push({
|
|
2859
|
+
...base2,
|
|
2860
|
+
state: "drifted",
|
|
2861
|
+
currentHash: onDefault.hash,
|
|
2862
|
+
diffSize: lineDelta(anchor, onDefault.lines),
|
|
2863
|
+
remoteState: "drifted-on-default"
|
|
2864
|
+
});
|
|
2865
|
+
updated.push(anchor);
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
results.push({
|
|
2869
|
+
...base2,
|
|
2870
|
+
state: "match",
|
|
2871
|
+
currentHash,
|
|
2872
|
+
...pinned ? { remoteState: "matches-ref" } : {}
|
|
2873
|
+
});
|
|
2874
|
+
const refresh = restamp || anchor.resolved_at === void 0;
|
|
2875
|
+
updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
|
|
2876
|
+
if (refresh) dirty = true;
|
|
2426
2877
|
}
|
|
2427
2878
|
let frozen = false;
|
|
2428
2879
|
if (dirty) {
|
|
@@ -2435,16 +2886,19 @@ var anchorResolveCommand = define({
|
|
|
2435
2886
|
if (!frozen) await store.updateAnchors(path, id, updated, actor);
|
|
2436
2887
|
}
|
|
2437
2888
|
const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
|
|
2438
|
-
const
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
const
|
|
2889
|
+
const unreachable = results.filter(
|
|
2890
|
+
(entry) => isUncheckedReason(entry.reason)
|
|
2891
|
+
).length;
|
|
2892
|
+
const checked = results.length - unreachable;
|
|
2893
|
+
const matches2 = results.filter((entry) => entry.state === "match").length;
|
|
2894
|
+
const note = `${matches2}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
|
|
2895
|
+
const clean = checked > 0 && matches2 === checked && unreachable === 0;
|
|
2442
2896
|
if (clean) {
|
|
2443
2897
|
try {
|
|
2444
2898
|
await store.verify(
|
|
2445
2899
|
path,
|
|
2446
2900
|
id,
|
|
2447
|
-
`anchor-resolve: ${
|
|
2901
|
+
`anchor-resolve: ${note} (regex resolver)`,
|
|
2448
2902
|
actor,
|
|
2449
2903
|
now()
|
|
2450
2904
|
);
|
|
@@ -2460,18 +2914,81 @@ var anchorResolveCommand = define({
|
|
|
2460
2914
|
}
|
|
2461
2915
|
return { conceptId: id, results, verified: true, ...frozenNote };
|
|
2462
2916
|
}
|
|
2463
|
-
return {
|
|
2917
|
+
return {
|
|
2918
|
+
conceptId: id,
|
|
2919
|
+
results,
|
|
2920
|
+
verified: false,
|
|
2921
|
+
...unreachable ? { note } : {},
|
|
2922
|
+
...frozenNote
|
|
2923
|
+
};
|
|
2464
2924
|
},
|
|
2465
2925
|
// A stored hash that no longer resolves is a broken anchor, not an absence:
|
|
2466
2926
|
// the file was deleted or the symbol renamed, and exiting zero on it would
|
|
2467
2927
|
// let the one edit that destroys an anchor pass the gate that exists to
|
|
2468
2928
|
// catch it. An anchor nobody ever stamped is still just unstamped, and one
|
|
2469
|
-
//
|
|
2470
|
-
//
|
|
2929
|
+
// whose remote nothing could reach was never checked — failing CI on either
|
|
2930
|
+
// would gate on work this command did not do.
|
|
2471
2931
|
failsWhen: (result) => result.results.some(
|
|
2472
|
-
(entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason
|
|
2932
|
+
(entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
|
|
2473
2933
|
)
|
|
2474
2934
|
});
|
|
2935
|
+
function lineDelta(anchor, current) {
|
|
2936
|
+
return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
|
|
2937
|
+
}
|
|
2938
|
+
function headHash(source, anchor) {
|
|
2939
|
+
if (source.head === void 0) return void 0;
|
|
2940
|
+
const resolved = resolveAnchor(source.head, anchor);
|
|
2941
|
+
if (!resolved) return void 0;
|
|
2942
|
+
return {
|
|
2943
|
+
hash: hashAnchorText(resolved.text),
|
|
2944
|
+
lines: resolved.endLine - resolved.startLine + 1
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
async function readSources(anchors, root, offline) {
|
|
2948
|
+
const origin = new LazyOrigin(root);
|
|
2949
|
+
if (anchors.some((anchor) => anchor.repo)) await origin.prime();
|
|
2950
|
+
const foreign = new Map(
|
|
2951
|
+
anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
|
|
2952
|
+
);
|
|
2953
|
+
const local = anchors.filter((anchor) => !foreign.get(anchor));
|
|
2954
|
+
const remote = anchors.filter((anchor) => foreign.get(anchor));
|
|
2955
|
+
const reads = await readAnchorFiles(
|
|
2956
|
+
local.map((anchor) => anchor.file),
|
|
2957
|
+
anchorFileReader(root)
|
|
2958
|
+
);
|
|
2959
|
+
const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
|
|
2960
|
+
offline
|
|
2961
|
+
});
|
|
2962
|
+
const sources = /* @__PURE__ */ new Map();
|
|
2963
|
+
for (const anchor of local) {
|
|
2964
|
+
const read = reads.get(anchor.file);
|
|
2965
|
+
sources.set(
|
|
2966
|
+
anchor,
|
|
2967
|
+
read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2970
|
+
for (const anchor of remote) {
|
|
2971
|
+
const repo = anchor.repo;
|
|
2972
|
+
const key = normalizeRepoUrl(repo);
|
|
2973
|
+
const atDefault = blobs.get(wantKey(key, void 0, anchor.file));
|
|
2974
|
+
const primary = anchor.ref ? blobs.get(wantKey(key, anchor.ref, anchor.file)) : atDefault;
|
|
2975
|
+
if (!primary?.ok) {
|
|
2976
|
+
sources.set(anchor, {
|
|
2977
|
+
ok: false,
|
|
2978
|
+
reason: primary?.ok === false ? primary.reason : "remote-unreachable",
|
|
2979
|
+
repo
|
|
2980
|
+
});
|
|
2981
|
+
continue;
|
|
2982
|
+
}
|
|
2983
|
+
sources.set(anchor, {
|
|
2984
|
+
ok: true,
|
|
2985
|
+
source: primary.source,
|
|
2986
|
+
repo,
|
|
2987
|
+
...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
2990
|
+
return sources;
|
|
2991
|
+
}
|
|
2475
2992
|
|
|
2476
2993
|
// src/commands/answer.ts
|
|
2477
2994
|
import { z as z9 } from "zod";
|
|
@@ -2479,7 +2996,7 @@ var answerCommand = define({
|
|
|
2479
2996
|
name: "answer",
|
|
2480
2997
|
tool: "kb_answer",
|
|
2481
2998
|
usage: "answer <concept-id> <answer...>",
|
|
2482
|
-
description: "Resolve an open question:
|
|
2999
|
+
description: "Resolve an open question: set status, stamp who and when, append an Answer section. If the answer overturns a decision or assumption, supersede that record explicitly.",
|
|
2483
3000
|
input: z9.object({ bundlePath, conceptId, answer: z9.string().min(1) }),
|
|
2484
3001
|
fromArgv: (argv, path) => ({
|
|
2485
3002
|
bundlePath: path,
|
|
@@ -2574,7 +3091,7 @@ var contextCommand = define({
|
|
|
2574
3091
|
name: "context",
|
|
2575
3092
|
tool: "kb_context",
|
|
2576
3093
|
usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
|
|
2577
|
-
description: "
|
|
3094
|
+
description: "Index block of pinned bases (ids, titles, standing) for injection at context birth. Takes no bundlePath \u2014 reads the workspace pin manifests. Empty when nothing is pinned; refuses over budget rather than truncating. Budget precedence: flags, then the manifest `context[profile]` over `context.default`, then the built-in profile, then package defaults.",
|
|
2578
3095
|
input: z12.object({
|
|
2579
3096
|
budgetTokens: z12.number().int().positive().optional().describe(
|
|
2580
3097
|
"Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
|
|
@@ -2629,8 +3146,8 @@ var days = (what, fallback) => z13.number().int().positive().optional().describe
|
|
|
2629
3146
|
var doctorCommand = define({
|
|
2630
3147
|
name: "doctor",
|
|
2631
3148
|
tool: "kb_doctor",
|
|
2632
|
-
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
|
|
2633
|
-
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.",
|
|
3149
|
+
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
|
|
3150
|
+
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.",
|
|
2634
3151
|
input: z13.object({
|
|
2635
3152
|
bundlePath,
|
|
2636
3153
|
repoRoot: REPO_ROOT,
|
|
@@ -2646,6 +3163,9 @@ var doctorCommand = define({
|
|
|
2646
3163
|
"How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
|
|
2647
3164
|
DEFAULT_AGING_DAYS
|
|
2648
3165
|
),
|
|
3166
|
+
offline: z13.boolean().optional().describe(
|
|
3167
|
+
"Read foreign anchors from the local repo cache only, never fetching."
|
|
3168
|
+
),
|
|
2649
3169
|
strict: z13.boolean().optional().describe(
|
|
2650
3170
|
"Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
|
|
2651
3171
|
)
|
|
@@ -2665,13 +3185,23 @@ var doctorCommand = define({
|
|
|
2665
3185
|
...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
|
|
2666
3186
|
...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
|
|
2667
3187
|
...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
|
|
3188
|
+
...argv.includes("--offline") ? { offline: true } : {},
|
|
2668
3189
|
...argv.includes("--strict") ? { strict: true } : {}
|
|
2669
3190
|
};
|
|
2670
3191
|
},
|
|
2671
|
-
run: async ({ store, now }, {
|
|
3192
|
+
run: async ({ store, now }, {
|
|
3193
|
+
bundlePath: path,
|
|
3194
|
+
expiringDays,
|
|
3195
|
+
unverifiedDays,
|
|
3196
|
+
agingDays,
|
|
3197
|
+
repoRoot,
|
|
3198
|
+
offline
|
|
3199
|
+
}) => {
|
|
2672
3200
|
const checkedAt = now();
|
|
2673
3201
|
const records = await store.list(path);
|
|
2674
|
-
const anchorDrift = await store.detectDrift(records, repoRoot
|
|
3202
|
+
const anchorDrift = await store.detectDrift(records, repoRoot, {
|
|
3203
|
+
offline: offline === true
|
|
3204
|
+
});
|
|
2675
3205
|
const report = doctor(records, {
|
|
2676
3206
|
...expiringDays !== void 0 ? { expiringDays } : {},
|
|
2677
3207
|
...unverifiedDays !== void 0 ? { unverifiedDays } : {},
|
|
@@ -2760,7 +3290,7 @@ var listCommand = define({
|
|
|
2760
3290
|
name: "list",
|
|
2761
3291
|
tool: "kb_list",
|
|
2762
3292
|
usage: "list [type]",
|
|
2763
|
-
description: "Every record, optionally
|
|
3293
|
+
description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
|
|
2764
3294
|
input: z15.object({ bundlePath, type: z15.enum(KB_RECORD_TYPES).optional() }),
|
|
2765
3295
|
fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
|
|
2766
3296
|
run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
|
|
@@ -2778,7 +3308,7 @@ var loadCommand = define({
|
|
|
2778
3308
|
name: "load",
|
|
2779
3309
|
tool: "kb_load",
|
|
2780
3310
|
usage: "load [type] [--budget N | --all] [--repo-root PATH]",
|
|
2781
|
-
description: "
|
|
3311
|
+
description: "Load the whole base, each record with its standing \u2014 call it first, at the point of use, since compaction drops it. Superseded records arrive as stubs; kb_trace has the history. Over budget it refuses: kb_catalog, then kb_pack, or narrow with `type`; `all` bypasses. Never read record files directly \u2014 only kb_* tools resolve supersession. `digest` stamps the base's content, so hooks know when to reload.",
|
|
2782
3312
|
input: z16.object({
|
|
2783
3313
|
bundlePath,
|
|
2784
3314
|
type: z16.enum(KB_RECORD_TYPES).optional(),
|
|
@@ -2830,7 +3360,7 @@ var logCommand = define({
|
|
|
2830
3360
|
name: "log",
|
|
2831
3361
|
tool: "kb_log",
|
|
2832
3362
|
usage: "log",
|
|
2833
|
-
description: "
|
|
3363
|
+
description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
|
|
2834
3364
|
input: z17.object({ bundlePath }),
|
|
2835
3365
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
2836
3366
|
run: ({ store }, { bundlePath: path }) => store.readLog(path)
|
|
@@ -2842,7 +3372,7 @@ var noDecisionCommand = define({
|
|
|
2842
3372
|
name: "no-decision",
|
|
2843
3373
|
tool: "kb_no_decision",
|
|
2844
3374
|
usage: "no-decision <reason...>",
|
|
2845
|
-
description:
|
|
3375
|
+
description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
|
|
2846
3376
|
input: z18.object({ bundlePath, reason: z18.string().min(1) }),
|
|
2847
3377
|
fromArgv: (argv, path) => ({
|
|
2848
3378
|
bundlePath: path,
|
|
@@ -2865,7 +3395,7 @@ var packCommand = define({
|
|
|
2865
3395
|
name: "pack",
|
|
2866
3396
|
tool: "kb_pack",
|
|
2867
3397
|
usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
|
|
2868
|
-
description: "
|
|
3398
|
+
description: "Bounded neighbourhood around one record: within `hops`, ranked, cut to `maxNodes`, with every cut record named under Excluded. Use when the base is over kb_load's budget and the work centres on a record you can name. Refuses over budget rather than truncating. Everything below the header is byte-stable across runs. Resolves supersession like kb_load.",
|
|
2869
3399
|
input: z19.object({
|
|
2870
3400
|
bundlePath,
|
|
2871
3401
|
conceptId,
|
|
@@ -2965,7 +3495,7 @@ var pinCommand = define({
|
|
|
2965
3495
|
name: "pin",
|
|
2966
3496
|
tool: "kb_pin",
|
|
2967
3497
|
usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
|
|
2968
|
-
description: "Pin a base into a workspace
|
|
3498
|
+
description: "Pin a base into a workspace manifest so kb_context surfaces it. Layers, nearest wins: project `.strauss/kb-pins.json` (default), `--local` (personal, gitignored), `--user` (`~/.strauss`). Idempotent; `--mode full|index`, `--profiles`, `--frozen`/`--unfreeze` update only those fields. A path with no records pins with a warning. Never touches the base itself.",
|
|
2969
3499
|
input: z20.object({
|
|
2970
3500
|
bundlePath,
|
|
2971
3501
|
mode: z20.enum(["full", "index"]).optional().describe(
|
|
@@ -3009,7 +3539,7 @@ var pinsCommand = define({
|
|
|
3009
3539
|
name: "pins",
|
|
3010
3540
|
tool: "kb_pins",
|
|
3011
3541
|
usage: "pins",
|
|
3012
|
-
description: "Every pinned base across the manifest layers,
|
|
3542
|
+
description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
|
|
3013
3543
|
input: z21.object({}),
|
|
3014
3544
|
fromArgv: () => ({}),
|
|
3015
3545
|
run: ({ store }) => listPins(store, process.cwd())
|
|
@@ -3063,7 +3593,7 @@ var readIndexCommand = define({
|
|
|
3063
3593
|
name: "index",
|
|
3064
3594
|
tool: "kb_index",
|
|
3065
3595
|
usage: "index",
|
|
3066
|
-
description: "The index
|
|
3596
|
+
description: "The index \u2014 title, type, status, description per record \u2014 rebuilt if stale. Cheapest re-orientation after compaction: call it (or kb_context) first, then kb_load or fetch by id.",
|
|
3067
3597
|
input: z23.object({ bundlePath }),
|
|
3068
3598
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
3069
3599
|
run: ({ store }, { bundlePath: path }) => store.readIndex(path)
|
|
@@ -3075,7 +3605,7 @@ var schemaCommand = define({
|
|
|
3075
3605
|
name: "schema",
|
|
3076
3606
|
tool: "kb_schema",
|
|
3077
3607
|
usage: "schema",
|
|
3078
|
-
description: "JSON Schema for
|
|
3608
|
+
description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
|
|
3079
3609
|
input: z24.object({}),
|
|
3080
3610
|
fromArgv: () => ({}),
|
|
3081
3611
|
run: () => Promise.resolve(kbJsonSchemas())
|
|
@@ -3087,7 +3617,7 @@ var statusCommand = define({
|
|
|
3087
3617
|
name: "status",
|
|
3088
3618
|
tool: "kb_status",
|
|
3089
3619
|
usage: "status <concept-id> <status>",
|
|
3090
|
-
description: "Move a record's status
|
|
3620
|
+
description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
|
|
3091
3621
|
input: z25.object({
|
|
3092
3622
|
bundlePath,
|
|
3093
3623
|
conceptId,
|
|
@@ -3111,7 +3641,7 @@ var supersedeCommand = define({
|
|
|
3111
3641
|
name: "supersede",
|
|
3112
3642
|
tool: "kb_supersede",
|
|
3113
3643
|
usage: "supersede <concept-id> <replacement-id>",
|
|
3114
|
-
description: "Mark a record superseded by another,
|
|
3644
|
+
description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
|
|
3115
3645
|
input: z26.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
3116
3646
|
fromArgv: (argv, path) => ({
|
|
3117
3647
|
bundlePath: path,
|
|
@@ -3130,7 +3660,7 @@ import { z as z27 } from "zod";
|
|
|
3130
3660
|
var syncInstructionsCommand = define({
|
|
3131
3661
|
name: "sync-instructions",
|
|
3132
3662
|
usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
|
|
3133
|
-
description: "
|
|
3663
|
+
description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
|
|
3134
3664
|
input: z27.object({
|
|
3135
3665
|
file: z27.string().min(1).describe("The instruction file to edit in place."),
|
|
3136
3666
|
budgetTokens: z27.number().int().positive().optional(),
|
|
@@ -3166,7 +3696,7 @@ var traceCommand = define({
|
|
|
3166
3696
|
name: "trace",
|
|
3167
3697
|
tool: "kb_trace",
|
|
3168
3698
|
usage: "trace <concept-id> [edges...]",
|
|
3169
|
-
description: '
|
|
3699
|
+
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".',
|
|
3170
3700
|
input: z28.object({
|
|
3171
3701
|
bundlePath,
|
|
3172
3702
|
conceptId,
|
|
@@ -3210,7 +3740,7 @@ var unpinCommand = define({
|
|
|
3210
3740
|
name: "unpin",
|
|
3211
3741
|
tool: "kb_unpin",
|
|
3212
3742
|
usage: "unpin [bundle-path]",
|
|
3213
|
-
description: "Remove a base from every
|
|
3743
|
+
description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
|
|
3214
3744
|
input: z30.object({ bundlePath }),
|
|
3215
3745
|
fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
|
|
3216
3746
|
run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
|
|
@@ -3238,7 +3768,7 @@ var verifyCommand = define({
|
|
|
3238
3768
|
name: "verify",
|
|
3239
3769
|
tool: "kb_verify",
|
|
3240
3770
|
usage: "verify <concept-id> --note <text>",
|
|
3241
|
-
description: "Append
|
|
3771
|
+
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.",
|
|
3242
3772
|
input: z32.object({
|
|
3243
3773
|
bundlePath,
|
|
3244
3774
|
conceptId,
|
|
@@ -3267,15 +3797,7 @@ var writeCommand = define({
|
|
|
3267
3797
|
name: "write",
|
|
3268
3798
|
tool: "kb_write",
|
|
3269
3799
|
usage: "write <type> < record.json",
|
|
3270
|
-
description:
|
|
3271
|
-
"Write one record. Search first \u2014 the same knowledge filed twice under different slugs is how a base rots, and a duplicate concept id is rejected rather than overwritten. Call kb_types for the sections each type accepts.",
|
|
3272
|
-
"",
|
|
3273
|
-
"Judgment the tool cannot enforce for you:",
|
|
3274
|
-
"- An unsourced claim is an `assumption` record with assumption: true, never a `fact` with a vague source. The distinction is what lets a later reader separate what was established from what was guessed.",
|
|
3275
|
-
"- When two records conflict, say so in a `risk`, an `open-question`, or a superseding `decision`. Quietly picking a winner destroys the disagreement, which is usually the useful part.",
|
|
3276
|
-
"- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
|
|
3277
|
-
"- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
|
|
3278
|
-
].join("\n"),
|
|
3800
|
+
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.",
|
|
3279
3801
|
input: z33.object({
|
|
3280
3802
|
bundlePath,
|
|
3281
3803
|
type: z33.enum(KB_RECORD_TYPES),
|
|
@@ -3307,14 +3829,7 @@ var writeDecisionCommand = define({
|
|
|
3307
3829
|
name: "write-decision",
|
|
3308
3830
|
tool: "kb_write_decision",
|
|
3309
3831
|
usage: "write-decision < decision.json",
|
|
3310
|
-
description:
|
|
3311
|
-
"Write a decision. Takes `alternative` and `impact` as fields rather than free sections, because what was rejected is the part a later reader cannot reconstruct from the code \u2014 a heading is too easy to leave empty.",
|
|
3312
|
-
"",
|
|
3313
|
-
"What belongs in one:",
|
|
3314
|
-
'- Record a decision when a later reader would otherwise "simplify" the constraint away. If the diff already answers the question, there is nothing here to write.',
|
|
3315
|
-
"- `alternative` is what you turned down and why, not a list of everything considered.",
|
|
3316
|
-
"- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
|
|
3317
|
-
].join("\n"),
|
|
3832
|
+
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.",
|
|
3318
3833
|
input: z34.object({ bundlePath, input: decisionInputSchema }),
|
|
3319
3834
|
fromArgv: async (_argv, path, stdin) => ({
|
|
3320
3835
|
bundlePath: path,
|
|
@@ -3396,7 +3911,7 @@ function parseMarkdownWithFrontmatter(text, schema) {
|
|
|
3396
3911
|
|
|
3397
3912
|
// src/search-index.ts
|
|
3398
3913
|
import { stat as stat2 } from "fs/promises";
|
|
3399
|
-
import { join as
|
|
3914
|
+
import { join as join4 } from "path";
|
|
3400
3915
|
var SEARCH_INDEX_FILE = ".index.sqlite";
|
|
3401
3916
|
var COLLECTION = "kb";
|
|
3402
3917
|
async function searchBase(bundlePath2, query, options = {}) {
|
|
@@ -3405,7 +3920,7 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
3405
3920
|
let store = null;
|
|
3406
3921
|
try {
|
|
3407
3922
|
store = await qmd.createStore({
|
|
3408
|
-
dbPath:
|
|
3923
|
+
dbPath: join4(bundlePath2, SEARCH_INDEX_FILE),
|
|
3409
3924
|
config: {
|
|
3410
3925
|
collections: {
|
|
3411
3926
|
[COLLECTION]: {
|
|
@@ -3440,7 +3955,7 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
3440
3955
|
}
|
|
3441
3956
|
}
|
|
3442
3957
|
async function isStale(bundlePath2) {
|
|
3443
|
-
const indexAt = await stat2(
|
|
3958
|
+
const indexAt = await stat2(join4(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
|
|
3444
3959
|
if (!indexAt) return true;
|
|
3445
3960
|
const { readdir: readdir2 } = await import("fs/promises");
|
|
3446
3961
|
const names = (await readdir2(bundlePath2).catch(() => [])).filter(
|
|
@@ -3449,7 +3964,7 @@ async function isStale(bundlePath2) {
|
|
|
3449
3964
|
let stale = false;
|
|
3450
3965
|
await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
|
|
3451
3966
|
if (stale) return;
|
|
3452
|
-
const at = await stat2(
|
|
3967
|
+
const at = await stat2(join4(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
|
|
3453
3968
|
if (at > indexAt) stale = true;
|
|
3454
3969
|
});
|
|
3455
3970
|
return stale;
|
|
@@ -3488,14 +4003,14 @@ import { createHash as createHash2 } from "crypto";
|
|
|
3488
4003
|
import {
|
|
3489
4004
|
appendFile,
|
|
3490
4005
|
link,
|
|
3491
|
-
mkdir as
|
|
4006
|
+
mkdir as mkdir3,
|
|
3492
4007
|
readdir,
|
|
3493
4008
|
readFile as readFile4,
|
|
3494
4009
|
rename,
|
|
3495
4010
|
unlink,
|
|
3496
4011
|
writeFile as writeFile3
|
|
3497
4012
|
} from "fs/promises";
|
|
3498
|
-
import { join as
|
|
4013
|
+
import { join as join5, resolve as resolve5, sep as sep3 } from "path";
|
|
3499
4014
|
|
|
3500
4015
|
// src/kb-links/inbound.ts
|
|
3501
4016
|
function inboundIndex(bundle) {
|
|
@@ -3657,7 +4172,7 @@ function appendUnionMergeLine(contents) {
|
|
|
3657
4172
|
}
|
|
3658
4173
|
|
|
3659
4174
|
// src/kb-store.ts
|
|
3660
|
-
var KB_DIR =
|
|
4175
|
+
var KB_DIR = join5(".strauss", "kb");
|
|
3661
4176
|
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
3662
4177
|
var DEFAULT_LOAD_BUDGET = 25e3;
|
|
3663
4178
|
var KbStore = class {
|
|
@@ -3688,7 +4203,7 @@ var KbStore = class {
|
|
|
3688
4203
|
const conceptId2 = `${input.type}.${input.slug}`;
|
|
3689
4204
|
const root = this.root(bundlePath2);
|
|
3690
4205
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
3691
|
-
await
|
|
4206
|
+
await mkdir3(root, { recursive: true });
|
|
3692
4207
|
await this.publish(
|
|
3693
4208
|
target,
|
|
3694
4209
|
stringifyMarkdownWithFrontmatter(input.body, frontmatter),
|
|
@@ -3752,7 +4267,7 @@ var KbStore = class {
|
|
|
3752
4267
|
const records = await mapLimit(
|
|
3753
4268
|
wanted,
|
|
3754
4269
|
DEFAULT_IO_CONCURRENCY,
|
|
3755
|
-
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile4(
|
|
4270
|
+
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile4(join5(root, name), "utf8"))
|
|
3756
4271
|
);
|
|
3757
4272
|
return records.filter((record) => record !== null);
|
|
3758
4273
|
}
|
|
@@ -3920,7 +4435,8 @@ ${answer}
|
|
|
3920
4435
|
* at the repo root, and the MCP server's cwd is the workspace.
|
|
3921
4436
|
*
|
|
3922
4437
|
* Public because `doctor` needs the same map with the same degradation: a
|
|
3923
|
-
* sweep that failed to read the tree should report no drift, not fail
|
|
4438
|
+
* sweep that failed to read the tree should report no drift, not fail — and
|
|
4439
|
+
* `offline: false` there, because a sweep is worth a fetch.
|
|
3924
4440
|
*
|
|
3925
4441
|
* When no root was given and not one anchored file was found, the finding is
|
|
3926
4442
|
* discarded. A base read from somewhere other than the tree it describes
|
|
@@ -3932,10 +4448,13 @@ ${answer}
|
|
|
3932
4448
|
* plausible, and the misses become findings again; an explicit `repoRoot` is
|
|
3933
4449
|
* taken at its word either way.
|
|
3934
4450
|
*/
|
|
3935
|
-
async detectDrift(records, repoRoot) {
|
|
4451
|
+
async detectDrift(records, repoRoot, options = {}) {
|
|
3936
4452
|
try {
|
|
3937
4453
|
const drift = await detectAnchorDrift(records, {
|
|
3938
|
-
repoRoot: repoRoot ?? process.cwd()
|
|
4454
|
+
repoRoot: repoRoot ?? process.cwd(),
|
|
4455
|
+
// Offline by default: a read path must never spend a network fetch per
|
|
4456
|
+
// call. `doctor` and `anchor-resolve` are the verbs that go get it.
|
|
4457
|
+
remote: { offline: options.offline !== false }
|
|
3939
4458
|
});
|
|
3940
4459
|
if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
|
|
3941
4460
|
this.logger.warn?.({
|
|
@@ -4053,11 +4572,11 @@ ${answer}
|
|
|
4053
4572
|
async readIndex(bundlePath2) {
|
|
4054
4573
|
const root = this.root(bundlePath2);
|
|
4055
4574
|
const expected = renderIndex(await this.list(bundlePath2));
|
|
4056
|
-
const stored = await readFile4(
|
|
4575
|
+
const stored = await readFile4(join5(root, INDEX_FILE), "utf8").catch(
|
|
4057
4576
|
() => null
|
|
4058
4577
|
);
|
|
4059
4578
|
if (indexIsStale(stored, expected)) {
|
|
4060
|
-
await this.publish(
|
|
4579
|
+
await this.publish(join5(root, INDEX_FILE), expected, true, INDEX_FILE);
|
|
4061
4580
|
this.logger.info?.({
|
|
4062
4581
|
operation: "kb.index.repair",
|
|
4063
4582
|
bundlePath: root,
|
|
@@ -4075,7 +4594,7 @@ ${answer}
|
|
|
4075
4594
|
*/
|
|
4076
4595
|
async readLog(bundlePath2) {
|
|
4077
4596
|
const raw = await readFile4(
|
|
4078
|
-
|
|
4597
|
+
join5(this.root(bundlePath2), LOG_FILE),
|
|
4079
4598
|
"utf8"
|
|
4080
4599
|
).catch(() => "");
|
|
4081
4600
|
const result = parseLog(raw);
|
|
@@ -4216,7 +4735,7 @@ ${answer}
|
|
|
4216
4735
|
* file must not fail the mutation it guards.
|
|
4217
4736
|
*/
|
|
4218
4737
|
async ensureGitattributes(root) {
|
|
4219
|
-
const target =
|
|
4738
|
+
const target = join5(root, GITATTRIBUTES_FILE);
|
|
4220
4739
|
try {
|
|
4221
4740
|
let existing;
|
|
4222
4741
|
try {
|
|
@@ -4267,7 +4786,7 @@ ${answer}
|
|
|
4267
4786
|
async record(root, entry) {
|
|
4268
4787
|
await this.ensureGitattributes(root);
|
|
4269
4788
|
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
4270
|
-
await appendFile(
|
|
4789
|
+
await appendFile(join5(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
4271
4790
|
this.logger.warn?.({
|
|
4272
4791
|
operation: "kb.log.append",
|
|
4273
4792
|
outcome: "failed",
|
|
@@ -4304,7 +4823,7 @@ ${answer}
|
|
|
4304
4823
|
{ conceptId: conceptId2 }
|
|
4305
4824
|
);
|
|
4306
4825
|
}
|
|
4307
|
-
return
|
|
4826
|
+
return join5(this.root(bundlePath2), `${conceptId2}.md`);
|
|
4308
4827
|
}
|
|
4309
4828
|
};
|
|
4310
4829
|
function estimateTokens(record) {
|
|
@@ -4447,7 +4966,7 @@ function typeRank(record) {
|
|
|
4447
4966
|
}
|
|
4448
4967
|
|
|
4449
4968
|
// src/version.ts
|
|
4450
|
-
var VERSION = true ? "0.1.
|
|
4969
|
+
var VERSION = true ? "0.1.15" : "0.0.0-dev";
|
|
4451
4970
|
|
|
4452
4971
|
export {
|
|
4453
4972
|
kbSourceSchema,
|
|
@@ -4479,10 +4998,13 @@ export {
|
|
|
4479
4998
|
composeNoDecisionRecord,
|
|
4480
4999
|
isNoDecisionRecord,
|
|
4481
5000
|
selectDecisions,
|
|
5001
|
+
isCanonicalRepoUrl,
|
|
5002
|
+
repoCacheDir,
|
|
5003
|
+
readRemoteAnchors,
|
|
5004
|
+
anchorFilePath,
|
|
4482
5005
|
regexResolver,
|
|
4483
5006
|
hashAnchorText,
|
|
4484
5007
|
resolveAnchor,
|
|
4485
|
-
anchorFilePath,
|
|
4486
5008
|
detectAnchorDrift,
|
|
4487
5009
|
Fault,
|
|
4488
5010
|
ErrorTypes,
|
|
@@ -4560,4 +5082,4 @@ export {
|
|
|
4560
5082
|
KbStore,
|
|
4561
5083
|
VERSION
|
|
4562
5084
|
};
|
|
4563
|
-
//# sourceMappingURL=chunk-
|
|
5085
|
+
//# sourceMappingURL=chunk-KNIUBCZY.js.map
|