@saasontools/strauss-kb 0.1.10 → 0.1.12

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/cli-main.cjs CHANGED
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli.ts
27
- var import_node_path7 = require("path");
27
+ var import_node_path8 = require("path");
28
28
 
29
29
  // src/decision-record.ts
30
30
  var import_zod3 = require("zod");
@@ -52,7 +52,31 @@ var kbVerifiedEventSchema = kbActorStampSchema.extend({
52
52
  });
53
53
  var kbAnchorSchema = import_zod.z.object({
54
54
  file: import_zod.z.string().min(1),
55
- symbol: import_zod.z.string().min(1).optional()
55
+ symbol: import_zod.z.string().min(1).optional(),
56
+ /**
57
+ * Which repository the file lives in — a remote URL
58
+ * (`https://github.com/org/name`) or a short name. Absent means the base's
59
+ * own repository, which is what nearly every anchor means.
60
+ *
61
+ * Unvalidated beyond not-blank: one repository has many spellings.
62
+ * Matched after normalisation; see ARCHITECTURE.
63
+ */
64
+ repo: import_zod.z.string().trim().min(1).optional(),
65
+ /**
66
+ * The git rev the evidence was taken at. Prefer a commit SHA: a branch
67
+ * name is a moving pointer, so an anchor pinned to one says the evidence
68
+ * came from wherever that branch happens to be now, which is not a
69
+ * baseline. Recorded and preserved in v1; ref-pinned reads land with
70
+ * SAA-709.
71
+ */
72
+ ref: import_zod.z.string().trim().min(1).optional(),
73
+ hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
74
+ message: "hash must be sha256:<64 hex chars>"
75
+ }).optional(),
76
+ /** ISO 8601 timestamp of the last successful resolution. */
77
+ resolved_at: import_zod.z.string().min(1).optional(),
78
+ /** Line count of the text the hash was taken over. */
79
+ lines: import_zod.z.number().int().positive().optional()
56
80
  }).strict();
57
81
  var KB_RECORD_TYPES = [
58
82
  "fact",
@@ -319,9 +343,596 @@ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
319
343
  );
320
344
  }
321
345
 
322
- // src/commands/answer.ts
346
+ // src/commands/anchor-resolve.ts
323
347
  var import_zod6 = require("zod");
324
348
 
349
+ // src/anchor-resolver.ts
350
+ var import_node_child_process = require("child_process");
351
+ var import_node_crypto = require("crypto");
352
+ var import_promises = require("fs/promises");
353
+ var import_node_path = require("path");
354
+ var import_node_util = require("util");
355
+
356
+ // src/concurrency.ts
357
+ var DEFAULT_IO_CONCURRENCY = 16;
358
+ async function mapLimit(items, limit, fn) {
359
+ if (!Number.isInteger(limit) || limit < 1) {
360
+ throw new RangeError(
361
+ `mapLimit: "limit" must be a positive integer, got ${limit}`
362
+ );
363
+ }
364
+ const out = new Array(items.length);
365
+ let next = 0;
366
+ let failed = false;
367
+ const runners = Array.from(
368
+ { length: Math.min(limit, items.length) },
369
+ async () => {
370
+ while (!failed && next < items.length) {
371
+ const at = next++;
372
+ try {
373
+ out[at] = await fn(items[at], at);
374
+ } catch (error) {
375
+ failed = true;
376
+ throw error;
377
+ }
378
+ }
379
+ }
380
+ );
381
+ await Promise.all(runners);
382
+ return out;
383
+ }
384
+
385
+ // src/anchor-resolver.ts
386
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
387
+ var MAX_ANCHOR_FILE_BYTES = 1048576;
388
+ var PARENT_SCOPE_LINES = 50;
389
+ var CLEAN_STATE = { blockComment: false, template: false };
390
+ function stripLine(line, state) {
391
+ let out = "";
392
+ let index = 0;
393
+ let { blockComment, template } = state;
394
+ while (index < line.length) {
395
+ const char = line[index];
396
+ const next = line[index + 1];
397
+ if (blockComment) {
398
+ if (char === "*" && next === "/") {
399
+ blockComment = false;
400
+ index += 2;
401
+ continue;
402
+ }
403
+ index += 1;
404
+ continue;
405
+ }
406
+ if (template) {
407
+ if (char === "\\") {
408
+ index += 2;
409
+ continue;
410
+ }
411
+ if (char === "`") template = false;
412
+ index += 1;
413
+ continue;
414
+ }
415
+ if (char === "/" && next === "*") {
416
+ blockComment = true;
417
+ index += 2;
418
+ continue;
419
+ }
420
+ if (char === "/" && next === "/") break;
421
+ if (char === "`") {
422
+ template = true;
423
+ index += 1;
424
+ continue;
425
+ }
426
+ if (char === "'" || char === '"') {
427
+ const quote = char;
428
+ index += 1;
429
+ while (index < line.length) {
430
+ if (line[index] === "\\") {
431
+ index += 2;
432
+ continue;
433
+ }
434
+ if (line[index] === quote) {
435
+ index += 1;
436
+ break;
437
+ }
438
+ index += 1;
439
+ }
440
+ continue;
441
+ }
442
+ out += char;
443
+ index += 1;
444
+ }
445
+ return { code: out, state: { blockComment, template } };
446
+ }
447
+ function span(lines, from, to) {
448
+ return {
449
+ text: lines.slice(from, to + 1).join("\n"),
450
+ startLine: from + 1,
451
+ endLine: to + 1
452
+ };
453
+ }
454
+ function captureBraceBlock(lines, matchLine) {
455
+ let depth = 0;
456
+ let opened = false;
457
+ let state = CLEAN_STATE;
458
+ for (let index = matchLine; index < lines.length; index++) {
459
+ const stripped = stripLine(lines[index] ?? "", state);
460
+ state = stripped.state;
461
+ for (const char of stripped.code) {
462
+ if (char === "{") {
463
+ depth += 1;
464
+ opened = true;
465
+ } else if (char === "}") {
466
+ depth = Math.max(0, depth - 1);
467
+ } else if (char === ";" && !opened) {
468
+ return span(lines, matchLine, index);
469
+ }
470
+ }
471
+ if (opened && depth === 0) return span(lines, matchLine, index);
472
+ }
473
+ return null;
474
+ }
475
+ var PYTHON_HEADER = /^\s*(?:async\s+)?(?:def|class)\s+[A-Za-z_]\w*\s*[(:]/;
476
+ function captureIndentedBlock(lines, matchLine) {
477
+ const header = lines[matchLine] ?? "";
478
+ const indent = header.length - header.trimStart().length;
479
+ let headerEnd = -1;
480
+ for (let index = matchLine; index < lines.length && index <= matchLine + 20; index++) {
481
+ const code = stripLine(lines[index] ?? "", CLEAN_STATE).code.trimEnd();
482
+ if (code.endsWith(":")) {
483
+ headerEnd = index;
484
+ break;
485
+ }
486
+ if (code.includes(":")) return span(lines, matchLine, index);
487
+ }
488
+ if (headerEnd === -1) return null;
489
+ let end = headerEnd;
490
+ for (let index = headerEnd + 1; index < lines.length; index++) {
491
+ const line = lines[index] ?? "";
492
+ if (line.trim() === "") continue;
493
+ const lineIndent = line.length - line.trimStart().length;
494
+ if (lineIndent <= indent) break;
495
+ end = index;
496
+ }
497
+ return end === headerEnd ? null : span(lines, matchLine, end);
498
+ }
499
+ var TIERS = [
500
+ (name) => new RegExp(
501
+ `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
502
+ ),
503
+ (name) => new RegExp(`\\b${name}\\s*[:=]`),
504
+ (name) => new RegExp(`\\b${name}\\s*\\(`),
505
+ (name) => new RegExp(`\\b${name}\\b`)
506
+ ];
507
+ var regexResolver = {
508
+ name: "regex",
509
+ resolve(source, symbol) {
510
+ const segments = symbol.split(".");
511
+ const name = segments[segments.length - 1];
512
+ if (!name) return null;
513
+ const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
514
+ const escaped = escapeRegExp(name);
515
+ const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
516
+ const lines = source.split("\n");
517
+ for (const tier of TIERS) {
518
+ const pattern = tier(escaped);
519
+ let candidates = lines.map((line, index) => ({ line, index })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
520
+ if (!candidates.length) continue;
521
+ if (parentPattern && candidates.length > 1) {
522
+ const distances = candidates.map(
523
+ (index) => distanceToParent(lines, index, parentPattern)
524
+ );
525
+ const nearest = Math.min(...distances);
526
+ if (Number.isFinite(nearest)) {
527
+ candidates = candidates.filter((_, at) => distances[at] === nearest);
528
+ }
529
+ }
530
+ if (candidates.length !== 1) return null;
531
+ const matchLine = candidates[0];
532
+ return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
533
+ }
534
+ return null;
535
+ }
536
+ };
537
+ function escapeRegExp(value) {
538
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
539
+ }
540
+ function distanceToParent(lines, index, parent) {
541
+ const floor = Math.max(0, index - PARENT_SCOPE_LINES);
542
+ for (let at = index; at >= floor; at--) {
543
+ if (parent.test(lines[at] ?? "")) return index - at;
544
+ }
545
+ return Number.POSITIVE_INFINITY;
546
+ }
547
+ function hashAnchorText(text) {
548
+ return `sha256:${(0, import_node_crypto.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
549
+ }
550
+ function resolveAnchor(source, anchor, resolver = regexResolver) {
551
+ const normalized = source.replace(/\r\n/g, "\n");
552
+ if (!anchor.symbol) {
553
+ const lines = normalized.split("\n");
554
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
555
+ return {
556
+ text: normalized,
557
+ startLine: 1,
558
+ endLine: Math.max(1, lines.length)
559
+ };
560
+ }
561
+ return resolver.resolve(normalized, anchor.symbol);
562
+ }
563
+ function anchorFilePath(repoRoot, file) {
564
+ const path = (0, import_node_path.resolve)(repoRoot, file.replace(/^\.\//, ""));
565
+ const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(repoRoot), path);
566
+ if (rel === "" || rel === ".." || rel.startsWith(`..${import_node_path.sep}`) || (0, import_node_path.isAbsolute)(rel)) {
567
+ return null;
568
+ }
569
+ return path;
570
+ }
571
+ function contains(root, path) {
572
+ const rel = (0, import_node_path.relative)(root, path);
573
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${import_node_path.sep}`) && !(0, import_node_path.isAbsolute)(rel);
574
+ }
575
+ function normalizeRepoUrl(value) {
576
+ let url = value.trim().replace(/^git\+/, "");
577
+ const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
578
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
579
+ url = url.replace(/^ssh:\/\/(?:[^@/]+@)?/, "https://");
580
+ url = trimTrailingSlashes(url);
581
+ if (url.endsWith(".git")) url = url.slice(0, -4);
582
+ return trimTrailingSlashes(url).toLowerCase();
583
+ }
584
+ function trimTrailingSlashes(value) {
585
+ let end = value.length;
586
+ while (end > 0 && value[end - 1] === "/") end -= 1;
587
+ return value.slice(0, end);
588
+ }
589
+ function repoPath(normalized) {
590
+ const withoutScheme = normalized.replace(/^[a-z0-9+.-]+:\/\//, "");
591
+ const segments = withoutScheme.split("/").filter(Boolean);
592
+ return segments.length > 1 ? segments.slice(1).join("/") : "";
593
+ }
594
+ function repoIdentifies(declared, originUrl) {
595
+ if (!originUrl) return false;
596
+ const origin = normalizeRepoUrl(originUrl);
597
+ const want = normalizeRepoUrl(declared);
598
+ if (!want || !origin) return false;
599
+ if (want === origin) return true;
600
+ const path = repoPath(origin);
601
+ if (!path) return false;
602
+ return want === path || want === (path.split("/").pop() ?? "");
603
+ }
604
+ async function repoOriginUrl(repoRoot) {
605
+ try {
606
+ const { stdout } = await execFileAsync(
607
+ "git",
608
+ ["-C", repoRoot, "config", "--get", "remote.origin.url"],
609
+ { timeout: 5e3 }
610
+ );
611
+ return stdout.trim() || null;
612
+ } catch {
613
+ return null;
614
+ }
615
+ }
616
+ var LazyOrigin = class {
617
+ constructor(repoRoot) {
618
+ this.repoRoot = repoRoot;
619
+ }
620
+ repoRoot;
621
+ url = null;
622
+ asked = false;
623
+ /** Asks git once, so later `isForeign` calls need no await. */
624
+ async prime() {
625
+ if (this.asked) return;
626
+ this.url = await repoOriginUrl(this.repoRoot);
627
+ this.asked = true;
628
+ }
629
+ /** Only meaningful after `prime`; an unprimed origin identifies nothing. */
630
+ isForeign(anchor) {
631
+ if (!anchor.repo) return false;
632
+ return !repoIdentifies(anchor.repo, this.url);
633
+ }
634
+ async foreign(anchor) {
635
+ if (!anchor.repo) return false;
636
+ await this.prime();
637
+ return this.isForeign(anchor);
638
+ }
639
+ };
640
+ function errorCode(error) {
641
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
642
+ }
643
+ function anchorFileReader(repoRoot) {
644
+ let rootOnce;
645
+ const realRoot = () => {
646
+ rootOnce ??= (0, import_promises.realpath)((0, import_node_path.resolve)(repoRoot)).catch((error) => {
647
+ rootOnce = void 0;
648
+ throw error;
649
+ });
650
+ return rootOnce;
651
+ };
652
+ return (file) => readAnchorFileWithRoot(repoRoot, file, realRoot);
653
+ }
654
+ async function readAnchorFileWithRoot(repoRoot, file, realRoot) {
655
+ const lexical = anchorFilePath(repoRoot, file);
656
+ if (lexical === null) return { ok: false, reason: "outside-repo" };
657
+ let root;
658
+ let path;
659
+ try {
660
+ root = await realRoot();
661
+ path = await (0, import_promises.realpath)(lexical);
662
+ } catch (error) {
663
+ const code = errorCode(error);
664
+ if (code === "ENOENT" || code === "ENOTDIR") {
665
+ return { ok: false, reason: "file-missing" };
666
+ }
667
+ return { ok: false, reason: "file-unreadable" };
668
+ }
669
+ if (!contains(root, path)) return { ok: false, reason: "outside-repo" };
670
+ try {
671
+ const stats = await (0, import_promises.stat)(path);
672
+ if (!stats.isFile()) return { ok: false, reason: "file-unreadable" };
673
+ if (stats.size > MAX_ANCHOR_FILE_BYTES) {
674
+ return { ok: false, reason: "file-too-large" };
675
+ }
676
+ return { ok: true, source: await (0, import_promises.readFile)(path, "utf8") };
677
+ } catch (error) {
678
+ const code = errorCode(error);
679
+ if (code === "ENOENT" || code === "ENOTDIR") {
680
+ return { ok: false, reason: "file-missing" };
681
+ }
682
+ return { ok: false, reason: "file-unreadable" };
683
+ }
684
+ }
685
+ function looksLikeWrongRepoRoot(drift) {
686
+ let checked = 0;
687
+ for (const entries of drift.values()) {
688
+ for (const entry of entries) {
689
+ if (entry.reason === "foreign-repo") continue;
690
+ checked += 1;
691
+ if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
692
+ return false;
693
+ }
694
+ }
695
+ }
696
+ return checked > 0;
697
+ }
698
+ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY) {
699
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
700
+ throw new RangeError(
701
+ `readAnchorFiles: option "concurrency" must be a positive integer, got ${concurrency}`
702
+ );
703
+ }
704
+ const wanted = [...new Set(files)];
705
+ const results = await mapLimit(wanted, concurrency, async (file) => {
706
+ try {
707
+ return await read(file);
708
+ } catch {
709
+ return { ok: false, reason: "file-unreadable" };
710
+ }
711
+ });
712
+ return new Map(wanted.map((file, at) => [file, results[at]]));
713
+ }
714
+ async function detectAnchorDrift(records, options = {}) {
715
+ const repoRoot = options.repoRoot ?? process.cwd();
716
+ const resolver = options.resolver ?? regexResolver;
717
+ const origin = new LazyOrigin(repoRoot);
718
+ const planned = /* @__PURE__ */ new Map();
719
+ let declaresRepo = false;
720
+ for (const record of records) {
721
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
722
+ (anchor) => anchor.hash
723
+ );
724
+ if (!anchors.length) continue;
725
+ if (anchors.some((anchor) => anchor.repo)) declaresRepo = true;
726
+ planned.set(
727
+ record.conceptId,
728
+ anchors.map((anchor) => ({ anchor, foreign: false }))
729
+ );
730
+ }
731
+ if (declaresRepo) {
732
+ await origin.prime();
733
+ for (const entries of planned.values()) {
734
+ for (const entry of entries)
735
+ entry.foreign = origin.isForeign(entry.anchor);
736
+ }
737
+ }
738
+ const files = [];
739
+ for (const entries of planned.values()) {
740
+ for (const entry of entries) {
741
+ if (!entry.foreign) files.push(entry.anchor.file);
742
+ }
743
+ }
744
+ const reads = await readAnchorFiles(
745
+ files,
746
+ options.reader ?? anchorFileReader(repoRoot),
747
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
748
+ );
749
+ const drift = /* @__PURE__ */ new Map();
750
+ for (const record of records) {
751
+ const entries = [];
752
+ for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
753
+ const base = {
754
+ file: anchor.file,
755
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
756
+ storedHash: anchor.hash
757
+ };
758
+ if (foreign) {
759
+ entries.push({
760
+ ...base,
761
+ state: "unresolved",
762
+ diffSize: null,
763
+ reason: "foreign-repo"
764
+ });
765
+ continue;
766
+ }
767
+ const read = reads.get(anchor.file);
768
+ if (!read.ok) {
769
+ entries.push({
770
+ ...base,
771
+ state: "unresolved",
772
+ diffSize: null,
773
+ reason: read.reason
774
+ });
775
+ continue;
776
+ }
777
+ const resolved = resolveAnchor(read.source, anchor, resolver);
778
+ if (!resolved) {
779
+ entries.push({
780
+ ...base,
781
+ state: "unresolved",
782
+ diffSize: null,
783
+ reason: "symbol-not-found"
784
+ });
785
+ continue;
786
+ }
787
+ const currentHash = hashAnchorText(resolved.text);
788
+ const currentLines = resolved.endLine - resolved.startLine + 1;
789
+ entries.push({
790
+ ...base,
791
+ state: currentHash === anchor.hash ? "match" : "drifted",
792
+ currentHash,
793
+ diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines)
794
+ });
795
+ }
796
+ if (entries.length) drift.set(record.conceptId, entries);
797
+ }
798
+ return drift;
799
+ }
800
+
801
+ // src/errors.ts
802
+ var BaseError = class extends Error {
803
+ code;
804
+ errorType;
805
+ fault;
806
+ retriable;
807
+ reportToUser;
808
+ details;
809
+ constructor(props) {
810
+ super(props.message);
811
+ this.name = props.name ?? this.constructor.name;
812
+ this.code = props.code ?? 500;
813
+ this.errorType = props.errorType;
814
+ this.fault = props.fault;
815
+ this.retriable = props.retriable ?? true;
816
+ this.reportToUser = props.reportToUser ?? false;
817
+ this.details = props.details;
818
+ }
819
+ };
820
+
821
+ // src/kb-errors.ts
822
+ var KbRecordAlreadyExistsError = class extends BaseError {
823
+ constructor(conceptId2) {
824
+ super({
825
+ message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
826
+ errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
827
+ code: 409,
828
+ fault: "User" /* User */,
829
+ retriable: false,
830
+ reportToUser: true,
831
+ details: { conceptId: conceptId2, action: "refused" }
832
+ });
833
+ this.conceptId = conceptId2;
834
+ }
835
+ conceptId;
836
+ };
837
+ var KbRecordNotFoundError = class extends BaseError {
838
+ constructor(conceptId2) {
839
+ super({
840
+ message: `kb: ${conceptId2} does not exist`,
841
+ errorType: "KbRecordNotFound" /* KbRecordNotFound */,
842
+ code: 404,
843
+ fault: "User" /* User */,
844
+ retriable: false,
845
+ reportToUser: true,
846
+ details: { conceptId: conceptId2 }
847
+ });
848
+ this.conceptId = conceptId2;
849
+ }
850
+ conceptId;
851
+ };
852
+ var KbWriteConflictError = class extends BaseError {
853
+ constructor(conceptId2) {
854
+ super({
855
+ message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
856
+ errorType: "KbWriteConflict" /* KbWriteConflict */,
857
+ code: 409,
858
+ fault: "System" /* System */,
859
+ retriable: true,
860
+ reportToUser: true,
861
+ details: { conceptId: conceptId2 }
862
+ });
863
+ this.conceptId = conceptId2;
864
+ }
865
+ conceptId;
866
+ };
867
+ var KbSelfVerificationError = class extends BaseError {
868
+ constructor(conceptId2, actor, generatedBy) {
869
+ super({
870
+ message: `kb: ${conceptId2} was generated by ${generatedBy}, and a record's generator cannot verify it \u2014 only a human or a different actor can`,
871
+ errorType: "KbSelfVerification" /* KbSelfVerification */,
872
+ code: 400,
873
+ fault: "User" /* User */,
874
+ retriable: false,
875
+ reportToUser: true,
876
+ details: { conceptId: conceptId2, actor, generatedBy, action: "refused" }
877
+ });
878
+ this.conceptId = conceptId2;
879
+ this.actor = actor;
880
+ this.generatedBy = generatedBy;
881
+ }
882
+ conceptId;
883
+ actor;
884
+ generatedBy;
885
+ };
886
+ var KbPackBudgetExceededError = class extends BaseError {
887
+ constructor(recordCount, approxTokens2, budgetTokens, excluded) {
888
+ super({
889
+ message: `kb: a pack of ${recordCount} records is ~${approxTokens2} tokens against a budget of ${budgetTokens} \u2014 lower hops or maxNodes, or raise the budget`,
890
+ errorType: "KbPackBudgetExceeded" /* KbPackBudgetExceeded */,
891
+ code: 400,
892
+ fault: "User" /* User */,
893
+ retriable: false,
894
+ reportToUser: true,
895
+ details: { recordCount, approxTokens: approxTokens2, budgetTokens, excluded }
896
+ });
897
+ this.recordCount = recordCount;
898
+ this.approxTokens = approxTokens2;
899
+ this.budgetTokens = budgetTokens;
900
+ this.excluded = excluded;
901
+ }
902
+ recordCount;
903
+ approxTokens;
904
+ budgetTokens;
905
+ excluded;
906
+ };
907
+ var KbMissingFlagValueError = class extends BaseError {
908
+ constructor(flag) {
909
+ super({
910
+ message: `kb: ${flag} needs a value \u2014 pass ${flag} <value> or ${flag}=<value>`,
911
+ errorType: "KbMissingFlagValue" /* KbMissingFlagValue */,
912
+ code: 400,
913
+ fault: "User" /* User */,
914
+ retriable: false,
915
+ reportToUser: true,
916
+ details: { flag }
917
+ });
918
+ this.flag = flag;
919
+ }
920
+ flag;
921
+ };
922
+ var KbInvalidConceptIdError = class extends BaseError {
923
+ constructor(message, details) {
924
+ super({
925
+ message: `kb: ${message}`,
926
+ errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
927
+ code: 400,
928
+ fault: "User" /* User */,
929
+ retriable: false,
930
+ reportToUser: true,
931
+ details
932
+ });
933
+ }
934
+ };
935
+
325
936
  // src/kb-pins/budgets.ts
326
937
  function asBudgets(value) {
327
938
  if (value === null || typeof value !== "object") return {};
@@ -371,18 +982,18 @@ var KbBaseFrozenError = class extends Error {
371
982
  };
372
983
 
373
984
  // src/kb-pins/frozen.ts
374
- var import_node_path3 = require("path");
985
+ var import_node_path4 = require("path");
375
986
 
376
987
  // src/kb-pins/layers.ts
377
- var import_promises = require("fs/promises");
988
+ var import_promises2 = require("fs/promises");
378
989
  var import_node_os = require("os");
379
- var import_node_path2 = require("path");
990
+ var import_node_path3 = require("path");
380
991
 
381
992
  // src/kb-pins/model.ts
382
- var import_node_path = require("path");
993
+ var import_node_path2 = require("path");
383
994
  var import_zod4 = require("zod");
384
- var PINS_FILE = (0, import_node_path.join)(".strauss", "kb-pins.json");
385
- var PINS_LOCAL_FILE = (0, import_node_path.join)(".strauss", "kb-pins.local.json");
995
+ var PINS_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.json");
996
+ var PINS_LOCAL_FILE = (0, import_node_path2.join)(".strauss", "kb-pins.local.json");
386
997
  var PIN_LAYERS = ["project", "local", "user"];
387
998
  var pinSchema = import_zod4.z.object({
388
999
  /** Relative to the manifest's root, so the file is committable. */
@@ -432,10 +1043,10 @@ function userRoot() {
432
1043
  return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
433
1044
  }
434
1045
  function layerRoot(workspaceDir, layer) {
435
- return layer === "user" ? userRoot() : (0, import_node_path2.resolve)(workspaceDir);
1046
+ return layer === "user" ? userRoot() : (0, import_node_path3.resolve)(workspaceDir);
436
1047
  }
437
1048
  function layerFile(workspaceDir, layer) {
438
- return (0, import_node_path2.join)(
1049
+ return (0, import_node_path3.join)(
439
1050
  layerRoot(workspaceDir, layer),
440
1051
  layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
441
1052
  );
@@ -444,7 +1055,7 @@ async function readPinsLayer(workspaceDir, layer) {
444
1055
  const file = layerFile(workspaceDir, layer);
445
1056
  let raw;
446
1057
  try {
447
- raw = await (0, import_promises.readFile)(file, "utf8");
1058
+ raw = await (0, import_promises2.readFile)(file, "utf8");
448
1059
  } catch {
449
1060
  return { pins: [] };
450
1061
  }
@@ -468,16 +1079,16 @@ async function readPinsLayer(workspaceDir, layer) {
468
1079
  }
469
1080
  async function writePinsLayer(workspaceDir, layer, manifest) {
470
1081
  const file = layerFile(workspaceDir, layer);
471
- await (0, import_promises.mkdir)((0, import_node_path2.dirname)(file), { recursive: true });
472
- await (0, import_promises.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1082
+ await (0, import_promises2.mkdir)((0, import_node_path3.dirname)(file), { recursive: true });
1083
+ await (0, import_promises2.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
473
1084
  `, "utf8");
474
1085
  }
475
1086
  function resolvePinPath(rootDir, path) {
476
- 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));
1087
+ 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));
477
1088
  }
478
1089
  function storablePath(rootDir, bundlePath2) {
479
- const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(rootDir), (0, import_node_path2.resolve)(bundlePath2));
480
- return (rel === "" ? "." : rel).split(import_node_path2.sep).join("/");
1090
+ const rel = (0, import_node_path3.relative)((0, import_node_path3.resolve)(rootDir), (0, import_node_path3.resolve)(bundlePath2));
1091
+ return (rel === "" ? "." : rel).split(import_node_path3.sep).join("/");
481
1092
  }
482
1093
  async function readMergedPins(workspaceDir) {
483
1094
  const manifests = {};
@@ -505,7 +1116,7 @@ async function readMergedPins(workspaceDir) {
505
1116
  // src/kb-pins/frozen.ts
506
1117
  async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
507
1118
  const merged = await readMergedPins(workspaceDir);
508
- const absolute = (0, import_node_path3.resolve)(bundlePath2);
1119
+ const absolute = (0, import_node_path4.resolve)(bundlePath2);
509
1120
  const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
510
1121
  if (pin?.frozen === true) {
511
1122
  throw new KbBaseFrozenError(pin.path, pin.layer);
@@ -546,218 +1157,84 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
546
1157
  const warning = records.length === 0 ? `no records found at ${absolute} \u2014 pinned anyway; bases are routinely pinned before they are populated` : void 0;
547
1158
  const fields = {
548
1159
  ...options.mode ? { mode: options.mode } : {},
549
- ...options.profiles?.length ? { profiles: options.profiles } : {},
550
- ...options.frozen !== void 0 ? { frozen: options.frozen } : {}
551
- };
552
- if (existing) {
553
- const updated = { ...existing, ...fields };
554
- if (Object.keys(fields).length) {
555
- await writePinsLayer(workspaceDir, layer, {
556
- ...manifest,
557
- pins: manifest.pins.map(
558
- (entry2) => entry2 === existing ? updated : entry2
559
- )
560
- });
561
- }
562
- return {
563
- path: existing.path,
564
- layer,
565
- pinnedAt: existing.pinnedAt ?? at,
566
- alreadyPinned: true,
567
- ...updated.mode ? { mode: updated.mode } : {},
568
- ...updated.profiles ? { profiles: updated.profiles } : {},
569
- ...updated.frozen !== void 0 ? { frozen: updated.frozen } : {},
570
- ...warning ? { warning } : {}
571
- };
572
- }
573
- const entry = {
574
- path: storablePath(root, bundlePath2),
575
- pinnedAt: at,
576
- ...fields
577
- };
578
- await writePinsLayer(workspaceDir, layer, {
579
- ...manifest,
580
- pins: [...manifest.pins, entry]
581
- });
582
- return {
583
- path: entry.path,
584
- layer,
585
- pinnedAt: at,
586
- alreadyPinned: false,
587
- ...fields,
588
- ...warning ? { warning } : {}
589
- };
590
- }
591
-
592
- // src/kb-pins/unpin.ts
593
- var import_node_path4 = require("path");
594
- async function unpinBase(workspaceDir, bundlePath2) {
595
- const layers = [];
596
- for (const layer of PIN_LAYERS) {
597
- const root = layerRoot(workspaceDir, layer);
598
- let manifest;
599
- try {
600
- manifest = await readPinsLayer(workspaceDir, layer);
601
- } catch {
602
- continue;
603
- }
604
- const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
605
- const kept = manifest.pins.filter(
606
- (entry) => resolvePinPath(root, entry.path) !== absolute
607
- );
608
- if (kept.length !== manifest.pins.length) {
609
- await writePinsLayer(workspaceDir, layer, { ...manifest, pins: kept });
610
- layers.push(layer);
611
- }
612
- }
613
- return {
614
- path: storablePath((0, import_node_path4.resolve)(workspaceDir), bundlePath2),
615
- removed: layers.length > 0,
616
- layers
617
- };
618
- }
619
-
620
- // src/commands/model.ts
621
- var import_zod5 = require("zod");
622
-
623
- // src/errors.ts
624
- var BaseError = class extends Error {
625
- code;
626
- errorType;
627
- fault;
628
- retriable;
629
- reportToUser;
630
- details;
631
- constructor(props) {
632
- super(props.message);
633
- this.name = props.name ?? this.constructor.name;
634
- this.code = props.code ?? 500;
635
- this.errorType = props.errorType;
636
- this.fault = props.fault;
637
- this.retriable = props.retriable ?? true;
638
- this.reportToUser = props.reportToUser ?? false;
639
- this.details = props.details;
640
- }
641
- };
642
-
643
- // src/kb-errors.ts
644
- var KbRecordAlreadyExistsError = class extends BaseError {
645
- constructor(conceptId2) {
646
- super({
647
- message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
648
- errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
649
- code: 409,
650
- fault: "User" /* User */,
651
- retriable: false,
652
- reportToUser: true,
653
- details: { conceptId: conceptId2, action: "refused" }
654
- });
655
- this.conceptId = conceptId2;
656
- }
657
- conceptId;
658
- };
659
- var KbRecordNotFoundError = class extends BaseError {
660
- constructor(conceptId2) {
661
- super({
662
- message: `kb: ${conceptId2} does not exist`,
663
- errorType: "KbRecordNotFound" /* KbRecordNotFound */,
664
- code: 404,
665
- fault: "User" /* User */,
666
- retriable: false,
667
- reportToUser: true,
668
- details: { conceptId: conceptId2 }
669
- });
670
- this.conceptId = conceptId2;
671
- }
672
- conceptId;
673
- };
674
- var KbWriteConflictError = class extends BaseError {
675
- constructor(conceptId2) {
676
- super({
677
- message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
678
- errorType: "KbWriteConflict" /* KbWriteConflict */,
679
- code: 409,
680
- fault: "System" /* System */,
681
- retriable: true,
682
- reportToUser: true,
683
- details: { conceptId: conceptId2 }
684
- });
685
- this.conceptId = conceptId2;
686
- }
687
- conceptId;
688
- };
689
- var KbSelfVerificationError = class extends BaseError {
690
- constructor(conceptId2, actor, generatedBy) {
691
- super({
692
- message: `kb: ${conceptId2} was generated by ${generatedBy}, and a record's generator cannot verify it \u2014 only a human or a different actor can`,
693
- errorType: "KbSelfVerification" /* KbSelfVerification */,
694
- code: 400,
695
- fault: "User" /* User */,
696
- retriable: false,
697
- reportToUser: true,
698
- details: { conceptId: conceptId2, actor, generatedBy, action: "refused" }
699
- });
700
- this.conceptId = conceptId2;
701
- this.actor = actor;
702
- this.generatedBy = generatedBy;
703
- }
704
- conceptId;
705
- actor;
706
- generatedBy;
707
- };
708
- var KbPackBudgetExceededError = class extends BaseError {
709
- constructor(recordCount, approxTokens2, budgetTokens, excluded) {
710
- super({
711
- message: `kb: a pack of ${recordCount} records is ~${approxTokens2} tokens against a budget of ${budgetTokens} \u2014 lower hops or maxNodes, or raise the budget`,
712
- errorType: "KbPackBudgetExceeded" /* KbPackBudgetExceeded */,
713
- code: 400,
714
- fault: "User" /* User */,
715
- retriable: false,
716
- reportToUser: true,
717
- details: { recordCount, approxTokens: approxTokens2, budgetTokens, excluded }
718
- });
719
- this.recordCount = recordCount;
720
- this.approxTokens = approxTokens2;
721
- this.budgetTokens = budgetTokens;
722
- this.excluded = excluded;
723
- }
724
- recordCount;
725
- approxTokens;
726
- budgetTokens;
727
- excluded;
728
- };
729
- var KbMissingFlagValueError = class extends BaseError {
730
- constructor(flag) {
731
- super({
732
- message: `kb: ${flag} needs a value \u2014 pass ${flag} <value> or ${flag}=<value>`,
733
- errorType: "KbMissingFlagValue" /* KbMissingFlagValue */,
734
- code: 400,
735
- fault: "User" /* User */,
736
- retriable: false,
737
- reportToUser: true,
738
- details: { flag }
739
- });
740
- this.flag = flag;
1160
+ ...options.profiles?.length ? { profiles: options.profiles } : {},
1161
+ ...options.frozen !== void 0 ? { frozen: options.frozen } : {}
1162
+ };
1163
+ if (existing) {
1164
+ const updated = { ...existing, ...fields };
1165
+ if (Object.keys(fields).length) {
1166
+ await writePinsLayer(workspaceDir, layer, {
1167
+ ...manifest,
1168
+ pins: manifest.pins.map(
1169
+ (entry2) => entry2 === existing ? updated : entry2
1170
+ )
1171
+ });
1172
+ }
1173
+ return {
1174
+ path: existing.path,
1175
+ layer,
1176
+ pinnedAt: existing.pinnedAt ?? at,
1177
+ alreadyPinned: true,
1178
+ ...updated.mode ? { mode: updated.mode } : {},
1179
+ ...updated.profiles ? { profiles: updated.profiles } : {},
1180
+ ...updated.frozen !== void 0 ? { frozen: updated.frozen } : {},
1181
+ ...warning ? { warning } : {}
1182
+ };
741
1183
  }
742
- flag;
743
- };
744
- var KbInvalidConceptIdError = class extends BaseError {
745
- constructor(message, details) {
746
- super({
747
- message: `kb: ${message}`,
748
- errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
749
- code: 400,
750
- fault: "User" /* User */,
751
- retriable: false,
752
- reportToUser: true,
753
- details
754
- });
1184
+ const entry = {
1185
+ path: storablePath(root, bundlePath2),
1186
+ pinnedAt: at,
1187
+ ...fields
1188
+ };
1189
+ await writePinsLayer(workspaceDir, layer, {
1190
+ ...manifest,
1191
+ pins: [...manifest.pins, entry]
1192
+ });
1193
+ return {
1194
+ path: entry.path,
1195
+ layer,
1196
+ pinnedAt: at,
1197
+ alreadyPinned: false,
1198
+ ...fields,
1199
+ ...warning ? { warning } : {}
1200
+ };
1201
+ }
1202
+
1203
+ // src/kb-pins/unpin.ts
1204
+ var import_node_path5 = require("path");
1205
+ async function unpinBase(workspaceDir, bundlePath2) {
1206
+ const layers = [];
1207
+ for (const layer of PIN_LAYERS) {
1208
+ const root = layerRoot(workspaceDir, layer);
1209
+ let manifest;
1210
+ try {
1211
+ manifest = await readPinsLayer(workspaceDir, layer);
1212
+ } catch {
1213
+ continue;
1214
+ }
1215
+ const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
1216
+ const kept = manifest.pins.filter(
1217
+ (entry) => resolvePinPath(root, entry.path) !== absolute
1218
+ );
1219
+ if (kept.length !== manifest.pins.length) {
1220
+ await writePinsLayer(workspaceDir, layer, { ...manifest, pins: kept });
1221
+ layers.push(layer);
1222
+ }
755
1223
  }
756
- };
1224
+ return {
1225
+ path: storablePath((0, import_node_path5.resolve)(workspaceDir), bundlePath2),
1226
+ removed: layers.length > 0,
1227
+ layers
1228
+ };
1229
+ }
757
1230
 
758
1231
  // src/commands/model.ts
1232
+ var import_zod5 = require("zod");
759
1233
  var bundlePath = import_zod5.z.string().min(1).describe("Absolute path to the knowledge base directory.");
760
1234
  var conceptId = import_zod5.z.string().min(1).describe("e.g. decision.cursor-v2");
1235
+ var REPO_ROOT = import_zod5.z.string().min(1).optional().describe(
1236
+ "Where the anchored source lives, for the drift check. Defaults to the working directory."
1237
+ );
761
1238
  function define(command) {
762
1239
  return command;
763
1240
  }
@@ -777,13 +1254,175 @@ function argvFlag(argv, name) {
777
1254
  return value;
778
1255
  }
779
1256
 
1257
+ // src/commands/anchor-resolve.ts
1258
+ var anchorResolveCommand = define({
1259
+ name: "anchor-resolve",
1260
+ tool: "kb_anchor_resolve",
1261
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--rebaseline] [--restamp]",
1262
+ 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.",
1263
+ input: import_zod6.z.object({
1264
+ bundlePath,
1265
+ conceptId,
1266
+ repoRoot: import_zod6.z.string().min(1).optional(),
1267
+ rebaseline: import_zod6.z.boolean().optional().describe(
1268
+ "Accept the current code as the new baseline for anchors that drifted."
1269
+ ),
1270
+ restamp: import_zod6.z.boolean().optional().describe(
1271
+ "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
1272
+ )
1273
+ }),
1274
+ fromArgv: (argv, path) => ({
1275
+ bundlePath: path,
1276
+ conceptId: argv[1],
1277
+ repoRoot: argvFlag(argv, "--repo-root"),
1278
+ rebaseline: argv.includes("--rebaseline"),
1279
+ restamp: argv.includes("--restamp")
1280
+ }),
1281
+ run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, rebaseline, restamp }) => {
1282
+ const root = repoRoot ?? process.cwd();
1283
+ const record = await store.read(path, id);
1284
+ if (!record) throw new KbRecordNotFoundError(id);
1285
+ const anchors = record.frontmatter.strauss_anchors ?? [];
1286
+ if (!anchors.length) {
1287
+ return {
1288
+ conceptId: id,
1289
+ results: [],
1290
+ verified: false,
1291
+ note: "record has no anchors"
1292
+ };
1293
+ }
1294
+ const results = [];
1295
+ const updated = [];
1296
+ const origin = new LazyOrigin(root);
1297
+ let dirty = false;
1298
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
1299
+ const foreign = new Map(
1300
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
1301
+ );
1302
+ const reads = await readAnchorFiles(
1303
+ anchors.filter((anchor) => !foreign.get(anchor)).map((anchor) => anchor.file),
1304
+ anchorFileReader(root)
1305
+ );
1306
+ for (const anchor of anchors) {
1307
+ const base = {
1308
+ file: anchor.file,
1309
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1310
+ // Carried onto unresolved findings too: an anchor that once hashed
1311
+ // and now resolves to nothing is a broken anchor, and the exit code
1312
+ // has to be able to tell it from one nobody ever stamped.
1313
+ ...anchor.hash ? { storedHash: anchor.hash } : {}
1314
+ };
1315
+ if (foreign.get(anchor)) {
1316
+ results.push({ ...base, state: "unresolved", reason: "foreign-repo" });
1317
+ updated.push(anchor);
1318
+ continue;
1319
+ }
1320
+ const fileRead = reads.get(anchor.file);
1321
+ if (!fileRead.ok) {
1322
+ results.push({ ...base, state: "unresolved", reason: fileRead.reason });
1323
+ updated.push(anchor);
1324
+ continue;
1325
+ }
1326
+ const resolved = resolveAnchor(fileRead.source, anchor);
1327
+ if (!resolved) {
1328
+ results.push({
1329
+ ...base,
1330
+ state: "unresolved",
1331
+ reason: "symbol-not-found"
1332
+ });
1333
+ updated.push(anchor);
1334
+ continue;
1335
+ }
1336
+ const currentHash = hashAnchorText(resolved.text);
1337
+ const currentLines = resolved.endLine - resolved.startLine + 1;
1338
+ const stamped = {
1339
+ ...anchor,
1340
+ hash: currentHash,
1341
+ lines: currentLines,
1342
+ resolved_at: now()
1343
+ };
1344
+ if (!anchor.hash) {
1345
+ results.push({ ...base, state: "stamped", currentHash });
1346
+ updated.push(stamped);
1347
+ dirty = true;
1348
+ } else if (anchor.hash === currentHash) {
1349
+ results.push({
1350
+ ...base,
1351
+ state: "match",
1352
+ currentHash
1353
+ });
1354
+ const refresh = restamp || anchor.resolved_at === void 0;
1355
+ updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
1356
+ if (refresh) dirty = true;
1357
+ } else {
1358
+ results.push({
1359
+ ...base,
1360
+ state: "drifted",
1361
+ currentHash,
1362
+ diffSize: anchor.lines === void 0 ? null : Math.abs(currentLines - anchor.lines),
1363
+ ...rebaseline ? { rebaselined: true } : {}
1364
+ });
1365
+ updated.push(rebaseline ? stamped : anchor);
1366
+ if (rebaseline) dirty = true;
1367
+ }
1368
+ }
1369
+ let frozen = false;
1370
+ if (dirty) {
1371
+ try {
1372
+ await assertBaseNotFrozen(process.cwd(), path);
1373
+ } catch (error) {
1374
+ if (!(error instanceof KbBaseFrozenError)) throw error;
1375
+ frozen = true;
1376
+ }
1377
+ if (!frozen) await store.updateAnchors(path, id, updated, actor);
1378
+ }
1379
+ const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
1380
+ const checked = results.filter((entry) => entry.reason !== "foreign-repo");
1381
+ const skipped = results.length - checked.length;
1382
+ const matches2 = checked.filter((entry) => entry.state === "match").length;
1383
+ const clean = checked.length > 0 && checked.every((entry) => entry.state === "match");
1384
+ if (clean) {
1385
+ try {
1386
+ await store.verify(
1387
+ path,
1388
+ id,
1389
+ `anchor-resolve: ${matches2}/${checked.length} anchors match${skipped ? `, ${skipped} in another repo` : ""} (regex resolver)`,
1390
+ actor,
1391
+ now()
1392
+ );
1393
+ } catch (error) {
1394
+ if (!(error instanceof KbSelfVerificationError)) throw error;
1395
+ return {
1396
+ conceptId: id,
1397
+ results,
1398
+ verified: false,
1399
+ verifyRefused: "self-verification",
1400
+ ...frozenNote
1401
+ };
1402
+ }
1403
+ return { conceptId: id, results, verified: true, ...frozenNote };
1404
+ }
1405
+ return { conceptId: id, results, verified: false, ...frozenNote };
1406
+ },
1407
+ // A stored hash that no longer resolves is a broken anchor, not an absence:
1408
+ // the file was deleted or the symbol renamed, and exiting zero on it would
1409
+ // let the one edit that destroys an anchor pass the gate that exists to
1410
+ // catch it. An anchor nobody ever stamped is still just unstamped, and one
1411
+ // belonging to another repository was never this run's to check — failing CI
1412
+ // on either would gate on work this command did not do.
1413
+ failsWhen: (result) => result.results.some(
1414
+ (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && entry.reason !== "foreign-repo"
1415
+ )
1416
+ });
1417
+
780
1418
  // src/commands/answer.ts
1419
+ var import_zod7 = require("zod");
781
1420
  var answerCommand = define({
782
1421
  name: "answer",
783
1422
  tool: "kb_answer",
784
1423
  usage: "answer <concept-id> <answer...>",
785
1424
  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.",
786
- input: import_zod6.z.object({ bundlePath, conceptId, answer: import_zod6.z.string().min(1) }),
1425
+ input: import_zod7.z.object({ bundlePath, conceptId, answer: import_zod7.z.string().min(1) }),
787
1426
  fromArgv: (argv, path) => ({
788
1427
  bundlePath: path,
789
1428
  conceptId: argv[1],
@@ -797,7 +1436,7 @@ var answerCommand = define({
797
1436
  });
798
1437
 
799
1438
  // src/commands/catalog.ts
800
- var import_zod7 = require("zod");
1439
+ var import_zod8 = require("zod");
801
1440
 
802
1441
  // src/adjudicate.ts
803
1442
  var STANDING = {
@@ -809,7 +1448,7 @@ var STANDING = {
809
1448
  rejected: "rejected",
810
1449
  superseded: "superseded"
811
1450
  };
812
- function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
1451
+ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date(), anchorDrift) {
813
1452
  const byId = new Map(bundle.map((record) => [record.conceptId, record]));
814
1453
  return hits.map((record) => {
815
1454
  const status = record.frontmatter.strauss_status;
@@ -839,6 +1478,20 @@ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
839
1478
  if (!record.frontmatter.verified?.length) {
840
1479
  warnings.push({ kind: "unverified" });
841
1480
  }
1481
+ const moved = (anchorDrift?.get(record.conceptId) ?? []).filter(
1482
+ (entry) => entry.state !== "match" && entry.reason !== "foreign-repo"
1483
+ );
1484
+ if (moved.length) {
1485
+ warnings.push({
1486
+ kind: "drifted",
1487
+ anchors: moved.map(({ file, symbol, diffSize, reason }) => ({
1488
+ file,
1489
+ ...symbol !== void 0 ? { symbol } : {},
1490
+ diffSize,
1491
+ ...reason !== void 0 ? { reason } : {}
1492
+ }))
1493
+ });
1494
+ }
842
1495
  return { record, standing: STANDING[status], heads, warnings };
843
1496
  });
844
1497
  }
@@ -943,9 +1596,9 @@ var catalogCommand = define({
943
1596
  tool: "kb_catalog",
944
1597
  usage: "catalog [type]",
945
1598
  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.",
946
- input: import_zod7.z.object({
1599
+ input: import_zod8.z.object({
947
1600
  bundlePath,
948
- type: import_zod7.z.enum(KB_RECORD_TYPES).optional()
1601
+ type: import_zod8.z.enum(KB_RECORD_TYPES).optional()
949
1602
  }),
950
1603
  fromArgv: (argv, path) => ({
951
1604
  bundlePath: path,
@@ -1000,10 +1653,10 @@ function count(value, noun) {
1000
1653
  }
1001
1654
 
1002
1655
  // src/commands/context.ts
1003
- var import_zod8 = require("zod");
1656
+ var import_zod9 = require("zod");
1004
1657
 
1005
1658
  // src/kb-context.ts
1006
- var import_promises2 = require("fs/promises");
1659
+ var import_promises3 = require("fs/promises");
1007
1660
 
1008
1661
  // src/kb-index.ts
1009
1662
  var INDEX_FILE = "INDEX.md";
@@ -1230,13 +1883,13 @@ function toHookJson(block, event) {
1230
1883
  var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
1231
1884
  var CONTEXT_END = "<!-- strauss-kb:end -->";
1232
1885
  async function syncInstructions(file, block) {
1233
- const existing = await (0, import_promises2.readFile)(file, "utf8").catch(() => null);
1886
+ const existing = await (0, import_promises3.readFile)(file, "utf8").catch(() => null);
1234
1887
  const region = block ? `${CONTEXT_BEGIN}
1235
1888
  ${block.trim()}
1236
1889
  ${CONTEXT_END}` : null;
1237
1890
  if (existing === null) {
1238
1891
  if (!region) return { file, action: "unchanged" };
1239
- await (0, import_promises2.writeFile)(file, `${region}
1892
+ await (0, import_promises3.writeFile)(file, `${region}
1240
1893
  `, "utf8");
1241
1894
  return { file, action: "created" };
1242
1895
  }
@@ -1247,11 +1900,11 @@ ${CONTEXT_END}` : null;
1247
1900
  const after = existing.slice(end + CONTEXT_END.length);
1248
1901
  const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
1249
1902
  if (next === existing) return { file, action: "unchanged" };
1250
- await (0, import_promises2.writeFile)(file, next, "utf8");
1903
+ await (0, import_promises3.writeFile)(file, next, "utf8");
1251
1904
  return { file, action: region ? "replaced" : "removed" };
1252
1905
  }
1253
1906
  if (!region) return { file, action: "unchanged" };
1254
- await (0, import_promises2.writeFile)(
1907
+ await (0, import_promises3.writeFile)(
1255
1908
  file,
1256
1909
  `${existing.replace(/\n*$/, "\n\n")}${region}
1257
1910
  `,
@@ -1266,20 +1919,20 @@ var contextCommand = define({
1266
1919
  tool: "kb_context",
1267
1920
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
1268
1921
  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.",
1269
- input: import_zod8.z.object({
1270
- budgetTokens: import_zod8.z.number().int().positive().optional().describe(
1922
+ input: import_zod9.z.object({
1923
+ budgetTokens: import_zod9.z.number().int().positive().optional().describe(
1271
1924
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
1272
1925
  ),
1273
- fullUnderTokens: import_zod8.z.number().int().positive().optional().describe(
1926
+ fullUnderTokens: import_zod9.z.number().int().positive().optional().describe(
1274
1927
  "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."
1275
1928
  ),
1276
- profile: import_zod8.z.string().optional().describe(
1929
+ profile: import_zod9.z.string().optional().describe(
1277
1930
  "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."
1278
1931
  ),
1279
- format: import_zod8.z.enum(["markdown", "json"]).optional().describe(
1932
+ format: import_zod9.z.enum(["markdown", "json"]).optional().describe(
1280
1933
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
1281
1934
  ),
1282
- event: import_zod8.z.string().optional().describe(
1935
+ event: import_zod9.z.string().optional().describe(
1283
1936
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
1284
1937
  )
1285
1938
  }),
@@ -1315,7 +1968,7 @@ var contextCommand = define({
1315
1968
  });
1316
1969
 
1317
1970
  // src/commands/doctor.ts
1318
- var import_zod9 = require("zod");
1971
+ var import_zod10 = require("zod");
1319
1972
 
1320
1973
  // src/kb-edges.ts
1321
1974
  var KB_EDGE_KINDS = [
@@ -1439,7 +2092,8 @@ var CHECK_HEADLINES = {
1439
2092
  aging: "still open or still proposed long after it was written",
1440
2093
  orphaned: "no other record links to it",
1441
2094
  "broken-supersession": "the supersession pointers do not resolve",
1442
- "superseded-but-cited": "a live record's body links to one that no longer holds"
2095
+ "superseded-but-cited": "a live record's body links to one that no longer holds",
2096
+ drifted: "the code an anchor points at moved out from under its hash"
1443
2097
  };
1444
2098
  var DAY_MS = 864e5;
1445
2099
  function doctor(bundle, options = {}) {
@@ -1449,7 +2103,7 @@ function doctor(bundle, options = {}) {
1449
2103
  agingDays: options.agingDays ?? DEFAULT_AGING_DAYS
1450
2104
  };
1451
2105
  const now = options.now ?? /* @__PURE__ */ new Date();
1452
- const adjudicated = adjudicate(bundle, bundle, now);
2106
+ const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
1453
2107
  const standings = new Map(
1454
2108
  adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
1455
2109
  );
@@ -1463,7 +2117,8 @@ function doctor(bundle, options = {}) {
1463
2117
  group("aging", aging(inForce, now, thresholds.agingDays)),
1464
2118
  group("orphaned", orphaned(bundle)),
1465
2119
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
1466
- group("superseded-but-cited", supersededButCited(bundle, standings))
2120
+ group("superseded-but-cited", supersededButCited(bundle, standings)),
2121
+ group("drifted", drifted(inForce))
1467
2122
  ];
1468
2123
  const counts = Object.fromEntries(
1469
2124
  groups.map((entry) => [entry.check, entry.count])
@@ -1649,6 +2304,29 @@ function supersededButCited(bundle, standings) {
1649
2304
  }
1650
2305
  return findings;
1651
2306
  }
2307
+ function drifted(hits) {
2308
+ const findings = [];
2309
+ for (const hit of hits) {
2310
+ const warning = hit.warnings.find((entry) => entry.kind === "drifted");
2311
+ if (!warning) continue;
2312
+ findings.push(
2313
+ finding(
2314
+ hit.record,
2315
+ `${warning.anchors.length} ${warning.anchors.length === 1 ? "anchor no longer matches" : "anchors no longer match"}: ${warning.anchors.map((anchor) => {
2316
+ const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
2317
+ if (anchor.reason) return `${at} (${anchor.reason})`;
2318
+ if (anchor.diffSize === null) {
2319
+ return `${at} (changed, size unrecorded)`;
2320
+ }
2321
+ return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
2322
+ }).join(", ")}`
2323
+ )
2324
+ );
2325
+ }
2326
+ return findings.sort(
2327
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
2328
+ );
2329
+ }
1652
2330
  function replaces(later, earlier) {
1653
2331
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
1654
2332
  }
@@ -1672,14 +2350,15 @@ function ageInDays(record, now) {
1672
2350
  }
1673
2351
 
1674
2352
  // src/commands/doctor.ts
1675
- var days = (what, fallback) => import_zod9.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
2353
+ var days = (what, fallback) => import_zod10.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
1676
2354
  var doctorCommand = define({
1677
2355
  name: "doctor",
1678
2356
  tool: "kb_doctor",
1679
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
1680
- 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.",
1681
- input: import_zod9.z.object({
2357
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--strict]",
2358
+ 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.",
2359
+ input: import_zod10.z.object({
1682
2360
  bundlePath,
2361
+ repoRoot: REPO_ROOT,
1683
2362
  expiringDays: days(
1684
2363
  "How far ahead `expiring` looks, in days.",
1685
2364
  DEFAULT_EXPIRING_DAYS
@@ -1692,7 +2371,7 @@ var doctorCommand = define({
1692
2371
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
1693
2372
  DEFAULT_AGING_DAYS
1694
2373
  ),
1695
- strict: import_zod9.z.boolean().optional().describe(
2374
+ strict: import_zod10.z.boolean().optional().describe(
1696
2375
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
1697
2376
  )
1698
2377
  }),
@@ -1704,29 +2383,36 @@ var doctorCommand = define({
1704
2383
  const expiring2 = argvFlag(argv, "--expiring-days");
1705
2384
  const unverified2 = argvFlag(argv, "--unverified-days");
1706
2385
  const agingDays = argvFlag(argv, "--aging-days");
2386
+ const repoRoot = argvFlag(argv, "--repo-root");
1707
2387
  return {
1708
2388
  bundlePath: path,
2389
+ ...repoRoot !== void 0 ? { repoRoot } : {},
1709
2390
  ...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
1710
2391
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
1711
2392
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
1712
2393
  ...argv.includes("--strict") ? { strict: true } : {}
1713
2394
  };
1714
2395
  },
1715
- run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays }) => {
2396
+ run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays, repoRoot }) => {
1716
2397
  const checkedAt = now();
1717
- const report = doctor(await store.list(path), {
2398
+ const records = await store.list(path);
2399
+ const anchorDrift = await store.detectDrift(records, repoRoot);
2400
+ const report = doctor(records, {
1718
2401
  ...expiringDays !== void 0 ? { expiringDays } : {},
1719
2402
  ...unverifiedDays !== void 0 ? { unverifiedDays } : {},
1720
2403
  ...agingDays !== void 0 ? { agingDays } : {},
2404
+ ...anchorDrift !== void 0 ? { anchorDrift } : {},
1721
2405
  now: new Date(checkedAt)
1722
2406
  });
1723
2407
  return { bundlePath: path, checkedAt, ...report };
1724
2408
  },
1725
2409
  render: (result) => render2(result),
1726
- // Only expiry, and only under --strict. The other six checks report debt a
2410
+ // Only expiry, and only under --strict. The other seven checks report debt a
1727
2411
  // reader decides about; an expired record is the base asserting something it
1728
2412
  // already said it would stop standing behind, which is the one finding a
1729
- // pipeline can act on without a judgment call.
2413
+ // pipeline can act on without a judgment call. Drift has its own gate —
2414
+ // `anchor-resolve` exits non-zero on it, against a repo root the caller
2415
+ // named, which is the run a CI pipeline should be making anyway.
1730
2416
  failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
1731
2417
  });
1732
2418
  function render2(result) {
@@ -1761,13 +2447,13 @@ function render2(result) {
1761
2447
  }
1762
2448
 
1763
2449
  // src/commands/list.ts
1764
- var import_zod10 = require("zod");
2450
+ var import_zod11 = require("zod");
1765
2451
  var listCommand = define({
1766
2452
  name: "list",
1767
2453
  tool: "kb_list",
1768
2454
  usage: "list [type]",
1769
2455
  description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
1770
- input: import_zod10.z.object({ bundlePath, type: import_zod10.z.enum(KB_RECORD_TYPES).optional() }),
2456
+ input: import_zod11.z.object({ bundlePath, type: import_zod11.z.enum(KB_RECORD_TYPES).optional() }),
1771
2457
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
1772
2458
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
1773
2459
  conceptId: record.conceptId,
@@ -1779,36 +2465,40 @@ var listCommand = define({
1779
2465
  });
1780
2466
 
1781
2467
  // src/commands/load.ts
1782
- var import_zod11 = require("zod");
2468
+ var import_zod12 = require("zod");
1783
2469
  var loadCommand = define({
1784
2470
  name: "load",
1785
2471
  tool: "kb_load",
1786
- usage: "load [type] [--budget N] [--all]",
1787
- 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.",
1788
- input: import_zod11.z.object({
2472
+ usage: "load [type] [--budget N | --all] [--repo-root PATH]",
2473
+ description: "Loads the whole knowledge base at once, each record with its standing. Superseded records arrive as stubs; rejected and open records arrive whole. Refuses past the token budget \u2014 call kb_catalog, kb_pack on it; `all` bypasses the budget. Never read record files directly. Cache-stable; `digest` is the base's content stamp \u2014 hooks use it to tell you when to reload.",
2474
+ input: import_zod12.z.object({
1789
2475
  bundlePath,
1790
- type: import_zod11.z.enum(KB_RECORD_TYPES).optional(),
1791
- budgetTokens: import_zod11.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
1792
- all: import_zod11.z.boolean().optional().describe(
2476
+ type: import_zod12.z.enum(KB_RECORD_TYPES).optional(),
2477
+ budgetTokens: import_zod12.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
2478
+ all: import_zod12.z.boolean().optional().describe(
1793
2479
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
1794
- )
2480
+ ),
2481
+ repoRoot: REPO_ROOT
1795
2482
  }).refine((value) => !(value.all && value.budgetTokens !== void 0), {
1796
2483
  message: "all is mutually exclusive with budgetTokens: pass a ceiling or none, not both."
1797
2484
  }),
1798
2485
  fromArgv: (argv, path) => {
1799
2486
  const budget = argvFlag(argv, "--budget");
2487
+ const repoRoot = argvFlag(argv, "--repo-root");
1800
2488
  return {
1801
2489
  bundlePath: path,
1802
2490
  ...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {},
1803
2491
  ...budget ? { budgetTokens: Number(budget) } : {},
1804
- ...argv.includes("--all") ? { all: true } : {}
2492
+ ...argv.includes("--all") ? { all: true } : {},
2493
+ ...repoRoot !== void 0 ? { repoRoot } : {}
1805
2494
  };
1806
2495
  },
1807
- run: async ({ store }, { bundlePath: path, type, budgetTokens, all }) => {
2496
+ run: async ({ store }, { bundlePath: path, type, budgetTokens, all, repoRoot }) => {
1808
2497
  const result = await store.load(path, {
1809
2498
  ...type ? { type } : {},
1810
2499
  ...budgetTokens ? { budgetTokens } : {},
1811
- ...all ? { all } : {}
2500
+ ...all ? { all } : {},
2501
+ ...repoRoot !== void 0 ? { repoRoot } : {}
1812
2502
  });
1813
2503
  if (!result.loaded) return result;
1814
2504
  return {
@@ -1827,25 +2517,25 @@ var loadCommand = define({
1827
2517
  });
1828
2518
 
1829
2519
  // src/commands/log.ts
1830
- var import_zod12 = require("zod");
2520
+ var import_zod13 = require("zod");
1831
2521
  var logCommand = define({
1832
2522
  name: "log",
1833
2523
  tool: "kb_log",
1834
2524
  usage: "log",
1835
2525
  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.",
1836
- input: import_zod12.z.object({ bundlePath }),
2526
+ input: import_zod13.z.object({ bundlePath }),
1837
2527
  fromArgv: (_argv, path) => ({ bundlePath: path }),
1838
2528
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
1839
2529
  });
1840
2530
 
1841
2531
  // src/commands/no-decision.ts
1842
- var import_zod13 = require("zod");
2532
+ var import_zod14 = require("zod");
1843
2533
  var noDecisionCommand = define({
1844
2534
  name: "no-decision",
1845
2535
  tool: "kb_no_decision",
1846
2536
  usage: "no-decision <reason...>",
1847
2537
  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.',
1848
- input: import_zod13.z.object({ bundlePath, reason: import_zod13.z.string().min(1) }),
2538
+ input: import_zod14.z.object({ bundlePath, reason: import_zod14.z.string().min(1) }),
1849
2539
  fromArgv: (argv, path) => ({
1850
2540
  bundlePath: path,
1851
2541
  reason: argv.slice(1).join(" ").trim()
@@ -1862,20 +2552,20 @@ var noDecisionCommand = define({
1862
2552
  });
1863
2553
 
1864
2554
  // src/commands/pack.ts
1865
- var import_zod14 = require("zod");
2555
+ var import_zod15 = require("zod");
1866
2556
  var packCommand = define({
1867
2557
  name: "pack",
1868
2558
  tool: "kb_pack",
1869
2559
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
1870
2560
  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.",
1871
- input: import_zod14.z.object({
2561
+ input: import_zod15.z.object({
1872
2562
  bundlePath,
1873
2563
  conceptId,
1874
- hops: import_zod14.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
1875
- maxNodes: import_zod14.z.number().int().positive().optional().describe(
2564
+ hops: import_zod15.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
2565
+ maxNodes: import_zod15.z.number().int().positive().optional().describe(
1876
2566
  "How many records the pack may hold, root included. Defaults to 20."
1877
2567
  ),
1878
- budgetTokens: import_zod14.z.number().int().positive().optional().describe(
2568
+ budgetTokens: import_zod15.z.number().int().positive().optional().describe(
1879
2569
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
1880
2570
  )
1881
2571
  }),
@@ -1962,22 +2652,22 @@ function warningLabel(warning) {
1962
2652
  }
1963
2653
 
1964
2654
  // src/commands/pin.ts
1965
- var import_zod15 = require("zod");
2655
+ var import_zod16 = require("zod");
1966
2656
  var pinCommand = define({
1967
2657
  name: "pin",
1968
2658
  tool: "kb_pin",
1969
2659
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
1970
2660
  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.",
1971
- input: import_zod15.z.object({
2661
+ input: import_zod16.z.object({
1972
2662
  bundlePath,
1973
- mode: import_zod15.z.enum(["full", "index"]).optional().describe(
2663
+ mode: import_zod16.z.enum(["full", "index"]).optional().describe(
1974
2664
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
1975
2665
  ),
1976
- profiles: import_zod15.z.array(import_zod15.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
1977
- layer: import_zod15.z.enum(["project", "local", "user"]).optional().describe(
2666
+ profiles: import_zod16.z.array(import_zod16.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
2667
+ layer: import_zod16.z.enum(["project", "local", "user"]).optional().describe(
1978
2668
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
1979
2669
  ),
1980
- frozen: import_zod15.z.boolean().optional().describe(
2670
+ frozen: import_zod16.z.boolean().optional().describe(
1981
2671
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
1982
2672
  )
1983
2673
  }),
@@ -2006,38 +2696,48 @@ var pinCommand = define({
2006
2696
  });
2007
2697
 
2008
2698
  // src/commands/pins.ts
2009
- var import_zod16 = require("zod");
2699
+ var import_zod17 = require("zod");
2010
2700
  var pinsCommand = define({
2011
2701
  name: "pins",
2012
2702
  tool: "kb_pins",
2013
2703
  usage: "pins",
2014
2704
  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.",
2015
- input: import_zod16.z.object({}),
2705
+ input: import_zod17.z.object({}),
2016
2706
  fromArgv: () => ({}),
2017
2707
  run: ({ store }) => listPins(store, process.cwd())
2018
2708
  });
2019
2709
 
2020
2710
  // src/commands/query.ts
2021
- var import_zod17 = require("zod");
2711
+ var import_zod18 = require("zod");
2022
2712
  var queryCommand = define({
2023
2713
  name: "query",
2024
2714
  tool: "kb_query",
2025
- usage: "query <text...>",
2026
- 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.",
2027
- input: import_zod17.z.object({
2715
+ usage: "query <text...> [--repo-root PATH]",
2716
+ description: "Search; every hit carries its standing. Flagged, never filtered: a superseded hit returns with its replacement, a rejected one is marked. Prefer kb_load when the base fits its budget \u2014 a full read beats search. Results are volatile: place them at the tail, not the cached prefix. Never read record files directly.",
2717
+ input: import_zod18.z.object({
2028
2718
  bundlePath,
2029
- text: import_zod17.z.string().optional(),
2030
- type: import_zod17.z.enum(KB_RECORD_TYPES).optional(),
2031
- includeNonCurrent: import_zod17.z.boolean().optional()
2719
+ text: import_zod18.z.string().optional(),
2720
+ type: import_zod18.z.enum(KB_RECORD_TYPES).optional(),
2721
+ includeNonCurrent: import_zod18.z.boolean().optional(),
2722
+ repoRoot: REPO_ROOT
2032
2723
  }),
2033
- fromArgv: (argv, path) => ({
2034
- bundlePath: path,
2035
- text: argv.slice(1).join(" ").trim(),
2036
- includeNonCurrent: true
2037
- }),
2038
- run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
2724
+ // `--repo-root` is a flag, so its value must not fall into the search text.
2725
+ fromArgv: (argv, path) => {
2726
+ const repoRoot = argvFlag(argv, "--repo-root");
2727
+ const words = argv.slice(1);
2728
+ const flag = words.indexOf("--repo-root");
2729
+ if (flag !== -1) words.splice(flag, 2);
2730
+ return {
2731
+ bundlePath: path,
2732
+ text: words.join(" ").trim(),
2733
+ includeNonCurrent: true,
2734
+ ...repoRoot !== void 0 ? { repoRoot } : {}
2735
+ };
2736
+ },
2737
+ run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent, repoRoot }) => (await store.query(path, text ?? "", {
2039
2738
  ...type ? { type } : {},
2040
- includeNonCurrent: includeNonCurrent === true
2739
+ includeNonCurrent: includeNonCurrent === true,
2740
+ ...repoRoot !== void 0 ? { repoRoot } : {}
2041
2741
  })).map((hit) => ({
2042
2742
  conceptId: hit.record.conceptId,
2043
2743
  title: hit.record.frontmatter.title ?? null,
@@ -2050,27 +2750,27 @@ var queryCommand = define({
2050
2750
  });
2051
2751
 
2052
2752
  // src/commands/read-index.ts
2053
- var import_zod18 = require("zod");
2753
+ var import_zod19 = require("zod");
2054
2754
  var readIndexCommand = define({
2055
2755
  name: "index",
2056
2756
  tool: "kb_index",
2057
2757
  usage: "index",
2058
2758
  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.",
2059
- input: import_zod18.z.object({ bundlePath }),
2759
+ input: import_zod19.z.object({ bundlePath }),
2060
2760
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2061
2761
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
2062
2762
  });
2063
2763
 
2064
2764
  // src/commands/schema.ts
2065
- var import_zod21 = require("zod");
2765
+ var import_zod22 = require("zod");
2066
2766
 
2067
2767
  // src/json-schema.ts
2068
- var import_zod20 = require("zod");
2768
+ var import_zod21 = require("zod");
2069
2769
 
2070
2770
  // src/kb-log.ts
2071
- var import_zod19 = require("zod");
2771
+ var import_zod20 = require("zod");
2072
2772
  var LOG_FILE = "log.jsonl";
2073
- var kbLogEntrySchema = import_zod19.z.object({
2773
+ var kbLogEntrySchema = import_zod20.z.object({
2074
2774
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
2075
2775
  // below), and a value that isn't actually chronological — a Unix
2076
2776
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -2079,12 +2779,12 @@ var kbLogEntrySchema = import_zod19.z.object({
2079
2779
  // and rejects everything else, including a non-`Z` offset — so a
2080
2780
  // malformed `at` is reported the same way a malformed line already is,
2081
2781
  // rather than silently sorting into the wrong place.
2082
- at: import_zod19.z.iso.datetime(),
2083
- by: import_zod19.z.string().min(1),
2084
- operation: import_zod19.z.string().min(1),
2085
- conceptId: import_zod19.z.string().min(1),
2782
+ at: import_zod20.z.iso.datetime(),
2783
+ by: import_zod20.z.string().min(1),
2784
+ operation: import_zod20.z.string().min(1),
2785
+ conceptId: import_zod20.z.string().min(1),
2086
2786
  /** Second concept id, where the operation relates two — supersession. */
2087
- target: import_zod19.z.string().min(1).optional()
2787
+ target: import_zod20.z.string().min(1).optional()
2088
2788
  }).strict();
2089
2789
  function renderLogEntry(entry) {
2090
2790
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -2122,11 +2822,11 @@ function parseLog(raw) {
2122
2822
  // src/json-schema.ts
2123
2823
  function kbJsonSchemas() {
2124
2824
  return {
2125
- recordFrontmatter: import_zod20.z.toJSONSchema(kbRecordFrontmatterSchema, {
2825
+ recordFrontmatter: import_zod21.z.toJSONSchema(kbRecordFrontmatterSchema, {
2126
2826
  io: "input"
2127
2827
  }),
2128
- composeInput: import_zod20.z.toJSONSchema(composeInputSchema, { io: "input" }),
2129
- logEntry: import_zod20.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
2828
+ composeInput: import_zod21.z.toJSONSchema(composeInputSchema, { io: "input" }),
2829
+ logEntry: import_zod21.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
2130
2830
  };
2131
2831
  }
2132
2832
 
@@ -2136,22 +2836,22 @@ var schemaCommand = define({
2136
2836
  tool: "kb_schema",
2137
2837
  usage: "schema",
2138
2838
  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.",
2139
- input: import_zod21.z.object({}),
2839
+ input: import_zod22.z.object({}),
2140
2840
  fromArgv: () => ({}),
2141
2841
  run: () => Promise.resolve(kbJsonSchemas())
2142
2842
  });
2143
2843
 
2144
2844
  // src/commands/status.ts
2145
- var import_zod22 = require("zod");
2845
+ var import_zod23 = require("zod");
2146
2846
  var statusCommand = define({
2147
2847
  name: "status",
2148
2848
  tool: "kb_status",
2149
2849
  usage: "status <concept-id> <status>",
2150
2850
  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.",
2151
- input: import_zod22.z.object({
2851
+ input: import_zod23.z.object({
2152
2852
  bundlePath,
2153
2853
  conceptId,
2154
- status: import_zod22.z.enum(KB_RECORD_STATUSES)
2854
+ status: import_zod23.z.enum(KB_RECORD_STATUSES)
2155
2855
  }),
2156
2856
  fromArgv: (argv, path) => ({
2157
2857
  bundlePath: path,
@@ -2166,13 +2866,13 @@ var statusCommand = define({
2166
2866
  });
2167
2867
 
2168
2868
  // src/commands/supersede.ts
2169
- var import_zod23 = require("zod");
2869
+ var import_zod24 = require("zod");
2170
2870
  var supersedeCommand = define({
2171
2871
  name: "supersede",
2172
2872
  tool: "kb_supersede",
2173
2873
  usage: "supersede <concept-id> <replacement-id>",
2174
2874
  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.",
2175
- input: import_zod23.z.object({ bundlePath, conceptId, replacementId: conceptId }),
2875
+ input: import_zod24.z.object({ bundlePath, conceptId, replacementId: conceptId }),
2176
2876
  fromArgv: (argv, path) => ({
2177
2877
  bundlePath: path,
2178
2878
  conceptId: argv[1],
@@ -2186,16 +2886,16 @@ var supersedeCommand = define({
2186
2886
  });
2187
2887
 
2188
2888
  // src/commands/sync-instructions.ts
2189
- var import_zod24 = require("zod");
2889
+ var import_zod25 = require("zod");
2190
2890
  var syncInstructionsCommand = define({
2191
2891
  name: "sync-instructions",
2192
2892
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
2193
2893
  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.",
2194
- input: import_zod24.z.object({
2195
- file: import_zod24.z.string().min(1).describe("The instruction file to edit in place."),
2196
- budgetTokens: import_zod24.z.number().int().positive().optional(),
2197
- fullUnderTokens: import_zod24.z.number().int().positive().optional(),
2198
- profile: import_zod24.z.string().optional()
2894
+ input: import_zod25.z.object({
2895
+ file: import_zod25.z.string().min(1).describe("The instruction file to edit in place."),
2896
+ budgetTokens: import_zod25.z.number().int().positive().optional(),
2897
+ fullUnderTokens: import_zod25.z.number().int().positive().optional(),
2898
+ profile: import_zod25.z.string().optional()
2199
2899
  }),
2200
2900
  fromArgv: (argv) => {
2201
2901
  const budget = argvFlag(argv, "--budget");
@@ -2221,7 +2921,7 @@ var syncInstructionsCommand = define({
2221
2921
  });
2222
2922
 
2223
2923
  // src/commands/trace.ts
2224
- var import_zod25 = require("zod");
2924
+ var import_zod26 = require("zod");
2225
2925
 
2226
2926
  // src/trace.ts
2227
2927
  var TRACE_EDGES = ["supersession", "anchor", "source"];
@@ -2267,11 +2967,11 @@ var traceCommand = define({
2267
2967
  tool: "kb_trace",
2268
2968
  usage: "trace <concept-id> [edges...]",
2269
2969
  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.',
2270
- input: import_zod25.z.object({
2970
+ input: import_zod26.z.object({
2271
2971
  bundlePath,
2272
2972
  conceptId,
2273
- edges: import_zod25.z.array(import_zod25.z.enum(TRACE_EDGES)).optional(),
2274
- depth: import_zod25.z.number().int().positive().optional()
2973
+ edges: import_zod26.z.array(import_zod26.z.enum(TRACE_EDGES)).optional(),
2974
+ depth: import_zod26.z.number().int().positive().optional()
2275
2975
  }),
2276
2976
  fromArgv: (argv, path) => ({
2277
2977
  bundlePath: path,
@@ -2293,53 +2993,53 @@ var traceCommand = define({
2293
2993
  });
2294
2994
 
2295
2995
  // src/commands/types.ts
2296
- var import_zod26 = require("zod");
2996
+ var import_zod27 = require("zod");
2297
2997
  var typesCommand = define({
2298
2998
  name: "types",
2299
2999
  tool: "kb_types",
2300
3000
  usage: "types",
2301
3001
  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.",
2302
- input: import_zod26.z.object({}),
3002
+ input: import_zod27.z.object({}),
2303
3003
  fromArgv: () => ({}),
2304
3004
  run: () => Promise.resolve(RECORD_TYPES)
2305
3005
  });
2306
3006
 
2307
3007
  // src/commands/unpin.ts
2308
- var import_zod27 = require("zod");
3008
+ var import_zod28 = require("zod");
2309
3009
  var unpinCommand = define({
2310
3010
  name: "unpin",
2311
3011
  tool: "kb_unpin",
2312
3012
  usage: "unpin [bundle-path]",
2313
3013
  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.",
2314
- input: import_zod27.z.object({ bundlePath }),
3014
+ input: import_zod28.z.object({ bundlePath }),
2315
3015
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
2316
3016
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
2317
3017
  });
2318
3018
 
2319
3019
  // src/commands/validate.ts
2320
- var import_zod28 = require("zod");
3020
+ var import_zod29 = require("zod");
2321
3021
  var validateCommand = define({
2322
3022
  name: "validate",
2323
3023
  tool: "kb_validate",
2324
3024
  usage: "validate",
2325
3025
  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.",
2326
- input: import_zod28.z.object({ bundlePath }),
3026
+ input: import_zod29.z.object({ bundlePath }),
2327
3027
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2328
3028
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
2329
3029
  failsWhen: (result) => Array.isArray(result) && result.length > 0
2330
3030
  });
2331
3031
 
2332
3032
  // src/commands/verify.ts
2333
- var import_zod29 = require("zod");
3033
+ var import_zod30 = require("zod");
2334
3034
  var verifyCommand = define({
2335
3035
  name: "verify",
2336
3036
  tool: "kb_verify",
2337
3037
  usage: "verify <concept-id> --note <text>",
2338
3038
  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.",
2339
- input: import_zod29.z.object({
3039
+ input: import_zod30.z.object({
2340
3040
  bundlePath,
2341
3041
  conceptId,
2342
- note: import_zod29.z.string().refine((s) => s.trim().length > 0, {
3042
+ note: import_zod30.z.string().refine((s) => s.trim().length > 0, {
2343
3043
  message: "note must say what the check found"
2344
3044
  })
2345
3045
  }),
@@ -2359,7 +3059,7 @@ var verifyCommand = define({
2359
3059
  });
2360
3060
 
2361
3061
  // src/commands/write.ts
2362
- var import_zod30 = require("zod");
3062
+ var import_zod31 = require("zod");
2363
3063
  var writeCommand = define({
2364
3064
  name: "write",
2365
3065
  tool: "kb_write",
@@ -2373,9 +3073,9 @@ var writeCommand = define({
2373
3073
  "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
2374
3074
  "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
2375
3075
  ].join("\n"),
2376
- input: import_zod30.z.object({
3076
+ input: import_zod31.z.object({
2377
3077
  bundlePath,
2378
- type: import_zod30.z.enum(KB_RECORD_TYPES),
3078
+ type: import_zod31.z.enum(KB_RECORD_TYPES),
2379
3079
  input: composeInputSchema
2380
3080
  }),
2381
3081
  fromArgv: async (argv, path, stdin) => ({
@@ -2399,7 +3099,7 @@ var writeCommand = define({
2399
3099
  });
2400
3100
 
2401
3101
  // src/commands/write-decision.ts
2402
- var import_zod31 = require("zod");
3102
+ var import_zod32 = require("zod");
2403
3103
  var writeDecisionCommand = define({
2404
3104
  name: "write-decision",
2405
3105
  tool: "kb_write_decision",
@@ -2412,7 +3112,7 @@ var writeDecisionCommand = define({
2412
3112
  "- `alternative` is what you turned down and why, not a list of everything considered.",
2413
3113
  "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
2414
3114
  ].join("\n"),
2415
- input: import_zod31.z.object({ bundlePath, input: decisionInputSchema }),
3115
+ input: import_zod32.z.object({ bundlePath, input: decisionInputSchema }),
2416
3116
  fromArgv: async (_argv, path, stdin) => ({
2417
3117
  bundlePath: path,
2418
3118
  input: JSON.parse(await stdin())
@@ -2441,6 +3141,7 @@ var KB_COMMANDS = [
2441
3141
  supersedeCommand,
2442
3142
  answerCommand,
2443
3143
  verifyCommand,
3144
+ anchorResolveCommand,
2444
3145
  loadCommand,
2445
3146
  catalogCommand,
2446
3147
  packCommand,
@@ -2464,9 +3165,9 @@ var KB_COMMANDS_BY_NAME = new Map(
2464
3165
  );
2465
3166
 
2466
3167
  // src/kb-store.ts
2467
- var import_node_crypto = require("crypto");
2468
- var import_promises4 = require("fs/promises");
2469
- var import_node_path6 = require("path");
3168
+ var import_node_crypto2 = require("crypto");
3169
+ var import_promises5 = require("fs/promises");
3170
+ var import_node_path7 = require("path");
2470
3171
 
2471
3172
  // src/markdown.ts
2472
3173
  var import_gray_matter = __toESM(require("gray-matter"), 1);
@@ -2494,8 +3195,8 @@ function parseMarkdownWithFrontmatter(text, schema) {
2494
3195
  }
2495
3196
 
2496
3197
  // src/search-index.ts
2497
- var import_promises3 = require("fs/promises");
2498
- var import_node_path5 = require("path");
3198
+ var import_promises4 = require("fs/promises");
3199
+ var import_node_path6 = require("path");
2499
3200
  var SEARCH_INDEX_FILE = ".index.sqlite";
2500
3201
  var COLLECTION = "kb";
2501
3202
  async function searchBase(bundlePath2, query, options = {}) {
@@ -2504,7 +3205,7 @@ async function searchBase(bundlePath2, query, options = {}) {
2504
3205
  let store = null;
2505
3206
  try {
2506
3207
  store = await qmd.createStore({
2507
- dbPath: (0, import_node_path5.join)(bundlePath2, SEARCH_INDEX_FILE),
3208
+ dbPath: (0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE),
2508
3209
  config: {
2509
3210
  collections: {
2510
3211
  [COLLECTION]: {
@@ -2539,16 +3240,19 @@ async function searchBase(bundlePath2, query, options = {}) {
2539
3240
  }
2540
3241
  }
2541
3242
  async function isStale(bundlePath2) {
2542
- const indexAt = await (0, import_promises3.stat)((0, import_node_path5.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
3243
+ const indexAt = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
2543
3244
  if (!indexAt) return true;
2544
3245
  const { readdir: readdir2 } = await import("fs/promises");
2545
- const names = await readdir2(bundlePath2).catch(() => []);
2546
- for (const name of names) {
2547
- if (!name.endsWith(".md") || name === INDEX_FILE) continue;
2548
- const at = await (0, import_promises3.stat)((0, import_node_path5.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
2549
- if (at > indexAt) return true;
2550
- }
2551
- return false;
3246
+ const names = (await readdir2(bundlePath2).catch(() => [])).filter(
3247
+ (name) => name.endsWith(".md") && name !== INDEX_FILE
3248
+ );
3249
+ let stale = false;
3250
+ await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
3251
+ if (stale) return;
3252
+ const at = await (0, import_promises4.stat)((0, import_node_path6.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
3253
+ if (at > indexAt) stale = true;
3254
+ });
3255
+ return stale;
2552
3256
  }
2553
3257
  function resolveHits(hits, records) {
2554
3258
  const byName = /* @__PURE__ */ new Map();
@@ -2688,7 +3392,7 @@ function appendUnionMergeLine(contents) {
2688
3392
  }
2689
3393
 
2690
3394
  // src/kb-store.ts
2691
- var KB_DIR = (0, import_node_path6.join)(".strauss", "kb");
3395
+ var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
2692
3396
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
2693
3397
  var DEFAULT_LOAD_BUDGET = 25e3;
2694
3398
  var KbStore = class {
@@ -2719,7 +3423,7 @@ var KbStore = class {
2719
3423
  const conceptId2 = `${input.type}.${input.slug}`;
2720
3424
  const root = this.root(bundlePath2);
2721
3425
  const target = this.recordPath(bundlePath2, conceptId2);
2722
- await (0, import_promises4.mkdir)(root, { recursive: true });
3426
+ await (0, import_promises5.mkdir)(root, { recursive: true });
2723
3427
  await this.publish(
2724
3428
  target,
2725
3429
  stringifyMarkdownWithFrontmatter(input.body, frontmatter),
@@ -2758,7 +3462,7 @@ var KbStore = class {
2758
3462
  const target = this.recordPath(bundlePath2, conceptId2);
2759
3463
  let raw;
2760
3464
  try {
2761
- raw = await (0, import_promises4.readFile)(target, "utf8");
3465
+ raw = await (0, import_promises5.readFile)(target, "utf8");
2762
3466
  } catch {
2763
3467
  return null;
2764
3468
  }
@@ -2775,15 +3479,15 @@ var KbStore = class {
2775
3479
  const root = this.root(bundlePath2);
2776
3480
  let names;
2777
3481
  try {
2778
- names = await (0, import_promises4.readdir)(root);
3482
+ names = await (0, import_promises5.readdir)(root);
2779
3483
  } catch {
2780
3484
  return [];
2781
3485
  }
2782
3486
  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}.`));
2783
- const records = await Promise.all(
2784
- wanted.map(
2785
- async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises4.readFile)((0, import_node_path6.join)(root, name), "utf8"))
2786
- )
3487
+ const records = await mapLimit(
3488
+ wanted,
3489
+ DEFAULT_IO_CONCURRENCY,
3490
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises5.readFile)((0, import_node_path7.join)(root, name), "utf8"))
2787
3491
  );
2788
3492
  return records.filter((record) => record !== null);
2789
3493
  }
@@ -2805,6 +3509,21 @@ var KbStore = class {
2805
3509
  { operation: `status:${status}`, by: actor }
2806
3510
  );
2807
3511
  }
3512
+ /**
3513
+ * Replaces a record's anchors wholesale, preserving everything else.
3514
+ *
3515
+ * Wholesale rather than merged: the caller just resolved the anchors it is
3516
+ * writing, so it holds the complete current set, and a merge would keep
3517
+ * stale entries the resolution pass deliberately dropped.
3518
+ */
3519
+ async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
3520
+ return this.mutate(
3521
+ bundlePath2,
3522
+ conceptId2,
3523
+ (frontmatter) => ({ ...frontmatter, strauss_anchors: anchors }),
3524
+ { operation: "anchor-resolve", by: actor }
3525
+ );
3526
+ }
2808
3527
  /**
2809
3528
  * Appends one `verified[]` event: who checked the record, when, and what the
2810
3529
  * check found. Append-only — prior events are history, and are spread into
@@ -2903,9 +3622,12 @@ ${answer}
2903
3622
  const bundle = await this.list(bundlePath2);
2904
3623
  const needle = text.trim();
2905
3624
  const hits = needle ? await this.rank(bundlePath2, needle, bundle) : bundle;
3625
+ const narrowed = options.type ? hits.filter((r) => r.frontmatter.type === options.type) : hits;
2906
3626
  const adjudicated = adjudicate(
2907
- options.type ? hits.filter((r) => r.frontmatter.type === options.type) : hits,
2908
- bundle
3627
+ narrowed,
3628
+ bundle,
3629
+ /* @__PURE__ */ new Date(),
3630
+ await this.detectDrift(narrowed, options.repoRoot)
2909
3631
  );
2910
3632
  if (options.includeNonCurrent) return adjudicated;
2911
3633
  const present = new Set(adjudicated.map((hit) => hit.record.conceptId));
@@ -2924,6 +3646,50 @@ ${answer}
2924
3646
  const lowered = needle.toLowerCase();
2925
3647
  return bundle.filter((record) => matches(record, lowered));
2926
3648
  }
3649
+ /**
3650
+ * Anchor drift over the records about to be handed back. Like the search
3651
+ * index, this is an enrichment: a filesystem failure degrades to "no drift
3652
+ * reported" rather than failing the read. Anchors without a stored hash are
3653
+ * skipped inside `detectAnchorDrift`, so a base nobody has stamped pays no
3654
+ * fs cost here. `repoRoot` defaults to the working directory — the CLI runs
3655
+ * at the repo root, and the MCP server's cwd is the workspace.
3656
+ *
3657
+ * Public because `doctor` needs the same map with the same degradation: a
3658
+ * sweep that failed to read the tree should report no drift, not fail.
3659
+ *
3660
+ * When no root was given and not one anchored file was found, the finding is
3661
+ * discarded. A base read from somewhere other than the tree it describes
3662
+ * misses every file at once, and that shape is far likelier to be a wrong
3663
+ * default root than a repository where every anchored file was deleted on
3664
+ * the same day. Reporting it would put a drift warning on every record in
3665
+ * the base, which teaches a reader to ignore the warning — the one outcome
3666
+ * worse than not having it. One file found anywhere makes the root
3667
+ * plausible, and the misses become findings again; an explicit `repoRoot` is
3668
+ * taken at its word either way.
3669
+ */
3670
+ async detectDrift(records, repoRoot) {
3671
+ try {
3672
+ const drift = await detectAnchorDrift(records, {
3673
+ repoRoot: repoRoot ?? process.cwd()
3674
+ });
3675
+ if (repoRoot === void 0 && looksLikeWrongRepoRoot(drift)) {
3676
+ this.logger.warn?.({
3677
+ operation: "kb.anchor-drift",
3678
+ outcome: "skipped",
3679
+ reason: "no anchored file found under the default repo root"
3680
+ });
3681
+ return void 0;
3682
+ }
3683
+ return drift;
3684
+ } catch (error) {
3685
+ this.logger.warn?.({
3686
+ operation: "kb.anchor-drift",
3687
+ outcome: "skipped",
3688
+ error: error instanceof Error ? error.message : "unknown"
3689
+ });
3690
+ return void 0;
3691
+ }
3692
+ }
2927
3693
  /**
2928
3694
  * The whole base, adjudicated, when it is small enough to hand over.
2929
3695
  *
@@ -2958,10 +3724,16 @@ ${answer}
2958
3724
  const budgetTokens = options.budgetTokens ?? DEFAULT_LOAD_BUDGET;
2959
3725
  const bundle = await this.list(bundlePath2);
2960
3726
  const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
2961
- const adjudicated = adjudicate(wanted, bundle);
3727
+ const adjudicated = adjudicate(
3728
+ wanted,
3729
+ bundle,
3730
+ /* @__PURE__ */ new Date(),
3731
+ await this.detectDrift(wanted, options.repoRoot)
3732
+ );
2962
3733
  const records = adjudicated.filter((hit) => hit.standing !== "superseded");
2963
3734
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
2964
3735
  const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
3736
+ const bundleDigestValue = bundleDigest(records, superseded);
2965
3737
  if (!options.all && approxTokens2 > budgetTokens) {
2966
3738
  return {
2967
3739
  loaded: false,
@@ -2972,7 +3744,8 @@ ${answer}
2972
3744
  approxTokens: approxTokens2,
2973
3745
  budgetTokens,
2974
3746
  type: options.type
2975
- })
3747
+ }),
3748
+ digest: bundleDigestValue
2976
3749
  };
2977
3750
  }
2978
3751
  return {
@@ -2981,7 +3754,8 @@ ${answer}
2981
3754
  tokensLoaded: approxTokens2,
2982
3755
  budgetTokens: options.all ? null : budgetTokens,
2983
3756
  records,
2984
- superseded
3757
+ superseded,
3758
+ digest: bundleDigestValue
2985
3759
  };
2986
3760
  }
2987
3761
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
@@ -3006,11 +3780,11 @@ ${answer}
3006
3780
  async readIndex(bundlePath2) {
3007
3781
  const root = this.root(bundlePath2);
3008
3782
  const expected = renderIndex(await this.list(bundlePath2));
3009
- const stored = await (0, import_promises4.readFile)((0, import_node_path6.join)(root, INDEX_FILE), "utf8").catch(
3783
+ const stored = await (0, import_promises5.readFile)((0, import_node_path7.join)(root, INDEX_FILE), "utf8").catch(
3010
3784
  () => null
3011
3785
  );
3012
3786
  if (indexIsStale(stored, expected)) {
3013
- await this.publish((0, import_node_path6.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
3787
+ await this.publish((0, import_node_path7.join)(root, INDEX_FILE), expected, true, INDEX_FILE);
3014
3788
  this.logger.info?.({
3015
3789
  operation: "kb.index.repair",
3016
3790
  bundlePath: root,
@@ -3027,8 +3801,8 @@ ${answer}
3027
3801
  * knows which agent touched what. So a bad line is surfaced and left alone.
3028
3802
  */
3029
3803
  async readLog(bundlePath2) {
3030
- const raw = await (0, import_promises4.readFile)(
3031
- (0, import_node_path6.join)(this.root(bundlePath2), LOG_FILE),
3804
+ const raw = await (0, import_promises5.readFile)(
3805
+ (0, import_node_path7.join)(this.root(bundlePath2), LOG_FILE),
3032
3806
  "utf8"
3033
3807
  ).catch(() => "");
3034
3808
  const result = parseLog(raw);
@@ -3079,14 +3853,14 @@ ${answer}
3079
3853
  }
3080
3854
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
3081
3855
  const target = this.recordPath(bundlePath2, conceptId2);
3082
- const before = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
3856
+ const before = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
3083
3857
  if (before === null) throw new KbRecordNotFoundError(conceptId2);
3084
3858
  const parsed = this.parse(conceptId2, before);
3085
3859
  if (!parsed) throw new KbRecordNotFoundError(conceptId2);
3086
3860
  const frontmatter = change(parsed.frontmatter);
3087
3861
  const body = changeBody(parsed.body);
3088
3862
  const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
3089
- const witness = await (0, import_promises4.readFile)(target, "utf8").catch(() => null);
3863
+ const witness = await (0, import_promises5.readFile)(target, "utf8").catch(() => null);
3090
3864
  if (witness === null || digest(witness) !== digest(before)) {
3091
3865
  throw new KbWriteConflictError(conceptId2);
3092
3866
  }
@@ -3112,20 +3886,20 @@ ${answer}
3112
3886
  */
3113
3887
  async publish(target, contents, overwrite, conceptId2) {
3114
3888
  const staging = `${target}.${process.pid}.tmp`;
3115
- await (0, import_promises4.writeFile)(staging, contents, "utf8");
3889
+ await (0, import_promises5.writeFile)(staging, contents, "utf8");
3116
3890
  try {
3117
3891
  if (overwrite) {
3118
- await (0, import_promises4.rename)(staging, target);
3892
+ await (0, import_promises5.rename)(staging, target);
3119
3893
  return;
3120
3894
  }
3121
- await (0, import_promises4.link)(staging, target);
3895
+ await (0, import_promises5.link)(staging, target);
3122
3896
  } catch (error) {
3123
3897
  if (error.code === "EEXIST") {
3124
3898
  throw new KbRecordAlreadyExistsError(conceptId2);
3125
3899
  }
3126
3900
  throw error;
3127
3901
  } finally {
3128
- await (0, import_promises4.unlink)(staging).catch(() => void 0);
3902
+ await (0, import_promises5.unlink)(staging).catch(() => void 0);
3129
3903
  }
3130
3904
  }
3131
3905
  /**
@@ -3169,20 +3943,30 @@ ${answer}
3169
3943
  * file must not fail the mutation it guards.
3170
3944
  */
3171
3945
  async ensureGitattributes(root) {
3172
- const target = (0, import_node_path6.join)(root, GITATTRIBUTES_FILE);
3946
+ const target = (0, import_node_path7.join)(root, GITATTRIBUTES_FILE);
3173
3947
  try {
3174
3948
  let existing;
3175
3949
  try {
3176
- existing = await (0, import_promises4.readFile)(target, "utf8");
3950
+ existing = await (0, import_promises5.readFile)(target, "utf8");
3177
3951
  } catch (error) {
3178
3952
  if (error.code !== "ENOENT") throw error;
3179
3953
  existing = null;
3180
3954
  }
3181
3955
  if (existing === null) {
3182
- await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
3183
- encoding: "utf8",
3184
- flag: "wx"
3185
- });
3956
+ try {
3957
+ await (0, import_promises5.writeFile)(target, appendUnionMergeLine(""), {
3958
+ encoding: "utf8",
3959
+ flag: "wx"
3960
+ });
3961
+ } catch (error) {
3962
+ if (error.code !== "EEXIST") throw error;
3963
+ this.logger.info?.({
3964
+ operation: "kb.gitattributes.ensure",
3965
+ bundlePath: root,
3966
+ outcome: "exists"
3967
+ });
3968
+ return;
3969
+ }
3186
3970
  this.logger.info?.({
3187
3971
  operation: "kb.gitattributes.ensure",
3188
3972
  bundlePath: root,
@@ -3191,7 +3975,7 @@ ${answer}
3191
3975
  return;
3192
3976
  }
3193
3977
  if (!hasMergeDeclaration(existing)) {
3194
- await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
3978
+ await (0, import_promises5.appendFile)(target, appendUnionMergeLine(existing), "utf8");
3195
3979
  this.logger.info?.({
3196
3980
  operation: "kb.gitattributes.ensure",
3197
3981
  bundlePath: root,
@@ -3210,7 +3994,7 @@ ${answer}
3210
3994
  async record(root, entry) {
3211
3995
  await this.ensureGitattributes(root);
3212
3996
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
3213
- await (0, import_promises4.appendFile)((0, import_node_path6.join)(root, LOG_FILE), line, "utf8").catch((error) => {
3997
+ await (0, import_promises5.appendFile)((0, import_node_path7.join)(root, LOG_FILE), line, "utf8").catch((error) => {
3214
3998
  this.logger.warn?.({
3215
3999
  operation: "kb.log.append",
3216
4000
  outcome: "failed",
@@ -3236,18 +4020,18 @@ ${answer}
3236
4020
  };
3237
4021
  }
3238
4022
  root(bundlePath2) {
3239
- return (0, import_node_path6.resolve)(bundlePath2);
4023
+ return (0, import_node_path7.resolve)(bundlePath2);
3240
4024
  }
3241
4025
  // Concept ids are `<type>.<slug>` and map to a single file directly under the
3242
4026
  // bundle root; anything carrying a separator would escape it.
3243
4027
  recordPath(bundlePath2, conceptId2) {
3244
- if (conceptId2.includes(import_node_path6.sep) || conceptId2.includes("/")) {
4028
+ if (conceptId2.includes(import_node_path7.sep) || conceptId2.includes("/")) {
3245
4029
  throw new KbInvalidConceptIdError(
3246
4030
  "concept id must not contain a path separator",
3247
4031
  { conceptId: conceptId2 }
3248
4032
  );
3249
4033
  }
3250
- return (0, import_node_path6.join)(this.root(bundlePath2), `${conceptId2}.md`);
4034
+ return (0, import_node_path7.join)(this.root(bundlePath2), `${conceptId2}.md`);
3251
4035
  }
3252
4036
  };
3253
4037
  function estimateTokens(record) {
@@ -3286,11 +4070,27 @@ function normalizeActor(id) {
3286
4070
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
3287
4071
  }
3288
4072
  function digest(contents) {
3289
- return (0, import_node_crypto.createHash)("sha256").update(contents).digest("hex");
4073
+ return (0, import_node_crypto2.createHash)("sha256").update(contents).digest("hex");
4074
+ }
4075
+ function bundleDigest(records, superseded) {
4076
+ const entries = [
4077
+ ...records.map(
4078
+ (hit) => `${hit.record.conceptId}:current:${digest(
4079
+ stringifyMarkdownWithFrontmatter(
4080
+ hit.record.body,
4081
+ hit.record.frontmatter
4082
+ )
4083
+ )}`
4084
+ ),
4085
+ ...superseded.map(
4086
+ (entry) => `${entry.conceptId}:superseded:${digest(JSON.stringify(entry))}`
4087
+ )
4088
+ ].sort();
4089
+ return digest(entries.join("\n"));
3290
4090
  }
3291
4091
 
3292
4092
  // src/version.ts
3293
- var VERSION = true ? "0.1.10" : "0.0.0-dev";
4093
+ var VERSION = true ? "0.1.12" : "0.0.0-dev";
3294
4094
 
3295
4095
  // src/cli.ts
3296
4096
  async function runKbCli(argv) {
@@ -3349,18 +4149,18 @@ function takeLiteral(argv) {
3349
4149
  function takeBundle(argv) {
3350
4150
  const at = argv.indexOf("--bundle");
3351
4151
  if (at === -1) {
3352
- return { bundle: (0, import_node_path7.join)(process.cwd(), KB_DIR), rest: argv };
4152
+ return { bundle: (0, import_node_path8.join)(process.cwd(), KB_DIR), rest: argv };
3353
4153
  }
3354
4154
  const bundle = argv[at + 1];
3355
4155
  if (!bundle) die("--bundle requires a path");
3356
4156
  return { bundle, rest: [...argv.slice(0, at), ...argv.slice(at + 2)] };
3357
4157
  }
3358
4158
  function readStdin() {
3359
- return new Promise((resolve5, reject) => {
4159
+ return new Promise((resolve6, reject) => {
3360
4160
  let text = "";
3361
4161
  process.stdin.setEncoding("utf8");
3362
4162
  process.stdin.on("data", (chunk) => text += chunk);
3363
- process.stdin.on("end", () => resolve5(text));
4163
+ process.stdin.on("end", () => resolve6(text));
3364
4164
  process.stdin.on("error", reject);
3365
4165
  });
3366
4166
  }