@saasontools/strauss-kb 0.1.9 → 0.1.11
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 +17 -0
- package/README.md +155 -62
- package/dist/{chunk-MWWDD23L.js → chunk-CWWXMD35.js} +2 -2
- package/dist/{chunk-KVEEISYQ.js → chunk-I3WW4F6X.js} +2 -2
- package/dist/{chunk-OFDWRMY6.js → chunk-OVRQCQ6P.js} +1257 -304
- package/dist/chunk-OVRQCQ6P.js.map +1 -0
- package/dist/cli-main.cjs +1266 -321
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +1145 -186
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +294 -13
- package/dist/index.d.ts +294 -13
- package/dist/index.js +19 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +1262 -317
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-OFDWRMY6.js.map +0 -1
- /package/dist/{chunk-MWWDD23L.js.map → chunk-CWWXMD35.js.map} +0 -0
- /package/dist/{chunk-KVEEISYQ.js.map → chunk-I3WW4F6X.js.map} +0 -0
package/dist/mcp-main.cjs
CHANGED
|
@@ -53,7 +53,31 @@ var kbVerifiedEventSchema = kbActorStampSchema.extend({
|
|
|
53
53
|
});
|
|
54
54
|
var kbAnchorSchema = import_zod.z.object({
|
|
55
55
|
file: import_zod.z.string().min(1),
|
|
56
|
-
symbol: import_zod.z.string().min(1).optional()
|
|
56
|
+
symbol: import_zod.z.string().min(1).optional(),
|
|
57
|
+
/**
|
|
58
|
+
* Which repository the file lives in — a remote URL
|
|
59
|
+
* (`https://github.com/org/name`) or a short name. Absent means the base's
|
|
60
|
+
* own repository, which is what nearly every anchor means.
|
|
61
|
+
*
|
|
62
|
+
* Unvalidated beyond not-blank: one repository has many spellings.
|
|
63
|
+
* Matched after normalisation; see ARCHITECTURE.
|
|
64
|
+
*/
|
|
65
|
+
repo: import_zod.z.string().trim().min(1).optional(),
|
|
66
|
+
/**
|
|
67
|
+
* The git rev the evidence was taken at. Prefer a commit SHA: a branch
|
|
68
|
+
* name is a moving pointer, so an anchor pinned to one says the evidence
|
|
69
|
+
* came from wherever that branch happens to be now, which is not a
|
|
70
|
+
* baseline. Recorded and preserved in v1; ref-pinned reads land with
|
|
71
|
+
* SAA-709.
|
|
72
|
+
*/
|
|
73
|
+
ref: import_zod.z.string().trim().min(1).optional(),
|
|
74
|
+
hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
|
|
75
|
+
message: "hash must be sha256:<64 hex chars>"
|
|
76
|
+
}).optional(),
|
|
77
|
+
/** ISO 8601 timestamp of the last successful resolution. */
|
|
78
|
+
resolved_at: import_zod.z.string().min(1).optional(),
|
|
79
|
+
/** Line count of the text the hash was taken over. */
|
|
80
|
+
lines: import_zod.z.number().int().positive().optional()
|
|
57
81
|
}).strict();
|
|
58
82
|
var KB_RECORD_TYPES = [
|
|
59
83
|
"fact",
|
|
@@ -320,9 +344,596 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
|
|
|
320
344
|
);
|
|
321
345
|
}
|
|
322
346
|
|
|
323
|
-
// src/commands/
|
|
347
|
+
// src/commands/anchor-resolve.ts
|
|
324
348
|
var import_zod6 = require("zod");
|
|
325
349
|
|
|
350
|
+
// src/anchor-resolver.ts
|
|
351
|
+
var import_node_child_process = require("child_process");
|
|
352
|
+
var import_node_crypto = require("crypto");
|
|
353
|
+
var import_promises = require("fs/promises");
|
|
354
|
+
var import_node_path = require("path");
|
|
355
|
+
var import_node_util = require("util");
|
|
356
|
+
|
|
357
|
+
// src/concurrency.ts
|
|
358
|
+
var DEFAULT_IO_CONCURRENCY = 16;
|
|
359
|
+
async function mapLimit(items, limit, fn) {
|
|
360
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
361
|
+
throw new RangeError(
|
|
362
|
+
`mapLimit: "limit" must be a positive integer, got ${limit}`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
const out = new Array(items.length);
|
|
366
|
+
let next = 0;
|
|
367
|
+
let failed = false;
|
|
368
|
+
const runners = Array.from(
|
|
369
|
+
{ length: Math.min(limit, items.length) },
|
|
370
|
+
async () => {
|
|
371
|
+
while (!failed && next < items.length) {
|
|
372
|
+
const at = next++;
|
|
373
|
+
try {
|
|
374
|
+
out[at] = await fn(items[at], at);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
failed = true;
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
);
|
|
382
|
+
await Promise.all(runners);
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/anchor-resolver.ts
|
|
387
|
+
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
388
|
+
var MAX_ANCHOR_FILE_BYTES = 1048576;
|
|
389
|
+
var PARENT_SCOPE_LINES = 50;
|
|
390
|
+
var CLEAN_STATE = { blockComment: false, template: false };
|
|
391
|
+
function stripLine(line, state) {
|
|
392
|
+
let out = "";
|
|
393
|
+
let index = 0;
|
|
394
|
+
let { blockComment, template } = state;
|
|
395
|
+
while (index < line.length) {
|
|
396
|
+
const char = line[index];
|
|
397
|
+
const next = line[index + 1];
|
|
398
|
+
if (blockComment) {
|
|
399
|
+
if (char === "*" && next === "/") {
|
|
400
|
+
blockComment = false;
|
|
401
|
+
index += 2;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
index += 1;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (template) {
|
|
408
|
+
if (char === "\\") {
|
|
409
|
+
index += 2;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (char === "`") template = false;
|
|
413
|
+
index += 1;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (char === "/" && next === "*") {
|
|
417
|
+
blockComment = true;
|
|
418
|
+
index += 2;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (char === "/" && next === "/") break;
|
|
422
|
+
if (char === "`") {
|
|
423
|
+
template = true;
|
|
424
|
+
index += 1;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (char === "'" || char === '"') {
|
|
428
|
+
const quote = char;
|
|
429
|
+
index += 1;
|
|
430
|
+
while (index < line.length) {
|
|
431
|
+
if (line[index] === "\\") {
|
|
432
|
+
index += 2;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (line[index] === quote) {
|
|
436
|
+
index += 1;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
index += 1;
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
out += char;
|
|
444
|
+
index += 1;
|
|
445
|
+
}
|
|
446
|
+
return { code: out, state: { blockComment, template } };
|
|
447
|
+
}
|
|
448
|
+
function span(lines, from, to) {
|
|
449
|
+
return {
|
|
450
|
+
text: lines.slice(from, to + 1).join("\n"),
|
|
451
|
+
startLine: from + 1,
|
|
452
|
+
endLine: to + 1
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function captureBraceBlock(lines, matchLine) {
|
|
456
|
+
let depth = 0;
|
|
457
|
+
let opened = false;
|
|
458
|
+
let state = CLEAN_STATE;
|
|
459
|
+
for (let index = matchLine; index < lines.length; index++) {
|
|
460
|
+
const stripped = stripLine(lines[index] ?? "", state);
|
|
461
|
+
state = stripped.state;
|
|
462
|
+
for (const char of stripped.code) {
|
|
463
|
+
if (char === "{") {
|
|
464
|
+
depth += 1;
|
|
465
|
+
opened = true;
|
|
466
|
+
} else if (char === "}") {
|
|
467
|
+
depth = Math.max(0, depth - 1);
|
|
468
|
+
} else if (char === ";" && !opened) {
|
|
469
|
+
return span(lines, matchLine, index);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (opened && depth === 0) return span(lines, matchLine, index);
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
var PYTHON_HEADER = /^\s*(?:async\s+)?(?:def|class)\s+[A-Za-z_]\w*\s*[(:]/;
|
|
477
|
+
function captureIndentedBlock(lines, matchLine) {
|
|
478
|
+
const header = lines[matchLine] ?? "";
|
|
479
|
+
const indent = header.length - header.trimStart().length;
|
|
480
|
+
let headerEnd = -1;
|
|
481
|
+
for (let index = matchLine; index < lines.length && index <= matchLine + 20; index++) {
|
|
482
|
+
const code = stripLine(lines[index] ?? "", CLEAN_STATE).code.trimEnd();
|
|
483
|
+
if (code.endsWith(":")) {
|
|
484
|
+
headerEnd = index;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
if (code.includes(":")) return span(lines, matchLine, index);
|
|
488
|
+
}
|
|
489
|
+
if (headerEnd === -1) return null;
|
|
490
|
+
let end = headerEnd;
|
|
491
|
+
for (let index = headerEnd + 1; index < lines.length; index++) {
|
|
492
|
+
const line = lines[index] ?? "";
|
|
493
|
+
if (line.trim() === "") continue;
|
|
494
|
+
const lineIndent = line.length - line.trimStart().length;
|
|
495
|
+
if (lineIndent <= indent) break;
|
|
496
|
+
end = index;
|
|
497
|
+
}
|
|
498
|
+
return end === headerEnd ? null : span(lines, matchLine, end);
|
|
499
|
+
}
|
|
500
|
+
var TIERS = [
|
|
501
|
+
(name) => new RegExp(
|
|
502
|
+
`(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
|
|
503
|
+
),
|
|
504
|
+
(name) => new RegExp(`\\b${name}\\s*[:=]`),
|
|
505
|
+
(name) => new RegExp(`\\b${name}\\s*\\(`),
|
|
506
|
+
(name) => new RegExp(`\\b${name}\\b`)
|
|
507
|
+
];
|
|
508
|
+
var regexResolver = {
|
|
509
|
+
name: "regex",
|
|
510
|
+
resolve(source, symbol) {
|
|
511
|
+
const segments = symbol.split(".");
|
|
512
|
+
const name = segments[segments.length - 1];
|
|
513
|
+
if (!name) return null;
|
|
514
|
+
const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
|
|
515
|
+
const escaped = escapeRegExp(name);
|
|
516
|
+
const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
|
|
517
|
+
const lines = source.split("\n");
|
|
518
|
+
for (const tier of TIERS) {
|
|
519
|
+
const pattern = tier(escaped);
|
|
520
|
+
let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
|
|
521
|
+
if (!candidates.length) continue;
|
|
522
|
+
if (parentPattern && candidates.length > 1) {
|
|
523
|
+
const distances = candidates.map(
|
|
524
|
+
(index) => distanceToParent(lines, index, parentPattern)
|
|
525
|
+
);
|
|
526
|
+
const nearest = Math.min(...distances);
|
|
527
|
+
if (Number.isFinite(nearest)) {
|
|
528
|
+
candidates = candidates.filter((_, at) => distances[at] === nearest);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (candidates.length !== 1) return null;
|
|
532
|
+
const matchLine = candidates[0];
|
|
533
|
+
return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
function escapeRegExp(value) {
|
|
539
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
540
|
+
}
|
|
541
|
+
function distanceToParent(lines, index, parent) {
|
|
542
|
+
const floor = Math.max(0, index - PARENT_SCOPE_LINES);
|
|
543
|
+
for (let at = index; at >= floor; at--) {
|
|
544
|
+
if (parent.test(lines[at] ?? "")) return index - at;
|
|
545
|
+
}
|
|
546
|
+
return Number.POSITIVE_INFINITY;
|
|
547
|
+
}
|
|
548
|
+
function hashAnchorText(text) {
|
|
549
|
+
return `sha256:${(0, import_node_crypto.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
|
|
550
|
+
}
|
|
551
|
+
function resolveAnchor(source, anchor, resolver = regexResolver) {
|
|
552
|
+
const normalized = source.replace(/\r\n/g, "\n");
|
|
553
|
+
if (!anchor.symbol) {
|
|
554
|
+
const lines = normalized.split("\n");
|
|
555
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
556
|
+
return {
|
|
557
|
+
text: normalized,
|
|
558
|
+
startLine: 1,
|
|
559
|
+
endLine: Math.max(1, lines.length)
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return resolver.resolve(normalized, anchor.symbol);
|
|
563
|
+
}
|
|
564
|
+
function anchorFilePath(repoRoot, file) {
|
|
565
|
+
const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
|
|
566
|
+
const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
|
|
567
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
return path;
|
|
571
|
+
}
|
|
572
|
+
function contains(root, path) {
|
|
573
|
+
const rel = (0, import_node_path.relative)(root, path);
|
|
574
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
|
|
575
|
+
}
|
|
576
|
+
function normalizeRepoUrl(value) {
|
|
577
|
+
let url = value.trim().replace(/^git\+/, "");
|
|
578
|
+
const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
|
|
579
|
+
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
580
|
+
url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
|
|
581
|
+
url = trimTrailingSlashes(url);
|
|
582
|
+
if (url.endsWith(".git")) url = url.slice(0, -4);
|
|
583
|
+
return trimTrailingSlashes(url).toLowerCase();
|
|
584
|
+
}
|
|
585
|
+
function trimTrailingSlashes(value) {
|
|
586
|
+
let end = value.length;
|
|
587
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
588
|
+
return value.slice(0, end);
|
|
589
|
+
}
|
|
590
|
+
function repoPath(normalized) {
|
|
591
|
+
const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
|
|
592
|
+
const segments = withoutScheme.split("/").filter(Boolean);
|
|
593
|
+
return segments.length > 1 ? segments.slice(1).join("/") : "";
|
|
594
|
+
}
|
|
595
|
+
function repoIdentifies(declared, originUrl) {
|
|
596
|
+
if (!originUrl) return false;
|
|
597
|
+
const origin = normalizeRepoUrl(originUrl);
|
|
598
|
+
const want = normalizeRepoUrl(declared);
|
|
599
|
+
if (!want || !origin) return false;
|
|
600
|
+
if (want === origin) return true;
|
|
601
|
+
const path = repoPath(origin);
|
|
602
|
+
if (!path) return false;
|
|
603
|
+
return want === path || want === (path.split("/").pop() ?? "");
|
|
604
|
+
}
|
|
605
|
+
async function repoOriginUrl(repoRoot) {
|
|
606
|
+
try {
|
|
607
|
+
const { stdout } = await execFileAsync(
|
|
608
|
+
"git",
|
|
609
|
+
["-C", repoRoot, "config", "--get", "remote.origin.url"],
|
|
610
|
+
{ timeout: 5e3 }
|
|
611
|
+
);
|
|
612
|
+
return stdout.trim() || null;
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
var LazyOrigin = class {
|
|
618
|
+
constructor(repoRoot) {
|
|
619
|
+
this.repoRoot = repoRoot;
|
|
620
|
+
}
|
|
621
|
+
repoRoot;
|
|
622
|
+
url = null;
|
|
623
|
+
asked = false;
|
|
624
|
+
/** Asks git once, so later `isForeign` calls need no await. */
|
|
625
|
+
async prime() {
|
|
626
|
+
if (this.asked) return;
|
|
627
|
+
this.url = await repoOriginUrl(this.repoRoot);
|
|
628
|
+
this.asked = true;
|
|
629
|
+
}
|
|
630
|
+
/** Only meaningful after `prime`; an unprimed origin identifies nothing. */
|
|
631
|
+
isForeign(anchor) {
|
|
632
|
+
if (!anchor.repo) return false;
|
|
633
|
+
return !repoIdentifies(anchor.repo, this.url);
|
|
634
|
+
}
|
|
635
|
+
async foreign(anchor) {
|
|
636
|
+
if (!anchor.repo) return false;
|
|
637
|
+
await this.prime();
|
|
638
|
+
return this.isForeign(anchor);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
function errorCode(error) {
|
|
642
|
+
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
|
|
643
|
+
}
|
|
644
|
+
function anchorFileReader(repoRoot) {
|
|
645
|
+
let rootOnce;
|
|
646
|
+
const realRoot = () => {
|
|
647
|
+
rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
|
|
648
|
+
rootOnce = void 0;
|
|
649
|
+
throw error;
|
|
650
|
+
});
|
|
651
|
+
return rootOnce;
|
|
652
|
+
};
|
|
653
|
+
return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
|
|
654
|
+
}
|
|
655
|
+
async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
|
|
656
|
+
const lexical = anchorFilePath(repoRoot, file);
|
|
657
|
+
if (lexical === null) return { ok: false, reason: "outside-repo" };
|
|
658
|
+
let root;
|
|
659
|
+
let path;
|
|
660
|
+
try {
|
|
661
|
+
root = await realRoot();
|
|
662
|
+
path = await (0, import_promises.realpath)(lexical);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
const code = errorCode(error);
|
|
665
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
666
|
+
return { ok: false, reason: "file-missing" };
|
|
667
|
+
}
|
|
668
|
+
return { ok: false, reason: "file-unreadable" };
|
|
669
|
+
}
|
|
670
|
+
if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
|
|
671
|
+
try {
|
|
672
|
+
const stats = await (0, import_promises.stat)(path);
|
|
673
|
+
if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
|
|
674
|
+
if (stats.size > MAX_ANCHOR_FILE_BYTES) {
|
|
675
|
+
return { ok: false, reason: "file-too-large" };
|
|
676
|
+
}
|
|
677
|
+
return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
|
|
678
|
+
} catch (error) {
|
|
679
|
+
const code = errorCode(error);
|
|
680
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
681
|
+
return { ok: false, reason: "file-missing" };
|
|
682
|
+
}
|
|
683
|
+
return { ok: false, reason: "file-unreadable" };
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function looksLikeWrongRepoRoot(drift) {
|
|
687
|
+
let checked = 0;
|
|
688
|
+
for (const entries of drift.values()) {
|
|
689
|
+
for (const entry of entries) {
|
|
690
|
+
if (entry.reason === "foreign-repo") continue;
|
|
691
|
+
checked += 1;
|
|
692
|
+
if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return checked > 0;
|
|
698
|
+
}
|
|
699
|
+
async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
|
|
700
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
701
|
+
throw new RangeError(
|
|
702
|
+
`readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
const wanted = [...new Set(files)];
|
|
706
|
+
const results = await mapLimit(wanted, concurrency, async (file) => {
|
|
707
|
+
try {
|
|
708
|
+
return await read(file);
|
|
709
|
+
} catch {
|
|
710
|
+
return { ok: false, reason: "file-unreadable" };
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
return new Map(wanted.map((file, at) => [file, results[at]]));
|
|
714
|
+
}
|
|
715
|
+
async function detectAnchorDrift(records, options = {}) {
|
|
716
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
717
|
+
const resolver = options.resolver ?? regexResolver;
|
|
718
|
+
const origin = new LazyOrigin(repoRoot);
|
|
719
|
+
const planned = /* @__PURE__ */ new Map();
|
|
720
|
+
let declaresRepo = false;
|
|
721
|
+
for (const record of records) {
|
|
722
|
+
const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
|
|
723
|
+
(anchor) => anchor.hash
|
|
724
|
+
);
|
|
725
|
+
if (!anchors.length) continue;
|
|
726
|
+
if (anchors.some((anchor) => anchor.repo)) declaresRepo = true;
|
|
727
|
+
planned.set(
|
|
728
|
+
record.conceptId,
|
|
729
|
+
anchors.map((anchor) => ({ anchor, foreign: false }))
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
if (declaresRepo) {
|
|
733
|
+
await origin.prime();
|
|
734
|
+
for (const entries of planned.values()) {
|
|
735
|
+
for (const entry of entries)
|
|
736
|
+
entry.foreign = origin.isForeign(entry.anchor);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const files = [];
|
|
740
|
+
for (const entries of planned.values()) {
|
|
741
|
+
for (const entry of entries) {
|
|
742
|
+
if (!entry.foreign) files.push(entry.anchor.file);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const reads = await readAnchorFiles(
|
|
746
|
+
files,
|
|
747
|
+
options.reader ?? anchorFileReader(repoRoot),
|
|
748
|
+
options.concurrency ?? DEFAULT_IO_CONCURRENCY
|
|
749
|
+
);
|
|
750
|
+
const drift = /* @__PURE__ */ new Map();
|
|
751
|
+
for (const record of records) {
|
|
752
|
+
const entries = [];
|
|
753
|
+
for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
|
|
754
|
+
const base = {
|
|
755
|
+
file: anchor.file,
|
|
756
|
+
...anchor.symbol ? { symbol: anchor.symbol } : {},
|
|
757
|
+
storedHash: anchor.hash
|
|
758
|
+
};
|
|
759
|
+
if (foreign) {
|
|
760
|
+
entries.push({
|
|
761
|
+
...base,
|
|
762
|
+
state: "unresolved",
|
|
763
|
+
diffSize: null,
|
|
764
|
+
reason: "foreign-repo"
|
|
765
|
+
});
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
const read = reads.get(anchor.file);
|
|
769
|
+
if (!read.ok) {
|
|
770
|
+
entries.push({
|
|
771
|
+
...base,
|
|
772
|
+
state: "unresolved",
|
|
773
|
+
diffSize: null,
|
|
774
|
+
reason: read.reason
|
|
775
|
+
});
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const resolved = resolveAnchor(read.source, anchor, resolver);
|
|
779
|
+
if (!resolved) {
|
|
780
|
+
entries.push({
|
|
781
|
+
...base,
|
|
782
|
+
state: "unresolved",
|
|
783
|
+
diffSize: null,
|
|
784
|
+
reason: "symbol-not-found"
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const currentHash = hashAnchorText(resolved.text);
|
|
789
|
+
const currentLines = resolved.endLine - resolved.startLine + 1;
|
|
790
|
+
entries.push({
|
|
791
|
+
...base,
|
|
792
|
+
state: currentHash === anchor.hash ? "match" : "drifted",
|
|
793
|
+
currentHash,
|
|
794
|
+
diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
if (entries.length) drift.set(record.conceptId, entries);
|
|
798
|
+
}
|
|
799
|
+
return drift;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/errors.ts
|
|
803
|
+
var BaseError = class extends Error {
|
|
804
|
+
code;
|
|
805
|
+
errorType;
|
|
806
|
+
fault;
|
|
807
|
+
retriable;
|
|
808
|
+
reportToUser;
|
|
809
|
+
details;
|
|
810
|
+
constructor(props) {
|
|
811
|
+
super(props.message);
|
|
812
|
+
this.name = props.name ?? this.constructor.name;
|
|
813
|
+
this.code = props.code ?? 500;
|
|
814
|
+
this.errorType = props.errorType;
|
|
815
|
+
this.fault = props.fault;
|
|
816
|
+
this.retriable = props.retriable ?? true;
|
|
817
|
+
this.reportToUser = props.reportToUser ?? false;
|
|
818
|
+
this.details = props.details;
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// src/kb-errors.ts
|
|
823
|
+
var KbRecordAlreadyExistsError = class extends BaseError {
|
|
824
|
+
constructor(conceptId2) {
|
|
825
|
+
super({
|
|
826
|
+
message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
|
|
827
|
+
errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
|
|
828
|
+
code: 409,
|
|
829
|
+
fault: "User" /* User */,
|
|
830
|
+
retriable: false,
|
|
831
|
+
reportToUser: true,
|
|
832
|
+
details: { conceptId: conceptId2, action: "refused" }
|
|
833
|
+
});
|
|
834
|
+
this.conceptId = conceptId2;
|
|
835
|
+
}
|
|
836
|
+
conceptId;
|
|
837
|
+
};
|
|
838
|
+
var KbRecordNotFoundError = class extends BaseError {
|
|
839
|
+
constructor(conceptId2) {
|
|
840
|
+
super({
|
|
841
|
+
message: `kb: ${conceptId2} does not exist`,
|
|
842
|
+
errorType: "KbRecordNotFound" /* KbRecordNotFound */,
|
|
843
|
+
code: 404,
|
|
844
|
+
fault: "User" /* User */,
|
|
845
|
+
retriable: false,
|
|
846
|
+
reportToUser: true,
|
|
847
|
+
details: { conceptId: conceptId2 }
|
|
848
|
+
});
|
|
849
|
+
this.conceptId = conceptId2;
|
|
850
|
+
}
|
|
851
|
+
conceptId;
|
|
852
|
+
};
|
|
853
|
+
var KbWriteConflictError = class extends BaseError {
|
|
854
|
+
constructor(conceptId2) {
|
|
855
|
+
super({
|
|
856
|
+
message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
|
|
857
|
+
errorType: "KbWriteConflict" /* KbWriteConflict */,
|
|
858
|
+
code: 409,
|
|
859
|
+
fault: "System" /* System */,
|
|
860
|
+
retriable: true,
|
|
861
|
+
reportToUser: true,
|
|
862
|
+
details: { conceptId: conceptId2 }
|
|
863
|
+
});
|
|
864
|
+
this.conceptId = conceptId2;
|
|
865
|
+
}
|
|
866
|
+
conceptId;
|
|
867
|
+
};
|
|
868
|
+
var KbSelfVerificationError = class extends BaseError {
|
|
869
|
+
constructor(conceptId2, actor, generatedBy) {
|
|
870
|
+
super({
|
|
871
|
+
message: `kb: ${conceptId2} was generated by ${generatedBy}, and a record's generator cannot verify it \u2014 only a human or a different actor can`,
|
|
872
|
+
errorType: "KbSelfVerification" /* KbSelfVerification */,
|
|
873
|
+
code: 400,
|
|
874
|
+
fault: "User" /* User */,
|
|
875
|
+
retriable: false,
|
|
876
|
+
reportToUser: true,
|
|
877
|
+
details: { conceptId: conceptId2, actor, generatedBy, action: "refused" }
|
|
878
|
+
});
|
|
879
|
+
this.conceptId = conceptId2;
|
|
880
|
+
this.actor = actor;
|
|
881
|
+
this.generatedBy = generatedBy;
|
|
882
|
+
}
|
|
883
|
+
conceptId;
|
|
884
|
+
actor;
|
|
885
|
+
generatedBy;
|
|
886
|
+
};
|
|
887
|
+
var KbPackBudgetExceededError = class extends BaseError {
|
|
888
|
+
constructor(recordCount, approxTokens2, budgetTokens, excluded) {
|
|
889
|
+
super({
|
|
890
|
+
message: `kb: a pack of ${recordCount} records is ~${approxTokens2} tokens against a budget of ${budgetTokens} \u2014 lower hops or maxNodes, or raise the budget`,
|
|
891
|
+
errorType: "KbPackBudgetExceeded" /* KbPackBudgetExceeded */,
|
|
892
|
+
code: 400,
|
|
893
|
+
fault: "User" /* User */,
|
|
894
|
+
retriable: false,
|
|
895
|
+
reportToUser: true,
|
|
896
|
+
details: { recordCount, approxTokens: approxTokens2, budgetTokens, excluded }
|
|
897
|
+
});
|
|
898
|
+
this.recordCount = recordCount;
|
|
899
|
+
this.approxTokens = approxTokens2;
|
|
900
|
+
this.budgetTokens = budgetTokens;
|
|
901
|
+
this.excluded = excluded;
|
|
902
|
+
}
|
|
903
|
+
recordCount;
|
|
904
|
+
approxTokens;
|
|
905
|
+
budgetTokens;
|
|
906
|
+
excluded;
|
|
907
|
+
};
|
|
908
|
+
var KbMissingFlagValueError = class extends BaseError {
|
|
909
|
+
constructor(flag) {
|
|
910
|
+
super({
|
|
911
|
+
message: `kb: ${flag} needs a value \u2014 pass ${flag} <value> or ${flag}=<value>`,
|
|
912
|
+
errorType: "KbMissingFlagValue" /* KbMissingFlagValue */,
|
|
913
|
+
code: 400,
|
|
914
|
+
fault: "User" /* User */,
|
|
915
|
+
retriable: false,
|
|
916
|
+
reportToUser: true,
|
|
917
|
+
details: { flag }
|
|
918
|
+
});
|
|
919
|
+
this.flag = flag;
|
|
920
|
+
}
|
|
921
|
+
flag;
|
|
922
|
+
};
|
|
923
|
+
var KbInvalidConceptIdError = class extends BaseError {
|
|
924
|
+
constructor(message, details) {
|
|
925
|
+
super({
|
|
926
|
+
message: `kb: ${message}`,
|
|
927
|
+
errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
|
|
928
|
+
code: 400,
|
|
929
|
+
fault: "User" /* User */,
|
|
930
|
+
retriable: false,
|
|
931
|
+
reportToUser: true,
|
|
932
|
+
details
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
|
|
326
937
|
// src/kb-pins/budgets.ts
|
|
327
938
|
function asBudgets(value) {
|
|
328
939
|
if (value === null || typeof value !== "object") return {};
|
|
@@ -372,18 +983,18 @@ var KbBaseFrozenError = class extends Error {
|
|
|
372
983
|
};
|
|
373
984
|
|
|
374
985
|
// src/kb-pins/frozen.ts
|
|
375
|
-
var
|
|
986
|
+
var import_node_path4 = require("path");
|
|
376
987
|
|
|
377
988
|
// src/kb-pins/layers.ts
|
|
378
|
-
var
|
|
989
|
+
var import_promises2 = require("fs/promises");
|
|
379
990
|
var import_node_os = require("os");
|
|
380
|
-
var
|
|
991
|
+
var import_node_path3 = require("path");
|
|
381
992
|
|
|
382
993
|
// src/kb-pins/model.ts
|
|
383
|
-
var
|
|
994
|
+
var import_node_path2 = require("path");
|
|
384
995
|
var import_zod4 = require("zod");
|
|
385
|
-
var PINS_FILE = (0,
|
|
386
|
-
var PINS_LOCAL_FILE = (0,
|
|
996
|
+
var PINS_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.json");
|
|
997
|
+
var PINS_LOCAL_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.local.json");
|
|
387
998
|
var PIN_LAYERS = ["project", "local", "user"];
|
|
388
999
|
var pinSchema = import_zod4.z.object({
|
|
389
1000
|
/** Relative to the manifest's root, so the file is committable. */
|
|
@@ -433,10 +1044,10 @@ function userRoot() {
|
|
|
433
1044
|
return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
|
|
434
1045
|
}
|
|
435
1046
|
function layerRoot(workspaceDir, layer) {
|
|
436
|
-
return layer === "user" ? userRoot() : (0,
|
|
1047
|
+
return layer === "user" ? userRoot() : (0, import_node_path3.resolve)(workspaceDir);
|
|
437
1048
|
}
|
|
438
1049
|
function layerFile(workspaceDir, layer) {
|
|
439
|
-
return (0,
|
|
1050
|
+
return (0, import_node_path3.join)(
|
|
440
1051
|
layerRoot(workspaceDir, layer),
|
|
441
1052
|
layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
|
|
442
1053
|
);
|
|
@@ -445,7 +1056,7 @@ async function readPinsLayer(workspaceDir, layer) {
|
|
|
445
1056
|
const file = layerFile(workspaceDir, layer);
|
|
446
1057
|
let raw;
|
|
447
1058
|
try {
|
|
448
|
-
raw = await (0,
|
|
1059
|
+
raw = await (0, import_promises2.readFile)(file, "utf8");
|
|
449
1060
|
} catch {
|
|
450
1061
|
return { pins: [] };
|
|
451
1062
|
}
|
|
@@ -469,16 +1080,16 @@ async function readPinsLayer(workspaceDir, layer) {
|
|
|
469
1080
|
}
|
|
470
1081
|
async function writePinsLayer(workspaceDir, layer, manifest) {
|
|
471
1082
|
const file = layerFile(workspaceDir, layer);
|
|
472
|
-
await (0,
|
|
473
|
-
await (0,
|
|
1083
|
+
await (0, import_promises2.mkdir)((0, import_node_path3.dirname)(file), { recursive: true });
|
|
1084
|
+
await (0, import_promises2.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
|
|
474
1085
|
`, "utf8");
|
|
475
1086
|
}
|
|
476
1087
|
function resolvePinPath(rootDir, path) {
|
|
477
|
-
return (0,
|
|
1088
|
+
return (0, import_node_path3.isAbsolute)(path) ? (0, import_node_path3.resolve)(path) : (0, import_node_path3.resolve)(rootDir, path.split("/").join(import_node_path3.sep));
|
|
478
1089
|
}
|
|
479
1090
|
function storablePath(rootDir, bundlePath2) {
|
|
480
|
-
const rel = (0,
|
|
481
|
-
return (rel === "" ? "." : rel).split(
|
|
1091
|
+
const rel = (0, import_node_path3.relative)((0, import_node_path3.resolve)(rootDir), (0, import_node_path3.resolve)(bundlePath2));
|
|
1092
|
+
return (rel === "" ? "." : rel).split(import_node_path3.sep).join("/");
|
|
482
1093
|
}
|
|
483
1094
|
async function readMergedPins(workspaceDir) {
|
|
484
1095
|
const manifests = {};
|
|
@@ -506,7 +1117,7 @@ async function readMergedPins(workspaceDir) {
|
|
|
506
1117
|
// src/kb-pins/frozen.ts
|
|
507
1118
|
async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
|
|
508
1119
|
const merged = await readMergedPins(workspaceDir);
|
|
509
|
-
const absolute = (0,
|
|
1120
|
+
const absolute = (0, import_node_path4.resolve)(bundlePath2);
|
|
510
1121
|
const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
|
|
511
1122
|
if (pin?.frozen === true) {
|
|
512
1123
|
throw new KbBaseFrozenError(pin.path, pin.layer);
|
|
@@ -591,7 +1202,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
|
|
|
591
1202
|
}
|
|
592
1203
|
|
|
593
1204
|
// src/kb-pins/unpin.ts
|
|
594
|
-
var
|
|
1205
|
+
var import_node_path5 = require("path");
|
|
595
1206
|
async function unpinBase(workspaceDir, bundlePath2) {
|
|
596
1207
|
const layers = [];
|
|
597
1208
|
for (const layer of PIN_LAYERS) {
|
|
@@ -612,7 +1223,7 @@ async function unpinBase(workspaceDir, bundlePath2) {
|
|
|
612
1223
|
}
|
|
613
1224
|
}
|
|
614
1225
|
return {
|
|
615
|
-
path: storablePath((0,
|
|
1226
|
+
path: storablePath((0, import_node_path5.resolve)(workspaceDir), bundlePath2),
|
|
616
1227
|
removed: layers.length > 0,
|
|
617
1228
|
layers
|
|
618
1229
|
};
|
|
@@ -622,21 +1233,197 @@ async function unpinBase(workspaceDir, bundlePath2) {
|
|
|
622
1233
|
var import_zod5 = require("zod");
|
|
623
1234
|
var bundlePath = import_zod5.z.string().min(1).describe("Absolute path to the knowledge base directory.");
|
|
624
1235
|
var conceptId = import_zod5.z.string().min(1).describe("e.g. decision.cursor-v2");
|
|
1236
|
+
var REPO_ROOT = import_zod5.z.string().min(1).optional().describe(
|
|
1237
|
+
"Where the anchored source lives, for the drift check. Defaults to the working directory."
|
|
1238
|
+
);
|
|
625
1239
|
function define(command) {
|
|
626
1240
|
return command;
|
|
627
1241
|
}
|
|
628
1242
|
function argvFlag(argv, name) {
|
|
1243
|
+
const joined = argv.find((arg) => arg.startsWith(`${name}=`));
|
|
1244
|
+
if (joined !== void 0) {
|
|
1245
|
+
const value2 = joined.slice(name.length + 1);
|
|
1246
|
+
if (!value2) throw new KbMissingFlagValueError(name);
|
|
1247
|
+
return value2;
|
|
1248
|
+
}
|
|
629
1249
|
const at = argv.indexOf(name);
|
|
630
|
-
|
|
1250
|
+
if (at === -1) return void 0;
|
|
1251
|
+
const value = argv[at + 1];
|
|
1252
|
+
if (value === void 0 || value.startsWith("--")) {
|
|
1253
|
+
throw new KbMissingFlagValueError(name);
|
|
1254
|
+
}
|
|
1255
|
+
return value;
|
|
631
1256
|
}
|
|
632
1257
|
|
|
1258
|
+
// src/commands/anchor-resolve.ts
|
|
1259
|
+
var anchorResolveCommand = define({
|
|
1260
|
+
name: "anchor-resolve",
|
|
1261
|
+
tool: "kb_anchor_resolve",
|
|
1262
|
+
usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
|
|
1263
|
+
description: "Resolve a record's anchors against the working tree: stamp a hash onto anchors that lack one, report drift where the code moved. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was, not whether the claim still holds. Anchors naming another repository are skipped. Exits non-zero on drift.",
|
|
1264
|
+
input: import_zod6.z.object({
|
|
1265
|
+
bundlePath,
|
|
1266
|
+
conceptId,
|
|
1267
|
+
repoRoot: import_zod6.z.string().min(1).optional(),
|
|
1268
|
+
rebaseline: import_zod6.z.boolean().optional().describe(
|
|
1269
|
+
"Accept the current code as the new baseline for anchors that drifted."
|
|
1270
|
+
),
|
|
1271
|
+
restamp: import_zod6.z.boolean().optional().describe(
|
|
1272
|
+
"Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
|
|
1273
|
+
)
|
|
1274
|
+
}),
|
|
1275
|
+
fromArgv: (argv, path) => ({
|
|
1276
|
+
bundlePath: path,
|
|
1277
|
+
conceptId: argv[1],
|
|
1278
|
+
repoRoot: argvFlag(argv, "--repo-root"),
|
|
1279
|
+
rebaseline: argv.includes("--rebaseline"),
|
|
1280
|
+
restamp: argv.includes("--restamp")
|
|
1281
|
+
}),
|
|
1282
|
+
run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
|
|
1283
|
+
const root = repoRoot ?? process.cwd();
|
|
1284
|
+
const record = await store.read(path, id);
|
|
1285
|
+
if (!record) throw new KbRecordNotFoundError(id);
|
|
1286
|
+
const anchors = record.frontmatter.strauss_anchors ?? [];
|
|
1287
|
+
if (!anchors.length) {
|
|
1288
|
+
return {
|
|
1289
|
+
conceptId: id,
|
|
1290
|
+
results: [],
|
|
1291
|
+
verified: false,
|
|
1292
|
+
note: "record has no anchors"
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
const results = [];
|
|
1296
|
+
const updated = [];
|
|
1297
|
+
const origin = new LazyOrigin(root);
|
|
1298
|
+
let dirty = false;
|
|
1299
|
+
if (anchors.some((anchor) => anchor.repo)) await origin.prime();
|
|
1300
|
+
const foreign = new Map(
|
|
1301
|
+
anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
|
|
1302
|
+
);
|
|
1303
|
+
const reads = await readAnchorFiles(
|
|
1304
|
+
anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
|
|
1305
|
+
anchorFileReader(root)
|
|
1306
|
+
);
|
|
1307
|
+
for (const anchor of anchors) {
|
|
1308
|
+
const base = {
|
|
1309
|
+
file: anchor.file,
|
|
1310
|
+
...anchor.symbol ? { symbol: anchor.symbol } : {},
|
|
1311
|
+
// Carried onto unresolved findings too: an anchor that once hashed
|
|
1312
|
+
// and now resolves to nothing is a broken anchor, and the exit code
|
|
1313
|
+
// has to be able to tell it from one nobody ever stamped.
|
|
1314
|
+
...anchor.hash ? { storedHash: anchor.hash } : {}
|
|
1315
|
+
};
|
|
1316
|
+
if (foreign.get(anchor)) {
|
|
1317
|
+
results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
|
|
1318
|
+
updated.push(anchor);
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
const fileRead = reads.get(anchor.file);
|
|
1322
|
+
if (!fileRead.ok) {
|
|
1323
|
+
results.push({ ...base, state: "unresolved", reason: fileRead.reason });
|
|
1324
|
+
updated.push(anchor);
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
const resolved = resolveAnchor(fileRead.source, anchor);
|
|
1328
|
+
if (!resolved) {
|
|
1329
|
+
results.push({
|
|
1330
|
+
...base,
|
|
1331
|
+
state: "unresolved",
|
|
1332
|
+
reason: "symbol-not-found"
|
|
1333
|
+
});
|
|
1334
|
+
updated.push(anchor);
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
const currentHash = hashAnchorText(resolved.text);
|
|
1338
|
+
const currentLines = resolved.endLine - resolved.startLine + 1;
|
|
1339
|
+
const stamped = {
|
|
1340
|
+
...anchor,
|
|
1341
|
+
hash: currentHash,
|
|
1342
|
+
lines: currentLines,
|
|
1343
|
+
resolved_at: now()
|
|
1344
|
+
};
|
|
1345
|
+
if (!anchor.hash) {
|
|
1346
|
+
results.push({ ...base, state: "stamped", currentHash });
|
|
1347
|
+
updated.push(stamped);
|
|
1348
|
+
dirty = true;
|
|
1349
|
+
} else if (anchor.hash === currentHash) {
|
|
1350
|
+
results.push({
|
|
1351
|
+
...base,
|
|
1352
|
+
state: "match",
|
|
1353
|
+
currentHash
|
|
1354
|
+
});
|
|
1355
|
+
const refresh = restamp || anchor.resolved_at === void 0;
|
|
1356
|
+
updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
|
|
1357
|
+
if (refresh) dirty = true;
|
|
1358
|
+
} else {
|
|
1359
|
+
results.push({
|
|
1360
|
+
...base,
|
|
1361
|
+
state: "drifted",
|
|
1362
|
+
currentHash,
|
|
1363
|
+
diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
|
|
1364
|
+
...rebaseline ? { rebaselined: true } : {}
|
|
1365
|
+
});
|
|
1366
|
+
updated.push(rebaseline ? stamped : anchor);
|
|
1367
|
+
if (rebaseline) dirty = true;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
let frozen = false;
|
|
1371
|
+
if (dirty) {
|
|
1372
|
+
try {
|
|
1373
|
+
await assertBaseNotFrozen(process.cwd(), path);
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
if (!(error instanceof KbBaseFrozenError)) throw error;
|
|
1376
|
+
frozen = true;
|
|
1377
|
+
}
|
|
1378
|
+
if (!frozen) await store.updateAnchors(path, id, updated, actor);
|
|
1379
|
+
}
|
|
1380
|
+
const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
|
|
1381
|
+
const checked = results.filter((entry) => entry.reason !== "foreign-repo");
|
|
1382
|
+
const skipped = results.length - checked.length;
|
|
1383
|
+
const matches2 = checked.filter((entry) => entry.state === "match").length;
|
|
1384
|
+
const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
|
|
1385
|
+
if (clean) {
|
|
1386
|
+
try {
|
|
1387
|
+
await store.verify(
|
|
1388
|
+
path,
|
|
1389
|
+
id,
|
|
1390
|
+
`anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
|
|
1391
|
+
actor,
|
|
1392
|
+
now()
|
|
1393
|
+
);
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
if (!(error instanceof KbSelfVerificationError)) throw error;
|
|
1396
|
+
return {
|
|
1397
|
+
conceptId: id,
|
|
1398
|
+
results,
|
|
1399
|
+
verified: false,
|
|
1400
|
+
verifyRefused: "self-verification",
|
|
1401
|
+
...frozenNote
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
return { conceptId: id, results, verified: true, ...frozenNote };
|
|
1405
|
+
}
|
|
1406
|
+
return { conceptId: id, results, verified: false, ...frozenNote };
|
|
1407
|
+
},
|
|
1408
|
+
// A stored hash that no longer resolves is a broken anchor, not an absence:
|
|
1409
|
+
// the file was deleted or the symbol renamed, and exiting zero on it would
|
|
1410
|
+
// let the one edit that destroys an anchor pass the gate that exists to
|
|
1411
|
+
// catch it. An anchor nobody ever stamped is still just unstamped, and one
|
|
1412
|
+
// belonging to another repository was never this run's to check — failing CI
|
|
1413
|
+
// on either would gate on work this command did not do.
|
|
1414
|
+
failsWhen: (result) => result.results.some(
|
|
1415
|
+
(entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
|
|
1416
|
+
)
|
|
1417
|
+
});
|
|
1418
|
+
|
|
633
1419
|
// src/commands/answer.ts
|
|
1420
|
+
var import_zod7 = require("zod");
|
|
634
1421
|
var answerCommand = define({
|
|
635
1422
|
name: "answer",
|
|
636
1423
|
tool: "kb_answer",
|
|
637
1424
|
usage: "answer <concept-id> <answer...>",
|
|
638
1425
|
description: "Resolve an open question: sets the status, stamps who answered and when, and appends an Answer section. If the answer overturns an assumption or a decision, that is a supersession \u2014 do it explicitly.",
|
|
639
|
-
input:
|
|
1426
|
+
input: import_zod7.z.object({ bundlePath, conceptId, answer: import_zod7.z.string().min(1) }),
|
|
640
1427
|
fromArgv: (argv, path) => ({
|
|
641
1428
|
bundlePath: path,
|
|
642
1429
|
conceptId: argv[1],
|
|
@@ -649,11 +1436,8 @@ var answerCommand = define({
|
|
|
649
1436
|
}
|
|
650
1437
|
});
|
|
651
1438
|
|
|
652
|
-
// src/commands/
|
|
653
|
-
var
|
|
654
|
-
|
|
655
|
-
// src/kb-context.ts
|
|
656
|
-
var import_promises2 = require("fs/promises");
|
|
1439
|
+
// src/commands/catalog.ts
|
|
1440
|
+
var import_zod8 = require("zod");
|
|
657
1441
|
|
|
658
1442
|
// src/adjudicate.ts
|
|
659
1443
|
var STANDING = {
|
|
@@ -665,7 +1449,7 @@ var STANDING = {
|
|
|
665
1449
|
rejected: "rejected",
|
|
666
1450
|
superseded: "superseded"
|
|
667
1451
|
};
|
|
668
|
-
function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
|
|
1452
|
+
function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift) {
|
|
669
1453
|
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
670
1454
|
return hits.map((record) => {
|
|
671
1455
|
const status = record.frontmatter.strauss_status;
|
|
@@ -695,6 +1479,20 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
|
|
|
695
1479
|
if (!record.frontmatter.verified?.length) {
|
|
696
1480
|
warnings.push({ kind: "unverified" });
|
|
697
1481
|
}
|
|
1482
|
+
const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
|
|
1483
|
+
(entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
|
|
1484
|
+
);
|
|
1485
|
+
if (moved.length) {
|
|
1486
|
+
warnings.push({
|
|
1487
|
+
kind: "drifted",
|
|
1488
|
+
anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
|
|
1489
|
+
file,
|
|
1490
|
+
...symbol !== void 0 ? { symbol } : {},
|
|
1491
|
+
diffSize,
|
|
1492
|
+
...reason !== void 0 ? { reason } : {}
|
|
1493
|
+
}))
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
698
1496
|
return { record, standing: STANDING[status], heads, warnings };
|
|
699
1497
|
});
|
|
700
1498
|
}
|
|
@@ -744,8 +1542,122 @@ function successors(record, byId) {
|
|
|
744
1542
|
if (found) records.push(found);
|
|
745
1543
|
else missing.push(id);
|
|
746
1544
|
}
|
|
747
|
-
return { records, missing };
|
|
1545
|
+
return { records, missing };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// src/catalog.ts
|
|
1549
|
+
var EMPTY_STANDINGS = {
|
|
1550
|
+
current: 0,
|
|
1551
|
+
superseded: 0,
|
|
1552
|
+
rejected: 0,
|
|
1553
|
+
unsettled: 0,
|
|
1554
|
+
open: 0
|
|
1555
|
+
};
|
|
1556
|
+
function catalog(bundle, options = {}) {
|
|
1557
|
+
const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
|
|
1558
|
+
const entries = adjudicate(wanted, bundle, options.now ?? /* @__PURE__ */ new Date()).map((hit) => ({
|
|
1559
|
+
conceptId: hit.record.conceptId,
|
|
1560
|
+
type: hit.record.frontmatter.type,
|
|
1561
|
+
title: hit.record.frontmatter.title ?? null,
|
|
1562
|
+
standing: hit.standing,
|
|
1563
|
+
supersededBy: hit.heads.map((head) => head.conceptId),
|
|
1564
|
+
stale: hit.warnings.some((warning) => warning.kind === "stale")
|
|
1565
|
+
})).sort(byTypeThenTitle);
|
|
1566
|
+
const standings = { ...EMPTY_STANDINGS };
|
|
1567
|
+
for (const entry of entries) standings[entry.standing] += 1;
|
|
1568
|
+
return {
|
|
1569
|
+
entries,
|
|
1570
|
+
recordCount: entries.length,
|
|
1571
|
+
standings,
|
|
1572
|
+
currentCount: standings.current,
|
|
1573
|
+
supersededCount: standings.superseded,
|
|
1574
|
+
staleCount: entries.filter((entry) => entry.stale).length
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
function byTypeThenTitle(left, right) {
|
|
1578
|
+
return byCodeUnit(left.type, right.type) || byCodeUnit(left.title ?? "", right.title ?? "") || byCodeUnit(left.conceptId, right.conceptId);
|
|
1579
|
+
}
|
|
1580
|
+
function byCodeUnit(left, right) {
|
|
1581
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1582
|
+
}
|
|
1583
|
+
function renderCatalogLine(entry) {
|
|
1584
|
+
const parts = [
|
|
1585
|
+
entry.conceptId,
|
|
1586
|
+
entry.type,
|
|
1587
|
+
entry.title ?? "(untitled)",
|
|
1588
|
+
entry.standing === "superseded" ? `superseded \u2192 ${entry.supersededBy.join(", ") || "(no surviving head)"}` : entry.standing
|
|
1589
|
+
];
|
|
1590
|
+
if (entry.stale) parts.push("stale");
|
|
1591
|
+
return `- ${parts.join(" \xB7 ")}`;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// src/commands/catalog.ts
|
|
1595
|
+
var catalogCommand = define({
|
|
1596
|
+
name: "catalog",
|
|
1597
|
+
tool: "kb_catalog",
|
|
1598
|
+
usage: "catalog [type]",
|
|
1599
|
+
description: "Lists every record as one line \u2014 concept id, type, title, standing, and a stale flag \u2014 at roughly thirty tokens each. Pick this over kb_load once kb_load refuses: kb_catalog never refuses. Superseded records show only their replacement; fetch bodies with kb_load, kb_pack, kb_query, or kb_trace.",
|
|
1600
|
+
input: import_zod8.z.object({
|
|
1601
|
+
bundlePath,
|
|
1602
|
+
type: import_zod8.z.enum(KB_RECORD_TYPES).optional()
|
|
1603
|
+
}),
|
|
1604
|
+
fromArgv: (argv, path) => ({
|
|
1605
|
+
bundlePath: path,
|
|
1606
|
+
...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {}
|
|
1607
|
+
}),
|
|
1608
|
+
run: async ({ store }, { bundlePath: path, type }) => render(
|
|
1609
|
+
await store.catalog(path, { ...type ? { type } : {} }),
|
|
1610
|
+
path,
|
|
1611
|
+
type
|
|
1612
|
+
)
|
|
1613
|
+
});
|
|
1614
|
+
function render(result, bundle, type) {
|
|
1615
|
+
const lines = [
|
|
1616
|
+
`# KB Catalog${type ? ` \u2014 ${type}` : ""}`,
|
|
1617
|
+
`bundle: ${bundle}`,
|
|
1618
|
+
`${count(result.recordCount, "record")}: ${standingCounts(result)}`
|
|
1619
|
+
];
|
|
1620
|
+
if (result.staleCount) {
|
|
1621
|
+
lines.push(
|
|
1622
|
+
`${result.staleCount} stale \u2014 a flag over the standings above, not one of them`
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
lines.push("");
|
|
1626
|
+
if (!result.entries.length) {
|
|
1627
|
+
lines.push(
|
|
1628
|
+
type ? `(no records of type ${type})` : "(no records \u2014 this base is empty)"
|
|
1629
|
+
);
|
|
1630
|
+
} else {
|
|
1631
|
+
for (const entry of result.entries) lines.push(renderCatalogLine(entry));
|
|
1632
|
+
}
|
|
1633
|
+
lines.push(
|
|
1634
|
+
"",
|
|
1635
|
+
"Bodies are not here: kb_pack <conceptId> for the neighbourhood around one record, kb_load for the whole base when it fits the budget, kb_query for a lookup by wording, kb_trace <conceptId> for how a position was arrived at."
|
|
1636
|
+
);
|
|
1637
|
+
return lines.join("\n");
|
|
1638
|
+
}
|
|
1639
|
+
function standingCounts(result) {
|
|
1640
|
+
const ORDER = [
|
|
1641
|
+
"current",
|
|
1642
|
+
"open",
|
|
1643
|
+
"unsettled",
|
|
1644
|
+
"rejected",
|
|
1645
|
+
"superseded"
|
|
1646
|
+
];
|
|
1647
|
+
const parts = ORDER.filter((standing) => result.standings[standing]).map(
|
|
1648
|
+
(standing) => `${result.standings[standing]} ${standing}`
|
|
1649
|
+
);
|
|
1650
|
+
return parts.length ? parts.join(" \xB7 ") : "none";
|
|
748
1651
|
}
|
|
1652
|
+
function count(value, noun) {
|
|
1653
|
+
return `${value} ${value === 1 ? noun : `${noun}s`}`;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// src/commands/context.ts
|
|
1657
|
+
var import_zod9 = require("zod");
|
|
1658
|
+
|
|
1659
|
+
// src/kb-context.ts
|
|
1660
|
+
var import_promises3 = require("fs/promises");
|
|
749
1661
|
|
|
750
1662
|
// src/kb-index.ts
|
|
751
1663
|
var INDEX_FILE = "INDEX.md";
|
|
@@ -972,13 +1884,13 @@ function toHookJson(block, event) {
|
|
|
972
1884
|
var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
|
|
973
1885
|
var CONTEXT_END = "<!-- strauss-kb:end -->";
|
|
974
1886
|
async function syncInstructions(file, block) {
|
|
975
|
-
const existing = await (0,
|
|
1887
|
+
const existing = await (0, import_promises3.readFile)(file, "utf8").catch(() => null);
|
|
976
1888
|
const region = block ? `${CONTEXT_BEGIN}
|
|
977
1889
|
${block.trim()}
|
|
978
1890
|
${CONTEXT_END}` : null;
|
|
979
1891
|
if (existing === null) {
|
|
980
1892
|
if (!region) return { file, action: "unchanged" };
|
|
981
|
-
await (0,
|
|
1893
|
+
await (0, import_promises3.writeFile)(file, `${region}
|
|
982
1894
|
`, "utf8");
|
|
983
1895
|
return { file, action: "created" };
|
|
984
1896
|
}
|
|
@@ -989,11 +1901,11 @@ ${CONTEXT_END}` : null;
|
|
|
989
1901
|
const after = existing.slice(end + CONTEXT_END.length);
|
|
990
1902
|
const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
|
|
991
1903
|
if (next === existing) return { file, action: "unchanged" };
|
|
992
|
-
await (0,
|
|
1904
|
+
await (0, import_promises3.writeFile)(file, next, "utf8");
|
|
993
1905
|
return { file, action: region ? "replaced" : "removed" };
|
|
994
1906
|
}
|
|
995
1907
|
if (!region) return { file, action: "unchanged" };
|
|
996
|
-
await (0,
|
|
1908
|
+
await (0, import_promises3.writeFile)(
|
|
997
1909
|
file,
|
|
998
1910
|
`${existing.replace(/\n*$/, "\n\n")}${region}
|
|
999
1911
|
`,
|
|
@@ -1008,20 +1920,20 @@ var contextCommand = define({
|
|
|
1008
1920
|
tool: "kb_context",
|
|
1009
1921
|
usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
|
|
1010
1922
|
description: "The pinned-base index block, for injection at every context birth \u2014 startup, clear, resume, and after compaction. An index, not the content: concept ids, titles and standing, with the bodies left behind kb_load at the point of use. Emits nothing when nothing is pinned. Refuses with the list of bases and their sizes rather than truncating past its budget. Budgets resolve most-specific-first: explicit flags, then the workspace manifests' `context` tables (per profile, over their `default`), then the built-in profile (session-start, compact, turn), then package defaults \u2014 so a repo tunes its own numbers in .strauss/kb-pins.json without touching hook commands. Like kb_schema and kb_types this takes no bundlePath \u2014 it reads the workspace pin manifests, because which bases a session should see is workspace state, not a property of one base.",
|
|
1011
|
-
input:
|
|
1012
|
-
budgetTokens:
|
|
1923
|
+
input: import_zod9.z.object({
|
|
1924
|
+
budgetTokens: import_zod9.z.number().int().positive().optional().describe(
|
|
1013
1925
|
"Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
|
|
1014
1926
|
),
|
|
1015
|
-
fullUnderTokens:
|
|
1927
|
+
fullUnderTokens: import_zod9.z.number().int().positive().optional().describe(
|
|
1016
1928
|
"Per-base rendering threshold, applied before the budget: a base whose complete load fits under this arrives as full records instead of index lines, and the whole block still answers to budgetTokens. Off by default \u2014 index-only is the safe default at a context birth, because injected bodies outlive the qualifiers on them; the session-start profile opts tiny bases in at 1500."
|
|
1017
1929
|
),
|
|
1018
|
-
profile:
|
|
1930
|
+
profile: import_zod9.z.string().optional().describe(
|
|
1019
1931
|
"Named budget set: built-ins are session-start (full-under 1500), compact and turn (budget 2500); the manifests' `context` tables override per repo. Unknown names fall through to defaults rather than failing."
|
|
1020
1932
|
),
|
|
1021
|
-
format:
|
|
1933
|
+
format: import_zod9.z.enum(["markdown", "json"]).optional().describe(
|
|
1022
1934
|
"CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
|
|
1023
1935
|
),
|
|
1024
|
-
event:
|
|
1936
|
+
event: import_zod9.z.string().optional().describe(
|
|
1025
1937
|
"hookEventName stamped into the JSON envelope. Only meaningful with format=json."
|
|
1026
1938
|
)
|
|
1027
1939
|
}),
|
|
@@ -1057,7 +1969,7 @@ var contextCommand = define({
|
|
|
1057
1969
|
});
|
|
1058
1970
|
|
|
1059
1971
|
// src/commands/doctor.ts
|
|
1060
|
-
var
|
|
1972
|
+
var import_zod10 = require("zod");
|
|
1061
1973
|
|
|
1062
1974
|
// src/kb-edges.ts
|
|
1063
1975
|
var KB_EDGE_KINDS = [
|
|
@@ -1181,7 +2093,8 @@ var CHECK_HEADLINES = {
|
|
|
1181
2093
|
aging: "still open or still proposed long after it was written",
|
|
1182
2094
|
orphaned: "no other record links to it",
|
|
1183
2095
|
"broken-supersession": "the supersession pointers do not resolve",
|
|
1184
|
-
"superseded-but-cited": "a live record's body links to one that no longer holds"
|
|
2096
|
+
"superseded-but-cited": "a live record's body links to one that no longer holds",
|
|
2097
|
+
drifted: "the code an anchor points at moved out from under its hash"
|
|
1185
2098
|
};
|
|
1186
2099
|
var DAY_MS = 864e5;
|
|
1187
2100
|
function doctor(bundle, options = {}) {
|
|
@@ -1191,7 +2104,7 @@ function doctor(bundle, options = {}) {
|
|
|
1191
2104
|
agingDays: options.agingDays ?? DEFAULT_AGING_DAYS
|
|
1192
2105
|
};
|
|
1193
2106
|
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1194
|
-
const adjudicated = adjudicate(bundle, bundle, now);
|
|
2107
|
+
const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
|
|
1195
2108
|
const standings = new Map(
|
|
1196
2109
|
adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
|
|
1197
2110
|
);
|
|
@@ -1205,7 +2118,8 @@ function doctor(bundle, options = {}) {
|
|
|
1205
2118
|
group("aging", aging(inForce, now, thresholds.agingDays)),
|
|
1206
2119
|
group("orphaned", orphaned(bundle)),
|
|
1207
2120
|
group("broken-supersession", brokenSupersession(bundle, adjudicated)),
|
|
1208
|
-
group("superseded-but-cited", supersededButCited(bundle, standings))
|
|
2121
|
+
group("superseded-but-cited", supersededButCited(bundle, standings)),
|
|
2122
|
+
group("drifted", drifted(inForce))
|
|
1209
2123
|
];
|
|
1210
2124
|
const counts = Object.fromEntries(
|
|
1211
2125
|
groups.map((entry) => [entry.check, entry.count])
|
|
@@ -1391,6 +2305,29 @@ function supersededButCited(bundle, standings) {
|
|
|
1391
2305
|
}
|
|
1392
2306
|
return findings;
|
|
1393
2307
|
}
|
|
2308
|
+
function drifted(hits) {
|
|
2309
|
+
const findings = [];
|
|
2310
|
+
for (const hit of hits) {
|
|
2311
|
+
const warning = hit.warnings.find((entry) => entry.kind === "drifted");
|
|
2312
|
+
if (!warning) continue;
|
|
2313
|
+
findings.push(
|
|
2314
|
+
finding(
|
|
2315
|
+
hit.record,
|
|
2316
|
+
`${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
|
|
2317
|
+
const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
|
|
2318
|
+
if (anchor.reason) return `${at} (${anchor.reason})`;
|
|
2319
|
+
if (anchor.diffSize === null) {
|
|
2320
|
+
return `${at} (changed, size unrecorded)`;
|
|
2321
|
+
}
|
|
2322
|
+
return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
|
|
2323
|
+
}).join(", ")}`
|
|
2324
|
+
)
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
return findings.sort(
|
|
2328
|
+
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
1394
2331
|
function replaces(later, earlier) {
|
|
1395
2332
|
return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
|
|
1396
2333
|
}
|
|
@@ -1414,14 +2351,15 @@ function ageInDays(record, now) {
|
|
|
1414
2351
|
}
|
|
1415
2352
|
|
|
1416
2353
|
// src/commands/doctor.ts
|
|
1417
|
-
var days = (what, fallback) =>
|
|
2354
|
+
var days = (what, fallback) => import_zod10.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
|
|
1418
2355
|
var doctorCommand = define({
|
|
1419
2356
|
name: "doctor",
|
|
1420
2357
|
tool: "kb_doctor",
|
|
1421
|
-
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
|
|
1422
|
-
description: "
|
|
1423
|
-
input:
|
|
2358
|
+
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
|
|
2359
|
+
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.",
|
|
2360
|
+
input: import_zod10.z.object({
|
|
1424
2361
|
bundlePath,
|
|
2362
|
+
repoRoot: REPO_ROOT,
|
|
1425
2363
|
expiringDays: days(
|
|
1426
2364
|
"How far ahead `expiring` looks, in days.",
|
|
1427
2365
|
DEFAULT_EXPIRING_DAYS
|
|
@@ -1434,7 +2372,7 @@ var doctorCommand = define({
|
|
|
1434
2372
|
"How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
|
|
1435
2373
|
DEFAULT_AGING_DAYS
|
|
1436
2374
|
),
|
|
1437
|
-
strict:
|
|
2375
|
+
strict: import_zod10.z.boolean().optional().describe(
|
|
1438
2376
|
"Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
|
|
1439
2377
|
)
|
|
1440
2378
|
}),
|
|
@@ -1446,32 +2384,39 @@ var doctorCommand = define({
|
|
|
1446
2384
|
const expiring2 = argvFlag(argv, "--expiring-days");
|
|
1447
2385
|
const unverified2 = argvFlag(argv, "--unverified-days");
|
|
1448
2386
|
const agingDays = argvFlag(argv, "--aging-days");
|
|
2387
|
+
const repoRoot = argvFlag(argv, "--repo-root");
|
|
1449
2388
|
return {
|
|
1450
2389
|
bundlePath: path,
|
|
2390
|
+
...repoRoot !== void 0 ? { repoRoot } : {},
|
|
1451
2391
|
...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
|
|
1452
2392
|
...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
|
|
1453
2393
|
...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
|
|
1454
2394
|
...argv.includes("--strict") ? { strict: true } : {}
|
|
1455
2395
|
};
|
|
1456
2396
|
},
|
|
1457
|
-
run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays }) => {
|
|
2397
|
+
run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
|
|
1458
2398
|
const checkedAt = now();
|
|
1459
|
-
const
|
|
2399
|
+
const records = await store.list(path);
|
|
2400
|
+
const anchorDrift = await store.detectDrift(records, repoRoot);
|
|
2401
|
+
const report = doctor(records, {
|
|
1460
2402
|
...expiringDays !== void 0 ? { expiringDays } : {},
|
|
1461
2403
|
...unverifiedDays !== void 0 ? { unverifiedDays } : {},
|
|
1462
2404
|
...agingDays !== void 0 ? { agingDays } : {},
|
|
2405
|
+
...anchorDrift !== void 0 ? { anchorDrift } : {},
|
|
1463
2406
|
now: new Date(checkedAt)
|
|
1464
2407
|
});
|
|
1465
2408
|
return { bundlePath: path, checkedAt, ...report };
|
|
1466
2409
|
},
|
|
1467
|
-
render: (result) =>
|
|
1468
|
-
// Only expiry, and only under --strict. The other
|
|
2410
|
+
render: (result) => render2(result),
|
|
2411
|
+
// Only expiry, and only under --strict. The other seven checks report debt a
|
|
1469
2412
|
// reader decides about; an expired record is the base asserting something it
|
|
1470
2413
|
// already said it would stop standing behind, which is the one finding a
|
|
1471
|
-
// pipeline can act on without a judgment call.
|
|
2414
|
+
// pipeline can act on without a judgment call. Drift has its own gate —
|
|
2415
|
+
// `anchor-resolve` exits non-zero on it, against a repo root the caller
|
|
2416
|
+
// named, which is the run a CI pipeline should be making anyway.
|
|
1472
2417
|
failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
|
|
1473
2418
|
});
|
|
1474
|
-
function
|
|
2419
|
+
function render2(result) {
|
|
1475
2420
|
const { thresholds } = result;
|
|
1476
2421
|
const lines = [
|
|
1477
2422
|
`# KB Doctor \u2014 ${result.bundlePath}`,
|
|
@@ -1503,13 +2448,13 @@ function render(result) {
|
|
|
1503
2448
|
}
|
|
1504
2449
|
|
|
1505
2450
|
// src/commands/list.ts
|
|
1506
|
-
var
|
|
2451
|
+
var import_zod11 = require("zod");
|
|
1507
2452
|
var listCommand = define({
|
|
1508
2453
|
name: "list",
|
|
1509
2454
|
tool: "kb_list",
|
|
1510
2455
|
usage: "list [type]",
|
|
1511
2456
|
description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
|
|
1512
|
-
input:
|
|
2457
|
+
input: import_zod11.z.object({ bundlePath, type: import_zod11.z.enum(KB_RECORD_TYPES).optional() }),
|
|
1513
2458
|
fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
|
|
1514
2459
|
run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
|
|
1515
2460
|
conceptId: record.conceptId,
|
|
@@ -1521,36 +2466,40 @@ var listCommand = define({
|
|
|
1521
2466
|
});
|
|
1522
2467
|
|
|
1523
2468
|
// src/commands/load.ts
|
|
1524
|
-
var
|
|
2469
|
+
var import_zod12 = require("zod");
|
|
1525
2470
|
var loadCommand = define({
|
|
1526
2471
|
name: "load",
|
|
1527
2472
|
tool: "kb_load",
|
|
1528
|
-
usage: "load [type] [--budget N | --all]",
|
|
1529
|
-
description: "
|
|
1530
|
-
input:
|
|
2473
|
+
usage: "load [type] [--budget N | --all] [--repo-root PATH]",
|
|
2474
|
+
description: "Loads the whole knowledge base at once, each record with its standing. Superseded records arrive as stubs (name, replacement, date); rejected and open records arrive whole. Refuses past the token budget rather than truncating \u2014 call kb_catalog, then kb_pack on the record that matters, or narrow with `type`; kb_query for a lookup by wording. `all` bypasses the budget.",
|
|
2475
|
+
input: import_zod12.z.object({
|
|
1531
2476
|
bundlePath,
|
|
1532
|
-
type:
|
|
1533
|
-
budgetTokens:
|
|
1534
|
-
all:
|
|
1535
|
-
"
|
|
1536
|
-
)
|
|
2477
|
+
type: import_zod12.z.enum(KB_RECORD_TYPES).optional(),
|
|
2478
|
+
budgetTokens: import_zod12.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
|
|
2479
|
+
all: import_zod12.z.boolean().optional().describe(
|
|
2480
|
+
"Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
|
|
2481
|
+
),
|
|
2482
|
+
repoRoot: REPO_ROOT
|
|
1537
2483
|
}).refine((value) => !(value.all && value.budgetTokens !== void 0), {
|
|
1538
|
-
message: "all
|
|
2484
|
+
message: "all is mutually exclusive with budgetTokens: pass a ceiling or none, not both."
|
|
1539
2485
|
}),
|
|
1540
2486
|
fromArgv: (argv, path) => {
|
|
1541
2487
|
const budget = argvFlag(argv, "--budget");
|
|
2488
|
+
const repoRoot = argvFlag(argv, "--repo-root");
|
|
1542
2489
|
return {
|
|
1543
2490
|
bundlePath: path,
|
|
1544
2491
|
...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {},
|
|
1545
2492
|
...budget ? { budgetTokens: Number(budget) } : {},
|
|
1546
|
-
...argv.includes("--all") ? { all: true } : {}
|
|
2493
|
+
...argv.includes("--all") ? { all: true } : {},
|
|
2494
|
+
...repoRoot !== void 0 ? { repoRoot } : {}
|
|
1547
2495
|
};
|
|
1548
2496
|
},
|
|
1549
|
-
run: async ({ store }, { bundlePath: path, type, budgetTokens, all }) => {
|
|
2497
|
+
run: async ({ store }, { bundlePath: path, type, budgetTokens, all, repoRoot }) => {
|
|
1550
2498
|
const result = await store.load(path, {
|
|
1551
2499
|
...type ? { type } : {},
|
|
1552
2500
|
...budgetTokens ? { budgetTokens } : {},
|
|
1553
|
-
...all ? { all } : {}
|
|
2501
|
+
...all ? { all } : {},
|
|
2502
|
+
...repoRoot !== void 0 ? { repoRoot } : {}
|
|
1554
2503
|
});
|
|
1555
2504
|
if (!result.loaded) return result;
|
|
1556
2505
|
return {
|
|
@@ -1569,25 +2518,25 @@ var loadCommand = define({
|
|
|
1569
2518
|
});
|
|
1570
2519
|
|
|
1571
2520
|
// src/commands/log.ts
|
|
1572
|
-
var
|
|
2521
|
+
var import_zod13 = require("zod");
|
|
1573
2522
|
var logCommand = define({
|
|
1574
2523
|
name: "log",
|
|
1575
2524
|
tool: "kb_log",
|
|
1576
2525
|
usage: "log",
|
|
1577
2526
|
description: "What touched what, and when. The only artifact here that cannot be reconstructed from the records, so malformed lines are reported rather than repaired.",
|
|
1578
|
-
input:
|
|
2527
|
+
input: import_zod13.z.object({ bundlePath }),
|
|
1579
2528
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1580
2529
|
run: ({ store }, { bundlePath: path }) => store.readLog(path)
|
|
1581
2530
|
});
|
|
1582
2531
|
|
|
1583
2532
|
// src/commands/no-decision.ts
|
|
1584
|
-
var
|
|
2533
|
+
var import_zod14 = require("zod");
|
|
1585
2534
|
var noDecisionCommand = define({
|
|
1586
2535
|
name: "no-decision",
|
|
1587
2536
|
tool: "kb_no_decision",
|
|
1588
2537
|
usage: "no-decision <reason...>",
|
|
1589
2538
|
description: 'Claim in one sentence that there was nothing to decide. Gating on "did you write a decision?" rewards writing a junk one; gating on "did you answer?" does not, so silence has to be expressible. Idempotent \u2014 restating it is not a collision.',
|
|
1590
|
-
input:
|
|
2539
|
+
input: import_zod14.z.object({ bundlePath, reason: import_zod14.z.string().min(1) }),
|
|
1591
2540
|
fromArgv: (argv, path) => ({
|
|
1592
2541
|
bundlePath: path,
|
|
1593
2542
|
reason: argv.slice(1).join(" ").trim()
|
|
@@ -1604,20 +2553,20 @@ var noDecisionCommand = define({
|
|
|
1604
2553
|
});
|
|
1605
2554
|
|
|
1606
2555
|
// src/commands/pack.ts
|
|
1607
|
-
var
|
|
2556
|
+
var import_zod15 = require("zod");
|
|
1608
2557
|
var packCommand = define({
|
|
1609
2558
|
name: "pack",
|
|
1610
2559
|
tool: "kb_pack",
|
|
1611
2560
|
usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
|
|
1612
2561
|
description: "The bounded neighbourhood around one record: everything within `hops` of the root, ranked and cut to `maxNodes`, with every cut record named under Excluded \u2014 a named gap is knowable, a silent one is not. Prefer this over kb_load when the base is too large to hold whole and the work centres on one record; prefer it over kb_query when the question needs the governed neighbourhood \u2014 what was settled and what binds near this record \u2014 rather than a lookup by wording. Superseded records arrive as name, replacement and date stubs exactly as kb_load emits them: their bodies no longer hold, and kb_trace has the history. Refuses outright rather than truncating when the pack would exceed its token budget \u2014 a partial pack is indistinguishable from a complete one \u2014 reporting the record count and every already-cut id so the caller can lower hops or maxNodes, or raise the budget. The header carries the bundle, root, budget and a timestamp; everything below the header is byte-identical across runs over an unchanged base, so two packs can be diffed and a changed byte means changed knowledge. This tool (with kb_load, kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.",
|
|
1613
|
-
input:
|
|
2562
|
+
input: import_zod15.z.object({
|
|
1614
2563
|
bundlePath,
|
|
1615
2564
|
conceptId,
|
|
1616
|
-
hops:
|
|
1617
|
-
maxNodes:
|
|
2565
|
+
hops: import_zod15.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
|
|
2566
|
+
maxNodes: import_zod15.z.number().int().positive().optional().describe(
|
|
1618
2567
|
"How many records the pack may hold, root included. Defaults to 20."
|
|
1619
2568
|
),
|
|
1620
|
-
budgetTokens:
|
|
2569
|
+
budgetTokens: import_zod15.z.number().int().positive().optional().describe(
|
|
1621
2570
|
"Approximate token ceiling over what is actually emitted. Defaults to 25000."
|
|
1622
2571
|
)
|
|
1623
2572
|
}),
|
|
@@ -1639,10 +2588,10 @@ var packCommand = define({
|
|
|
1639
2588
|
...maxNodes !== void 0 ? { maxNodes } : {},
|
|
1640
2589
|
...budgetTokens !== void 0 ? { budgetTokens } : {}
|
|
1641
2590
|
});
|
|
1642
|
-
return
|
|
2591
|
+
return render3(result, path, now());
|
|
1643
2592
|
}
|
|
1644
2593
|
});
|
|
1645
|
-
function
|
|
2594
|
+
function render3(result, bundle, at) {
|
|
1646
2595
|
const lines = [
|
|
1647
2596
|
`# KB Pack \u2014 ${result.root}`,
|
|
1648
2597
|
`bundle: ${bundle}`,
|
|
@@ -1704,22 +2653,22 @@ function warningLabel(warning) {
|
|
|
1704
2653
|
}
|
|
1705
2654
|
|
|
1706
2655
|
// src/commands/pin.ts
|
|
1707
|
-
var
|
|
2656
|
+
var import_zod16 = require("zod");
|
|
1708
2657
|
var pinCommand = define({
|
|
1709
2658
|
name: "pin",
|
|
1710
2659
|
tool: "kb_pin",
|
|
1711
2660
|
usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
|
|
1712
2661
|
description: "Pin a base into a workspace pin manifest, so `context` surfaces it at every context birth. Three layers, nearest wins: the committed project manifest (.strauss/kb-pins.json, the default), `--local` (.strauss/kb-pins.local.json, personal and gitignored), and `--user` (~/.strauss/kb-pins.json, every workspace). Idempotent \u2014 re-pinning changes nothing unless --mode, --profiles, or --frozen/--unfreeze are given, which update just those fields. `--mode full` preloads the whole base into the block regardless of the full-under threshold; `--mode index` never upgrades. `--profiles` scopes the pin to named context profiles. `--frozen` marks the base concluded: write commands against it refuse and `context` labels it read-only. A path with no records yet succeeds with a warning; bases are routinely pinned before they are populated. Pins are workspace state: the pinned base itself is never touched.",
|
|
1713
|
-
input:
|
|
2662
|
+
input: import_zod16.z.object({
|
|
1714
2663
|
bundlePath,
|
|
1715
|
-
mode:
|
|
2664
|
+
mode: import_zod16.z.enum(["full", "index"]).optional().describe(
|
|
1716
2665
|
"full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
|
|
1717
2666
|
),
|
|
1718
|
-
profiles:
|
|
1719
|
-
layer:
|
|
2667
|
+
profiles: import_zod16.z.array(import_zod16.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
|
|
2668
|
+
layer: import_zod16.z.enum(["project", "local", "user"]).optional().describe(
|
|
1720
2669
|
"Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
|
|
1721
2670
|
),
|
|
1722
|
-
frozen:
|
|
2671
|
+
frozen: import_zod16.z.boolean().optional().describe(
|
|
1723
2672
|
"true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
|
|
1724
2673
|
)
|
|
1725
2674
|
}),
|
|
@@ -1748,38 +2697,48 @@ var pinCommand = define({
|
|
|
1748
2697
|
});
|
|
1749
2698
|
|
|
1750
2699
|
// src/commands/pins.ts
|
|
1751
|
-
var
|
|
2700
|
+
var import_zod17 = require("zod");
|
|
1752
2701
|
var pinsCommand = define({
|
|
1753
2702
|
name: "pins",
|
|
1754
2703
|
tool: "kb_pins",
|
|
1755
2704
|
usage: "pins",
|
|
1756
2705
|
description: "Every pinned base across the manifest layers, each with its layer and whether it currently resolves to readable records. Reads the workspace manifests rather than any one base, like kb_context.",
|
|
1757
|
-
input:
|
|
2706
|
+
input: import_zod17.z.object({}),
|
|
1758
2707
|
fromArgv: () => ({}),
|
|
1759
2708
|
run: ({ store }) => listPins(store, process.cwd())
|
|
1760
2709
|
});
|
|
1761
2710
|
|
|
1762
2711
|
// src/commands/query.ts
|
|
1763
|
-
var
|
|
2712
|
+
var import_zod18 = require("zod");
|
|
1764
2713
|
var queryCommand = define({
|
|
1765
2714
|
name: "query",
|
|
1766
2715
|
tool: "kb_query",
|
|
1767
|
-
usage: "query <text...>",
|
|
1768
|
-
description: "Search and return each match with its standing. Results are flagged, never filtered: a superseded record comes back alongside whatever replaced it, and a rejected one is marked as something explicitly not adopted.
|
|
1769
|
-
input:
|
|
2716
|
+
usage: "query <text...> [--repo-root PATH]",
|
|
2717
|
+
description: "Search and return each match with its standing. Results are flagged, never filtered: a superseded record comes back alongside whatever replaced it, and a rejected one is marked as something explicitly not adopted. This is the lookup-by-wording rung, and the narrowest of the three: use it when you know roughly what the record says. The decision rule around it \u2014 while the base fits kb_load's token budget, kb_load it whole, because on this package's measurements a reader holding the whole base answered eight of nine questions whose wording appears in no record where embedding search answered four; once kb_load refuses, kb_catalog for one line per record and then kb_pack on the record the work centres on; and kb_query when the question is a point lookup rather than a neighbourhood. A query cannot tell you that nothing was decided \u2014 it returns its nearest hit whatever the distance \u2014 so reach for kb_catalog when the question is what exists. Never read record files directly: this tool (with kb_load, kb_catalog, kb_pack and kb_trace) is the only supported way to read a base; a file read bypasses supersession resolution and returns replaced records as if current.",
|
|
2718
|
+
input: import_zod18.z.object({
|
|
1770
2719
|
bundlePath,
|
|
1771
|
-
text:
|
|
1772
|
-
type:
|
|
1773
|
-
includeNonCurrent:
|
|
1774
|
-
|
|
1775
|
-
fromArgv: (argv, path) => ({
|
|
1776
|
-
bundlePath: path,
|
|
1777
|
-
text: argv.slice(1).join(" ").trim(),
|
|
1778
|
-
includeNonCurrent: true
|
|
2720
|
+
text: import_zod18.z.string().optional(),
|
|
2721
|
+
type: import_zod18.z.enum(KB_RECORD_TYPES).optional(),
|
|
2722
|
+
includeNonCurrent: import_zod18.z.boolean().optional(),
|
|
2723
|
+
repoRoot: REPO_ROOT
|
|
1779
2724
|
}),
|
|
1780
|
-
|
|
2725
|
+
// `--repo-root` is a flag, so its value must not fall into the search text.
|
|
2726
|
+
fromArgv: (argv, path) => {
|
|
2727
|
+
const repoRoot = argvFlag(argv, "--repo-root");
|
|
2728
|
+
const words = argv.slice(1);
|
|
2729
|
+
const flag = words.indexOf("--repo-root");
|
|
2730
|
+
if (flag !== -1) words.splice(flag, 2);
|
|
2731
|
+
return {
|
|
2732
|
+
bundlePath: path,
|
|
2733
|
+
text: words.join(" ").trim(),
|
|
2734
|
+
includeNonCurrent: true,
|
|
2735
|
+
...repoRoot !== void 0 ? { repoRoot } : {}
|
|
2736
|
+
};
|
|
2737
|
+
},
|
|
2738
|
+
run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent, repoRoot }) => (await store.query(path, text ?? "", {
|
|
1781
2739
|
...type ? { type } : {},
|
|
1782
|
-
includeNonCurrent: includeNonCurrent === true
|
|
2740
|
+
includeNonCurrent: includeNonCurrent === true,
|
|
2741
|
+
...repoRoot !== void 0 ? { repoRoot } : {}
|
|
1783
2742
|
})).map((hit) => ({
|
|
1784
2743
|
conceptId: hit.record.conceptId,
|
|
1785
2744
|
title: hit.record.frontmatter.title ?? null,
|
|
@@ -1792,27 +2751,27 @@ var queryCommand = define({
|
|
|
1792
2751
|
});
|
|
1793
2752
|
|
|
1794
2753
|
// src/commands/read-index.ts
|
|
1795
|
-
var
|
|
2754
|
+
var import_zod19 = require("zod");
|
|
1796
2755
|
var readIndexCommand = define({
|
|
1797
2756
|
name: "index",
|
|
1798
2757
|
tool: "kb_index",
|
|
1799
2758
|
usage: "index",
|
|
1800
2759
|
description: "The index, rebuilt if it disagrees with the records. One call gives the whole shape of the base: title, type, status, and description per record. The cheap re-orientation call after compaction or deep in a long session \u2014 a few hundred tokens; call it (or kb_context, when bases are pinned) first, then kb_load or fetch by concept id.",
|
|
1801
|
-
input:
|
|
2760
|
+
input: import_zod19.z.object({ bundlePath }),
|
|
1802
2761
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1803
2762
|
run: ({ store }, { bundlePath: path }) => store.readIndex(path)
|
|
1804
2763
|
});
|
|
1805
2764
|
|
|
1806
2765
|
// src/commands/schema.ts
|
|
1807
|
-
var
|
|
2766
|
+
var import_zod22 = require("zod");
|
|
1808
2767
|
|
|
1809
2768
|
// src/json-schema.ts
|
|
1810
|
-
var
|
|
2769
|
+
var import_zod21 = require("zod");
|
|
1811
2770
|
|
|
1812
2771
|
// src/kb-log.ts
|
|
1813
|
-
var
|
|
2772
|
+
var import_zod20 = require("zod");
|
|
1814
2773
|
var LOG_FILE = "log.jsonl";
|
|
1815
|
-
var kbLogEntrySchema =
|
|
2774
|
+
var kbLogEntrySchema = import_zod20.z.object({
|
|
1816
2775
|
// Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
|
|
1817
2776
|
// below), and a value that isn't actually chronological — a Unix
|
|
1818
2777
|
// timestamp, a human-typed date, garbage — would sort wrong without
|
|
@@ -1821,12 +2780,12 @@ var kbLogEntrySchema = import_zod18.z.object({
|
|
|
1821
2780
|
// and rejects everything else, including a non-`Z` offset — so a
|
|
1822
2781
|
// malformed `at` is reported the same way a malformed line already is,
|
|
1823
2782
|
// rather than silently sorting into the wrong place.
|
|
1824
|
-
at:
|
|
1825
|
-
by:
|
|
1826
|
-
operation:
|
|
1827
|
-
conceptId:
|
|
2783
|
+
at: import_zod20.z.iso.datetime(),
|
|
2784
|
+
by: import_zod20.z.string().min(1),
|
|
2785
|
+
operation: import_zod20.z.string().min(1),
|
|
2786
|
+
conceptId: import_zod20.z.string().min(1),
|
|
1828
2787
|
/** Second concept id, where the operation relates two — supersession. */
|
|
1829
|
-
target:
|
|
2788
|
+
target: import_zod20.z.string().min(1).optional()
|
|
1830
2789
|
}).strict();
|
|
1831
2790
|
function renderLogEntry(entry) {
|
|
1832
2791
|
return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
|
|
@@ -1864,11 +2823,11 @@ function parseLog(raw) {
|
|
|
1864
2823
|
// src/json-schema.ts
|
|
1865
2824
|
function kbJsonSchemas() {
|
|
1866
2825
|
return {
|
|
1867
|
-
recordFrontmatter:
|
|
2826
|
+
recordFrontmatter: import_zod21.z.toJSONSchema(kbRecordFrontmatterSchema, {
|
|
1868
2827
|
io: "input"
|
|
1869
2828
|
}),
|
|
1870
|
-
composeInput:
|
|
1871
|
-
logEntry:
|
|
2829
|
+
composeInput: import_zod21.z.toJSONSchema(composeInputSchema, { io: "input" }),
|
|
2830
|
+
logEntry: import_zod21.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
|
|
1872
2831
|
};
|
|
1873
2832
|
}
|
|
1874
2833
|
|
|
@@ -1878,22 +2837,22 @@ var schemaCommand = define({
|
|
|
1878
2837
|
tool: "kb_schema",
|
|
1879
2838
|
usage: "schema",
|
|
1880
2839
|
description: "JSON Schema for the frontmatter, the write input, and log entries \u2014 generated from the code that enforces them, so it cannot drift from what a write will accept.",
|
|
1881
|
-
input:
|
|
2840
|
+
input: import_zod22.z.object({}),
|
|
1882
2841
|
fromArgv: () => ({}),
|
|
1883
2842
|
run: () => Promise.resolve(kbJsonSchemas())
|
|
1884
2843
|
});
|
|
1885
2844
|
|
|
1886
2845
|
// src/commands/status.ts
|
|
1887
|
-
var
|
|
2846
|
+
var import_zod23 = require("zod");
|
|
1888
2847
|
var statusCommand = define({
|
|
1889
2848
|
name: "status",
|
|
1890
2849
|
tool: "kb_status",
|
|
1891
2850
|
usage: "status <concept-id> <status>",
|
|
1892
2851
|
description: "Move a record's status, leaving everything else alone. Uses a compare-and-swap, so a concurrent change fails loudly rather than being overwritten.",
|
|
1893
|
-
input:
|
|
2852
|
+
input: import_zod23.z.object({
|
|
1894
2853
|
bundlePath,
|
|
1895
2854
|
conceptId,
|
|
1896
|
-
status:
|
|
2855
|
+
status: import_zod23.z.enum(KB_RECORD_STATUSES)
|
|
1897
2856
|
}),
|
|
1898
2857
|
fromArgv: (argv, path) => ({
|
|
1899
2858
|
bundlePath: path,
|
|
@@ -1908,13 +2867,13 @@ var statusCommand = define({
|
|
|
1908
2867
|
});
|
|
1909
2868
|
|
|
1910
2869
|
// src/commands/supersede.ts
|
|
1911
|
-
var
|
|
2870
|
+
var import_zod24 = require("zod");
|
|
1912
2871
|
var supersedeCommand = define({
|
|
1913
2872
|
name: "supersede",
|
|
1914
2873
|
tool: "kb_supersede",
|
|
1915
2874
|
usage: "supersede <concept-id> <replacement-id>",
|
|
1916
2875
|
description: "Mark a record superseded by another, linking both directions. Use this rather than editing a record whose meaning changed \u2014 a record that quietly becomes something else invalidates every reference to it, and the earlier understanding is what a later trace needs.",
|
|
1917
|
-
input:
|
|
2876
|
+
input: import_zod24.z.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
1918
2877
|
fromArgv: (argv, path) => ({
|
|
1919
2878
|
bundlePath: path,
|
|
1920
2879
|
conceptId: argv[1],
|
|
@@ -1928,16 +2887,16 @@ var supersedeCommand = define({
|
|
|
1928
2887
|
});
|
|
1929
2888
|
|
|
1930
2889
|
// src/commands/sync-instructions.ts
|
|
1931
|
-
var
|
|
2890
|
+
var import_zod25 = require("zod");
|
|
1932
2891
|
var syncInstructionsCommand = define({
|
|
1933
2892
|
name: "sync-instructions",
|
|
1934
2893
|
usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
|
|
1935
2894
|
description: "Idempotently plant the `context` block between sentinel comments in an instruction file (AGENTS.md, CLAUDE.md), creating the block when absent and leaving everything outside the sentinels alone. CLI-only: this is file plumbing for runtimes whose instruction files are re-read where their conversations are not, not an agent capability \u2014 the capability is kb_context.",
|
|
1936
|
-
input:
|
|
1937
|
-
file:
|
|
1938
|
-
budgetTokens:
|
|
1939
|
-
fullUnderTokens:
|
|
1940
|
-
profile:
|
|
2895
|
+
input: import_zod25.z.object({
|
|
2896
|
+
file: import_zod25.z.string().min(1).describe("The instruction file to edit in place."),
|
|
2897
|
+
budgetTokens: import_zod25.z.number().int().positive().optional(),
|
|
2898
|
+
fullUnderTokens: import_zod25.z.number().int().positive().optional(),
|
|
2899
|
+
profile: import_zod25.z.string().optional()
|
|
1941
2900
|
}),
|
|
1942
2901
|
fromArgv: (argv) => {
|
|
1943
2902
|
const budget = argvFlag(argv, "--budget");
|
|
@@ -1963,7 +2922,7 @@ var syncInstructionsCommand = define({
|
|
|
1963
2922
|
});
|
|
1964
2923
|
|
|
1965
2924
|
// src/commands/trace.ts
|
|
1966
|
-
var
|
|
2925
|
+
var import_zod26 = require("zod");
|
|
1967
2926
|
|
|
1968
2927
|
// src/trace.ts
|
|
1969
2928
|
var TRACE_EDGES = ["supersession", "anchor", "source"];
|
|
@@ -2009,11 +2968,11 @@ var traceCommand = define({
|
|
|
2009
2968
|
tool: "kb_trace",
|
|
2010
2969
|
usage: "trace <concept-id> [edges...]",
|
|
2011
2970
|
description: 'How a position was arrived at, as a timeline ordered by when each record was written. Deliberately includes rejected, draft, and superseded records \u2014 in a history those are the content, not noise. Follows supersession, shared code anchors, and shared sources. Use when the question is "why is this the way it is" rather than "what do we hold now". This tool (with kb_load and kb_query) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.',
|
|
2012
|
-
input:
|
|
2971
|
+
input: import_zod26.z.object({
|
|
2013
2972
|
bundlePath,
|
|
2014
2973
|
conceptId,
|
|
2015
|
-
edges:
|
|
2016
|
-
depth:
|
|
2974
|
+
edges: import_zod26.z.array(import_zod26.z.enum(TRACE_EDGES)).optional(),
|
|
2975
|
+
depth: import_zod26.z.number().int().positive().optional()
|
|
2017
2976
|
}),
|
|
2018
2977
|
fromArgv: (argv, path) => ({
|
|
2019
2978
|
bundlePath: path,
|
|
@@ -2035,53 +2994,53 @@ var traceCommand = define({
|
|
|
2035
2994
|
});
|
|
2036
2995
|
|
|
2037
2996
|
// src/commands/types.ts
|
|
2038
|
-
var
|
|
2997
|
+
var import_zod27 = require("zod");
|
|
2039
2998
|
var typesCommand = define({
|
|
2040
2999
|
name: "types",
|
|
2041
3000
|
tool: "kb_types",
|
|
2042
3001
|
usage: "types",
|
|
2043
3002
|
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.",
|
|
2044
|
-
input:
|
|
3003
|
+
input: import_zod27.z.object({}),
|
|
2045
3004
|
fromArgv: () => ({}),
|
|
2046
3005
|
run: () => Promise.resolve(RECORD_TYPES)
|
|
2047
3006
|
});
|
|
2048
3007
|
|
|
2049
3008
|
// src/commands/unpin.ts
|
|
2050
|
-
var
|
|
3009
|
+
var import_zod28 = require("zod");
|
|
2051
3010
|
var unpinCommand = define({
|
|
2052
3011
|
name: "unpin",
|
|
2053
3012
|
tool: "kb_unpin",
|
|
2054
3013
|
usage: "unpin [bundle-path]",
|
|
2055
3014
|
description: "Remove a base from every pin manifest layer that holds it \u2014 project, local, and user \u2014 because unpinned means gone, not still injected from another file. Reports which layers were touched.",
|
|
2056
|
-
input:
|
|
3015
|
+
input: import_zod28.z.object({ bundlePath }),
|
|
2057
3016
|
fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
|
|
2058
3017
|
run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
|
|
2059
3018
|
});
|
|
2060
3019
|
|
|
2061
3020
|
// src/commands/validate.ts
|
|
2062
|
-
var
|
|
3021
|
+
var import_zod29 = require("zod");
|
|
2063
3022
|
var validateCommand = define({
|
|
2064
3023
|
name: "validate",
|
|
2065
3024
|
tool: "kb_validate",
|
|
2066
3025
|
usage: "validate",
|
|
2067
3026
|
description: "Check pointers no single record can see: supersession links that disagree between the two records, and assumptions that cite sources. Per-record shape is enforced on every read, so a problem here means someone edited a file by hand.",
|
|
2068
|
-
input:
|
|
3027
|
+
input: import_zod29.z.object({ bundlePath }),
|
|
2069
3028
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
2070
3029
|
run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
|
|
2071
3030
|
failsWhen: (result) => Array.isArray(result) && result.length > 0
|
|
2072
3031
|
});
|
|
2073
3032
|
|
|
2074
3033
|
// src/commands/verify.ts
|
|
2075
|
-
var
|
|
3034
|
+
var import_zod30 = require("zod");
|
|
2076
3035
|
var verifyCommand = define({
|
|
2077
3036
|
name: "verify",
|
|
2078
3037
|
tool: "kb_verify",
|
|
2079
3038
|
usage: "verify <concept-id> --note <text>",
|
|
2080
3039
|
description: "Append one verified[] event \u2014 who checked the record, when, and what the check found. Appends only; prior events are never rewritten. A record's own generator is refused unless the actor is human: re-reading your own output is not an independent check.",
|
|
2081
|
-
input:
|
|
3040
|
+
input: import_zod30.z.object({
|
|
2082
3041
|
bundlePath,
|
|
2083
3042
|
conceptId,
|
|
2084
|
-
note:
|
|
3043
|
+
note: import_zod30.z.string().refine((s) => s.trim().length > 0, {
|
|
2085
3044
|
message: "note must say what the check found"
|
|
2086
3045
|
})
|
|
2087
3046
|
}),
|
|
@@ -2101,7 +3060,7 @@ var verifyCommand = define({
|
|
|
2101
3060
|
});
|
|
2102
3061
|
|
|
2103
3062
|
// src/commands/write.ts
|
|
2104
|
-
var
|
|
3063
|
+
var import_zod31 = require("zod");
|
|
2105
3064
|
var writeCommand = define({
|
|
2106
3065
|
name: "write",
|
|
2107
3066
|
tool: "kb_write",
|
|
@@ -2115,9 +3074,9 @@ var writeCommand = define({
|
|
|
2115
3074
|
"- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
|
|
2116
3075
|
"- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
|
|
2117
3076
|
].join("\n"),
|
|
2118
|
-
input:
|
|
3077
|
+
input: import_zod31.z.object({
|
|
2119
3078
|
bundlePath,
|
|
2120
|
-
type:
|
|
3079
|
+
type: import_zod31.z.enum(KB_RECORD_TYPES),
|
|
2121
3080
|
input: composeInputSchema
|
|
2122
3081
|
}),
|
|
2123
3082
|
fromArgv: async (argv, path, stdin) => ({
|
|
@@ -2141,7 +3100,7 @@ var writeCommand = define({
|
|
|
2141
3100
|
});
|
|
2142
3101
|
|
|
2143
3102
|
// src/commands/write-decision.ts
|
|
2144
|
-
var
|
|
3103
|
+
var import_zod32 = require("zod");
|
|
2145
3104
|
var writeDecisionCommand = define({
|
|
2146
3105
|
name: "write-decision",
|
|
2147
3106
|
tool: "kb_write_decision",
|
|
@@ -2154,7 +3113,7 @@ var writeDecisionCommand = define({
|
|
|
2154
3113
|
"- `alternative` is what you turned down and why, not a list of everything considered.",
|
|
2155
3114
|
"- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
|
|
2156
3115
|
].join("\n"),
|
|
2157
|
-
input:
|
|
3116
|
+
input: import_zod32.z.object({ bundlePath, input: decisionInputSchema }),
|
|
2158
3117
|
fromArgv: async (_argv, path, stdin) => ({
|
|
2159
3118
|
bundlePath: path,
|
|
2160
3119
|
input: JSON.parse(await stdin())
|
|
@@ -2183,7 +3142,9 @@ var KB_COMMANDS = [
|
|
|
2183
3142
|
supersedeCommand,
|
|
2184
3143
|
answerCommand,
|
|
2185
3144
|
verifyCommand,
|
|
3145
|
+
anchorResolveCommand,
|
|
2186
3146
|
loadCommand,
|
|
3147
|
+
catalogCommand,
|
|
2187
3148
|
packCommand,
|
|
2188
3149
|
queryCommand,
|
|
2189
3150
|
traceCommand,
|
|
@@ -2205,9 +3166,9 @@ var KB_COMMANDS_BY_NAME = new Map(
|
|
|
2205
3166
|
);
|
|
2206
3167
|
|
|
2207
3168
|
// src/kb-store.ts
|
|
2208
|
-
var
|
|
2209
|
-
var
|
|
2210
|
-
var
|
|
3169
|
+
var import_node_crypto2 = require("crypto");
|
|
3170
|
+
var import_promises5 = require("fs/promises");
|
|
3171
|
+
var import_node_path7 = require("path");
|
|
2211
3172
|
|
|
2212
3173
|
// src/markdown.ts
|
|
2213
3174
|
var import_gray_matter = __toESM(require("gray-matter"), 1);
|
|
@@ -2234,129 +3195,9 @@ function parseMarkdownWithFrontmatter(text, schema) {
|
|
|
2234
3195
|
};
|
|
2235
3196
|
}
|
|
2236
3197
|
|
|
2237
|
-
// src/errors.ts
|
|
2238
|
-
var BaseError = class extends Error {
|
|
2239
|
-
code;
|
|
2240
|
-
errorType;
|
|
2241
|
-
fault;
|
|
2242
|
-
retriable;
|
|
2243
|
-
reportToUser;
|
|
2244
|
-
details;
|
|
2245
|
-
constructor(props) {
|
|
2246
|
-
super(props.message);
|
|
2247
|
-
this.name = props.name ?? this.constructor.name;
|
|
2248
|
-
this.code = props.code ?? 500;
|
|
2249
|
-
this.errorType = props.errorType;
|
|
2250
|
-
this.fault = props.fault;
|
|
2251
|
-
this.retriable = props.retriable ?? true;
|
|
2252
|
-
this.reportToUser = props.reportToUser ?? false;
|
|
2253
|
-
this.details = props.details;
|
|
2254
|
-
}
|
|
2255
|
-
};
|
|
2256
|
-
|
|
2257
|
-
// src/kb-errors.ts
|
|
2258
|
-
var KbRecordAlreadyExistsError = class extends BaseError {
|
|
2259
|
-
constructor(conceptId2) {
|
|
2260
|
-
super({
|
|
2261
|
-
message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
|
|
2262
|
-
errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
|
|
2263
|
-
code: 409,
|
|
2264
|
-
fault: "User" /* User */,
|
|
2265
|
-
retriable: false,
|
|
2266
|
-
reportToUser: true,
|
|
2267
|
-
details: { conceptId: conceptId2, action: "refused" }
|
|
2268
|
-
});
|
|
2269
|
-
this.conceptId = conceptId2;
|
|
2270
|
-
}
|
|
2271
|
-
conceptId;
|
|
2272
|
-
};
|
|
2273
|
-
var KbRecordNotFoundError = class extends BaseError {
|
|
2274
|
-
constructor(conceptId2) {
|
|
2275
|
-
super({
|
|
2276
|
-
message: `kb: ${conceptId2} does not exist`,
|
|
2277
|
-
errorType: "KbRecordNotFound" /* KbRecordNotFound */,
|
|
2278
|
-
code: 404,
|
|
2279
|
-
fault: "User" /* User */,
|
|
2280
|
-
retriable: false,
|
|
2281
|
-
reportToUser: true,
|
|
2282
|
-
details: { conceptId: conceptId2 }
|
|
2283
|
-
});
|
|
2284
|
-
this.conceptId = conceptId2;
|
|
2285
|
-
}
|
|
2286
|
-
conceptId;
|
|
2287
|
-
};
|
|
2288
|
-
var KbWriteConflictError = class extends BaseError {
|
|
2289
|
-
constructor(conceptId2) {
|
|
2290
|
-
super({
|
|
2291
|
-
message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
|
|
2292
|
-
errorType: "KbWriteConflict" /* KbWriteConflict */,
|
|
2293
|
-
code: 409,
|
|
2294
|
-
fault: "System" /* System */,
|
|
2295
|
-
retriable: true,
|
|
2296
|
-
reportToUser: true,
|
|
2297
|
-
details: { conceptId: conceptId2 }
|
|
2298
|
-
});
|
|
2299
|
-
this.conceptId = conceptId2;
|
|
2300
|
-
}
|
|
2301
|
-
conceptId;
|
|
2302
|
-
};
|
|
2303
|
-
var KbSelfVerificationError = class extends BaseError {
|
|
2304
|
-
constructor(conceptId2, actor, generatedBy) {
|
|
2305
|
-
super({
|
|
2306
|
-
message: `kb: ${conceptId2} was generated by ${generatedBy}, and a record's generator cannot verify it \u2014 only a human or a different actor can`,
|
|
2307
|
-
errorType: "KbSelfVerification" /* KbSelfVerification */,
|
|
2308
|
-
code: 400,
|
|
2309
|
-
fault: "User" /* User */,
|
|
2310
|
-
retriable: false,
|
|
2311
|
-
reportToUser: true,
|
|
2312
|
-
details: { conceptId: conceptId2, actor, generatedBy, action: "refused" }
|
|
2313
|
-
});
|
|
2314
|
-
this.conceptId = conceptId2;
|
|
2315
|
-
this.actor = actor;
|
|
2316
|
-
this.generatedBy = generatedBy;
|
|
2317
|
-
}
|
|
2318
|
-
conceptId;
|
|
2319
|
-
actor;
|
|
2320
|
-
generatedBy;
|
|
2321
|
-
};
|
|
2322
|
-
var KbPackBudgetExceededError = class extends BaseError {
|
|
2323
|
-
constructor(recordCount, approxTokens2, budgetTokens, excluded) {
|
|
2324
|
-
super({
|
|
2325
|
-
message: `kb: a pack of ${recordCount} records is ~${approxTokens2} tokens against a budget of ${budgetTokens} \u2014 lower hops or maxNodes, or raise the budget`,
|
|
2326
|
-
errorType: "KbPackBudgetExceeded" /* KbPackBudgetExceeded */,
|
|
2327
|
-
code: 400,
|
|
2328
|
-
fault: "User" /* User */,
|
|
2329
|
-
retriable: false,
|
|
2330
|
-
reportToUser: true,
|
|
2331
|
-
details: { recordCount, approxTokens: approxTokens2, budgetTokens, excluded }
|
|
2332
|
-
});
|
|
2333
|
-
this.recordCount = recordCount;
|
|
2334
|
-
this.approxTokens = approxTokens2;
|
|
2335
|
-
this.budgetTokens = budgetTokens;
|
|
2336
|
-
this.excluded = excluded;
|
|
2337
|
-
}
|
|
2338
|
-
recordCount;
|
|
2339
|
-
approxTokens;
|
|
2340
|
-
budgetTokens;
|
|
2341
|
-
excluded;
|
|
2342
|
-
};
|
|
2343
|
-
var KbInvalidConceptIdError = class extends BaseError {
|
|
2344
|
-
constructor(message, details) {
|
|
2345
|
-
super({
|
|
2346
|
-
message: `kb: ${message}`,
|
|
2347
|
-
errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
|
|
2348
|
-
code: 400,
|
|
2349
|
-
fault: "User" /* User */,
|
|
2350
|
-
retriable: false,
|
|
2351
|
-
reportToUser: true,
|
|
2352
|
-
details
|
|
2353
|
-
});
|
|
2354
|
-
}
|
|
2355
|
-
};
|
|
2356
|
-
|
|
2357
3198
|
// src/search-index.ts
|
|
2358
|
-
var
|
|
2359
|
-
var
|
|
3199
|
+
var import_promises4 = require("fs/promises");
|
|
3200
|
+
var import_node_path6 = require("path");
|
|
2360
3201
|
var SEARCH_INDEX_FILE = ".index.sqlite";
|
|
2361
3202
|
var COLLECTION = "kb";
|
|
2362
3203
|
async function searchBase(bundlePath2, query, options = {}) {
|
|
@@ -2365,7 +3206,7 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
2365
3206
|
let store = null;
|
|
2366
3207
|
try {
|
|
2367
3208
|
store = await qmd.createStore({
|
|
2368
|
-
dbPath: (0,
|
|
3209
|
+
dbPath: (0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE),
|
|
2369
3210
|
config: {
|
|
2370
3211
|
collections: {
|
|
2371
3212
|
[COLLECTION]: {
|
|
@@ -2400,16 +3241,19 @@ async function searchBase(bundlePath2, query, options = {}) {
|
|
|
2400
3241
|
}
|
|
2401
3242
|
}
|
|
2402
3243
|
async function isStale(bundlePath2) {
|
|
2403
|
-
const indexAt = await (0,
|
|
3244
|
+
const indexAt = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
|
|
2404
3245
|
if (!indexAt) return true;
|
|
2405
3246
|
const { readdir: readdir2 } = await import("fs/promises");
|
|
2406
|
-
const names = await readdir2(bundlePath2).catch(() => [])
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
3247
|
+
const names = (await readdir2(bundlePath2).catch(() => [])).filter(
|
|
3248
|
+
(name) => name.endsWith(".md") && name !== INDEX_FILE
|
|
3249
|
+
);
|
|
3250
|
+
let stale = false;
|
|
3251
|
+
await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
|
|
3252
|
+
if (stale) return;
|
|
3253
|
+
const at = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
|
|
3254
|
+
if (at > indexAt) stale = true;
|
|
3255
|
+
});
|
|
3256
|
+
return stale;
|
|
2413
3257
|
}
|
|
2414
3258
|
function resolveHits(hits, records) {
|
|
2415
3259
|
const byName = /* @__PURE__ */ new Map();
|
|
@@ -2549,7 +3393,7 @@ function appendUnionMergeLine(contents) {
|
|
|
2549
3393
|
}
|
|
2550
3394
|
|
|
2551
3395
|
// src/kb-store.ts
|
|
2552
|
-
var KB_DIR = (0,
|
|
3396
|
+
var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
|
|
2553
3397
|
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
2554
3398
|
var DEFAULT_LOAD_BUDGET = 25e3;
|
|
2555
3399
|
var KbStore = class {
|
|
@@ -2580,7 +3424,7 @@ var KbStore = class {
|
|
|
2580
3424
|
const conceptId2 = `${input.type}.${input.slug}`;
|
|
2581
3425
|
const root = this.root(bundlePath2);
|
|
2582
3426
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
2583
|
-
await (0,
|
|
3427
|
+
await (0, import_promises5.mkdir)(root, { recursive: true });
|
|
2584
3428
|
await this.publish(
|
|
2585
3429
|
target,
|
|
2586
3430
|
stringifyMarkdownWithFrontmatter(input.body, frontmatter),
|
|
@@ -2619,7 +3463,7 @@ var KbStore = class {
|
|
|
2619
3463
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
2620
3464
|
let raw;
|
|
2621
3465
|
try {
|
|
2622
|
-
raw = await (0,
|
|
3466
|
+
raw = await (0, import_promises5.readFile)(target, "utf8");
|
|
2623
3467
|
} catch {
|
|
2624
3468
|
return null;
|
|
2625
3469
|
}
|
|
@@ -2636,15 +3480,15 @@ var KbStore = class {
|
|
|
2636
3480
|
const root = this.root(bundlePath2);
|
|
2637
3481
|
let names;
|
|
2638
3482
|
try {
|
|
2639
|
-
names = await (0,
|
|
3483
|
+
names = await (0, import_promises5.readdir)(root);
|
|
2640
3484
|
} catch {
|
|
2641
3485
|
return [];
|
|
2642
3486
|
}
|
|
2643
3487
|
const wanted = names.sort().filter((name) => name.endsWith(".md") && !STORE_OWNED.has(name)).map((name) => ({ name, conceptId: name.slice(0, -".md".length) })).filter(({ conceptId: conceptId2 }) => !type || conceptId2.startsWith(`${type}.`));
|
|
2644
|
-
const records = await
|
|
2645
|
-
wanted
|
|
2646
|
-
|
|
2647
|
-
)
|
|
3488
|
+
const records = await mapLimit(
|
|
3489
|
+
wanted,
|
|
3490
|
+
DEFAULT_IO_CONCURRENCY,
|
|
3491
|
+
async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises5.readFile)((0, import_node_path7.join)(root, name), "utf8"))
|
|
2648
3492
|
);
|
|
2649
3493
|
return records.filter((record) => record !== null);
|
|
2650
3494
|
}
|
|
@@ -2666,6 +3510,21 @@ var KbStore = class {
|
|
|
2666
3510
|
{ operation: `status:${status}`, by: actor }
|
|
2667
3511
|
);
|
|
2668
3512
|
}
|
|
3513
|
+
/**
|
|
3514
|
+
* Replaces a record's anchors wholesale, preserving everything else.
|
|
3515
|
+
*
|
|
3516
|
+
* Wholesale rather than merged: the caller just resolved the anchors it is
|
|
3517
|
+
* writing, so it holds the complete current set, and a merge would keep
|
|
3518
|
+
* stale entries the resolution pass deliberately dropped.
|
|
3519
|
+
*/
|
|
3520
|
+
async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
|
|
3521
|
+
return this.mutate(
|
|
3522
|
+
bundlePath2,
|
|
3523
|
+
conceptId2,
|
|
3524
|
+
(frontmatter) => ({ ...frontmatter, strauss_anchors: anchors }),
|
|
3525
|
+
{ operation: "anchor-resolve", by: actor }
|
|
3526
|
+
);
|
|
3527
|
+
}
|
|
2669
3528
|
/**
|
|
2670
3529
|
* Appends one `verified[]` event: who checked the record, when, and what the
|
|
2671
3530
|
* check found. Append-only — prior events are history, and are spread into
|
|
@@ -2764,9 +3623,12 @@ ${answer}
|
|
|
2764
3623
|
const bundle = await this.list(bundlePath2);
|
|
2765
3624
|
const needle = text.trim();
|
|
2766
3625
|
const hits = needle ? await this.rank(bundlePath2, needle, bundle) : bundle;
|
|
3626
|
+
const narrowed = options.type ? hits.filter((r) => r.frontmatter.type === options.type) : hits;
|
|
2767
3627
|
const adjudicated = adjudicate(
|
|
2768
|
-
|
|
2769
|
-
bundle
|
|
3628
|
+
narrowed,
|
|
3629
|
+
bundle,
|
|
3630
|
+
/* @__PURE__ */ new Date(),
|
|
3631
|
+
await this.detectDrift(narrowed, options.repoRoot)
|
|
2770
3632
|
);
|
|
2771
3633
|
if (options.includeNonCurrent) return adjudicated;
|
|
2772
3634
|
const present = new Set(adjudicated.map((hit) => hit.record.conceptId));
|
|
@@ -2785,6 +3647,50 @@ ${answer}
|
|
|
2785
3647
|
const lowered = needle.toLowerCase();
|
|
2786
3648
|
return bundle.filter((record) => matches(record, lowered));
|
|
2787
3649
|
}
|
|
3650
|
+
/**
|
|
3651
|
+
* Anchor drift over the records about to be handed back. Like the search
|
|
3652
|
+
* index, this is an enrichment: a filesystem failure degrades to "no drift
|
|
3653
|
+
* reported" rather than failing the read. Anchors without a stored hash are
|
|
3654
|
+
* skipped inside `detectAnchorDrift`, so a base nobody has stamped pays no
|
|
3655
|
+
* fs cost here. `repoRoot` defaults to the working directory — the CLI runs
|
|
3656
|
+
* at the repo root, and the MCP server's cwd is the workspace.
|
|
3657
|
+
*
|
|
3658
|
+
* Public because `doctor` needs the same map with the same degradation: a
|
|
3659
|
+
* sweep that failed to read the tree should report no drift, not fail.
|
|
3660
|
+
*
|
|
3661
|
+
* When no root was given and not one anchored file was found, the finding is
|
|
3662
|
+
* discarded. A base read from somewhere other than the tree it describes
|
|
3663
|
+
* misses every file at once, and that shape is far likelier to be a wrong
|
|
3664
|
+
* default root than a repository where every anchored file was deleted on
|
|
3665
|
+
* the same day. Reporting it would put a drift warning on every record in
|
|
3666
|
+
* the base, which teaches a reader to ignore the warning — the one outcome
|
|
3667
|
+
* worse than not having it. One file found anywhere makes the root
|
|
3668
|
+
* plausible, and the misses become findings again; an explicit `repoRoot` is
|
|
3669
|
+
* taken at its word either way.
|
|
3670
|
+
*/
|
|
3671
|
+
async detectDrift(records, repoRoot) {
|
|
3672
|
+
try {
|
|
3673
|
+
const drift = await detectAnchorDrift(records, {
|
|
3674
|
+
repoRoot: repoRoot ?? process.cwd()
|
|
3675
|
+
});
|
|
3676
|
+
if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
|
|
3677
|
+
this.logger.warn?.({
|
|
3678
|
+
operation: "kb.anchor-drift",
|
|
3679
|
+
outcome: "skipped",
|
|
3680
|
+
reason: "no anchored file found under the default repo root"
|
|
3681
|
+
});
|
|
3682
|
+
return void 0;
|
|
3683
|
+
}
|
|
3684
|
+
return drift;
|
|
3685
|
+
} catch (error) {
|
|
3686
|
+
this.logger.warn?.({
|
|
3687
|
+
operation: "kb.anchor-drift",
|
|
3688
|
+
outcome: "skipped",
|
|
3689
|
+
error: error instanceof Error ? error.message : "unknown"
|
|
3690
|
+
});
|
|
3691
|
+
return void 0;
|
|
3692
|
+
}
|
|
3693
|
+
}
|
|
2788
3694
|
/**
|
|
2789
3695
|
* The whole base, adjudicated, when it is small enough to hand over.
|
|
2790
3696
|
*
|
|
@@ -2804,15 +3710,27 @@ ${answer}
|
|
|
2804
3710
|
* is indistinguishable from a complete one, so a caller would answer "that
|
|
2805
3711
|
* was never decided" from a slice it did not know was a slice.
|
|
2806
3712
|
*
|
|
2807
|
-
*
|
|
2808
|
-
*
|
|
2809
|
-
*
|
|
3713
|
+
* A token budget decides that, measured over what is actually handed back.
|
|
3714
|
+
* The refusal names the estimate and the budget, because a caller told only
|
|
3715
|
+
* "too big" cannot tell whether to narrow the type filter, raise the budget,
|
|
3716
|
+
* or stop loading the base whole altogether. Past the budget the answer is
|
|
3717
|
+
* the catalog and then a pack, which is what the refusal says.
|
|
3718
|
+
*
|
|
3719
|
+
* That refusal is the default guardrail. `all` bypasses the budget outright
|
|
3720
|
+
* and always hands back the whole bundle: an explicit, never-accidental
|
|
3721
|
+
* escape hatch for an operator who has the budget to spend, not a wider
|
|
3722
|
+
* default.
|
|
2810
3723
|
*/
|
|
2811
3724
|
async load(bundlePath2, options = {}) {
|
|
2812
3725
|
const budgetTokens = options.budgetTokens ?? DEFAULT_LOAD_BUDGET;
|
|
2813
3726
|
const bundle = await this.list(bundlePath2);
|
|
2814
3727
|
const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
|
|
2815
|
-
const adjudicated = adjudicate(
|
|
3728
|
+
const adjudicated = adjudicate(
|
|
3729
|
+
wanted,
|
|
3730
|
+
bundle,
|
|
3731
|
+
/* @__PURE__ */ new Date(),
|
|
3732
|
+
await this.detectDrift(wanted, options.repoRoot)
|
|
3733
|
+
);
|
|
2816
3734
|
const records = adjudicated.filter((hit) => hit.standing !== "superseded");
|
|
2817
3735
|
const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
|
|
2818
3736
|
const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
|
|
@@ -2821,7 +3739,12 @@ ${answer}
|
|
|
2821
3739
|
loaded: false,
|
|
2822
3740
|
recordCount: wanted.length,
|
|
2823
3741
|
approxTokens: approxTokens2,
|
|
2824
|
-
budgetTokens
|
|
3742
|
+
budgetTokens,
|
|
3743
|
+
message: refusalMessage({
|
|
3744
|
+
approxTokens: approxTokens2,
|
|
3745
|
+
budgetTokens,
|
|
3746
|
+
type: options.type
|
|
3747
|
+
})
|
|
2825
3748
|
};
|
|
2826
3749
|
}
|
|
2827
3750
|
return {
|
|
@@ -2837,6 +3760,10 @@ ${answer}
|
|
|
2837
3760
|
async trace(bundlePath2, seedId, options = {}) {
|
|
2838
3761
|
return trace(seedId, await this.list(bundlePath2), options);
|
|
2839
3762
|
}
|
|
3763
|
+
/** Every record named in one line each. See `catalog.ts`. */
|
|
3764
|
+
async catalog(bundlePath2, options = {}) {
|
|
3765
|
+
return catalog(await this.list(bundlePath2), options);
|
|
3766
|
+
}
|
|
2840
3767
|
/** A bounded neighbourhood around one record. See `pack.ts`. */
|
|
2841
3768
|
async pack(bundlePath2, rootId, options = {}) {
|
|
2842
3769
|
return pack(await this.list(bundlePath2), rootId, options);
|
|
@@ -2851,11 +3778,11 @@ ${answer}
|
|
|
2851
3778
|
async readIndex(bundlePath2) {
|
|
2852
3779
|
const root = this.root(bundlePath2);
|
|
2853
3780
|
const expected = renderIndex(await this.list(bundlePath2));
|
|
2854
|
-
const stored = await (0,
|
|
3781
|
+
const stored = await (0, import_promises5.readFile)((0, import_node_path7.join)(root, INDEX_FILE), "utf8").catch(
|
|
2855
3782
|
() => null
|
|
2856
3783
|
);
|
|
2857
3784
|
if (indexIsStale(stored, expected)) {
|
|
2858
|
-
await this.publish((0,
|
|
3785
|
+
await this.publish((0, import_node_path7.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
|
|
2859
3786
|
this.logger.info?.({
|
|
2860
3787
|
operation: "kb.index.repair",
|
|
2861
3788
|
bundlePath: root,
|
|
@@ -2872,8 +3799,8 @@ ${answer}
|
|
|
2872
3799
|
* knows which agent touched what. So a bad line is surfaced and left alone.
|
|
2873
3800
|
*/
|
|
2874
3801
|
async readLog(bundlePath2) {
|
|
2875
|
-
const raw = await (0,
|
|
2876
|
-
(0,
|
|
3802
|
+
const raw = await (0, import_promises5.readFile)(
|
|
3803
|
+
(0, import_node_path7.join)(this.root(bundlePath2), LOG_FILE),
|
|
2877
3804
|
"utf8"
|
|
2878
3805
|
).catch(() => "");
|
|
2879
3806
|
const result = parseLog(raw);
|
|
@@ -2924,14 +3851,14 @@ ${answer}
|
|
|
2924
3851
|
}
|
|
2925
3852
|
async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
|
|
2926
3853
|
const target = this.recordPath(bundlePath2, conceptId2);
|
|
2927
|
-
const before = await (0,
|
|
3854
|
+
const before = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
|
|
2928
3855
|
if (before === null) throw new KbRecordNotFoundError(conceptId2);
|
|
2929
3856
|
const parsed = this.parse(conceptId2, before);
|
|
2930
3857
|
if (!parsed) throw new KbRecordNotFoundError(conceptId2);
|
|
2931
3858
|
const frontmatter = change(parsed.frontmatter);
|
|
2932
3859
|
const body = changeBody(parsed.body);
|
|
2933
3860
|
const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
|
|
2934
|
-
const witness = await (0,
|
|
3861
|
+
const witness = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
|
|
2935
3862
|
if (witness === null || digest(witness) !== digest(before)) {
|
|
2936
3863
|
throw new KbWriteConflictError(conceptId2);
|
|
2937
3864
|
}
|
|
@@ -2957,20 +3884,20 @@ ${answer}
|
|
|
2957
3884
|
*/
|
|
2958
3885
|
async publish(target, contents, overwrite, conceptId2) {
|
|
2959
3886
|
const staging = `${target}.${process.pid}.tmp`;
|
|
2960
|
-
await (0,
|
|
3887
|
+
await (0, import_promises5.writeFile)(staging, contents, "utf8");
|
|
2961
3888
|
try {
|
|
2962
3889
|
if (overwrite) {
|
|
2963
|
-
await (0,
|
|
3890
|
+
await (0, import_promises5.rename)(staging, target);
|
|
2964
3891
|
return;
|
|
2965
3892
|
}
|
|
2966
|
-
await (0,
|
|
3893
|
+
await (0, import_promises5.link)(staging, target);
|
|
2967
3894
|
} catch (error) {
|
|
2968
3895
|
if (error.code === "EEXIST") {
|
|
2969
3896
|
throw new KbRecordAlreadyExistsError(conceptId2);
|
|
2970
3897
|
}
|
|
2971
3898
|
throw error;
|
|
2972
3899
|
} finally {
|
|
2973
|
-
await (0,
|
|
3900
|
+
await (0, import_promises5.unlink)(staging).catch(() => void 0);
|
|
2974
3901
|
}
|
|
2975
3902
|
}
|
|
2976
3903
|
/**
|
|
@@ -3014,20 +3941,30 @@ ${answer}
|
|
|
3014
3941
|
* file must not fail the mutation it guards.
|
|
3015
3942
|
*/
|
|
3016
3943
|
async ensureGitattributes(root) {
|
|
3017
|
-
const target = (0,
|
|
3944
|
+
const target = (0, import_node_path7.join)(root, GITATTRIBUTES_FILE);
|
|
3018
3945
|
try {
|
|
3019
3946
|
let existing;
|
|
3020
3947
|
try {
|
|
3021
|
-
existing = await (0,
|
|
3948
|
+
existing = await (0, import_promises5.readFile)(target, "utf8");
|
|
3022
3949
|
} catch (error) {
|
|
3023
3950
|
if (error.code !== "ENOENT") throw error;
|
|
3024
3951
|
existing = null;
|
|
3025
3952
|
}
|
|
3026
3953
|
if (existing === null) {
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3954
|
+
try {
|
|
3955
|
+
await (0, import_promises5.writeFile)(target, appendUnionMergeLine(""), {
|
|
3956
|
+
encoding: "utf8",
|
|
3957
|
+
flag: "wx"
|
|
3958
|
+
});
|
|
3959
|
+
} catch (error) {
|
|
3960
|
+
if (error.code !== "EEXIST") throw error;
|
|
3961
|
+
this.logger.info?.({
|
|
3962
|
+
operation: "kb.gitattributes.ensure",
|
|
3963
|
+
bundlePath: root,
|
|
3964
|
+
outcome: "exists"
|
|
3965
|
+
});
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3031
3968
|
this.logger.info?.({
|
|
3032
3969
|
operation: "kb.gitattributes.ensure",
|
|
3033
3970
|
bundlePath: root,
|
|
@@ -3036,7 +3973,7 @@ ${answer}
|
|
|
3036
3973
|
return;
|
|
3037
3974
|
}
|
|
3038
3975
|
if (!hasMergeDeclaration(existing)) {
|
|
3039
|
-
await (0,
|
|
3976
|
+
await (0, import_promises5.appendFile)(target, appendUnionMergeLine(existing), "utf8");
|
|
3040
3977
|
this.logger.info?.({
|
|
3041
3978
|
operation: "kb.gitattributes.ensure",
|
|
3042
3979
|
bundlePath: root,
|
|
@@ -3055,7 +3992,7 @@ ${answer}
|
|
|
3055
3992
|
async record(root, entry) {
|
|
3056
3993
|
await this.ensureGitattributes(root);
|
|
3057
3994
|
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
3058
|
-
await (0,
|
|
3995
|
+
await (0, import_promises5.appendFile)((0, import_node_path7.join)(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
3059
3996
|
this.logger.warn?.({
|
|
3060
3997
|
operation: "kb.log.append",
|
|
3061
3998
|
outcome: "failed",
|
|
@@ -3081,18 +4018,18 @@ ${answer}
|
|
|
3081
4018
|
};
|
|
3082
4019
|
}
|
|
3083
4020
|
root(bundlePath2) {
|
|
3084
|
-
return (0,
|
|
4021
|
+
return (0, import_node_path7.resolve)(bundlePath2);
|
|
3085
4022
|
}
|
|
3086
4023
|
// Concept ids are `<type>.<slug>` and map to a single file directly under the
|
|
3087
4024
|
// bundle root; anything carrying a separator would escape it.
|
|
3088
4025
|
recordPath(bundlePath2, conceptId2) {
|
|
3089
|
-
if (conceptId2.includes(
|
|
4026
|
+
if (conceptId2.includes(import_node_path7.sep) || conceptId2.includes("/")) {
|
|
3090
4027
|
throw new KbInvalidConceptIdError(
|
|
3091
4028
|
"concept id must not contain a path separator",
|
|
3092
4029
|
{ conceptId: conceptId2 }
|
|
3093
4030
|
);
|
|
3094
4031
|
}
|
|
3095
|
-
return (0,
|
|
4032
|
+
return (0, import_node_path7.join)(this.root(bundlePath2), `${conceptId2}.md`);
|
|
3096
4033
|
}
|
|
3097
4034
|
};
|
|
3098
4035
|
function estimateTokens(record) {
|
|
@@ -3103,6 +4040,14 @@ function estimateTokens(record) {
|
|
|
3103
4040
|
function estimateStubTokens(entry) {
|
|
3104
4041
|
return Math.ceil(JSON.stringify(entry).length / 4);
|
|
3105
4042
|
}
|
|
4043
|
+
function refusalMessage(refusal) {
|
|
4044
|
+
const scope = refusal.type ? ` of type ${refusal.type}` : "";
|
|
4045
|
+
return [
|
|
4046
|
+
`Refusing to load this base whole: ~${refusal.approxTokens} tokens is past the ${refusal.budgetTokens}-token budget.`,
|
|
4047
|
+
`Call kb_catalog for one line per record${scope} (id, type, title, standing), then kb_pack on the record that matters; kb_query works for a lookup by wording.`,
|
|
4048
|
+
`To load anyway: raise budgetTokens (currently ${refusal.budgetTokens}), or all=true to bypass the budget.`
|
|
4049
|
+
].join(" ");
|
|
4050
|
+
}
|
|
3106
4051
|
function stub(hit) {
|
|
3107
4052
|
return {
|
|
3108
4053
|
conceptId: hit.record.conceptId,
|
|
@@ -3123,11 +4068,11 @@ function normalizeActor(id) {
|
|
|
3123
4068
|
return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
|
|
3124
4069
|
}
|
|
3125
4070
|
function digest(contents) {
|
|
3126
|
-
return (0,
|
|
4071
|
+
return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
|
|
3127
4072
|
}
|
|
3128
4073
|
|
|
3129
4074
|
// src/version.ts
|
|
3130
|
-
var VERSION = true ? "0.1.
|
|
4075
|
+
var VERSION = true ? "0.1.11" : "0.0.0-dev";
|
|
3131
4076
|
|
|
3132
4077
|
// src/mcp.ts
|
|
3133
4078
|
function createKbMcpServer() {
|