@saasontools/strauss-kb 0.1.10 → 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/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/answer.ts
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 import_node_path3 = require("path");
986
+ var import_node_path4 = require("path");
376
987
 
377
988
  // src/kb-pins/layers.ts
378
- var import_promises = require("fs/promises");
989
+ var import_promises2 = require("fs/promises");
379
990
  var import_node_os = require("os");
380
- var import_node_path2 = require("path");
991
+ var import_node_path3 = require("path");
381
992
 
382
993
  // src/kb-pins/model.ts
383
- var import_node_path = require("path");
994
+ var import_node_path2 = require("path");
384
995
  var import_zod4 = require("zod");
385
- var PINS_FILE = (0, import_node_path.join)(".strauss", "kb-pins.json");
386
- var PINS_LOCAL_FILE = (0, import_node_path.join)(".strauss", "kb-pins.local.json");
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, import_node_path2.resolve)(workspaceDir);
1047
+ return layer === "user" ? userRoot() : (0, import_node_path3.resolve)(workspaceDir);
437
1048
  }
438
1049
  function layerFile(workspaceDir, layer) {
439
- return (0, import_node_path2.join)(
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, import_promises.readFile)(file, "utf8");
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, import_promises.mkdir)((0, import_node_path2.dirname)(file), { recursive: true });
473
- await (0, import_promises.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
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, import_node_path2.isAbsolute)(path) ? (0, import_node_path2.resolve)(path) : (0, import_node_path2.resolve)(rootDir, path.split("/").join(import_node_path2.sep));
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, import_node_path2.relative)((0, import_node_path2.resolve)(rootDir), (0, import_node_path2.resolve)(bundlePath2));
481
- return (rel === "" ? "." : rel).split(import_node_path2.sep).join("/");
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, import_node_path3.resolve)(bundlePath2);
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);
@@ -544,221 +1155,87 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
544
1155
  (entry2) => resolvePinPath(root, entry2.path) === absolute
545
1156
  );
546
1157
  const records = await store.list(absolute);
547
- const warning = records.length === 0 ? `no records found at ${absolute} \u2014 pinned anyway; bases are routinely pinned before they are populated` : void 0;
548
- const fields = {
549
- ...options.mode ? { mode: options.mode } : {},
550
- ...options.profiles?.length ? { profiles: options.profiles } : {},
551
- ...options.frozen !== void 0 ? { frozen: options.frozen } : {}
552
- };
553
- if (existing) {
554
- const updated = { ...existing, ...fields };
555
- if (Object.keys(fields).length) {
556
- await writePinsLayer(workspaceDir, layer, {
557
- ...manifest,
558
- pins: manifest.pins.map(
559
- (entry2) => entry2 === existing ? updated : entry2
560
- )
561
- });
562
- }
563
- return {
564
- path: existing.path,
565
- layer,
566
- pinnedAt: existing.pinnedAt ?? at,
567
- alreadyPinned: true,
568
- ...updated.mode ? { mode: updated.mode } : {},
569
- ...updated.profiles ? { profiles: updated.profiles } : {},
570
- ...updated.frozen !== void 0 ? { frozen: updated.frozen } : {},
571
- ...warning ? { warning } : {}
572
- };
573
- }
574
- const entry = {
575
- path: storablePath(root, bundlePath2),
576
- pinnedAt: at,
577
- ...fields
578
- };
579
- await writePinsLayer(workspaceDir, layer, {
580
- ...manifest,
581
- pins: [...manifest.pins, entry]
582
- });
583
- return {
584
- path: entry.path,
585
- layer,
586
- pinnedAt: at,
587
- alreadyPinned: false,
588
- ...fields,
589
- ...warning ? { warning } : {}
590
- };
591
- }
592
-
593
- // src/kb-pins/unpin.ts
594
- var import_node_path4 = require("path");
595
- async function unpinBase(workspaceDir, bundlePath2) {
596
- const layers = [];
597
- for (const layer of PIN_LAYERS) {
598
- const root = layerRoot(workspaceDir, layer);
599
- let manifest;
600
- try {
601
- manifest = await readPinsLayer(workspaceDir, layer);
602
- } catch {
603
- continue;
604
- }
605
- const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
606
- const kept = manifest.pins.filter(
607
- (entry) => resolvePinPath(root, entry.path) !== absolute
608
- );
609
- if (kept.length !== manifest.pins.length) {
610
- await writePinsLayer(workspaceDir, layer, { ...manifest, pins: kept });
611
- layers.push(layer);
612
- }
613
- }
614
- return {
615
- path: storablePath((0, import_node_path4.resolve)(workspaceDir), bundlePath2),
616
- removed: layers.length > 0,
617
- layers
618
- };
619
- }
620
-
621
- // src/commands/model.ts
622
- var import_zod5 = require("zod");
623
-
624
- // src/errors.ts
625
- var BaseError = class extends Error {
626
- code;
627
- errorType;
628
- fault;
629
- retriable;
630
- reportToUser;
631
- details;
632
- constructor(props) {
633
- super(props.message);
634
- this.name = props.name ?? this.constructor.name;
635
- this.code = props.code ?? 500;
636
- this.errorType = props.errorType;
637
- this.fault = props.fault;
638
- this.retriable = props.retriable ?? true;
639
- this.reportToUser = props.reportToUser ?? false;
640
- this.details = props.details;
641
- }
642
- };
643
-
644
- // src/kb-errors.ts
645
- var KbRecordAlreadyExistsError = class extends BaseError {
646
- constructor(conceptId2) {
647
- super({
648
- message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
649
- errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
650
- code: 409,
651
- fault: "User" /* User */,
652
- retriable: false,
653
- reportToUser: true,
654
- details: { conceptId: conceptId2, action: "refused" }
655
- });
656
- this.conceptId = conceptId2;
657
- }
658
- conceptId;
659
- };
660
- var KbRecordNotFoundError = class extends BaseError {
661
- constructor(conceptId2) {
662
- super({
663
- message: `kb: ${conceptId2} does not exist`,
664
- errorType: "KbRecordNotFound" /* KbRecordNotFound */,
665
- code: 404,
666
- fault: "User" /* User */,
667
- retriable: false,
668
- reportToUser: true,
669
- details: { conceptId: conceptId2 }
670
- });
671
- this.conceptId = conceptId2;
672
- }
673
- conceptId;
674
- };
675
- var KbWriteConflictError = class extends BaseError {
676
- constructor(conceptId2) {
677
- super({
678
- message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
679
- errorType: "KbWriteConflict" /* KbWriteConflict */,
680
- code: 409,
681
- fault: "System" /* System */,
682
- retriable: true,
683
- reportToUser: true,
684
- details: { conceptId: conceptId2 }
685
- });
686
- this.conceptId = conceptId2;
687
- }
688
- conceptId;
689
- };
690
- var KbSelfVerificationError = class extends BaseError {
691
- constructor(conceptId2, actor, generatedBy) {
692
- super({
693
- message: `kb: ${conceptId2} was generated by ${generatedBy}, and a record's generator cannot verify it \u2014 only a human or a different actor can`,
694
- errorType: "KbSelfVerification" /* KbSelfVerification */,
695
- code: 400,
696
- fault: "User" /* User */,
697
- retriable: false,
698
- reportToUser: true,
699
- details: { conceptId: conceptId2, actor, generatedBy, action: "refused" }
700
- });
701
- this.conceptId = conceptId2;
702
- this.actor = actor;
703
- this.generatedBy = generatedBy;
704
- }
705
- conceptId;
706
- actor;
707
- generatedBy;
708
- };
709
- var KbPackBudgetExceededError = class extends BaseError {
710
- constructor(recordCount, approxTokens2, budgetTokens, excluded) {
711
- super({
712
- message: `kb: a pack of ${recordCount} records is ~${approxTokens2} tokens against a budget of ${budgetTokens} \u2014 lower hops or maxNodes, or raise the budget`,
713
- errorType: "KbPackBudgetExceeded" /* KbPackBudgetExceeded */,
714
- code: 400,
715
- fault: "User" /* User */,
716
- retriable: false,
717
- reportToUser: true,
718
- details: { recordCount, approxTokens: approxTokens2, budgetTokens, excluded }
719
- });
720
- this.recordCount = recordCount;
721
- this.approxTokens = approxTokens2;
722
- this.budgetTokens = budgetTokens;
723
- this.excluded = excluded;
724
- }
725
- recordCount;
726
- approxTokens;
727
- budgetTokens;
728
- excluded;
729
- };
730
- var KbMissingFlagValueError = class extends BaseError {
731
- constructor(flag) {
732
- super({
733
- message: `kb: ${flag} needs a value \u2014 pass ${flag} <value> or ${flag}=<value>`,
734
- errorType: "KbMissingFlagValue" /* KbMissingFlagValue */,
735
- code: 400,
736
- fault: "User" /* User */,
737
- retriable: false,
738
- reportToUser: true,
739
- details: { flag }
740
- });
741
- this.flag = flag;
1158
+ const warning = records.length === 0 ? `no records found at ${absolute} \u2014 pinned anyway; bases are routinely pinned before they are populated` : void 0;
1159
+ const fields = {
1160
+ ...options.mode ? { mode: options.mode } : {},
1161
+ ...options.profiles?.length ? { profiles: options.profiles } : {},
1162
+ ...options.frozen !== void 0 ? { frozen: options.frozen } : {}
1163
+ };
1164
+ if (existing) {
1165
+ const updated = { ...existing, ...fields };
1166
+ if (Object.keys(fields).length) {
1167
+ await writePinsLayer(workspaceDir, layer, {
1168
+ ...manifest,
1169
+ pins: manifest.pins.map(
1170
+ (entry2) => entry2 === existing ? updated : entry2
1171
+ )
1172
+ });
1173
+ }
1174
+ return {
1175
+ path: existing.path,
1176
+ layer,
1177
+ pinnedAt: existing.pinnedAt ?? at,
1178
+ alreadyPinned: true,
1179
+ ...updated.mode ? { mode: updated.mode } : {},
1180
+ ...updated.profiles ? { profiles: updated.profiles } : {},
1181
+ ...updated.frozen !== void 0 ? { frozen: updated.frozen } : {},
1182
+ ...warning ? { warning } : {}
1183
+ };
742
1184
  }
743
- flag;
744
- };
745
- var KbInvalidConceptIdError = class extends BaseError {
746
- constructor(message, details) {
747
- super({
748
- message: `kb: ${message}`,
749
- errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
750
- code: 400,
751
- fault: "User" /* User */,
752
- retriable: false,
753
- reportToUser: true,
754
- details
755
- });
1185
+ const entry = {
1186
+ path: storablePath(root, bundlePath2),
1187
+ pinnedAt: at,
1188
+ ...fields
1189
+ };
1190
+ await writePinsLayer(workspaceDir, layer, {
1191
+ ...manifest,
1192
+ pins: [...manifest.pins, entry]
1193
+ });
1194
+ return {
1195
+ path: entry.path,
1196
+ layer,
1197
+ pinnedAt: at,
1198
+ alreadyPinned: false,
1199
+ ...fields,
1200
+ ...warning ? { warning } : {}
1201
+ };
1202
+ }
1203
+
1204
+ // src/kb-pins/unpin.ts
1205
+ var import_node_path5 = require("path");
1206
+ async function unpinBase(workspaceDir, bundlePath2) {
1207
+ const layers = [];
1208
+ for (const layer of PIN_LAYERS) {
1209
+ const root = layerRoot(workspaceDir, layer);
1210
+ let manifest;
1211
+ try {
1212
+ manifest = await readPinsLayer(workspaceDir, layer);
1213
+ } catch {
1214
+ continue;
1215
+ }
1216
+ const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
1217
+ const kept = manifest.pins.filter(
1218
+ (entry) => resolvePinPath(root, entry.path) !== absolute
1219
+ );
1220
+ if (kept.length !== manifest.pins.length) {
1221
+ await writePinsLayer(workspaceDir, layer, { ...manifest, pins: kept });
1222
+ layers.push(layer);
1223
+ }
756
1224
  }
757
- };
1225
+ return {
1226
+ path: storablePath((0, import_node_path5.resolve)(workspaceDir), bundlePath2),
1227
+ removed: layers.length > 0,
1228
+ layers
1229
+ };
1230
+ }
758
1231
 
759
1232
  // src/commands/model.ts
1233
+ var import_zod5 = require("zod");
760
1234
  var bundlePath = import_zod5.z.string().min(1).describe("Absolute path to the knowledge base directory.");
761
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
+ );
762
1239
  function define(command) {
763
1240
  return command;
764
1241
  }
@@ -778,13 +1255,175 @@ function argvFlag(argv, name) {
778
1255
  return value;
779
1256
  }
780
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
+
781
1419
  // src/commands/answer.ts
1420
+ var import_zod7 = require("zod");
782
1421
  var answerCommand = define({
783
1422
  name: "answer",
784
1423
  tool: "kb_answer",
785
1424
  usage: "answer <concept-id> <answer...>",
786
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.",
787
- input: import_zod6.z.object({ bundlePath, conceptId, answer: import_zod6.z.string().min(1) }),
1426
+ input: import_zod7.z.object({ bundlePath, conceptId, answer: import_zod7.z.string().min(1) }),
788
1427
  fromArgv: (argv, path) => ({
789
1428
  bundlePath: path,
790
1429
  conceptId: argv[1],
@@ -798,7 +1437,7 @@ var answerCommand = define({
798
1437
  });
799
1438
 
800
1439
  // src/commands/catalog.ts
801
- var import_zod7 = require("zod");
1440
+ var import_zod8 = require("zod");
802
1441
 
803
1442
  // src/adjudicate.ts
804
1443
  var STANDING = {
@@ -810,7 +1449,7 @@ var STANDING = {
810
1449
  rejected: "rejected",
811
1450
  superseded: "superseded"
812
1451
  };
813
- function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
1452
+ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift) {
814
1453
  const byId = new Map(bundle.map((record) => [record.conceptId, record]));
815
1454
  return hits.map((record) => {
816
1455
  const status = record.frontmatter.strauss_status;
@@ -840,6 +1479,20 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
840
1479
  if (!record.frontmatter.verified?.length) {
841
1480
  warnings.push({ kind: "unverified" });
842
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
+ }
843
1496
  return { record, standing: STANDING[status], heads, warnings };
844
1497
  });
845
1498
  }
@@ -944,9 +1597,9 @@ var catalogCommand = define({
944
1597
  tool: "kb_catalog",
945
1598
  usage: "catalog [type]",
946
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.",
947
- input: import_zod7.z.object({
1600
+ input: import_zod8.z.object({
948
1601
  bundlePath,
949
- type: import_zod7.z.enum(KB_RECORD_TYPES).optional()
1602
+ type: import_zod8.z.enum(KB_RECORD_TYPES).optional()
950
1603
  }),
951
1604
  fromArgv: (argv, path) => ({
952
1605
  bundlePath: path,
@@ -1001,10 +1654,10 @@ function count(value, noun) {
1001
1654
  }
1002
1655
 
1003
1656
  // src/commands/context.ts
1004
- var import_zod8 = require("zod");
1657
+ var import_zod9 = require("zod");
1005
1658
 
1006
1659
  // src/kb-context.ts
1007
- var import_promises2 = require("fs/promises");
1660
+ var import_promises3 = require("fs/promises");
1008
1661
 
1009
1662
  // src/kb-index.ts
1010
1663
  var INDEX_FILE = "INDEX.md";
@@ -1231,13 +1884,13 @@ function toHookJson(block, event) {
1231
1884
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
1232
1885
  var CONTEXT_END = "<!-- strauss-kb:end -->";
1233
1886
  async function syncInstructions(file, block) {
1234
- const existing = await (0, import_promises2.readFile)(file, "utf8").catch(() => null);
1887
+ const existing = await (0, import_promises3.readFile)(file, "utf8").catch(() => null);
1235
1888
  const region = block ? `${CONTEXT_BEGIN}
1236
1889
  ${block.trim()}
1237
1890
  ${CONTEXT_END}` : null;
1238
1891
  if (existing === null) {
1239
1892
  if (!region) return { file, action: "unchanged" };
1240
- await (0, import_promises2.writeFile)(file, `${region}
1893
+ await (0, import_promises3.writeFile)(file, `${region}
1241
1894
  `, "utf8");
1242
1895
  return { file, action: "created" };
1243
1896
  }
@@ -1248,11 +1901,11 @@ ${CONTEXT_END}` : null;
1248
1901
  const after = existing.slice(end + CONTEXT_END.length);
1249
1902
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
1250
1903
  if (next === existing) return { file, action: "unchanged" };
1251
- await (0, import_promises2.writeFile)(file, next, "utf8");
1904
+ await (0, import_promises3.writeFile)(file, next, "utf8");
1252
1905
  return { file, action: region ? "replaced" : "removed" };
1253
1906
  }
1254
1907
  if (!region) return { file, action: "unchanged" };
1255
- await (0, import_promises2.writeFile)(
1908
+ await (0, import_promises3.writeFile)(
1256
1909
  file,
1257
1910
  `${existing.replace(/\n*$/, "\n\n")}${region}
1258
1911
  `,
@@ -1267,20 +1920,20 @@ var contextCommand = define({
1267
1920
  tool: "kb_context",
1268
1921
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
1269
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.",
1270
- input: import_zod8.z.object({
1271
- budgetTokens: import_zod8.z.number().int().positive().optional().describe(
1923
+ input: import_zod9.z.object({
1924
+ budgetTokens: import_zod9.z.number().int().positive().optional().describe(
1272
1925
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
1273
1926
  ),
1274
- fullUnderTokens: import_zod8.z.number().int().positive().optional().describe(
1927
+ fullUnderTokens: import_zod9.z.number().int().positive().optional().describe(
1275
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."
1276
1929
  ),
1277
- profile: import_zod8.z.string().optional().describe(
1930
+ profile: import_zod9.z.string().optional().describe(
1278
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."
1279
1932
  ),
1280
- format: import_zod8.z.enum(["markdown", "json"]).optional().describe(
1933
+ format: import_zod9.z.enum(["markdown", "json"]).optional().describe(
1281
1934
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
1282
1935
  ),
1283
- event: import_zod8.z.string().optional().describe(
1936
+ event: import_zod9.z.string().optional().describe(
1284
1937
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
1285
1938
  )
1286
1939
  }),
@@ -1316,7 +1969,7 @@ var contextCommand = define({
1316
1969
  });
1317
1970
 
1318
1971
  // src/commands/doctor.ts
1319
- var import_zod9 = require("zod");
1972
+ var import_zod10 = require("zod");
1320
1973
 
1321
1974
  // src/kb-edges.ts
1322
1975
  var KB_EDGE_KINDS = [
@@ -1440,7 +2093,8 @@ var CHECK_HEADLINES = {
1440
2093
  aging: "still open or still proposed long after it was written",
1441
2094
  orphaned: "no other record links to it",
1442
2095
  "broken-supersession": "the supersession pointers do not resolve",
1443
- "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"
1444
2098
  };
1445
2099
  var DAY_MS = 864e5;
1446
2100
  function doctor(bundle, options = {}) {
@@ -1450,7 +2104,7 @@ function doctor(bundle, options = {}) {
1450
2104
  agingDays: options.agingDays ?? DEFAULT_AGING_DAYS
1451
2105
  };
1452
2106
  const now = options.now ?? /* @__PURE__ */ new Date();
1453
- const adjudicated = adjudicate(bundle, bundle, now);
2107
+ const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
1454
2108
  const standings = new Map(
1455
2109
  adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
1456
2110
  );
@@ -1464,7 +2118,8 @@ function doctor(bundle, options = {}) {
1464
2118
  group("aging", aging(inForce, now, thresholds.agingDays)),
1465
2119
  group("orphaned", orphaned(bundle)),
1466
2120
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
1467
- group("superseded-but-cited", supersededButCited(bundle, standings))
2121
+ group("superseded-but-cited", supersededButCited(bundle, standings)),
2122
+ group("drifted", drifted(inForce))
1468
2123
  ];
1469
2124
  const counts = Object.fromEntries(
1470
2125
  groups.map((entry) => [entry.check, entry.count])
@@ -1650,6 +2305,29 @@ function supersededButCited(bundle, standings) {
1650
2305
  }
1651
2306
  return findings;
1652
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
+ }
1653
2331
  function replaces(later, earlier) {
1654
2332
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
1655
2333
  }
@@ -1673,14 +2351,15 @@ function ageInDays(record, now) {
1673
2351
  }
1674
2352
 
1675
2353
  // src/commands/doctor.ts
1676
- var days = (what, fallback) => import_zod9.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
2354
+ var days = (what, fallback) => import_zod10.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
1677
2355
  var doctorCommand = define({
1678
2356
  name: "doctor",
1679
2357
  tool: "kb_doctor",
1680
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
1681
- description: "A health sweep over a whole base: what the calendar has already retired, what nobody ever confirmed, what has been open or proposed long enough that the status is now the answer, and what the graph has dropped on the floor. Read-only \u2014 it never writes, never supersedes, and never re-dates anything; every finding names a record for a person to repair. Seven checks, grouped and counted: expired (past `stale_after`), expiring (inside the window), unverified (an empty `verified[]` on a record old enough to matter), aging (still `open` or `proposed`), orphaned (no other record links to it), broken supersession (a chain that does not resolve), and superseded-but-cited (a live record whose body links to a record that no longer holds). Every group is reported even when empty, because a check that found nothing and a check that never ran look identical in a report that only lists findings.\n\nThis is the question no reader thinks to ask, which is why it needs a command: decay is invisible from inside a single record \u2014 a stale one reads exactly like a live one, and a question nobody answered reads exactly like one nobody asked. Reach for it when picking up a base someone else kept, before trusting a base you have not touched in months, or on a schedule; kb_validate is the narrower neighbour, checking only whether pointers between records agree.",
1682
- input: import_zod9.z.object({
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({
1683
2361
  bundlePath,
2362
+ repoRoot: REPO_ROOT,
1684
2363
  expiringDays: days(
1685
2364
  "How far ahead `expiring` looks, in days.",
1686
2365
  DEFAULT_EXPIRING_DAYS
@@ -1693,7 +2372,7 @@ var doctorCommand = define({
1693
2372
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
1694
2373
  DEFAULT_AGING_DAYS
1695
2374
  ),
1696
- strict: import_zod9.z.boolean().optional().describe(
2375
+ strict: import_zod10.z.boolean().optional().describe(
1697
2376
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
1698
2377
  )
1699
2378
  }),
@@ -1705,29 +2384,36 @@ var doctorCommand = define({
1705
2384
  const expiring2 = argvFlag(argv, "--expiring-days");
1706
2385
  const unverified2 = argvFlag(argv, "--unverified-days");
1707
2386
  const agingDays = argvFlag(argv, "--aging-days");
2387
+ const repoRoot = argvFlag(argv, "--repo-root");
1708
2388
  return {
1709
2389
  bundlePath: path,
2390
+ ...repoRoot !== void 0 ? { repoRoot } : {},
1710
2391
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
1711
2392
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
1712
2393
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
1713
2394
  ...argv.includes("--strict") ? { strict: true } : {}
1714
2395
  };
1715
2396
  },
1716
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays }) => {
2397
+ run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
1717
2398
  const checkedAt = now();
1718
- const report = doctor(await store.list(path), {
2399
+ const records = await store.list(path);
2400
+ const anchorDrift = await store.detectDrift(records, repoRoot);
2401
+ const report = doctor(records, {
1719
2402
  ...expiringDays !== void 0 ? { expiringDays } : {},
1720
2403
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
1721
2404
  ...agingDays !== void 0 ? { agingDays } : {},
2405
+ ...anchorDrift !== void 0 ? { anchorDrift } : {},
1722
2406
  now: new Date(checkedAt)
1723
2407
  });
1724
2408
  return { bundlePath: path, checkedAt, ...report };
1725
2409
  },
1726
2410
  render: (result) => render2(result),
1727
- // Only expiry, and only under --strict. The other six checks report debt a
2411
+ // Only expiry, and only under --strict. The other seven checks report debt a
1728
2412
  // reader decides about; an expired record is the base asserting something it
1729
2413
  // already said it would stop standing behind, which is the one finding a
1730
- // 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.
1731
2417
  failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
1732
2418
  });
1733
2419
  function render2(result) {
@@ -1762,13 +2448,13 @@ function render2(result) {
1762
2448
  }
1763
2449
 
1764
2450
  // src/commands/list.ts
1765
- var import_zod10 = require("zod");
2451
+ var import_zod11 = require("zod");
1766
2452
  var listCommand = define({
1767
2453
  name: "list",
1768
2454
  tool: "kb_list",
1769
2455
  usage: "list [type]",
1770
2456
  description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
1771
- input: import_zod10.z.object({ bundlePath, type: import_zod10.z.enum(KB_RECORD_TYPES).optional() }),
2457
+ input: import_zod11.z.object({ bundlePath, type: import_zod11.z.enum(KB_RECORD_TYPES).optional() }),
1772
2458
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
1773
2459
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
1774
2460
  conceptId: record.conceptId,
@@ -1780,36 +2466,40 @@ var listCommand = define({
1780
2466
  });
1781
2467
 
1782
2468
  // src/commands/load.ts
1783
- var import_zod11 = require("zod");
2469
+ var import_zod12 = require("zod");
1784
2470
  var loadCommand = define({
1785
2471
  name: "load",
1786
2472
  tool: "kb_load",
1787
- usage: "load [type] [--budget N] [--all]",
2473
+ usage: "load [type] [--budget N | --all] [--repo-root PATH]",
1788
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.",
1789
- input: import_zod11.z.object({
2475
+ input: import_zod12.z.object({
1790
2476
  bundlePath,
1791
- type: import_zod11.z.enum(KB_RECORD_TYPES).optional(),
1792
- budgetTokens: import_zod11.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
1793
- all: import_zod11.z.boolean().optional().describe(
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(
1794
2480
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
1795
- )
2481
+ ),
2482
+ repoRoot: REPO_ROOT
1796
2483
  }).refine((value) => !(value.all && value.budgetTokens !== void 0), {
1797
2484
  message: "all is mutually exclusive with budgetTokens: pass a ceiling or none, not both."
1798
2485
  }),
1799
2486
  fromArgv: (argv, path) => {
1800
2487
  const budget = argvFlag(argv, "--budget");
2488
+ const repoRoot = argvFlag(argv, "--repo-root");
1801
2489
  return {
1802
2490
  bundlePath: path,
1803
2491
  ...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {},
1804
2492
  ...budget ? { budgetTokens: Number(budget) } : {},
1805
- ...argv.includes("--all") ? { all: true } : {}
2493
+ ...argv.includes("--all") ? { all: true } : {},
2494
+ ...repoRoot !== void 0 ? { repoRoot } : {}
1806
2495
  };
1807
2496
  },
1808
- run: async ({ store }, { bundlePath: path, type, budgetTokens, all }) => {
2497
+ run: async ({ store }, { bundlePath: path, type, budgetTokens, all, repoRoot }) => {
1809
2498
  const result = await store.load(path, {
1810
2499
  ...type ? { type } : {},
1811
2500
  ...budgetTokens ? { budgetTokens } : {},
1812
- ...all ? { all } : {}
2501
+ ...all ? { all } : {},
2502
+ ...repoRoot !== void 0 ? { repoRoot } : {}
1813
2503
  });
1814
2504
  if (!result.loaded) return result;
1815
2505
  return {
@@ -1828,25 +2518,25 @@ var loadCommand = define({
1828
2518
  });
1829
2519
 
1830
2520
  // src/commands/log.ts
1831
- var import_zod12 = require("zod");
2521
+ var import_zod13 = require("zod");
1832
2522
  var logCommand = define({
1833
2523
  name: "log",
1834
2524
  tool: "kb_log",
1835
2525
  usage: "log",
1836
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.",
1837
- input: import_zod12.z.object({ bundlePath }),
2527
+ input: import_zod13.z.object({ bundlePath }),
1838
2528
  fromArgv: (_argv, path) => ({ bundlePath: path }),
1839
2529
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
1840
2530
  });
1841
2531
 
1842
2532
  // src/commands/no-decision.ts
1843
- var import_zod13 = require("zod");
2533
+ var import_zod14 = require("zod");
1844
2534
  var noDecisionCommand = define({
1845
2535
  name: "no-decision",
1846
2536
  tool: "kb_no_decision",
1847
2537
  usage: "no-decision <reason...>",
1848
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.',
1849
- input: import_zod13.z.object({ bundlePath, reason: import_zod13.z.string().min(1) }),
2539
+ input: import_zod14.z.object({ bundlePath, reason: import_zod14.z.string().min(1) }),
1850
2540
  fromArgv: (argv, path) => ({
1851
2541
  bundlePath: path,
1852
2542
  reason: argv.slice(1).join(" ").trim()
@@ -1863,20 +2553,20 @@ var noDecisionCommand = define({
1863
2553
  });
1864
2554
 
1865
2555
  // src/commands/pack.ts
1866
- var import_zod14 = require("zod");
2556
+ var import_zod15 = require("zod");
1867
2557
  var packCommand = define({
1868
2558
  name: "pack",
1869
2559
  tool: "kb_pack",
1870
2560
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
1871
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.",
1872
- input: import_zod14.z.object({
2562
+ input: import_zod15.z.object({
1873
2563
  bundlePath,
1874
2564
  conceptId,
1875
- hops: import_zod14.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
1876
- maxNodes: import_zod14.z.number().int().positive().optional().describe(
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(
1877
2567
  "How many records the pack may hold, root included. Defaults to 20."
1878
2568
  ),
1879
- budgetTokens: import_zod14.z.number().int().positive().optional().describe(
2569
+ budgetTokens: import_zod15.z.number().int().positive().optional().describe(
1880
2570
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
1881
2571
  )
1882
2572
  }),
@@ -1963,22 +2653,22 @@ function warningLabel(warning) {
1963
2653
  }
1964
2654
 
1965
2655
  // src/commands/pin.ts
1966
- var import_zod15 = require("zod");
2656
+ var import_zod16 = require("zod");
1967
2657
  var pinCommand = define({
1968
2658
  name: "pin",
1969
2659
  tool: "kb_pin",
1970
2660
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
1971
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.",
1972
- input: import_zod15.z.object({
2662
+ input: import_zod16.z.object({
1973
2663
  bundlePath,
1974
- mode: import_zod15.z.enum(["full", "index"]).optional().describe(
2664
+ mode: import_zod16.z.enum(["full", "index"]).optional().describe(
1975
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."
1976
2666
  ),
1977
- profiles: import_zod15.z.array(import_zod15.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
1978
- layer: import_zod15.z.enum(["project", "local", "user"]).optional().describe(
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(
1979
2669
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
1980
2670
  ),
1981
- frozen: import_zod15.z.boolean().optional().describe(
2671
+ frozen: import_zod16.z.boolean().optional().describe(
1982
2672
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
1983
2673
  )
1984
2674
  }),
@@ -2007,38 +2697,48 @@ var pinCommand = define({
2007
2697
  });
2008
2698
 
2009
2699
  // src/commands/pins.ts
2010
- var import_zod16 = require("zod");
2700
+ var import_zod17 = require("zod");
2011
2701
  var pinsCommand = define({
2012
2702
  name: "pins",
2013
2703
  tool: "kb_pins",
2014
2704
  usage: "pins",
2015
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.",
2016
- input: import_zod16.z.object({}),
2706
+ input: import_zod17.z.object({}),
2017
2707
  fromArgv: () => ({}),
2018
2708
  run: ({ store }) => listPins(store, process.cwd())
2019
2709
  });
2020
2710
 
2021
2711
  // src/commands/query.ts
2022
- var import_zod17 = require("zod");
2712
+ var import_zod18 = require("zod");
2023
2713
  var queryCommand = define({
2024
2714
  name: "query",
2025
2715
  tool: "kb_query",
2026
- usage: "query <text...>",
2716
+ usage: "query <text...> [--repo-root PATH]",
2027
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.",
2028
- input: import_zod17.z.object({
2718
+ input: import_zod18.z.object({
2029
2719
  bundlePath,
2030
- text: import_zod17.z.string().optional(),
2031
- type: import_zod17.z.enum(KB_RECORD_TYPES).optional(),
2032
- includeNonCurrent: import_zod17.z.boolean().optional()
2033
- }),
2034
- fromArgv: (argv, path) => ({
2035
- bundlePath: path,
2036
- text: argv.slice(1).join(" ").trim(),
2037
- 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
2038
2724
  }),
2039
- run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
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 ?? "", {
2040
2739
  ...type ? { type } : {},
2041
- includeNonCurrent: includeNonCurrent === true
2740
+ includeNonCurrent: includeNonCurrent === true,
2741
+ ...repoRoot !== void 0 ? { repoRoot } : {}
2042
2742
  })).map((hit) => ({
2043
2743
  conceptId: hit.record.conceptId,
2044
2744
  title: hit.record.frontmatter.title ?? null,
@@ -2051,27 +2751,27 @@ var queryCommand = define({
2051
2751
  });
2052
2752
 
2053
2753
  // src/commands/read-index.ts
2054
- var import_zod18 = require("zod");
2754
+ var import_zod19 = require("zod");
2055
2755
  var readIndexCommand = define({
2056
2756
  name: "index",
2057
2757
  tool: "kb_index",
2058
2758
  usage: "index",
2059
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.",
2060
- input: import_zod18.z.object({ bundlePath }),
2760
+ input: import_zod19.z.object({ bundlePath }),
2061
2761
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2062
2762
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
2063
2763
  });
2064
2764
 
2065
2765
  // src/commands/schema.ts
2066
- var import_zod21 = require("zod");
2766
+ var import_zod22 = require("zod");
2067
2767
 
2068
2768
  // src/json-schema.ts
2069
- var import_zod20 = require("zod");
2769
+ var import_zod21 = require("zod");
2070
2770
 
2071
2771
  // src/kb-log.ts
2072
- var import_zod19 = require("zod");
2772
+ var import_zod20 = require("zod");
2073
2773
  var LOG_FILE = "log.jsonl";
2074
- var kbLogEntrySchema = import_zod19.z.object({
2774
+ var kbLogEntrySchema = import_zod20.z.object({
2075
2775
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
2076
2776
  // below), and a value that isn't actually chronological — a Unix
2077
2777
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -2080,12 +2780,12 @@ var kbLogEntrySchema = import_zod19.z.object({
2080
2780
  // and rejects everything else, including a non-`Z` offset — so a
2081
2781
  // malformed `at` is reported the same way a malformed line already is,
2082
2782
  // rather than silently sorting into the wrong place.
2083
- at: import_zod19.z.iso.datetime(),
2084
- by: import_zod19.z.string().min(1),
2085
- operation: import_zod19.z.string().min(1),
2086
- conceptId: import_zod19.z.string().min(1),
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),
2087
2787
  /** Second concept id, where the operation relates two — supersession. */
2088
- target: import_zod19.z.string().min(1).optional()
2788
+ target: import_zod20.z.string().min(1).optional()
2089
2789
  }).strict();
2090
2790
  function renderLogEntry(entry) {
2091
2791
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -2123,11 +2823,11 @@ function parseLog(raw) {
2123
2823
  // src/json-schema.ts
2124
2824
  function kbJsonSchemas() {
2125
2825
  return {
2126
- recordFrontmatter: import_zod20.z.toJSONSchema(kbRecordFrontmatterSchema, {
2826
+ recordFrontmatter: import_zod21.z.toJSONSchema(kbRecordFrontmatterSchema, {
2127
2827
  io: "input"
2128
2828
  }),
2129
- composeInput: import_zod20.z.toJSONSchema(composeInputSchema, { io: "input" }),
2130
- logEntry: import_zod20.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
2829
+ composeInput: import_zod21.z.toJSONSchema(composeInputSchema, { io: "input" }),
2830
+ logEntry: import_zod21.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
2131
2831
  };
2132
2832
  }
2133
2833
 
@@ -2137,22 +2837,22 @@ var schemaCommand = define({
2137
2837
  tool: "kb_schema",
2138
2838
  usage: "schema",
2139
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.",
2140
- input: import_zod21.z.object({}),
2840
+ input: import_zod22.z.object({}),
2141
2841
  fromArgv: () => ({}),
2142
2842
  run: () => Promise.resolve(kbJsonSchemas())
2143
2843
  });
2144
2844
 
2145
2845
  // src/commands/status.ts
2146
- var import_zod22 = require("zod");
2846
+ var import_zod23 = require("zod");
2147
2847
  var statusCommand = define({
2148
2848
  name: "status",
2149
2849
  tool: "kb_status",
2150
2850
  usage: "status <concept-id> <status>",
2151
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.",
2152
- input: import_zod22.z.object({
2852
+ input: import_zod23.z.object({
2153
2853
  bundlePath,
2154
2854
  conceptId,
2155
- status: import_zod22.z.enum(KB_RECORD_STATUSES)
2855
+ status: import_zod23.z.enum(KB_RECORD_STATUSES)
2156
2856
  }),
2157
2857
  fromArgv: (argv, path) => ({
2158
2858
  bundlePath: path,
@@ -2167,13 +2867,13 @@ var statusCommand = define({
2167
2867
  });
2168
2868
 
2169
2869
  // src/commands/supersede.ts
2170
- var import_zod23 = require("zod");
2870
+ var import_zod24 = require("zod");
2171
2871
  var supersedeCommand = define({
2172
2872
  name: "supersede",
2173
2873
  tool: "kb_supersede",
2174
2874
  usage: "supersede <concept-id> <replacement-id>",
2175
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.",
2176
- input: import_zod23.z.object({ bundlePath, conceptId, replacementId: conceptId }),
2876
+ input: import_zod24.z.object({ bundlePath, conceptId, replacementId: conceptId }),
2177
2877
  fromArgv: (argv, path) => ({
2178
2878
  bundlePath: path,
2179
2879
  conceptId: argv[1],
@@ -2187,16 +2887,16 @@ var supersedeCommand = define({
2187
2887
  });
2188
2888
 
2189
2889
  // src/commands/sync-instructions.ts
2190
- var import_zod24 = require("zod");
2890
+ var import_zod25 = require("zod");
2191
2891
  var syncInstructionsCommand = define({
2192
2892
  name: "sync-instructions",
2193
2893
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
2194
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.",
2195
- input: import_zod24.z.object({
2196
- file: import_zod24.z.string().min(1).describe("The instruction file to edit in place."),
2197
- budgetTokens: import_zod24.z.number().int().positive().optional(),
2198
- fullUnderTokens: import_zod24.z.number().int().positive().optional(),
2199
- profile: import_zod24.z.string().optional()
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()
2200
2900
  }),
2201
2901
  fromArgv: (argv) => {
2202
2902
  const budget = argvFlag(argv, "--budget");
@@ -2222,7 +2922,7 @@ var syncInstructionsCommand = define({
2222
2922
  });
2223
2923
 
2224
2924
  // src/commands/trace.ts
2225
- var import_zod25 = require("zod");
2925
+ var import_zod26 = require("zod");
2226
2926
 
2227
2927
  // src/trace.ts
2228
2928
  var TRACE_EDGES = ["supersession", "anchor", "source"];
@@ -2268,11 +2968,11 @@ var traceCommand = define({
2268
2968
  tool: "kb_trace",
2269
2969
  usage: "trace <concept-id> [edges...]",
2270
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.',
2271
- input: import_zod25.z.object({
2971
+ input: import_zod26.z.object({
2272
2972
  bundlePath,
2273
2973
  conceptId,
2274
- edges: import_zod25.z.array(import_zod25.z.enum(TRACE_EDGES)).optional(),
2275
- depth: import_zod25.z.number().int().positive().optional()
2974
+ edges: import_zod26.z.array(import_zod26.z.enum(TRACE_EDGES)).optional(),
2975
+ depth: import_zod26.z.number().int().positive().optional()
2276
2976
  }),
2277
2977
  fromArgv: (argv, path) => ({
2278
2978
  bundlePath: path,
@@ -2294,53 +2994,53 @@ var traceCommand = define({
2294
2994
  });
2295
2995
 
2296
2996
  // src/commands/types.ts
2297
- var import_zod26 = require("zod");
2997
+ var import_zod27 = require("zod");
2298
2998
  var typesCommand = define({
2299
2999
  name: "types",
2300
3000
  tool: "kb_types",
2301
3001
  usage: "types",
2302
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.",
2303
- input: import_zod26.z.object({}),
3003
+ input: import_zod27.z.object({}),
2304
3004
  fromArgv: () => ({}),
2305
3005
  run: () => Promise.resolve(RECORD_TYPES)
2306
3006
  });
2307
3007
 
2308
3008
  // src/commands/unpin.ts
2309
- var import_zod27 = require("zod");
3009
+ var import_zod28 = require("zod");
2310
3010
  var unpinCommand = define({
2311
3011
  name: "unpin",
2312
3012
  tool: "kb_unpin",
2313
3013
  usage: "unpin [bundle-path]",
2314
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.",
2315
- input: import_zod27.z.object({ bundlePath }),
3015
+ input: import_zod28.z.object({ bundlePath }),
2316
3016
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
2317
3017
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
2318
3018
  });
2319
3019
 
2320
3020
  // src/commands/validate.ts
2321
- var import_zod28 = require("zod");
3021
+ var import_zod29 = require("zod");
2322
3022
  var validateCommand = define({
2323
3023
  name: "validate",
2324
3024
  tool: "kb_validate",
2325
3025
  usage: "validate",
2326
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.",
2327
- input: import_zod28.z.object({ bundlePath }),
3027
+ input: import_zod29.z.object({ bundlePath }),
2328
3028
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2329
3029
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
2330
3030
  failsWhen: (result) => Array.isArray(result) && result.length > 0
2331
3031
  });
2332
3032
 
2333
3033
  // src/commands/verify.ts
2334
- var import_zod29 = require("zod");
3034
+ var import_zod30 = require("zod");
2335
3035
  var verifyCommand = define({
2336
3036
  name: "verify",
2337
3037
  tool: "kb_verify",
2338
3038
  usage: "verify <concept-id> --note <text>",
2339
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.",
2340
- input: import_zod29.z.object({
3040
+ input: import_zod30.z.object({
2341
3041
  bundlePath,
2342
3042
  conceptId,
2343
- note: import_zod29.z.string().refine((s) => s.trim().length > 0, {
3043
+ note: import_zod30.z.string().refine((s) => s.trim().length > 0, {
2344
3044
  message: "note must say what the check found"
2345
3045
  })
2346
3046
  }),
@@ -2360,7 +3060,7 @@ var verifyCommand = define({
2360
3060
  });
2361
3061
 
2362
3062
  // src/commands/write.ts
2363
- var import_zod30 = require("zod");
3063
+ var import_zod31 = require("zod");
2364
3064
  var writeCommand = define({
2365
3065
  name: "write",
2366
3066
  tool: "kb_write",
@@ -2374,9 +3074,9 @@ var writeCommand = define({
2374
3074
  "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
2375
3075
  "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
2376
3076
  ].join("\n"),
2377
- input: import_zod30.z.object({
3077
+ input: import_zod31.z.object({
2378
3078
  bundlePath,
2379
- type: import_zod30.z.enum(KB_RECORD_TYPES),
3079
+ type: import_zod31.z.enum(KB_RECORD_TYPES),
2380
3080
  input: composeInputSchema
2381
3081
  }),
2382
3082
  fromArgv: async (argv, path, stdin) => ({
@@ -2400,7 +3100,7 @@ var writeCommand = define({
2400
3100
  });
2401
3101
 
2402
3102
  // src/commands/write-decision.ts
2403
- var import_zod31 = require("zod");
3103
+ var import_zod32 = require("zod");
2404
3104
  var writeDecisionCommand = define({
2405
3105
  name: "write-decision",
2406
3106
  tool: "kb_write_decision",
@@ -2413,7 +3113,7 @@ var writeDecisionCommand = define({
2413
3113
  "- `alternative` is what you turned down and why, not a list of everything considered.",
2414
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`."
2415
3115
  ].join("\n"),
2416
- input: import_zod31.z.object({ bundlePath, input: decisionInputSchema }),
3116
+ input: import_zod32.z.object({ bundlePath, input: decisionInputSchema }),
2417
3117
  fromArgv: async (_argv, path, stdin) => ({
2418
3118
  bundlePath: path,
2419
3119
  input: JSON.parse(await stdin())
@@ -2442,6 +3142,7 @@ var KB_COMMANDS = [
2442
3142
  supersedeCommand,
2443
3143
  answerCommand,
2444
3144
  verifyCommand,
3145
+ anchorResolveCommand,
2445
3146
  loadCommand,
2446
3147
  catalogCommand,
2447
3148
  packCommand,
@@ -2465,9 +3166,9 @@ var KB_COMMANDS_BY_NAME = new Map(
2465
3166
  );
2466
3167
 
2467
3168
  // src/kb-store.ts
2468
- var import_node_crypto = require("crypto");
2469
- var import_promises4 = require("fs/promises");
2470
- var import_node_path6 = require("path");
3169
+ var import_node_crypto2 = require("crypto");
3170
+ var import_promises5 = require("fs/promises");
3171
+ var import_node_path7 = require("path");
2471
3172
 
2472
3173
  // src/markdown.ts
2473
3174
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -2495,8 +3196,8 @@ function parseMarkdownWithFrontmatter(text, schema) {
2495
3196
  }
2496
3197
 
2497
3198
  // src/search-index.ts
2498
- var import_promises3 = require("fs/promises");
2499
- var import_node_path5 = require("path");
3199
+ var import_promises4 = require("fs/promises");
3200
+ var import_node_path6 = require("path");
2500
3201
  var SEARCH_INDEX_FILE = ".index.sqlite";
2501
3202
  var COLLECTION = "kb";
2502
3203
  async function searchBase(bundlePath2, query, options = {}) {
@@ -2505,7 +3206,7 @@ async function searchBase(bundlePath2, query, options = {}) {
2505
3206
  let store = null;
2506
3207
  try {
2507
3208
  store = await qmd.createStore({
2508
- dbPath: (0, import_node_path5.join)(bundlePath2, SEARCH_INDEX_FILE),
3209
+ dbPath: (0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE),
2509
3210
  config: {
2510
3211
  collections: {
2511
3212
  [COLLECTION]: {
@@ -2540,16 +3241,19 @@ async function searchBase(bundlePath2, query, options = {}) {
2540
3241
  }
2541
3242
  }
2542
3243
  async function isStale(bundlePath2) {
2543
- const indexAt = await (0, import_promises3.stat)((0, import_node_path5.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3244
+ const indexAt = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
2544
3245
  if (!indexAt) return true;
2545
3246
  const { readdir: readdir2 } = await import("fs/promises");
2546
- const names = await readdir2(bundlePath2).catch(() => []);
2547
- for (const name of names) {
2548
- if (!name.endsWith(".md") || name === INDEX_FILE) continue;
2549
- const at = await (0, import_promises3.stat)((0, import_node_path5.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
2550
- if (at > indexAt) return true;
2551
- }
2552
- return false;
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;
2553
3257
  }
2554
3258
  function resolveHits(hits, records) {
2555
3259
  const byName = /* @__PURE__ */ new Map();
@@ -2689,7 +3393,7 @@ function appendUnionMergeLine(contents) {
2689
3393
  }
2690
3394
 
2691
3395
  // src/kb-store.ts
2692
- var KB_DIR = (0, import_node_path6.join)(".strauss", "kb");
3396
+ var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
2693
3397
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
2694
3398
  var DEFAULT_LOAD_BUDGET = 25e3;
2695
3399
  var KbStore = class {
@@ -2720,7 +3424,7 @@ var KbStore = class {
2720
3424
  const conceptId2 = `${input.type}.${input.slug}`;
2721
3425
  const root = this.root(bundlePath2);
2722
3426
  const target = this.recordPath(bundlePath2, conceptId2);
2723
- await (0, import_promises4.mkdir)(root, { recursive: true });
3427
+ await (0, import_promises5.mkdir)(root, { recursive: true });
2724
3428
  await this.publish(
2725
3429
  target,
2726
3430
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -2759,7 +3463,7 @@ var KbStore = class {
2759
3463
  const target = this.recordPath(bundlePath2, conceptId2);
2760
3464
  let raw;
2761
3465
  try {
2762
- raw = await (0, import_promises4.readFile)(target, "utf8");
3466
+ raw = await (0, import_promises5.readFile)(target, "utf8");
2763
3467
  } catch {
2764
3468
  return null;
2765
3469
  }
@@ -2776,15 +3480,15 @@ var KbStore = class {
2776
3480
  const root = this.root(bundlePath2);
2777
3481
  let names;
2778
3482
  try {
2779
- names = await (0, import_promises4.readdir)(root);
3483
+ names = await (0, import_promises5.readdir)(root);
2780
3484
  } catch {
2781
3485
  return [];
2782
3486
  }
2783
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}.`));
2784
- const records = await Promise.all(
2785
- wanted.map(
2786
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises4.readFile)((0, import_node_path6.join)(root, name), "utf8"))
2787
- )
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"))
2788
3492
  );
2789
3493
  return records.filter((record) => record !== null);
2790
3494
  }
@@ -2806,6 +3510,21 @@ var KbStore = class {
2806
3510
  { operation: `status:${status}`, by: actor }
2807
3511
  );
2808
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
+ }
2809
3528
  /**
2810
3529
  * Appends one `verified[]` event: who checked the record, when, and what the
2811
3530
  * check found. Append-only — prior events are history, and are spread into
@@ -2904,9 +3623,12 @@ ${answer}
2904
3623
  const bundle = await this.list(bundlePath2);
2905
3624
  const needle = text.trim();
2906
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;
2907
3627
  const adjudicated = adjudicate(
2908
- options.type ? hits.filter((r) => r.frontmatter.type === options.type) : hits,
2909
- bundle
3628
+ narrowed,
3629
+ bundle,
3630
+ /* @__PURE__ */ new Date(),
3631
+ await this.detectDrift(narrowed, options.repoRoot)
2910
3632
  );
2911
3633
  if (options.includeNonCurrent) return adjudicated;
2912
3634
  const present = new Set(adjudicated.map((hit) => hit.record.conceptId));
@@ -2925,6 +3647,50 @@ ${answer}
2925
3647
  const lowered = needle.toLowerCase();
2926
3648
  return bundle.filter((record) => matches(record, lowered));
2927
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
+ }
2928
3694
  /**
2929
3695
  * The whole base, adjudicated, when it is small enough to hand over.
2930
3696
  *
@@ -2959,7 +3725,12 @@ ${answer}
2959
3725
  const budgetTokens = options.budgetTokens ?? DEFAULT_LOAD_BUDGET;
2960
3726
  const bundle = await this.list(bundlePath2);
2961
3727
  const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
2962
- const adjudicated = adjudicate(wanted, bundle);
3728
+ const adjudicated = adjudicate(
3729
+ wanted,
3730
+ bundle,
3731
+ /* @__PURE__ */ new Date(),
3732
+ await this.detectDrift(wanted, options.repoRoot)
3733
+ );
2963
3734
  const records = adjudicated.filter((hit) => hit.standing !== "superseded");
2964
3735
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
2965
3736
  const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
@@ -3007,11 +3778,11 @@ ${answer}
3007
3778
  async readIndex(bundlePath2) {
3008
3779
  const root = this.root(bundlePath2);
3009
3780
  const expected = renderIndex(await this.list(bundlePath2));
3010
- const stored = await (0, import_promises4.readFile)((0, import_node_path6.join)(root, INDEX_FILE), "utf8").catch(
3781
+ const stored = await (0, import_promises5.readFile)((0, import_node_path7.join)(root, INDEX_FILE), "utf8").catch(
3011
3782
  () => null
3012
3783
  );
3013
3784
  if (indexIsStale(stored, expected)) {
3014
- await this.publish((0, import_node_path6.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
3785
+ await this.publish((0, import_node_path7.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
3015
3786
  this.logger.info?.({
3016
3787
  operation: "kb.index.repair",
3017
3788
  bundlePath: root,
@@ -3028,8 +3799,8 @@ ${answer}
3028
3799
  * knows which agent touched what. So a bad line is surfaced and left alone.
3029
3800
  */
3030
3801
  async readLog(bundlePath2) {
3031
- const raw = await (0, import_promises4.readFile)(
3032
- (0, import_node_path6.join)(this.root(bundlePath2), LOG_FILE),
3802
+ const raw = await (0, import_promises5.readFile)(
3803
+ (0, import_node_path7.join)(this.root(bundlePath2), LOG_FILE),
3033
3804
  "utf8"
3034
3805
  ).catch(() => "");
3035
3806
  const result = parseLog(raw);
@@ -3080,14 +3851,14 @@ ${answer}
3080
3851
  }
3081
3852
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
3082
3853
  const target = this.recordPath(bundlePath2, conceptId2);
3083
- const before = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
3854
+ const before = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
3084
3855
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
3085
3856
  const parsed = this.parse(conceptId2, before);
3086
3857
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
3087
3858
  const frontmatter = change(parsed.frontmatter);
3088
3859
  const body = changeBody(parsed.body);
3089
3860
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
3090
- const witness = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
3861
+ const witness = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
3091
3862
  if (witness === null || digest(witness) !== digest(before)) {
3092
3863
  throw new KbWriteConflictError(conceptId2);
3093
3864
  }
@@ -3113,20 +3884,20 @@ ${answer}
3113
3884
  */
3114
3885
  async publish(target, contents, overwrite, conceptId2) {
3115
3886
  const staging = `${target}.${process.pid}.tmp`;
3116
- await (0, import_promises4.writeFile)(staging, contents, "utf8");
3887
+ await (0, import_promises5.writeFile)(staging, contents, "utf8");
3117
3888
  try {
3118
3889
  if (overwrite) {
3119
- await (0, import_promises4.rename)(staging, target);
3890
+ await (0, import_promises5.rename)(staging, target);
3120
3891
  return;
3121
3892
  }
3122
- await (0, import_promises4.link)(staging, target);
3893
+ await (0, import_promises5.link)(staging, target);
3123
3894
  } catch (error) {
3124
3895
  if (error.code === "EEXIST") {
3125
3896
  throw new KbRecordAlreadyExistsError(conceptId2);
3126
3897
  }
3127
3898
  throw error;
3128
3899
  } finally {
3129
- await (0, import_promises4.unlink)(staging).catch(() => void 0);
3900
+ await (0, import_promises5.unlink)(staging).catch(() => void 0);
3130
3901
  }
3131
3902
  }
3132
3903
  /**
@@ -3170,20 +3941,30 @@ ${answer}
3170
3941
  * file must not fail the mutation it guards.
3171
3942
  */
3172
3943
  async ensureGitattributes(root) {
3173
- const target = (0, import_node_path6.join)(root, GITATTRIBUTES_FILE);
3944
+ const target = (0, import_node_path7.join)(root, GITATTRIBUTES_FILE);
3174
3945
  try {
3175
3946
  let existing;
3176
3947
  try {
3177
- existing = await (0, import_promises4.readFile)(target, "utf8");
3948
+ existing = await (0, import_promises5.readFile)(target, "utf8");
3178
3949
  } catch (error) {
3179
3950
  if (error.code !== "ENOENT") throw error;
3180
3951
  existing = null;
3181
3952
  }
3182
3953
  if (existing === null) {
3183
- await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
3184
- encoding: "utf8",
3185
- flag: "wx"
3186
- });
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
+ }
3187
3968
  this.logger.info?.({
3188
3969
  operation: "kb.gitattributes.ensure",
3189
3970
  bundlePath: root,
@@ -3192,7 +3973,7 @@ ${answer}
3192
3973
  return;
3193
3974
  }
3194
3975
  if (!hasMergeDeclaration(existing)) {
3195
- await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
3976
+ await (0, import_promises5.appendFile)(target, appendUnionMergeLine(existing), "utf8");
3196
3977
  this.logger.info?.({
3197
3978
  operation: "kb.gitattributes.ensure",
3198
3979
  bundlePath: root,
@@ -3211,7 +3992,7 @@ ${answer}
3211
3992
  async record(root, entry) {
3212
3993
  await this.ensureGitattributes(root);
3213
3994
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
3214
- await (0, import_promises4.appendFile)((0, import_node_path6.join)(root, LOG_FILE), line, "utf8").catch((error) => {
3995
+ await (0, import_promises5.appendFile)((0, import_node_path7.join)(root, LOG_FILE), line, "utf8").catch((error) => {
3215
3996
  this.logger.warn?.({
3216
3997
  operation: "kb.log.append",
3217
3998
  outcome: "failed",
@@ -3237,18 +4018,18 @@ ${answer}
3237
4018
  };
3238
4019
  }
3239
4020
  root(bundlePath2) {
3240
- return (0, import_node_path6.resolve)(bundlePath2);
4021
+ return (0, import_node_path7.resolve)(bundlePath2);
3241
4022
  }
3242
4023
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
3243
4024
  // bundle root; anything carrying a separator would escape it.
3244
4025
  recordPath(bundlePath2, conceptId2) {
3245
- if (conceptId2.includes(import_node_path6.sep) || conceptId2.includes("/")) {
4026
+ if (conceptId2.includes(import_node_path7.sep) || conceptId2.includes("/")) {
3246
4027
  throw new KbInvalidConceptIdError(
3247
4028
  "concept id must not contain a path separator",
3248
4029
  { conceptId: conceptId2 }
3249
4030
  );
3250
4031
  }
3251
- return (0, import_node_path6.join)(this.root(bundlePath2), `${conceptId2}.md`);
4032
+ return (0, import_node_path7.join)(this.root(bundlePath2), `${conceptId2}.md`);
3252
4033
  }
3253
4034
  };
3254
4035
  function estimateTokens(record) {
@@ -3287,11 +4068,11 @@ function normalizeActor(id) {
3287
4068
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
3288
4069
  }
3289
4070
  function digest(contents) {
3290
- return (0, import_node_crypto.createHash)("sha256").update(contents).digest("hex");
4071
+ return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
3291
4072
  }
3292
4073
 
3293
4074
  // src/version.ts
3294
- var VERSION = true ? "0.1.10" : "0.0.0-dev";
4075
+ var VERSION = true ? "0.1.11" : "0.0.0-dev";
3295
4076
 
3296
4077
  // src/mcp.ts
3297
4078
  function createKbMcpServer() {