@saasontools/strauss-kb 0.1.19 → 0.1.21

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/index.cjs CHANGED
@@ -40,12 +40,14 @@ __export(index_exports, {
40
40
  DEFAULT_LOAD_BUDGET: () => DEFAULT_LOAD_BUDGET,
41
41
  DEFAULT_PACK_HOPS: () => DEFAULT_PACK_HOPS,
42
42
  DEFAULT_PACK_MAX_NODES: () => DEFAULT_PACK_MAX_NODES,
43
+ DEFAULT_THRESHOLDS: () => DEFAULT_THRESHOLDS,
43
44
  DEFAULT_TYPED_LINK_RELS: () => DEFAULT_TYPED_LINK_RELS,
44
45
  DEFAULT_UNVERIFIED_DAYS: () => DEFAULT_UNVERIFIED_DAYS,
45
46
  ErrorTypes: () => ErrorTypes,
46
47
  Fault: () => Fault,
47
48
  INDEX_FILE: () => INDEX_FILE,
48
49
  KB_CAUSAL_LINK_RELS: () => KB_CAUSAL_LINK_RELS,
50
+ KB_CLASSES: () => KB_CLASSES,
49
51
  KB_COMMANDS: () => KB_COMMANDS,
50
52
  KB_COMMANDS_BY_NAME: () => KB_COMMANDS_BY_NAME,
51
53
  KB_CONCEPT_ID_PATTERN: () => KB_CONCEPT_ID_PATTERN,
@@ -59,10 +61,15 @@ __export(index_exports, {
59
61
  KB_RECORD_TYPES: () => KB_RECORD_TYPES,
60
62
  KB_SLUG_PATTERN: () => KB_SLUG_PATTERN,
61
63
  KbBaseFrozenError: () => KbBaseFrozenError,
64
+ KbClassifyInputError: () => KbClassifyInputError,
62
65
  KbInvalidConceptIdError: () => KbInvalidConceptIdError,
63
66
  KbMissingFlagValueError: () => KbMissingFlagValueError,
64
67
  KbPackBudgetExceededError: () => KbPackBudgetExceededError,
65
68
  KbPinsMalformedError: () => KbPinsMalformedError,
69
+ KbPromoteCollisionError: () => KbPromoteCollisionError,
70
+ KbPromoteSelfError: () => KbPromoteSelfError,
71
+ KbPromoteStandingError: () => KbPromoteStandingError,
72
+ KbPromoteStoppedError: () => KbPromoteStoppedError,
66
73
  KbRecordAlreadyExistsError: () => KbRecordAlreadyExistsError,
67
74
  KbRecordNotFoundError: () => KbRecordNotFoundError,
68
75
  KbSelfVerificationError: () => KbSelfVerificationError,
@@ -75,16 +82,20 @@ __export(index_exports, {
75
82
  PINS_FILE: () => PINS_FILE,
76
83
  PINS_LOCAL_FILE: () => PINS_LOCAL_FILE,
77
84
  PIN_LAYERS: () => PIN_LAYERS,
85
+ PROMOTION_SOURCE_ID: () => PROMOTION_SOURCE_ID,
78
86
  RECORD_TYPES: () => RECORD_TYPES,
79
87
  SEARCH_INDEX_FILE: () => SEARCH_INDEX_FILE,
80
88
  TRACE_EDGES: () => TRACE_EDGES,
81
89
  TreeSitterResolver: () => TreeSitterResolver,
82
90
  adjudicate: () => adjudicate,
83
91
  anchorFilePath: () => anchorFilePath,
92
+ anchorOnHunk: () => anchorOnHunk,
84
93
  assertBaseNotFrozen: () => assertBaseNotFrozen,
85
94
  backlinks: () => backlinks,
86
95
  buildContext: () => buildContext,
96
+ carry: () => carry,
87
97
  catalog: () => catalog,
98
+ classifyDiff: () => classifyDiff,
88
99
  classifyDrift: () => classifyDrift,
89
100
  composeDecisionRecord: () => composeDecisionRecord,
90
101
  composeInputSchema: () => composeInputSchema,
@@ -110,8 +121,11 @@ __export(index_exports, {
110
121
  isKbLinkRel: () => isKbLinkRel,
111
122
  isKbRecordType: () => isKbRecordType,
112
123
  isNoDecisionRecord: () => isNoDecisionRecord,
124
+ isReviewTag: () => isReviewTag,
113
125
  kbActorStampSchema: () => kbActorStampSchema,
114
126
  kbAnchorSchema: () => kbAnchorSchema,
127
+ kbAnchorSpanSchema: () => kbAnchorSpanSchema,
128
+ kbAnchorWriteSchema: () => kbAnchorWriteSchema,
115
129
  kbConceptIdSchema: () => kbConceptIdSchema,
116
130
  kbJsonSchemas: () => kbJsonSchemas,
117
131
  kbLinkSchema: () => kbLinkSchema,
@@ -131,6 +145,8 @@ __export(index_exports, {
131
145
  parseMarkdownWithFrontmatter: () => parseMarkdownWithFrontmatter,
132
146
  pinBase: () => pinBase,
133
147
  prepareResolvers: () => prepareResolvers,
148
+ promoteCandidates: () => promoteCandidates,
149
+ promoteInputSchema: () => promoteInputSchema,
134
150
  readMergedPins: () => readMergedPins,
135
151
  readPinsLayer: () => readPinsLayer,
136
152
  readRemoteAnchors: () => readRemoteAnchors,
@@ -152,6 +168,7 @@ __export(index_exports, {
152
168
  selectDecisions: () => selectDecisions,
153
169
  splitMarkdownFrontmatter: () => splitMarkdownFrontmatter,
154
170
  stringifyMarkdownWithFrontmatter: () => stringifyMarkdownWithFrontmatter,
171
+ symbolRangeIndex: () => symbolRangeIndex,
155
172
  syncInstructions: () => syncInstructions,
156
173
  toHookJson: () => toHookJson,
157
174
  trace: () => trace,
@@ -242,9 +259,24 @@ var kbVerifiedEventSchema = kbActorStampSchema.extend({
242
259
  message: "note must say what the check found"
243
260
  })
244
261
  });
262
+ var kbAnchorSpanSchema = import_zod.z.object({
263
+ start: import_zod.z.number().int().positive(),
264
+ end: import_zod.z.number().int().positive()
265
+ }).strict();
245
266
  var kbAnchorSchema = import_zod.z.object({
246
267
  file: import_zod.z.string().min(1),
247
268
  symbol: import_zod.z.string().min(1).optional(),
269
+ /**
270
+ * The lines the concept names, when no symbol covers them — deleted code,
271
+ * YAML, SQL, Markdown. Alternative to `symbol`, never a refinement of it.
272
+ */
273
+ span: kbAnchorSpanSchema.optional(),
274
+ /**
275
+ * Which side of the change the anchor describes. `old` is code as it was
276
+ * committed at `ref`, which is the only way to anchor something deleted;
277
+ * absent means the working tree.
278
+ */
279
+ side: import_zod.z.enum(["old", "new"]).optional(),
248
280
  /**
249
281
  * Which repository the file lives in — a remote URL
250
282
  * (`https://github.com/org/name`) or a short name. Absent means the base's
@@ -283,8 +315,38 @@ var kbAnchorSchema = import_zod.z.object({
283
315
  * before resolvers were named, which is read as `regex` — the only one
284
316
  * there was. A hash from a different resolver is drift, not a match.
285
317
  */
286
- resolver: import_zod.z.enum(["tree-sitter", "regex"]).optional()
318
+ resolver: import_zod.z.enum(["tree-sitter", "regex", "span"]).optional()
287
319
  }).strict();
320
+ var kbAnchorWriteSchema = kbAnchorSchema.superRefine((anchor, ctx) => {
321
+ if (anchor.span && anchor.symbol) {
322
+ ctx.addIssue({
323
+ code: import_zod.z.ZodIssueCode.custom,
324
+ path: ["span"],
325
+ message: "an anchor names a symbol or a span, not both"
326
+ });
327
+ }
328
+ if (anchor.span && anchor.span.end < anchor.span.start) {
329
+ ctx.addIssue({
330
+ code: import_zod.z.ZodIssueCode.custom,
331
+ path: ["span", "end"],
332
+ message: "span end must not precede start"
333
+ });
334
+ }
335
+ if (anchor.span && anchor.hash_kind === "ast") {
336
+ ctx.addIssue({
337
+ code: import_zod.z.ZodIssueCode.custom,
338
+ path: ["hash_kind"],
339
+ message: "a span is hashed raw, never ast"
340
+ });
341
+ }
342
+ if (anchor.side === "old" && !anchor.ref) {
343
+ ctx.addIssue({
344
+ code: import_zod.z.ZodIssueCode.custom,
345
+ path: ["ref"],
346
+ message: 'side: "old" needs a ref \u2014 committed code has no other address'
347
+ });
348
+ }
349
+ });
288
350
  var kbLinkSchema = import_zod.z.object({
289
351
  target: import_zod.z.string().min(1),
290
352
  rel: import_zod.z.string().min(1)
@@ -371,9 +433,17 @@ var Fault = /* @__PURE__ */ ((Fault2) => {
371
433
  })(Fault || {});
372
434
  var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
373
435
  ErrorTypes2["KbRecordAlreadyExists"] = "KbRecordAlreadyExists";
436
+ ErrorTypes2["KbClassifyInput"] = "KbClassifyInput";
437
+ ErrorTypes2["KbFlagConflict"] = "KbFlagConflict";
438
+ ErrorTypes2["KbInvalidActor"] = "KbInvalidActor";
374
439
  ErrorTypes2["KbInvalidConceptId"] = "KbInvalidConceptId";
440
+ ErrorTypes2["KbMatchInput"] = "KbMatchInput";
375
441
  ErrorTypes2["KbMissingFlagValue"] = "KbMissingFlagValue";
376
442
  ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
443
+ ErrorTypes2["KbPromoteCollision"] = "KbPromoteCollision";
444
+ ErrorTypes2["KbPromoteSelf"] = "KbPromoteSelf";
445
+ ErrorTypes2["KbPromoteStanding"] = "KbPromoteStanding";
446
+ ErrorTypes2["KbPromoteStopped"] = "KbPromoteStopped";
377
447
  ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
378
448
  ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
379
449
  ErrorTypes2["KbStampBaselineUnreadable"] = "KbStampBaselineUnreadable";
@@ -466,6 +536,38 @@ var KbSelfVerificationError = class extends BaseError {
466
536
  actor;
467
537
  generatedBy;
468
538
  };
539
+ var KbInvalidActorError = class extends BaseError {
540
+ constructor(actor, reason) {
541
+ super({
542
+ message: `kb: actor ${JSON.stringify(actor)} ${reason} \u2014 set STRAUSS_KB_ACTOR, e.g. human:alice or agent:reviewer`,
543
+ errorType: "KbInvalidActor" /* KbInvalidActor */,
544
+ code: 400,
545
+ fault: "User" /* User */,
546
+ retriable: false,
547
+ reportToUser: true,
548
+ details: { actor, reason, action: "refused" }
549
+ });
550
+ this.actor = actor;
551
+ this.reason = reason;
552
+ }
553
+ actor;
554
+ reason;
555
+ };
556
+ var KbFlagConflictError = class extends BaseError {
557
+ constructor(flags) {
558
+ super({
559
+ message: `kb: ${flags.join(" and ")} cannot be combined`,
560
+ errorType: "KbFlagConflict" /* KbFlagConflict */,
561
+ code: 400,
562
+ fault: "User" /* User */,
563
+ retriable: false,
564
+ reportToUser: true,
565
+ details: { flags }
566
+ });
567
+ this.flags = flags;
568
+ }
569
+ flags;
570
+ };
469
571
  var KbPackBudgetExceededError = class extends BaseError {
470
572
  constructor(recordCount, approxTokens2, budgetTokens, excluded) {
471
573
  super({
@@ -519,6 +621,89 @@ var KbMissingFlagValueError = class extends BaseError {
519
621
  }
520
622
  flag;
521
623
  };
624
+ var KbClassifyInputError = class extends BaseError {
625
+ constructor(reason) {
626
+ super({
627
+ message: `classify: ${reason}`,
628
+ errorType: "KbClassifyInput" /* KbClassifyInput */,
629
+ code: 400,
630
+ fault: "User" /* User */,
631
+ retriable: false,
632
+ reportToUser: true,
633
+ details: { reason }
634
+ });
635
+ this.reason = reason;
636
+ }
637
+ reason;
638
+ };
639
+ var KbPromoteCollisionError = class extends BaseError {
640
+ constructor(conceptId2, to) {
641
+ super({
642
+ message: `kb: ${to} already holds ${conceptId2} \u2014 re-run with force to overwrite it`,
643
+ errorType: "KbPromoteCollision" /* KbPromoteCollision */,
644
+ code: 409,
645
+ fault: "User" /* User */,
646
+ retriable: false,
647
+ reportToUser: true,
648
+ details: { conceptId: conceptId2, to, action: "refused" }
649
+ });
650
+ this.conceptId = conceptId2;
651
+ this.to = to;
652
+ }
653
+ conceptId;
654
+ to;
655
+ };
656
+ var KbPromoteStandingError = class extends BaseError {
657
+ constructor(conceptId2, standing) {
658
+ super({
659
+ message: `kb: ${conceptId2} is ${standing} \u2014 only a record that still stands can be promoted`,
660
+ errorType: "KbPromoteStanding" /* KbPromoteStanding */,
661
+ code: 409,
662
+ fault: "User" /* User */,
663
+ retriable: false,
664
+ reportToUser: true,
665
+ details: { conceptId: conceptId2, standing, action: "refused" }
666
+ });
667
+ this.conceptId = conceptId2;
668
+ this.standing = standing;
669
+ }
670
+ conceptId;
671
+ standing;
672
+ };
673
+ var KbPromoteSelfError = class extends BaseError {
674
+ constructor(to) {
675
+ super({
676
+ message: `kb: ${to} is the base being promoted from \u2014 name a different target`,
677
+ errorType: "KbPromoteSelf" /* KbPromoteSelf */,
678
+ code: 400,
679
+ fault: "User" /* User */,
680
+ retriable: false,
681
+ reportToUser: true,
682
+ details: { to, action: "refused" }
683
+ });
684
+ this.to = to;
685
+ }
686
+ to;
687
+ };
688
+ var KbPromoteStoppedError = class extends BaseError {
689
+ constructor(conceptId2, landed, reason) {
690
+ super({
691
+ message: `kb: promotion stopped at ${conceptId2} (${reason}) \u2014 landed: ${landed.length ? landed.join(", ") : "nothing"}`,
692
+ errorType: "KbPromoteStopped" /* KbPromoteStopped */,
693
+ code: 500,
694
+ fault: "System" /* System */,
695
+ retriable: false,
696
+ reportToUser: true,
697
+ details: { conceptId: conceptId2, landed, reason, action: "stopped" }
698
+ });
699
+ this.conceptId = conceptId2;
700
+ this.landed = landed;
701
+ this.reason = reason;
702
+ }
703
+ conceptId;
704
+ landed;
705
+ reason;
706
+ };
522
707
  var KbInvalidConceptIdError = class extends BaseError {
523
708
  constructor(message, details) {
524
709
  super({
@@ -698,7 +883,9 @@ function revRef(rev) {
698
883
  var UNCHECKED_REASONS = [
699
884
  "remote-unreachable",
700
885
  "repo-unauthorized",
701
- "default-branch-unknown"
886
+ "default-branch-unknown",
887
+ /** Local, but the same finding: a shallow clone has no rev to read. */
888
+ "ref-unavailable"
702
889
  ];
703
890
  function isUncheckedReason(reason) {
704
891
  return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
@@ -766,6 +953,11 @@ function refShapeIsSafe(ref) {
766
953
  if (ref.includes("..")) return false;
767
954
  return REF_SHAPE.test(ref);
768
955
  }
956
+ function localRevShapeIsSafe(rev) {
957
+ if (!rev || rev.length > MAX_REF_LENGTH) return false;
958
+ if (rev.includes("..")) return false;
959
+ return /^[A-Za-z0-9][A-Za-z0-9._/^~-]*$/.test(rev);
960
+ }
769
961
  async function refIsWellFormed(ref) {
770
962
  if (!refShapeIsSafe(ref)) return false;
771
963
  const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
@@ -1164,6 +1356,142 @@ function bundleDigest(records, superseded) {
1164
1356
  return bundleStamp(records, superseded).digest;
1165
1357
  }
1166
1358
 
1359
+ // src/drift/git.ts
1360
+ var import_node_child_process3 = require("child_process");
1361
+ var import_node_util3 = require("util");
1362
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
1363
+ var MAX_GIT_OUTPUT_BYTES = 1048576;
1364
+ var MAX_RANGE_DIFF_BYTES = 8 * 1048576;
1365
+ var GIT_TIMEOUT_MS = 5e3;
1366
+ var RANGE_DIFF_TIMEOUT_MS = 2e4;
1367
+ async function git2(cwd, args, limits = {}) {
1368
+ const env = { ...process.env };
1369
+ delete env["GIT_DIR"];
1370
+ delete env["GIT_WORK_TREE"];
1371
+ delete env["GIT_INDEX_FILE"];
1372
+ try {
1373
+ const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
1374
+ timeout: limits.timeoutMs ?? GIT_TIMEOUT_MS,
1375
+ maxBuffer: limits.maxBytes ?? MAX_GIT_OUTPUT_BYTES,
1376
+ env
1377
+ });
1378
+ return { ok: true, stdout };
1379
+ } catch (error) {
1380
+ return { ok: false, reason: failureOf(error) };
1381
+ }
1382
+ }
1383
+ function failureOf(error) {
1384
+ const { code, killed } = error;
1385
+ if (code === "ENOENT") return "git-missing";
1386
+ if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "too-large";
1387
+ if (killed === true) return "timeout";
1388
+ return "failed";
1389
+ }
1390
+ async function readFileAtRef(repoRoot, anchor) {
1391
+ if (!filePathIsSafe(anchor.file))
1392
+ return { ok: false, reason: "outside-repo" };
1393
+ if (!anchor.ref || !refShapeIsSafe(anchor.ref)) {
1394
+ return { ok: false, reason: "ref-unreadable" };
1395
+ }
1396
+ const blob = await catBlob(repoRoot, anchor.ref, anchor.file);
1397
+ if (blob !== null) return { ok: true, source: blob };
1398
+ return {
1399
+ ok: false,
1400
+ reason: await hasCommit(repoRoot, anchor.ref) ? "ref-unreadable" : "ref-unavailable"
1401
+ };
1402
+ }
1403
+ async function hasCommit(repoRoot, ref) {
1404
+ const found = await git2(repoRoot, [
1405
+ "cat-file",
1406
+ "-e",
1407
+ "--end-of-options",
1408
+ `${ref}^{commit}`
1409
+ ]);
1410
+ return found.ok;
1411
+ }
1412
+ async function listRepoFiles(repoRoot) {
1413
+ const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
1414
+ if (!result.ok) return [];
1415
+ return result.stdout.split("\0").filter(Boolean);
1416
+ }
1417
+ var DIFF_RANGE = /^(.+?)(\.{2,3})(.+)$/;
1418
+ async function readRangeDiff(repoRoot, range, maxBytes = MAX_RANGE_DIFF_BYTES) {
1419
+ const parts = DIFF_RANGE.exec(range);
1420
+ if (!parts) return { ok: false, reason: "bad-range" };
1421
+ const [, base2 = "", dots = "", head = ""] = parts;
1422
+ if (!localRevShapeIsSafe(base2) || !localRevShapeIsSafe(head)) {
1423
+ return { ok: false, reason: "bad-range" };
1424
+ }
1425
+ const result = await git2(
1426
+ repoRoot,
1427
+ [
1428
+ "-c",
1429
+ "core.quotePath=false",
1430
+ "diff",
1431
+ "--unified=0",
1432
+ "--no-color",
1433
+ "--no-ext-diff",
1434
+ "--no-textconv",
1435
+ "--find-renames",
1436
+ "--src-prefix=a/",
1437
+ "--dst-prefix=b/",
1438
+ "--end-of-options",
1439
+ `${base2}${dots}${head}`,
1440
+ "--"
1441
+ ],
1442
+ { maxBytes, timeoutMs: RANGE_DIFF_TIMEOUT_MS }
1443
+ );
1444
+ if (result.ok) return { ok: true, text: result.stdout };
1445
+ return {
1446
+ ok: false,
1447
+ reason: result.reason === "failed" ? "bad-range" : result.reason
1448
+ };
1449
+ }
1450
+ async function readOldSource(repoRoot, anchor) {
1451
+ if (!filePathIsSafe(anchor.file))
1452
+ return { ok: false, reason: "unrecoverable" };
1453
+ if (anchor.ref && refShapeIsSafe(anchor.ref)) {
1454
+ const shown2 = await catBlob(repoRoot, anchor.ref, anchor.file);
1455
+ if (shown2 !== null) {
1456
+ return {
1457
+ ok: true,
1458
+ source: shown2,
1459
+ origin: { kind: "ref", ref: anchor.ref }
1460
+ };
1461
+ }
1462
+ }
1463
+ const at2 = anchor.resolved_at;
1464
+ if (!at2 || Number.isNaN(Date.parse(at2))) {
1465
+ return { ok: false, reason: "unrecoverable" };
1466
+ }
1467
+ const found = await git2(repoRoot, [
1468
+ "log",
1469
+ "-1",
1470
+ "--format=%H",
1471
+ `--before=${at2}`,
1472
+ "--end-of-options",
1473
+ "HEAD",
1474
+ "--",
1475
+ anchor.file
1476
+ ]);
1477
+ const sha = found.ok ? found.stdout.trim() : "";
1478
+ if (!sha || !refShapeIsSafe(sha))
1479
+ return { ok: false, reason: "unrecoverable" };
1480
+ const shown = await catBlob(repoRoot, sha, anchor.file);
1481
+ if (shown === null) return { ok: false, reason: "unrecoverable" };
1482
+ return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
1483
+ }
1484
+ async function catBlob(repoRoot, ref, file) {
1485
+ const path = file.replace(/^\.\//, "");
1486
+ const result = await git2(repoRoot, [
1487
+ "cat-file",
1488
+ "blob",
1489
+ "--end-of-options",
1490
+ `${ref}:${path}`
1491
+ ]);
1492
+ return result.ok ? result.stdout : null;
1493
+ }
1494
+
1167
1495
  // src/anchor-resolver/read.ts
1168
1496
  var import_promises2 = require("fs/promises");
1169
1497
  var import_node_path2 = require("path");
@@ -1229,6 +1557,7 @@ function looksLikeWrongRepoRoot(drift) {
1229
1557
  for (const entries of drift.values()) {
1230
1558
  for (const entry of entries) {
1231
1559
  if (entry.repo !== void 0) continue;
1560
+ if (entry.side === "old") continue;
1232
1561
  checked += 1;
1233
1562
  if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
1234
1563
  return false;
@@ -1364,7 +1693,7 @@ function size(bytes) {
1364
1693
  return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1365
1694
  }
1366
1695
  function pause(ms) {
1367
- return new Promise((resolve6) => setTimeout(resolve6, ms));
1696
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
1368
1697
  }
1369
1698
 
1370
1699
  // src/grammars/manifest.ts
@@ -1936,8 +2265,8 @@ function captureBraceBlock(lines, matchLine) {
1936
2265
  }
1937
2266
  var PYTHON_HEADER = /^\s*(?:async\s+)?(?:def|class)\s+[A-Za-z_]\w*\s*[(:]/;
1938
2267
  function captureIndentedBlock(lines, matchLine) {
1939
- const header = lines[matchLine] ?? "";
1940
- const indent = header.length - header.trimStart().length;
2268
+ const header2 = lines[matchLine] ?? "";
2269
+ const indent = header2.length - header2.trimStart().length;
1941
2270
  let headerEnd = -1;
1942
2271
  for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1943
2272
  const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
@@ -1958,42 +2287,55 @@ function captureIndentedBlock(lines, matchLine) {
1958
2287
  }
1959
2288
  return end === headerEnd ? null : span(lines, matchLine, end);
1960
2289
  }
1961
- var TIERS = [
1962
- (name) => new RegExp(
1963
- `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
1964
- ),
1965
- (name) => new RegExp(`\\b${name}\\s*[:=]`),
2290
+ var declarationTier = (name) => new RegExp(
2291
+ `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
2292
+ );
2293
+ var assignmentTier = (name) => new RegExp(`\\b${name}\\s*[:=]`);
2294
+ var anchoredAssignmentTier = (name) => new RegExp(
2295
+ `^\\s*(?:export\\s+|readonly\\s+|pub\\s+|static\\s+|private\\s+|public\\s+|protected\\s+)*${name}\\s*[:=]`
2296
+ );
2297
+ var DEFINITION_TIERS = [declarationTier, anchoredAssignmentTier];
2298
+ var MENTION_TIERS = [
1966
2299
  (name) => new RegExp(`\\b${name}\\s*\\(`),
1967
2300
  (name) => new RegExp(`\\b${name}\\b`)
1968
2301
  ];
2302
+ var TIERS = [declarationTier, assignmentTier, ...MENTION_TIERS];
2303
+ function resolveWith(tiers, source, symbol) {
2304
+ const segments = symbol.split(".");
2305
+ const name = segments[segments.length - 1];
2306
+ if (!name) return null;
2307
+ const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
2308
+ const escaped = escapeRegExp(name);
2309
+ const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
2310
+ const lines = source.split("\n");
2311
+ for (const tier of tiers) {
2312
+ const pattern = tier(escaped);
2313
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
2314
+ if (!candidates.length) continue;
2315
+ if (parentPattern && candidates.length > 1) {
2316
+ const distances = candidates.map(
2317
+ (index2) => distanceToParent(lines, index2, parentPattern)
2318
+ );
2319
+ const nearest = Math.min(...distances);
2320
+ if (Number.isFinite(nearest)) {
2321
+ candidates = candidates.filter((_, at2) => distances[at2] === nearest);
2322
+ }
2323
+ }
2324
+ if (candidates.length !== 1) return null;
2325
+ const matchLine = candidates[0];
2326
+ return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
2327
+ }
2328
+ return null;
2329
+ }
1969
2330
  var regexResolver = {
1970
2331
  name: "regex",
1971
2332
  resolve(source, symbol) {
1972
- const segments = symbol.split(".");
1973
- const name = segments[segments.length - 1];
1974
- if (!name) return null;
1975
- const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
1976
- const escaped = escapeRegExp(name);
1977
- const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
1978
- const lines = source.split("\n");
1979
- for (const tier of TIERS) {
1980
- const pattern = tier(escaped);
1981
- let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1982
- if (!candidates.length) continue;
1983
- if (parentPattern && candidates.length > 1) {
1984
- const distances = candidates.map(
1985
- (index2) => distanceToParent(lines, index2, parentPattern)
1986
- );
1987
- const nearest = Math.min(...distances);
1988
- if (Number.isFinite(nearest)) {
1989
- candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1990
- }
1991
- }
1992
- if (candidates.length !== 1) return null;
1993
- const matchLine = candidates[0];
1994
- return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
1995
- }
1996
- return null;
2333
+ return resolveWith(TIERS, source, symbol);
2334
+ },
2335
+ attempt(source, symbol, _file, options) {
2336
+ const tiers = options?.afterParsedMiss ? DEFINITION_TIERS : TIERS;
2337
+ const span2 = resolveWith(tiers, source, symbol);
2338
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1997
2339
  }
1998
2340
  };
1999
2341
  function escapeRegExp(value) {
@@ -2015,6 +2357,7 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
2015
2357
  }
2016
2358
  function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2017
2359
  const normalized = source.replace(/\r\n/g, "\n");
2360
+ if (anchor.span) return sliceSpan(normalized, anchor.span);
2018
2361
  if (!anchor.symbol) {
2019
2362
  const lines = normalized.split("\n");
2020
2363
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
@@ -2027,11 +2370,17 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2027
2370
  }
2028
2371
  };
2029
2372
  }
2373
+ let afterParsedMiss = false;
2030
2374
  for (const resolver of resolvers) {
2031
- const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
2375
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file, {
2376
+ afterParsedMiss
2377
+ }) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
2032
2378
  if (attempt.kind === "abstain") continue;
2033
2379
  if (attempt.kind === "unresolved") {
2034
- if (attempt.reason === "symbol-not-found") continue;
2380
+ if (attempt.reason === "symbol-not-found") {
2381
+ if (resolver.attempt) afterParsedMiss = true;
2382
+ continue;
2383
+ }
2035
2384
  return { ok: false, reason: attempt.reason };
2036
2385
  }
2037
2386
  const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
@@ -2044,12 +2393,28 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2044
2393
  }
2045
2394
  return { ok: false, reason: "symbol-not-found" };
2046
2395
  }
2396
+ function sliceSpan(source, range) {
2397
+ const lines = source.split("\n");
2398
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
2399
+ if (range.end > lines.length) {
2400
+ return { ok: false, reason: "span-out-of-range" };
2401
+ }
2402
+ return {
2403
+ ok: true,
2404
+ span: {
2405
+ text: lines.slice(range.start - 1, range.end).join("\n"),
2406
+ startLine: range.start,
2407
+ endLine: range.end
2408
+ },
2409
+ resolver: "span"
2410
+ };
2411
+ }
2047
2412
  function fromResolve(resolver, source, symbol, file) {
2048
2413
  const span2 = resolver.resolve(source, symbol, file);
2049
2414
  return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
2050
2415
  }
2051
2416
  function isResolverName(name) {
2052
- return name === "tree-sitter" || name === "regex";
2417
+ return name === "tree-sitter" || name === "regex" || name === "span";
2053
2418
  }
2054
2419
  async function prepareResolvers(resolvers, files) {
2055
2420
  for (const resolver of resolvers) await resolver.prepare?.(files);
@@ -2068,6 +2433,9 @@ function resolverChanged(source, anchor, produced) {
2068
2433
  return before !== null && hashAnchorText(before.text) === anchor.hash;
2069
2434
  }
2070
2435
  function anchorHashOf(anchor, outcome) {
2436
+ if (outcome.resolver === "span") {
2437
+ return { hash: hashAnchorText(outcome.span.text), kind: "raw" };
2438
+ }
2071
2439
  const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
2072
2440
  const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
2073
2441
  return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
@@ -2101,37 +2469,60 @@ async function detectAnchorDrift(records, options = {}) {
2101
2469
  }
2102
2470
  }
2103
2471
  const files = [];
2472
+ const committedWants = [];
2104
2473
  const wants = [];
2105
2474
  for (const entries of planned.values()) {
2106
2475
  for (const { anchor, foreign } of entries) {
2107
- if (!foreign) files.push(anchor.file);
2108
- else wants.push(...remoteWants(anchor));
2476
+ if (foreign) wants.push(...remoteWants(anchor));
2477
+ else if (anchor.side === "old") committedWants.push(anchor);
2478
+ else files.push(anchor.file);
2109
2479
  }
2110
2480
  }
2111
- const [reads, remote] = await Promise.all([
2481
+ const [reads, committed, remote] = await Promise.all([
2112
2482
  readAnchorFiles(
2113
2483
  files,
2114
2484
  options.reader ?? anchorFileReader(repoRoot),
2115
2485
  options.concurrency ?? DEFAULT_IO_CONCURRENCY
2116
2486
  ),
2487
+ readCommitted(repoRoot, committedWants, options),
2117
2488
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
2118
2489
  ]);
2119
2490
  await prepareResolvers(resolvers, [
2120
2491
  ...files,
2492
+ ...committedWants.map((anchor) => anchor.file),
2121
2493
  ...wants.map((want) => want.file)
2122
2494
  ]);
2123
2495
  const drift = /* @__PURE__ */ new Map();
2124
2496
  for (const record of records) {
2125
2497
  const entries = [];
2126
2498
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
2127
- entries.push(
2128
- foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
2129
- );
2499
+ if (foreign) {
2500
+ entries.push(remoteEntry(anchor, remote, resolvers));
2501
+ continue;
2502
+ }
2503
+ const read = anchor.side === "old" ? committed.get(atRefKey(anchor)) : reads.get(anchor.file);
2504
+ entries.push(localEntry(anchor, read, resolvers));
2130
2505
  }
2131
2506
  if (entries.length) drift.set(record.conceptId, entries);
2132
2507
  }
2133
2508
  return drift;
2134
2509
  }
2510
+ function atRefKey(anchor) {
2511
+ return `${anchor.ref ?? ""}\0${anchor.file}`;
2512
+ }
2513
+ async function readCommitted(repoRoot, anchors, options = {}) {
2514
+ if (!anchors.length) return /* @__PURE__ */ new Map();
2515
+ const read = options.readAtRef ?? readFileAtRef;
2516
+ const byKey = /* @__PURE__ */ new Map();
2517
+ for (const anchor of anchors) byKey.set(atRefKey(anchor), anchor);
2518
+ const keys = [...byKey.keys()];
2519
+ const results = await mapLimit(
2520
+ keys,
2521
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY,
2522
+ (key2) => read(repoRoot, byKey.get(key2))
2523
+ );
2524
+ return new Map(keys.map((key2, at2) => [key2, results[at2]]));
2525
+ }
2135
2526
  function remoteWants(anchor) {
2136
2527
  const repo = anchor.repo;
2137
2528
  const wants = [{ repo, file: anchor.file }];
@@ -2142,6 +2533,7 @@ function base(anchor) {
2142
2533
  return {
2143
2534
  file: anchor.file,
2144
2535
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
2536
+ ...anchor.side === "old" ? { side: "old" } : {},
2145
2537
  storedHash: anchor.hash
2146
2538
  };
2147
2539
  }
@@ -2155,9 +2547,15 @@ function unresolved(anchor, reason, repo) {
2155
2547
  ...classOf(reason)
2156
2548
  };
2157
2549
  }
2550
+ var GONE_REASONS = /* @__PURE__ */ new Set([
2551
+ "file-missing",
2552
+ "symbol-not-found",
2553
+ "span-out-of-range",
2554
+ "ref-unreadable"
2555
+ ]);
2158
2556
  function provisionalDriftClass(entry) {
2159
2557
  if (entry.state === "unresolved") {
2160
- return entry.reason === "file-missing" || entry.reason === "symbol-not-found" ? "gone" : void 0;
2558
+ return GONE_REASONS.has(entry.reason) ? "gone" : void 0;
2161
2559
  }
2162
2560
  return entry.state === "drifted" ? "changed" : void 0;
2163
2561
  }
@@ -2226,6 +2624,13 @@ function remoteEntry(anchor, remote, resolvers) {
2226
2624
  remoteState: "drifted-from-ref"
2227
2625
  });
2228
2626
  }
2627
+ if (anchor.side === "old") {
2628
+ return compared(anchor, current, {
2629
+ repo,
2630
+ ...extras,
2631
+ remoteState: "matches-ref"
2632
+ });
2633
+ }
2229
2634
  const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
2230
2635
  return head?.ok && head.current.hash !== anchor.hash ? {
2231
2636
  ...compared(anchor, head.current, {
@@ -2261,19 +2666,28 @@ var kbLogEntrySchema = import_zod3.z.object({
2261
2666
  by: import_zod3.z.string().min(1),
2262
2667
  operation: import_zod3.z.string().min(1),
2263
2668
  conceptId: import_zod3.z.string().min(1),
2264
- /** Second concept id, where the operation relates two — supersession. */
2669
+ /**
2670
+ * The operation's other end, where it has one: a second concept id for
2671
+ * supersession, the other base's path for promotion.
2672
+ */
2265
2673
  target: import_zod3.z.string().min(1).optional()
2266
2674
  }).strict();
2267
2675
  function renderLogEntry(entry) {
2268
2676
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
2269
2677
  `;
2270
2678
  }
2679
+ var CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})/;
2271
2680
  function parseLog(raw) {
2272
2681
  const entries = [];
2273
2682
  const malformed = [];
2274
2683
  const seen = /* @__PURE__ */ new Set();
2684
+ let conflicted = false;
2275
2685
  raw.split("\n").forEach((text, index2) => {
2276
2686
  if (!text.trim()) return;
2687
+ if (CONFLICT_MARKER.test(text)) {
2688
+ conflicted = true;
2689
+ return;
2690
+ }
2277
2691
  let value;
2278
2692
  try {
2279
2693
  value = JSON.parse(text);
@@ -2294,7 +2708,7 @@ function parseLog(raw) {
2294
2708
  entries.sort(
2295
2709
  (left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
2296
2710
  );
2297
- return { entries, malformed };
2711
+ return { entries, malformed, conflicted };
2298
2712
  }
2299
2713
 
2300
2714
  // src/search-index.ts
@@ -2343,8 +2757,8 @@ async function searchBase(bundlePath2, query, options = {}) {
2343
2757
  async function isStale(bundlePath2) {
2344
2758
  const indexAt = await (0, import_promises5.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
2345
2759
  if (!indexAt) return true;
2346
- const { readdir: readdir2 } = await import("fs/promises");
2347
- const names = (await readdir2(bundlePath2).catch(() => [])).filter(
2760
+ const { readdir: readdir3 } = await import("fs/promises");
2761
+ const names = (await readdir3(bundlePath2).catch(() => [])).filter(
2348
2762
  (name) => name.endsWith(".md") && name !== INDEX_FILE
2349
2763
  );
2350
2764
  let stale = false;
@@ -2759,14 +3173,14 @@ function catalog(bundle, options = {}) {
2759
3173
  supersededBy: hit.heads.map((head) => head.conceptId),
2760
3174
  stale: hit.warnings.some((warning) => warning.kind === "stale")
2761
3175
  })).sort(byTypeThenTitle);
2762
- const standings = { ...EMPTY_STANDINGS };
2763
- for (const entry of entries) standings[entry.standing] += 1;
3176
+ const standings2 = { ...EMPTY_STANDINGS };
3177
+ for (const entry of entries) standings2[entry.standing] += 1;
2764
3178
  return {
2765
3179
  entries,
2766
3180
  recordCount: entries.length,
2767
- standings,
2768
- currentCount: standings.current,
2769
- supersededCount: standings.superseded,
3181
+ standings: standings2,
3182
+ currentCount: standings2.current,
3183
+ supersededCount: standings2.superseded,
2770
3184
  staleCount: entries.filter((entry) => entry.stale).length
2771
3185
  };
2772
3186
  }
@@ -2922,9 +3336,13 @@ function hasEdge(edges, edge) {
2922
3336
  );
2923
3337
  }
2924
3338
 
3339
+ // src/kb-files.ts
3340
+ var STORE_OWNED_FILES = [INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE];
3341
+
2925
3342
  // src/kb-gitattributes.ts
2926
3343
  var GITATTRIBUTES_FILE = ".gitattributes";
2927
- var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
3344
+ var GENERATED = "linguist-generated";
3345
+ var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union ${GENERATED}=true`;
2928
3346
  function parseLine(line) {
2929
3347
  const trimmed = line.trim();
2930
3348
  if (!trimmed || trimmed.startsWith("#")) return null;
@@ -2932,23 +3350,39 @@ function parseLine(line) {
2932
3350
  return pattern === void 0 ? null : { pattern, attrs };
2933
3351
  }
2934
3352
  function hasMergeDeclaration(contents) {
3353
+ return declares(contents, LOG_FILE, "merge");
3354
+ }
3355
+ function declares(contents, pattern, attribute) {
2935
3356
  return contents.split("\n").some((line) => {
2936
3357
  const parsed = parseLine(line);
2937
- if (!parsed || parsed.pattern !== LOG_FILE) return false;
3358
+ if (!parsed || parsed.pattern !== pattern) return false;
2938
3359
  return parsed.attrs.some(
2939
- (attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
3360
+ (attr) => attr === attribute || attr === `-${attribute}` || attr.startsWith(`${attribute}=`)
2940
3361
  );
2941
3362
  });
2942
3363
  }
2943
- function appendUnionMergeLine(contents) {
3364
+ function missingGitattributesLines(contents) {
3365
+ const needsMerge = !hasMergeDeclaration(contents);
3366
+ const generated = STORE_OWNED_FILES.filter(
3367
+ // The union-merge line carries the log's `linguist-generated` too, so the
3368
+ // log needs its own line only where that line is already there without it.
3369
+ (file) => !(needsMerge && file === LOG_FILE)
3370
+ ).filter((file) => !declares(contents, file, GENERATED)).map((file) => `${file} ${GENERATED}=true`);
3371
+ return needsMerge ? [UNION_MERGE_LINE, ...generated] : generated;
3372
+ }
3373
+ var GITATTRIBUTES_BLOCK = `${missingGitattributesLines("").join("\n")}
3374
+ `;
3375
+ function appendGitattributesLines(contents) {
3376
+ const lines = missingGitattributesLines(contents);
3377
+ if (lines.length === 0) return "";
2944
3378
  const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
2945
- return `${separator}${UNION_MERGE_LINE}
3379
+ return `${separator}${lines.join("\n")}
2946
3380
  `;
2947
3381
  }
2948
3382
 
2949
3383
  // src/kb-store.ts
2950
3384
  var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
2951
- var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
3385
+ var STORE_OWNED = new Set(STORE_OWNED_FILES);
2952
3386
  var DEFAULT_LOAD_BUDGET = 25e3;
2953
3387
  var KbStore = class {
2954
3388
  constructor(logger = {}) {
@@ -2961,6 +3395,7 @@ var KbStore = class {
2961
3395
  * its contents.
2962
3396
  */
2963
3397
  async write(bundlePath2, input, actor = "unknown") {
3398
+ assertActor(actor);
2964
3399
  if (!KB_SLUG_PATTERN.test(input.slug)) {
2965
3400
  throw new KbInvalidConceptIdError("slug must be kebab-case", {
2966
3401
  slug: input.slug
@@ -3062,6 +3497,7 @@ var KbStore = class {
3062
3497
  * timeouts.
3063
3498
  */
3064
3499
  async setStatus(bundlePath2, conceptId2, status, actor = "unknown") {
3500
+ assertActor(actor);
3065
3501
  return this.mutate(
3066
3502
  bundlePath2,
3067
3503
  conceptId2,
@@ -3075,12 +3511,17 @@ var KbStore = class {
3075
3511
  * Wholesale rather than merged: the caller just resolved the anchors it is
3076
3512
  * writing, so it holds the complete current set, and a merge would keep
3077
3513
  * stale entries the resolution pass deliberately dropped.
3514
+ *
3515
+ * Through the write schema: this is a write, and a defect a hand-edit put in
3516
+ * the frontmatter must not be published back out under an actor stamp.
3078
3517
  */
3079
3518
  async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
3519
+ assertActor(actor);
3520
+ const checked = anchors.map((anchor) => kbAnchorWriteSchema.parse(anchor));
3080
3521
  return this.mutate(
3081
3522
  bundlePath2,
3082
3523
  conceptId2,
3083
- (frontmatter) => ({ ...frontmatter, strauss_anchors: anchors }),
3524
+ (frontmatter) => ({ ...frontmatter, strauss_anchors: checked }),
3084
3525
  { operation: "anchor-resolve", by: actor }
3085
3526
  );
3086
3527
  }
@@ -3096,6 +3537,7 @@ var KbStore = class {
3096
3537
  * logs what it publishes.
3097
3538
  */
3098
3539
  async verify(bundlePath2, conceptId2, note, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
3540
+ assertActor(actor, { named: true });
3099
3541
  const event = kbVerifiedEventSchema.parse({ by: actor, at: at2, note });
3100
3542
  const existing = await this.read(bundlePath2, conceptId2);
3101
3543
  if (!existing) throw new KbRecordNotFoundError(conceptId2);
@@ -3126,6 +3568,7 @@ var KbStore = class {
3126
3568
  * in normal use, and validation drops to catching hand-edits.
3127
3569
  */
3128
3570
  async supersede(bundlePath2, conceptId2, replacementId, actor = "unknown") {
3571
+ assertActor(actor);
3129
3572
  const replacement = await this.read(bundlePath2, replacementId);
3130
3573
  if (!replacement) throw new KbRecordNotFoundError(replacementId);
3131
3574
  const superseded = await this.markSuperseded(
@@ -3149,6 +3592,7 @@ var KbStore = class {
3149
3592
  }
3150
3593
  /** Resolves an open question, stamping who answered and when. */
3151
3594
  async answer(bundlePath2, conceptId2, answer, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
3595
+ assertActor(actor);
3152
3596
  return this.mutate(
3153
3597
  bundlePath2,
3154
3598
  conceptId2,
@@ -3166,6 +3610,36 @@ ${answer}
3166
3610
  `
3167
3611
  );
3168
3612
  }
3613
+ /**
3614
+ * Removes one record, logged as `sweep`. The only path in this store that
3615
+ * deletes — see the specification for the scope that makes it safe.
3616
+ *
3617
+ * `expected` is re-read and re-checked immediately before the unlink, the
3618
+ * compare-and-swap `mutate` makes: a record retagged or moved out of a
3619
+ * terminal status since the caller listed it is reported, not removed.
3620
+ */
3621
+ async deleteRecord(bundlePath2, conceptId2, expected, actor = "unknown") {
3622
+ assertActor(actor);
3623
+ const target = this.recordPath(bundlePath2, conceptId2);
3624
+ const witness = await this.read(bundlePath2, conceptId2);
3625
+ if (!witness) throw new KbRecordNotFoundError(conceptId2);
3626
+ const { tags, strauss_status } = witness.frontmatter;
3627
+ if (!(tags ?? []).includes(expected.tag) || !expected.statuses.includes(strauss_status)) {
3628
+ return "changed-since-listing";
3629
+ }
3630
+ try {
3631
+ await (0, import_promises6.unlink)(target);
3632
+ } catch (error) {
3633
+ if (error.code !== "ENOENT") throw error;
3634
+ throw new KbRecordNotFoundError(conceptId2);
3635
+ }
3636
+ await this.record(this.root(bundlePath2), {
3637
+ operation: "sweep",
3638
+ by: actor,
3639
+ conceptId: conceptId2
3640
+ });
3641
+ return "deleted";
3642
+ }
3169
3643
  /**
3170
3644
  * Records matching a text query, each carrying its standing.
3171
3645
  *
@@ -3401,12 +3875,25 @@ ${answer}
3401
3875
  }
3402
3876
  return expected;
3403
3877
  }
3878
+ /**
3879
+ * Drops the derived search index, so the next search rebuilds it.
3880
+ *
3881
+ * `searchBase` re-indexes when a record is newer than the index, which no
3882
+ * deletion makes true — a swept record would stay findable until some other
3883
+ * record was written.
3884
+ */
3885
+ async dropSearchIndex(bundlePath2) {
3886
+ await (0, import_promises6.unlink)((0, import_node_path7.join)(this.root(bundlePath2), SEARCH_INDEX_FILE)).catch(
3887
+ () => void 0
3888
+ );
3889
+ }
3404
3890
  /**
3405
3891
  * The log, with unparseable lines reported rather than repaired.
3406
3892
  *
3407
3893
  * The log is the bundle's only artifact that cannot be reconstructed — the
3408
3894
  * records rebuild the index, and the code outlives both, but nothing else
3409
3895
  * knows which agent touched what. So a bad line is surfaced and left alone.
3896
+ * Conflict markers are read past rather than reported per line.
3410
3897
  */
3411
3898
  async readLog(bundlePath2) {
3412
3899
  const raw = await (0, import_promises6.readFile)(
@@ -3414,6 +3901,13 @@ ${answer}
3414
3901
  "utf8"
3415
3902
  ).catch(() => "");
3416
3903
  const result = parseLog(raw);
3904
+ if (result.conflicted) {
3905
+ this.logger.warn?.({
3906
+ operation: "kb.log.parse",
3907
+ bundlePath: this.root(bundlePath2),
3908
+ outcome: "conflicted"
3909
+ });
3910
+ }
3417
3911
  for (const bad of result.malformed) {
3418
3912
  this.logger.warn?.({
3419
3913
  operation: "kb.log.parse",
@@ -3423,6 +3917,15 @@ ${answer}
3423
3917
  }
3424
3918
  return result;
3425
3919
  }
3920
+ /**
3921
+ * Appends one log entry for a move the store cannot see from one base.
3922
+ * Promotion writes into a target base and has to be legible from the source
3923
+ * base too, where nothing was written.
3924
+ */
3925
+ async note(bundlePath2, entry) {
3926
+ assertActor(entry.by);
3927
+ await this.record(this.root(bundlePath2), entry);
3928
+ }
3426
3929
  /**
3427
3930
  * `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
3428
3931
  * a missing target (a broken link, legal per compose.ts) or a CAS conflict
@@ -3513,7 +4016,8 @@ ${answer}
3513
4016
  /**
3514
4017
  * Declares union merge for the log, so two worktrees writing the same
3515
4018
  * bundle interleave their `log.jsonl` lines on merge rather than one
3516
- * side's appends silently losing to git's ordinary line-level merge.
4019
+ * side's appends silently losing to git's ordinary line-level merge — and
4020
+ * marks every store-owned file generated, so GitHub collapses it in a diff.
3517
4021
  *
3518
4022
  * Called from `record` — every path that appends a log line, not just
3519
4023
  * `write` — so a bundle only ever mutated through `setStatus`/`verify`/
@@ -3526,10 +4030,9 @@ ${answer}
3526
4030
  * race and created the file between the `readFile` below and this call,
3527
4031
  * `wx` fails instead of truncating what that writer just wrote, and the
3528
4032
  * failure is swallowed by the catch below same as any other best-effort
3529
- * miss. A file that exists but declares no merge strategy for the log
3530
- * gets the line appended, never a wholesale rewrite; one that already
3531
- * declares any merge strategy — this one or a user's own — is left alone
3532
- * entirely (see `hasMergeDeclaration`).
4033
+ * miss. A file that exists gets only the lines it lacks appended, never a
4034
+ * wholesale rewrite; an attribute it already sets this one's value or a
4035
+ * user's own — is left alone (see `missingGitattributesLines`).
3533
4036
  *
3534
4037
  * `readFile` failing is `existing === null` only for `ENOENT` — genuinely
3535
4038
  * missing. Any other error (a permission problem, a transient `EMFILE`,
@@ -3540,12 +4043,12 @@ ${answer}
3540
4043
  * therefore left untouched and reported as a failure like any other.
3541
4044
  *
3542
4045
  * Two processes racing the append branch — both read a file without the
3543
- * line, both append it — is possible and left unguarded: `appendFile` is
3544
- * `O_APPEND`, so the result is two copies of the same line rather than a
3545
- * torn write, and `hasMergeDeclaration` sees a duplicate declaration as
3546
- * "already declared" on the next call. A cheap-to-detect, harmless-to-
3547
- * leave residue, not a reason to add a cross-process lock (see
3548
- * `ARCHITECTURE.md`'s rejection of one for the same trade on records).
4046
+ * lines, both append them — is possible and left unguarded: `appendFile` is
4047
+ * `O_APPEND`, so the result is two copies of the same lines rather than a
4048
+ * torn write, and the next call sees a duplicate declaration as "already
4049
+ * declared". A cheap-to-detect, harmless-to-leave residue, not a reason to
4050
+ * add a cross-process lock (see `ARCHITECTURE.md`'s rejection of one for
4051
+ * the same trade on records).
3549
4052
  *
3550
4053
  * Best-effort, like the log append it precedes: failing to write this
3551
4054
  * file must not fail the mutation it guards.
@@ -3562,7 +4065,7 @@ ${answer}
3562
4065
  }
3563
4066
  if (existing === null) {
3564
4067
  try {
3565
- await (0, import_promises6.writeFile)(target, appendUnionMergeLine(""), {
4068
+ await (0, import_promises6.writeFile)(target, appendGitattributesLines(""), {
3566
4069
  encoding: "utf8",
3567
4070
  flag: "wx"
3568
4071
  });
@@ -3582,8 +4085,9 @@ ${answer}
3582
4085
  });
3583
4086
  return;
3584
4087
  }
3585
- if (!hasMergeDeclaration(existing)) {
3586
- await (0, import_promises6.appendFile)(target, appendUnionMergeLine(existing), "utf8");
4088
+ const addition = appendGitattributesLines(existing);
4089
+ if (addition) {
4090
+ await (0, import_promises6.appendFile)(target, addition, "utf8");
3587
4091
  this.logger.info?.({
3588
4092
  operation: "kb.gitattributes.ensure",
3589
4093
  bundlePath: root,
@@ -3677,6 +4181,15 @@ function normalizeActor(id) {
3677
4181
  if (colon === -1) return id.toLowerCase();
3678
4182
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
3679
4183
  }
4184
+ var KB_ACTOR_PATTERN = /^[A-Za-z][\w-]*(?::[\p{L}\p{M}\p{N}_.@+/-]+)?$/u;
4185
+ function assertActor(actor, { named = false } = {}) {
4186
+ if (!KB_ACTOR_PATTERN.test(actor)) {
4187
+ throw new KbInvalidActorError(actor, "is not kind or kind:name");
4188
+ }
4189
+ if (named && normalizeActor(actor) === "unknown") {
4190
+ throw new KbInvalidActorError(actor, "cannot verify: name who checked");
4191
+ }
4192
+ }
3680
4193
 
3681
4194
  // src/compose.ts
3682
4195
  var import_zod4 = require("zod");
@@ -3692,7 +4205,7 @@ var composeInputSchema = import_zod4.z.object({
3692
4205
  why: import_zod4.z.string().min(1),
3693
4206
  /** Keyed by section heading from the type's spec. Unknown keys rejected. */
3694
4207
  sections: import_zod4.z.record(import_zod4.z.string(), import_zod4.z.string().min(1)).optional(),
3695
- anchors: import_zod4.z.array(kbAnchorSchema).optional(),
4208
+ anchors: import_zod4.z.array(kbAnchorWriteSchema).optional(),
3696
4209
  sources: import_zod4.z.array(kbSourceSchema).optional(),
3697
4210
  /** No source exists, as a claim rather than a sentinel in `sources`. */
3698
4211
  assumption: import_zod4.z.boolean().optional(),
@@ -4352,7 +4865,7 @@ function kbJsonSchemas() {
4352
4865
 
4353
4866
  // src/match-diff.ts
4354
4867
  function matchToDiff(files, records, options = {}) {
4355
- const ranges = indexRanges(options.symbolRanges ?? []);
4868
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
4356
4869
  const anchored = records.filter(
4357
4870
  (record) => (record.frontmatter.strauss_anchors ?? []).length > 0
4358
4871
  );
@@ -4370,8 +4883,8 @@ function matchToDiff(files, records, options = {}) {
4370
4883
  let precision = "symbol";
4371
4884
  for (const { record, anchors } of candidates) {
4372
4885
  const placement = place(anchors, file.filePath, hunk, ranges);
4373
- if (placement === "miss") continue;
4374
- if (placement === "file") precision = "file";
4886
+ if (placement.kind === "miss") continue;
4887
+ if (placement.kind === "file") precision = "file";
4375
4888
  hits.push(record);
4376
4889
  }
4377
4890
  if (!hits.length) continue;
@@ -4385,19 +4898,51 @@ function matchToDiff(files, records, options = {}) {
4385
4898
  }
4386
4899
  return matches3;
4387
4900
  }
4901
+ function placeOnHunk(record, filePath, hunk, symbolRanges = []) {
4902
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
4903
+ (anchor) => normalize(anchor.file) === normalize(filePath)
4904
+ );
4905
+ return place(anchors, filePath, hunk, asIndex(symbolRanges));
4906
+ }
4907
+ function asIndex(ranges) {
4908
+ return isIndex(ranges) ? ranges : symbolRangeIndex(ranges);
4909
+ }
4910
+ function isIndex(ranges) {
4911
+ return !Array.isArray(ranges);
4912
+ }
4913
+ function anchorOnHunk(record, filePath, hunk, symbolRanges = []) {
4914
+ return placeOnHunk(record, filePath, hunk, symbolRanges).anchor;
4915
+ }
4388
4916
  function place(anchors, filePath, hunk, ranges) {
4389
- let fallback = "miss";
4917
+ let fallback = { kind: "miss" };
4390
4918
  for (const anchor of anchors) {
4391
- if (!anchor.symbol) return "file";
4392
- const resolved = ranges.get(key(filePath, anchor.symbol));
4919
+ if (side(anchor.side) !== side(hunk.side)) continue;
4920
+ if (anchor.span) {
4921
+ if (overlaps(
4922
+ { startLine: anchor.span.start, endLine: anchor.span.end },
4923
+ hunk
4924
+ )) {
4925
+ return { kind: "symbol", anchor };
4926
+ }
4927
+ continue;
4928
+ }
4929
+ if (!anchor.symbol) return { kind: "file", anchor };
4930
+ const resolved = ranges.get(
4931
+ key(filePath, anchor.symbol, side(anchor.side))
4932
+ );
4393
4933
  if (!resolved?.length) {
4394
- fallback = "file";
4934
+ if (fallback.kind === "miss") fallback = { kind: "file", anchor };
4395
4935
  continue;
4396
4936
  }
4397
- if (resolved.some((range) => overlaps(range, hunk))) return "symbol";
4937
+ if (resolved.some((range) => overlaps(range, hunk))) {
4938
+ return { kind: "symbol", anchor };
4939
+ }
4398
4940
  }
4399
4941
  return fallback;
4400
4942
  }
4943
+ function side(value) {
4944
+ return value ?? "new";
4945
+ }
4401
4946
  function overlaps(range, hunk) {
4402
4947
  return range.startLine <= hunk.endLine && hunk.startLine <= range.endLine;
4403
4948
  }
@@ -4415,29 +4960,198 @@ function order(records) {
4415
4960
  )
4416
4961
  );
4417
4962
  }
4418
- function indexRanges(ranges) {
4963
+ function symbolRangeIndex(ranges) {
4419
4964
  const byKey = /* @__PURE__ */ new Map();
4420
4965
  for (const range of ranges) {
4421
- const id = key(range.file, range.symbol);
4966
+ const id = key(range.file, range.symbol, side(range.side));
4422
4967
  byKey.set(id, [...byKey.get(id) ?? [], range]);
4423
4968
  }
4424
4969
  return byKey;
4425
4970
  }
4426
- function key(file, symbol) {
4427
- return `${normalize(file)}#${symbol}`;
4971
+ function key(file, symbol, at2) {
4972
+ return `${normalize(file)}#${symbol}#${at2}`;
4428
4973
  }
4429
4974
  function normalize(path) {
4430
4975
  return path.replace(/^\.\//, "");
4431
4976
  }
4432
4977
 
4433
- // src/validate.ts
4434
- function validateBundle(records) {
4435
- const byId = new Map(records.map((record) => [record.conceptId, record]));
4436
- const problems = [];
4437
- const report = (check, conceptId2, note, severity = "error") => problems.push({ check, conceptId: conceptId2, note, severity });
4438
- for (const record of records) {
4439
- const { conceptId: conceptId2, frontmatter: fm } = record;
4440
- if (!isKbRecordType(fm.type)) {
4978
+ // src/classify/model.ts
4979
+ var KB_CLASSES = [
4980
+ "test",
4981
+ "config",
4982
+ "ci",
4983
+ "docs",
4984
+ "lockfile",
4985
+ "generated",
4986
+ "boilerplate",
4987
+ "rename",
4988
+ "source"
4989
+ ];
4990
+ var DEFAULT_THRESHOLDS = {
4991
+ boilerplate: 0.8,
4992
+ rename: 90
4993
+ };
4994
+
4995
+ // src/classify/rules.ts
4996
+ var PATH_RULES = [
4997
+ {
4998
+ name: "test-path",
4999
+ class: "test",
5000
+ test: /(^|\/)(__tests__|__mocks__|tests?)\/|\.(spec|test)\.[^/]+$/
5001
+ },
5002
+ {
5003
+ name: "ci-path",
5004
+ class: "ci",
5005
+ test: /(^|\/)\.github\/|(^|\/)(\.circleci|\.buildkite|\.gitlab|ci)\/[^/]*\.ya?ml$|(^|\/)Dockerfile(\.[^/]*)?$|\.tf$/
5006
+ },
5007
+ {
5008
+ name: "docs-path",
5009
+ class: "docs",
5010
+ test: /\.md$|(^|\/)docs\/|(^|\/)LICENSE(\.(md|txt|rst))?$/
5011
+ },
5012
+ {
5013
+ name: "lockfile-path",
5014
+ class: "lockfile",
5015
+ test: /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|Cargo\.lock|go\.sum)$/
5016
+ },
5017
+ {
5018
+ name: "config-path",
5019
+ class: "config",
5020
+ // `.jsonl` rides with `.json`: an append-only log of JSON is configuration
5021
+ // data too, and calling it source would send a reviewer to read it. Every
5022
+ // arm is anchored at both ends: `src/tsconfig-loader.ts` and `report.env.ts`
5023
+ // are source, not config.
5024
+ test: /\.(jsonc?|jsonl|ya?ml|toml|ini)$|(^|\/)\.env(?![^/]*\.[cm]?[jt]sx?$)([.-][^/]*)?$|[^/]+\.env$|(^|\/)tsconfig[^/]*\.json$|\.config\.[^/]+$|(^|\/)\.(eslintrc|prettierrc)[^/]*$/
5025
+ }
5026
+ ];
5027
+ var HEADER_LINES = 20;
5028
+ var GENERATED_MARKERS = [
5029
+ /@generated\b/i,
5030
+ /\bdo not edit\b/i,
5031
+ /\bcode generated by\b/i,
5032
+ /\bthis file was automatically generated\b/i
5033
+ ];
5034
+ var BOILERPLATE_SHAPES = [
5035
+ { name: "import", test: /^import\b|^\}\s*from\s+["']/ },
5036
+ { name: "re-export", test: /^export\s+(\*|\{|type\s*[{*])/ },
5037
+ {
5038
+ name: "class-shell",
5039
+ test: /^(export\s+)?(default\s+)?(abstract\s+)?class\s+[\w$]+[^{]*\{\s*\}$/
5040
+ },
5041
+ { name: "punctuation", test: /^[{}()[\],;]+$/ }
5042
+ ];
5043
+ function generatedMarker(lines) {
5044
+ for (const line of lines.slice(0, HEADER_LINES)) {
5045
+ const hit = GENERATED_MARKERS.find((marker) => marker.test(line));
5046
+ if (hit) return hit.source.replaceAll("\\b", "");
5047
+ }
5048
+ return void 0;
5049
+ }
5050
+ function isBoilerplateLine(line) {
5051
+ return BOILERPLATE_SHAPES.some((shape) => shape.test.test(line));
5052
+ }
5053
+
5054
+ // src/classify/classify.ts
5055
+ function classifyDiff(files, options = {}) {
5056
+ const overrides = currentOverrides(options.records ?? [], options.now);
5057
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
5058
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds };
5059
+ return files.map((file) => classifyFile(file, overrides, ranges, thresholds));
5060
+ }
5061
+ var OVERRIDE_CLASS = /* @__PURE__ */ new Map([
5062
+ ["review:generated", "generated"],
5063
+ ["review:boilerplate", "boilerplate"],
5064
+ ["review:move", "rename"]
5065
+ ]);
5066
+ var WHOLE_FILE = {
5067
+ startLine: 1,
5068
+ endLine: Number.MAX_SAFE_INTEGER
5069
+ };
5070
+ function classifyFile(file, overrides, ranges, thresholds) {
5071
+ const whole = overrides.find(
5072
+ ({ record }) => placeOnHunk(record, file.filePath, WHOLE_FILE, ranges).kind === "file"
5073
+ );
5074
+ const verdict = whole ? verdictOf(whole) : heuristic(file, thresholds);
5075
+ const hunks = file.hunks.map((hunk) => {
5076
+ const hit = whole ?? overrides.find(
5077
+ ({ record }) => placeOnHunk(record, file.filePath, hunk, ranges).kind !== "miss"
5078
+ );
5079
+ return {
5080
+ startLine: hunk.startLine,
5081
+ endLine: hunk.endLine,
5082
+ ...hit ? verdictOf(hit) : verdict
5083
+ };
5084
+ });
5085
+ return {
5086
+ filePath: file.filePath,
5087
+ ...verdict,
5088
+ ...file.renamedFrom ? { renamedFrom: file.renamedFrom } : {},
5089
+ ...hunks.some((hunk) => hunk.class !== verdict.class) ? { hunks } : {}
5090
+ };
5091
+ }
5092
+ function verdictOf(override) {
5093
+ return {
5094
+ class: override.class,
5095
+ reason: `kb-override ${override.record.conceptId}`
5096
+ };
5097
+ }
5098
+ function heuristic(file, thresholds) {
5099
+ const marker = generatedMarker(file.header ?? headOfDiff(file));
5100
+ if (marker)
5101
+ return { class: "generated", reason: `generated-header ${marker}` };
5102
+ const rule = PATH_RULES.find((entry) => entry.test.test(file.filePath));
5103
+ if (rule) return { class: rule.class, reason: rule.name };
5104
+ if (file.renamedFrom && !file.hunks.length && (file.similarity ?? 100) >= thresholds.rename) {
5105
+ return { class: "rename", reason: `rename ${file.renamedFrom}` };
5106
+ }
5107
+ const share = boilerplateShare(file);
5108
+ if (share !== void 0 && share >= thresholds.boilerplate) {
5109
+ return {
5110
+ class: "boilerplate",
5111
+ reason: `boilerplate ${Math.round(share * 100)}%`
5112
+ };
5113
+ }
5114
+ return { class: "source", reason: "default" };
5115
+ }
5116
+ function headOfDiff(file) {
5117
+ return file.hunks.flatMap(
5118
+ (hunk) => (hunk.side ?? "new") === "new" && hunk.startLine <= HEADER_LINES ? (hunk.lines ?? []).slice(0, HEADER_LINES - hunk.startLine + 1) : []
5119
+ );
5120
+ }
5121
+ function boilerplateShare(file) {
5122
+ const lines = file.hunks.flatMap((hunk) => hunk.lines ?? []).map((line) => line.trim()).filter(Boolean);
5123
+ if (!lines.length) return void 0;
5124
+ return lines.filter(isBoilerplateLine).length / lines.length;
5125
+ }
5126
+ function currentOverrides(records, now) {
5127
+ const tagged = records.flatMap((record) => {
5128
+ if (record.frontmatter.type !== "fact") return [];
5129
+ const tag = (record.frontmatter.tags ?? []).find(
5130
+ (entry) => OVERRIDE_CLASS.has(entry)
5131
+ );
5132
+ const asserted = tag && OVERRIDE_CLASS.get(tag);
5133
+ return asserted ? [{ record, class: asserted }] : [];
5134
+ });
5135
+ const current = new Set(
5136
+ adjudicate(
5137
+ tagged.map(({ record }) => record),
5138
+ records,
5139
+ now
5140
+ ).filter((entry) => entry.standing === "current").map((entry) => entry.record.conceptId)
5141
+ );
5142
+ return tagged.filter(({ record }) => current.has(record.conceptId)).sort(
5143
+ (left, right) => left.record.conceptId.localeCompare(right.record.conceptId)
5144
+ );
5145
+ }
5146
+
5147
+ // src/validate.ts
5148
+ function validateBundle(records) {
5149
+ const byId = new Map(records.map((record) => [record.conceptId, record]));
5150
+ const problems = [];
5151
+ const report = (check, conceptId2, note, severity = "error") => problems.push({ check, conceptId: conceptId2, note, severity });
5152
+ for (const record of records) {
5153
+ const { conceptId: conceptId2, frontmatter: fm } = record;
5154
+ if (!isKbRecordType(fm.type)) {
4441
5155
  report("type", conceptId2, `unrecognised type "${fm.type}"`);
4442
5156
  }
4443
5157
  if (fm.strauss_status === "superseded") {
@@ -4489,6 +5203,34 @@ function validateBundle(records) {
4489
5203
  }
4490
5204
  }
4491
5205
  for (const anchor of fm.strauss_anchors ?? []) {
5206
+ if (anchor.span && anchor.symbol) {
5207
+ report(
5208
+ "anchor_span",
5209
+ conceptId2,
5210
+ `anchor ${anchor.file} names both a symbol and a span \u2014 one or the other`
5211
+ );
5212
+ }
5213
+ if (anchor.span && anchor.span.end < anchor.span.start) {
5214
+ report(
5215
+ "anchor_span",
5216
+ conceptId2,
5217
+ `anchor ${anchor.file} span ${anchor.span.start}-${anchor.span.end} ends before it starts`
5218
+ );
5219
+ }
5220
+ if (anchor.span && anchor.hash_kind === "ast") {
5221
+ report(
5222
+ "anchor_span",
5223
+ conceptId2,
5224
+ `anchor ${anchor.file} is a span with hash_kind: "ast" \u2014 a span is hashed raw`
5225
+ );
5226
+ }
5227
+ if (anchor.side === "old" && !anchor.ref) {
5228
+ report(
5229
+ "anchor_side",
5230
+ conceptId2,
5231
+ `anchor ${anchor.file} is side: "old" with no ref`
5232
+ );
5233
+ }
4492
5234
  if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
4493
5235
  report(
4494
5236
  "anchor_repo",
@@ -4535,14 +5277,19 @@ var DAY_MS = 864e5;
4535
5277
  function anchorResolverCounts(bundle) {
4536
5278
  let treeSitter = 0;
4537
5279
  let regex = 0;
5280
+ let span2 = 0;
5281
+ let oldSide = 0;
4538
5282
  for (const record of bundle) {
4539
5283
  for (const anchor of record.frontmatter.strauss_anchors ?? []) {
4540
- if (!anchor.hash || !anchor.symbol) continue;
4541
- if (anchor.resolver === "tree-sitter") treeSitter += 1;
5284
+ if (!anchor.hash) continue;
5285
+ if (anchor.side === "old") oldSide += 1;
5286
+ if (anchor.span) span2 += 1;
5287
+ else if (!anchor.symbol) continue;
5288
+ else if (anchor.resolver === "tree-sitter") treeSitter += 1;
4542
5289
  else regex += 1;
4543
5290
  }
4544
5291
  }
4545
- return { total: treeSitter + regex, treeSitter, regex };
5292
+ return { total: treeSitter + regex + span2, treeSitter, regex, span: span2, oldSide };
4546
5293
  }
4547
5294
  function doctor(bundle, options = {}) {
4548
5295
  const thresholds = {
@@ -4552,7 +5299,7 @@ function doctor(bundle, options = {}) {
4552
5299
  };
4553
5300
  const now = options.now ?? /* @__PURE__ */ new Date();
4554
5301
  const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
4555
- const standings = new Map(
5302
+ const standings2 = new Map(
4556
5303
  adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
4557
5304
  );
4558
5305
  const inForce = adjudicated.filter(
@@ -4565,7 +5312,7 @@ function doctor(bundle, options = {}) {
4565
5312
  group("aging", aging(inForce, now, thresholds.agingDays)),
4566
5313
  group("orphaned", orphaned(bundle)),
4567
5314
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
4568
- group("superseded-but-cited", supersededButCited(bundle, standings)),
5315
+ group("superseded-but-cited", supersededButCited(bundle, standings2)),
4569
5316
  group("drifted", drifted(inForce)),
4570
5317
  group("unchecked", unchecked(inForce))
4571
5318
  ];
@@ -4731,14 +5478,14 @@ function brokenSupersession(bundle, adjudicated) {
4731
5478
  (left, right) => left.conceptId.localeCompare(right.conceptId)
4732
5479
  );
4733
5480
  }
4734
- function supersededButCited(bundle, standings) {
5481
+ function supersededButCited(bundle, standings2) {
4735
5482
  const byId = new Map(bundle.map((record) => [record.conceptId, record]));
4736
5483
  const findings = [];
4737
5484
  for (const record of bundle) {
4738
- const standing = standings.get(record.conceptId);
5485
+ const standing = standings2.get(record.conceptId);
4739
5486
  if (standing === "superseded" || standing === "rejected") continue;
4740
5487
  for (const target of edgeNeighbours(record, bundle, "body-link")) {
4741
- const targetStanding = standings.get(target.conceptId);
5488
+ const targetStanding = standings2.get(target.conceptId);
4742
5489
  if (targetStanding !== "superseded" && targetStanding !== "rejected") {
4743
5490
  continue;
4744
5491
  }
@@ -4943,19 +5690,11 @@ function argvPositional(argv, ...names) {
4943
5690
  }
4944
5691
 
4945
5692
  // src/commands/anchor-resolve.ts
4946
- function resolverSummary(results) {
4947
- const names = [
4948
- ...new Set(
4949
- results.flatMap((entry) => entry.resolver ? [entry.resolver] : [])
4950
- )
4951
- ].sort();
4952
- return names.length ? `${names.join(" + ")} resolver` : "whole-file";
4953
- }
4954
5693
  var anchorResolveCommand = define({
4955
5694
  name: "anchor-resolve",
4956
5695
  tool: "kb_anchor_resolve",
4957
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
4958
- description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
5696
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp] [--check]",
5697
+ description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. Never writes verified[]; a judgment is kb_verify. Exits non-zero on drift.",
4959
5698
  input: import_zod9.z.object({
4960
5699
  bundlePath,
4961
5700
  conceptId,
@@ -4968,6 +5707,9 @@ var anchorResolveCommand = define({
4968
5707
  ),
4969
5708
  restamp: import_zod9.z.boolean().optional().describe(
4970
5709
  "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
5710
+ ),
5711
+ check: import_zod9.z.boolean().optional().describe(
5712
+ "Resolve and report only: no hash, no `resolved_at`, no log entry."
4971
5713
  )
4972
5714
  }),
4973
5715
  fromArgv: (argv, path) => ({
@@ -4976,9 +5718,24 @@ var anchorResolveCommand = define({
4976
5718
  repoRoot: argvFlag(argv, "--repo-root"),
4977
5719
  offline: argv.includes("--offline"),
4978
5720
  rebaseline: argv.includes("--rebaseline"),
4979
- restamp: argv.includes("--restamp")
5721
+ restamp: argv.includes("--restamp"),
5722
+ check: argv.includes("--check")
4980
5723
  }),
4981
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
5724
+ run: async ({ store, actor, now }, {
5725
+ bundlePath: path,
5726
+ conceptId: id,
5727
+ repoRoot,
5728
+ offline,
5729
+ rebaseline,
5730
+ restamp,
5731
+ check
5732
+ }) => {
5733
+ if (check && (rebaseline || restamp)) {
5734
+ throw new KbFlagConflictError([
5735
+ "check",
5736
+ rebaseline ? "rebaseline" : "restamp"
5737
+ ]);
5738
+ }
4982
5739
  const root = repoRoot ?? process.cwd();
4983
5740
  const record = await store.read(path, id);
4984
5741
  if (!record) throw new KbRecordNotFoundError(id);
@@ -4987,7 +5744,6 @@ var anchorResolveCommand = define({
4987
5744
  return {
4988
5745
  conceptId: id,
4989
5746
  results: [],
4990
- verified: false,
4991
5747
  note: "record has no anchors"
4992
5748
  };
4993
5749
  }
@@ -5004,6 +5760,7 @@ var anchorResolveCommand = define({
5004
5760
  const base2 = {
5005
5761
  file: anchor.file,
5006
5762
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
5763
+ ...anchor.side === "old" ? { side: "old" } : {},
5007
5764
  // Carried onto unresolved findings too: an anchor that once hashed
5008
5765
  // and now resolves to nothing is a broken anchor, and the exit code
5009
5766
  // has to be able to tell it from one nobody ever stamped.
@@ -5044,7 +5801,7 @@ var anchorResolveCommand = define({
5044
5801
  if (!anchor.hash) {
5045
5802
  results.push({
5046
5803
  ...base2,
5047
- state: "stamped",
5804
+ state: check ? "unstamped" : "stamped",
5048
5805
  currentHash: stampedHash,
5049
5806
  hashKind: stampedKind,
5050
5807
  ...producedBy ? { resolver: producedBy } : {}
@@ -5096,7 +5853,7 @@ var anchorResolveCommand = define({
5096
5853
  if (refresh) dirty = true;
5097
5854
  }
5098
5855
  let frozen = false;
5099
- if (dirty) {
5856
+ if (dirty && !check) {
5100
5857
  try {
5101
5858
  await assertBaseNotFrozen(process.cwd(), path);
5102
5859
  } catch (error) {
@@ -5111,42 +5868,11 @@ var anchorResolveCommand = define({
5111
5868
  const unreachable = results.filter(
5112
5869
  (entry) => isUncheckedReason(entry.reason)
5113
5870
  ).length;
5114
- const checked = results.length - unreachable;
5115
5871
  const matches3 = results.filter((entry) => entry.state === "match").length;
5116
- const note = `${matches3}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
5117
- const clean = checked > 0 && matches3 === checked && unreachable === 0;
5118
- if (clean) {
5119
- try {
5120
- await store.verify(
5121
- path,
5122
- id,
5123
- `anchor-resolve: ${note} (${resolverSummary(results)})`,
5124
- actor,
5125
- now()
5126
- );
5127
- } catch (error) {
5128
- if (!(error instanceof KbSelfVerificationError)) throw error;
5129
- return {
5130
- conceptId: id,
5131
- results,
5132
- verified: false,
5133
- verifyRefused: "self-verification",
5134
- ...frozenNote,
5135
- ...hintNote
5136
- };
5137
- }
5138
- return {
5139
- conceptId: id,
5140
- results,
5141
- verified: true,
5142
- ...frozenNote,
5143
- ...hintNote
5144
- };
5145
- }
5872
+ const note = `${matches3}/${results.length - unreachable} anchors match, ${unreachable} unreachable`;
5146
5873
  return {
5147
5874
  conceptId: id,
5148
5875
  results,
5149
- verified: false,
5150
5876
  ...unreachable ? { note } : {},
5151
5877
  ...frozenNote,
5152
5878
  ...hintNote
@@ -5180,12 +5906,18 @@ async function readSources(anchors, root, offline) {
5180
5906
  const foreign = new Map(
5181
5907
  anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
5182
5908
  );
5183
- const local = anchors.filter((anchor) => !foreign.get(anchor));
5909
+ const local = anchors.filter(
5910
+ (anchor) => !foreign.get(anchor) && anchor.side !== "old"
5911
+ );
5912
+ const committed = anchors.filter(
5913
+ (anchor) => !foreign.get(anchor) && anchor.side === "old"
5914
+ );
5184
5915
  const remote = anchors.filter((anchor) => foreign.get(anchor));
5185
5916
  const reads = await readAnchorFiles(
5186
5917
  local.map((anchor) => anchor.file),
5187
5918
  anchorFileReader(root)
5188
5919
  );
5920
+ const atRef = await readCommitted(root, committed);
5189
5921
  const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
5190
5922
  offline
5191
5923
  });
@@ -5197,6 +5929,13 @@ async function readSources(anchors, root, offline) {
5197
5929
  read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
5198
5930
  );
5199
5931
  }
5932
+ for (const anchor of committed) {
5933
+ const read = atRef.get(atRefKey(anchor));
5934
+ sources.set(
5935
+ anchor,
5936
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
5937
+ );
5938
+ }
5200
5939
  for (const anchor of remote) {
5201
5940
  const repo = anchor.repo;
5202
5941
  const key2 = normalizeRepoUrl(repo);
@@ -5329,141 +6068,12 @@ function count(value, noun) {
5329
6068
  return `${value} ${value === 1 ? noun : `${noun}s`}`;
5330
6069
  }
5331
6070
 
5332
- // src/commands/context.ts
5333
- var import_zod13 = require("zod");
5334
- var contextCommand = define({
5335
- name: "context",
5336
- tool: "kb_context",
5337
- usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
5338
- description: "Index block of pinned bases (ids, titles, standing) for injection at context birth. Takes no bundlePath \u2014 reads the workspace pin manifests. Empty when nothing is pinned; refuses over budget rather than truncating. Budget precedence: flags, then the manifest `context[profile]` over `context.default`, then the built-in profile, then package defaults.",
5339
- input: import_zod13.z.object({
5340
- budgetTokens: import_zod13.z.number().int().positive().optional().describe(
5341
- "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
5342
- ),
5343
- fullUnderTokens: import_zod13.z.number().int().positive().optional().describe(
5344
- "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."
5345
- ),
5346
- profile: import_zod13.z.string().optional().describe(
5347
- "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."
5348
- ),
5349
- excludeTags: import_zod13.z.array(import_zod13.z.string().min(1)).optional().describe(
5350
- "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
5351
- ),
5352
- format: import_zod13.z.enum(["markdown", "json"]).optional().describe(
5353
- "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
5354
- ),
5355
- event: import_zod13.z.string().optional().describe(
5356
- "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
5357
- )
5358
- }),
5359
- fromArgv: (argv) => {
5360
- const budget = argvFlag(argv, "--budget");
5361
- const fullUnder = argvFlag(argv, "--full-under");
5362
- const profile = argvFlag(argv, "--profile");
5363
- const format = argvFlag(argv, "--format");
5364
- const event = argvFlag(argv, "--event");
5365
- const excludeTags = argvFlags(argv, "--exclude-tag");
5366
- return {
5367
- ...budget ? { budgetTokens: Number(budget) } : {},
5368
- ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
5369
- ...profile ? { profile } : {},
5370
- ...excludeTags.length ? { excludeTags } : {},
5371
- ...format ? { format } : {},
5372
- ...event ? { event } : {}
5373
- };
5374
- },
5375
- run: async ({ store }, { budgetTokens, fullUnderTokens, profile, excludeTags, format, event }) => {
5376
- const result = await buildContext(store, process.cwd(), {
5377
- ...budgetTokens ? { budgetTokens } : {},
5378
- ...fullUnderTokens ? { fullUnderTokens } : {},
5379
- ...profile ? { profile } : {},
5380
- ...excludeTags ? { excludeTags } : {},
5381
- // Degradations — a full pin that could not fit, a refused block — go
5382
- // to stderr as well as into the block itself: stderr is diagnostics on
5383
- // both surfaces (hooks discard it, MCP logs it), so an operator can
5384
- // see budget pressure without reading injected context.
5385
- warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
5386
- `)
5387
- });
5388
- if (!result.block) return "";
5389
- return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
5390
- }
5391
- });
5392
-
5393
- // src/commands/doctor.ts
6071
+ // src/commands/classify.ts
6072
+ var import_node_buffer = require("buffer");
6073
+ var import_promises10 = require("fs/promises");
6074
+ var import_node_path12 = require("path");
5394
6075
  var import_zod15 = require("zod");
5395
6076
 
5396
- // src/drift/git.ts
5397
- var import_node_child_process3 = require("child_process");
5398
- var import_node_util3 = require("util");
5399
- var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
5400
- var MAX_GIT_OUTPUT_BYTES = 1048576;
5401
- var GIT_TIMEOUT_MS = 5e3;
5402
- async function git2(cwd, args) {
5403
- const env = { ...process.env };
5404
- delete env["GIT_DIR"];
5405
- delete env["GIT_WORK_TREE"];
5406
- delete env["GIT_INDEX_FILE"];
5407
- try {
5408
- const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
5409
- timeout: GIT_TIMEOUT_MS,
5410
- maxBuffer: MAX_GIT_OUTPUT_BYTES,
5411
- env
5412
- });
5413
- return { ok: true, stdout };
5414
- } catch {
5415
- return { ok: false };
5416
- }
5417
- }
5418
- async function listRepoFiles(repoRoot) {
5419
- const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
5420
- if (!result.ok) return [];
5421
- return result.stdout.split("\0").filter(Boolean);
5422
- }
5423
- async function readOldSource(repoRoot, anchor) {
5424
- if (!filePathIsSafe(anchor.file))
5425
- return { ok: false, reason: "unrecoverable" };
5426
- if (anchor.ref && refShapeIsSafe(anchor.ref)) {
5427
- const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
5428
- if (shown2 !== null) {
5429
- return {
5430
- ok: true,
5431
- source: shown2,
5432
- origin: { kind: "ref", ref: anchor.ref }
5433
- };
5434
- }
5435
- }
5436
- const at2 = anchor.resolved_at;
5437
- if (!at2 || Number.isNaN(Date.parse(at2))) {
5438
- return { ok: false, reason: "unrecoverable" };
5439
- }
5440
- const found = await git2(repoRoot, [
5441
- "log",
5442
- "-1",
5443
- "--format=%H",
5444
- `--before=${at2}`,
5445
- "--end-of-options",
5446
- "HEAD",
5447
- "--",
5448
- anchor.file
5449
- ]);
5450
- const sha = found.ok ? found.stdout.trim() : "";
5451
- if (!sha || !refShapeIsSafe(sha))
5452
- return { ok: false, reason: "unrecoverable" };
5453
- const shown = await showFile(repoRoot, sha, anchor.file);
5454
- if (shown === null) return { ok: false, reason: "unrecoverable" };
5455
- return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
5456
- }
5457
- async function showFile(repoRoot, ref, file) {
5458
- const path = file.replace(/^\.\//, "");
5459
- const result = await git2(repoRoot, [
5460
- "show",
5461
- "--end-of-options",
5462
- `${ref}:${path}`
5463
- ]);
5464
- return result.ok ? result.stdout : null;
5465
- }
5466
-
5467
6077
  // src/drift/moved.ts
5468
6078
  var import_promises9 = require("fs/promises");
5469
6079
  var MAX_MOVED_SEARCH_FILES = 2e3;
@@ -5482,6 +6092,7 @@ function movedSearch(repoRoot, options = {}) {
5482
6092
  async find(anchor) {
5483
6093
  const stored = anchor.hash;
5484
6094
  if (!stored) return void 0;
6095
+ if (anchor.span) return sameFileWindow(anchor, read, stored);
5485
6096
  const language = languageForFile(anchor.file);
5486
6097
  if (!language) return sameFileWindow(anchor, read, stored);
5487
6098
  const candidates = await filesForLanguage(language);
@@ -5579,6 +6190,11 @@ async function classifyDrift(repoRoot, record, entries, options = {}) {
5579
6190
  );
5580
6191
  const out = [];
5581
6192
  for (const { anchor, entry } of wanted) {
6193
+ if (anchor.side === "old") {
6194
+ const settled2 = entry.class ?? "changed";
6195
+ out.push({ anchor, entry: { ...entry, class: settled2 }, class: settled2 });
6196
+ continue;
6197
+ }
5582
6198
  const movedTo = await search.find(anchor);
5583
6199
  if (movedTo) {
5584
6200
  out.push({
@@ -5652,8 +6268,8 @@ function unifiedDiff(before, after, options = {}) {
5652
6268
  }
5653
6269
  const truncated = body.length > max;
5654
6270
  const shown = truncated ? body.slice(0, max) : body;
5655
- const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
5656
- const lines = [header, ...shown];
6271
+ const header2 = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
6272
+ const lines = [header2, ...shown];
5657
6273
  if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
5658
6274
  return { text: lines.join("\n"), added, removed, truncated };
5659
6275
  }
@@ -5713,12 +6329,12 @@ async function reassessPacket(repoRoot, record, entries, options = {}) {
5713
6329
  ...options.search ? { search: options.search } : {},
5714
6330
  withHistory: options.withDiff !== false
5715
6331
  });
5716
- const open = classified.filter(
6332
+ const open2 = classified.filter(
5717
6333
  (found) => found.class === "changed" || found.class === "gone"
5718
6334
  );
5719
- if (!open.length) return { packet: null, classified };
5720
- const budget = diffBudget(open.length);
5721
- const anchors = open.map(
6335
+ if (!open2.length) return { packet: null, classified };
6336
+ const budget = diffBudget(open2.length);
6337
+ const anchors = open2.map(
5722
6338
  (found) => anchorPacket(found, options.withDiff === true, budget)
5723
6339
  );
5724
6340
  const type = record.frontmatter.type;
@@ -5792,46 +6408,633 @@ function claimOf(record) {
5792
6408
  return text ? { section, text } : null;
5793
6409
  }
5794
6410
 
5795
- // src/commands/reassess.ts
6411
+ // src/commands/match/command.ts
5796
6412
  var import_zod14 = require("zod");
5797
- var reassessCommand = define({
5798
- name: "reassess",
5799
- tool: "kb_reassess",
5800
- usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
5801
- description: "One drifted record, as something to judge: its claim, each anchor's drift class, the old-vs-new span diff, and the records that depend on it. Formatting-only drift is dropped. Empty when there is nothing to reassess. Writes: relocates moved anchors, keeping their hash; never verifies, supersedes, or changes standing.",
5802
- input: import_zod14.z.object({
5803
- bundlePath,
5804
- conceptId,
5805
- repoRoot: REPO_ROOT,
5806
- withDiff: import_zod14.z.boolean().optional().describe(
5807
- "Recover each anchor's committed span and render the diff. Reads git history."
5808
- )
5809
- }),
5810
- fromArgv: (argv, path) => {
5811
- const repoRoot = argvFlag(argv, "--repo-root");
5812
- return {
5813
- bundlePath: path,
5814
- conceptId: argv[1],
5815
- ...repoRoot !== void 0 ? { repoRoot } : {},
5816
- ...argv.includes("--with-diff") ? { withDiff: true } : {}
5817
- };
5818
- },
5819
- run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
5820
- const root = repoRoot ?? process.cwd();
5821
- const bundle = await store.list(path);
5822
- const record = bundle.find((entry) => entry.conceptId === id);
5823
- if (!record) throw new KbRecordNotFoundError(id);
5824
- const drift = await store.detectDrift([record], repoRoot);
5825
- const entries = drift?.get(id) ?? [];
5826
- if (!entries.some((entry) => entry.state !== "match")) {
5827
- return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
5828
- }
5829
- const standing = adjudicate(bundle, bundle).find(
5830
- (hit) => hit.record.conceptId === id
5831
- )?.standing;
5832
- const impact2 = await store.impact(path, id);
5833
- const { packet, classified } = await reassessPacket(root, record, entries, {
5834
- ...withDiff ? { withDiff: true } : {},
6413
+
6414
+ // src/commands/match/errors.ts
6415
+ var KbMatchInputError = class extends BaseError {
6416
+ constructor(reason) {
6417
+ super({
6418
+ message: `match: ${reason}`,
6419
+ errorType: "KbMatchInput" /* KbMatchInput */,
6420
+ code: 400,
6421
+ fault: "User" /* User */,
6422
+ retriable: false,
6423
+ reportToUser: true,
6424
+ details: { reason }
6425
+ });
6426
+ this.reason = reason;
6427
+ }
6428
+ reason;
6429
+ };
6430
+
6431
+ // src/commands/match/model.ts
6432
+ var import_zod13 = require("zod");
6433
+ var diffHunkSchema = import_zod13.z.object({
6434
+ startLine: import_zod13.z.number().int().positive(),
6435
+ endLine: import_zod13.z.number().int().positive(),
6436
+ side: import_zod13.z.enum(["old", "new"]).optional()
6437
+ }).passthrough();
6438
+ var diffFileSchema = import_zod13.z.object({
6439
+ filePath: import_zod13.z.string().min(1).describe("Repo-relative, spelled the way anchors are."),
6440
+ hunks: import_zod13.z.array(diffHunkSchema)
6441
+ });
6442
+ var symbolRangeSchema = import_zod13.z.object({
6443
+ file: import_zod13.z.string().min(1),
6444
+ symbol: import_zod13.z.string().min(1),
6445
+ startLine: import_zod13.z.number().int().positive(),
6446
+ endLine: import_zod13.z.number().int().positive()
6447
+ });
6448
+
6449
+ // src/commands/match/parse-unified-diff.ts
6450
+ var FILE_HEADER = /^diff --git (.+)$/;
6451
+ var HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
6452
+ var SIMILARITY = /^similarity index (\d+)%$/;
6453
+ var KNOWN_PREFIX = /^[ab]\//;
6454
+ function parseUnifiedDiff(patch, options = {}) {
6455
+ const files = [];
6456
+ let current;
6457
+ let listed = false;
6458
+ let shared;
6459
+ let oldPath;
6460
+ let rename4 = {};
6461
+ let inHeader = false;
6462
+ let added;
6463
+ let removed;
6464
+ const list = () => {
6465
+ if (!current || listed) return;
6466
+ files.push(current);
6467
+ listed = true;
6468
+ };
6469
+ const open2 = (filePath) => {
6470
+ current = { filePath, hunks: [], ...rename4 };
6471
+ listed = false;
6472
+ };
6473
+ const amend = () => {
6474
+ if (current) Object.assign(current, rename4);
6475
+ };
6476
+ const close = () => {
6477
+ if (options.keepEmpty) list();
6478
+ current = void 0;
6479
+ listed = false;
6480
+ added = void 0;
6481
+ removed = void 0;
6482
+ };
6483
+ for (const raw of patch.split("\n")) {
6484
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
6485
+ const start = FILE_HEADER.exec(line);
6486
+ if (start) {
6487
+ close();
6488
+ oldPath = void 0;
6489
+ rename4 = {};
6490
+ shared = sharedHeaderPath(start[1]);
6491
+ inHeader = true;
6492
+ if (shared) open2(shared);
6493
+ continue;
6494
+ }
6495
+ if (inHeader) {
6496
+ const similarity = SIMILARITY.exec(line);
6497
+ if (similarity) {
6498
+ rename4.similarity = Number(similarity[1]);
6499
+ amend();
6500
+ continue;
6501
+ }
6502
+ if (line.startsWith("rename from ")) {
6503
+ rename4.renamedFrom = unquote(line.slice(12).trim());
6504
+ amend();
6505
+ continue;
6506
+ }
6507
+ if (line.startsWith("rename to ")) {
6508
+ open2(unquote(line.slice(10).trim()));
6509
+ continue;
6510
+ }
6511
+ if (line.startsWith("--- ")) {
6512
+ oldPath = sidePath(line.slice(4), shared);
6513
+ continue;
6514
+ }
6515
+ if (line.startsWith("+++ ")) {
6516
+ const path = sidePath(line.slice(4), shared) ?? oldPath;
6517
+ if (path) open2(path);
6518
+ else current = void 0;
6519
+ continue;
6520
+ }
6521
+ }
6522
+ const hunk = HUNK.exec(line);
6523
+ if (!hunk) {
6524
+ if (!options.withLines || inHeader) continue;
6525
+ if (line.startsWith("+")) added?.lines?.push(line.slice(1));
6526
+ else if (line.startsWith("-")) removed?.lines?.push(line.slice(1));
6527
+ continue;
6528
+ }
6529
+ inHeader = false;
6530
+ added = void 0;
6531
+ removed = void 0;
6532
+ if (!current) continue;
6533
+ const [next, before] = hunksOf(hunk, options.withLines === true);
6534
+ added = next;
6535
+ removed = before;
6536
+ list();
6537
+ current.hunks.push(...before ? [next, before] : [next]);
6538
+ }
6539
+ close();
6540
+ return files;
6541
+ }
6542
+ function hunksOf(hunk, withLines) {
6543
+ const oldStart = Number(hunk[1]);
6544
+ const oldCount = hunk[2] === void 0 ? 1 : Number(hunk[2]);
6545
+ const newStart = Number(hunk[3]);
6546
+ const newCount = hunk[4] === void 0 ? 1 : Number(hunk[4]);
6547
+ const lines = withLines ? { lines: [] } : {};
6548
+ const added = newCount === 0 ? { ...point(newStart), ...lines } : { startLine: newStart, endLine: newStart + newCount - 1, ...lines };
6549
+ if (oldCount === 0) return [added];
6550
+ return [
6551
+ added,
6552
+ {
6553
+ startLine: oldStart,
6554
+ endLine: oldStart + oldCount - 1,
6555
+ side: "old",
6556
+ ...withLines ? { lines: [] } : {}
6557
+ }
6558
+ ];
6559
+ }
6560
+ function point(start) {
6561
+ const at2 = Math.max(1, start);
6562
+ return { startLine: at2, endLine: at2 };
6563
+ }
6564
+ function sidePath(raw, shared) {
6565
+ const text = unquote(raw.replace(/\t.*$/, "").trim());
6566
+ if (text === "/dev/null") return void 0;
6567
+ if (KNOWN_PREFIX.test(text)) return text.slice(2);
6568
+ return shared ?? text;
6569
+ }
6570
+ function sharedHeaderPath(rest) {
6571
+ const pair = splitHeaderPair(rest);
6572
+ if (!pair || pair[0] === pair[1]) return void 0;
6573
+ const from = unquote(pair[0]).split("/");
6574
+ const to = unquote(pair[1]).split("/");
6575
+ const shared = [];
6576
+ while (from.length > 1 && to.length > 1 && from.at(-1) === to.at(-1)) {
6577
+ shared.unshift(from.pop());
6578
+ to.pop();
6579
+ }
6580
+ return shared.length ? shared.join("/") : void 0;
6581
+ }
6582
+ function splitHeaderPair(rest) {
6583
+ if (rest.startsWith('"')) {
6584
+ const end = endOfQuoted(rest);
6585
+ if (end < 0 || rest[end + 1] !== " ") return void 0;
6586
+ return [rest.slice(0, end + 1), rest.slice(end + 2)];
6587
+ }
6588
+ const mid = (rest.length - 1) / 2;
6589
+ if (Number.isInteger(mid) && rest[mid] === " ") {
6590
+ return [rest.slice(0, mid), rest.slice(mid + 1)];
6591
+ }
6592
+ const at2 = rest.indexOf(" ");
6593
+ return at2 === -1 ? void 0 : [rest.slice(0, at2), rest.slice(at2 + 1)];
6594
+ }
6595
+ function endOfQuoted(text) {
6596
+ for (let at2 = 1; at2 < text.length; at2 += 1) {
6597
+ if (text[at2] === "\\") {
6598
+ at2 += 1;
6599
+ continue;
6600
+ }
6601
+ if (text[at2] === '"') return at2;
6602
+ }
6603
+ return -1;
6604
+ }
6605
+ var ESCAPES = {
6606
+ a: 7,
6607
+ b: 8,
6608
+ f: 12,
6609
+ n: 10,
6610
+ r: 13,
6611
+ t: 9,
6612
+ v: 11,
6613
+ '"': 34,
6614
+ "\\": 92
6615
+ };
6616
+ var OCTAL = /^[0-7]{3}/;
6617
+ var utf8 = new TextEncoder();
6618
+ function unquote(text) {
6619
+ if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) {
6620
+ return text;
6621
+ }
6622
+ const body = text.slice(1, -1);
6623
+ const bytes = [];
6624
+ for (let at2 = 0; at2 < body.length; ) {
6625
+ const slash = body.indexOf("\\", at2);
6626
+ if (slash < 0) {
6627
+ bytes.push(...utf8.encode(body.slice(at2)));
6628
+ break;
6629
+ }
6630
+ if (slash > at2) bytes.push(...utf8.encode(body.slice(at2, slash)));
6631
+ const octal = OCTAL.exec(body.slice(slash + 1, slash + 4));
6632
+ if (octal) {
6633
+ bytes.push(Number.parseInt(octal[0], 8));
6634
+ at2 = slash + 4;
6635
+ continue;
6636
+ }
6637
+ const next = body[slash + 1];
6638
+ if (next === void 0) {
6639
+ bytes.push(ESCAPES["\\"]);
6640
+ break;
6641
+ }
6642
+ const mapped = ESCAPES[next];
6643
+ if (mapped === void 0) bytes.push(...utf8.encode(next));
6644
+ else bytes.push(mapped);
6645
+ at2 = slash + 2;
6646
+ }
6647
+ return new TextDecoder().decode(Uint8Array.from(bytes));
6648
+ }
6649
+
6650
+ // src/commands/match/symbol-ranges.ts
6651
+ async function resolveSymbolRanges(repoRoot, files, records, offline = false) {
6652
+ const changed = new Set(files.map((file) => strip(file.filePath)));
6653
+ const wanted = [];
6654
+ const seen = /* @__PURE__ */ new Set();
6655
+ for (const record of records) {
6656
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
6657
+ if (!anchor.symbol || anchor.repo) continue;
6658
+ if (!changed.has(strip(anchor.file))) continue;
6659
+ const key2 = `${strip(anchor.file)}#${anchor.symbol}`;
6660
+ if (seen.has(key2)) continue;
6661
+ seen.add(key2);
6662
+ wanted.push(anchor);
6663
+ }
6664
+ }
6665
+ if (!wanted.length) return [];
6666
+ const paths = [...new Set(wanted.map((anchor) => anchor.file))];
6667
+ const sources = await readAnchorFiles(paths, anchorFileReader(repoRoot));
6668
+ const resolvers = defaultAnchorResolvers({ offline });
6669
+ await prepareResolvers(resolvers, paths);
6670
+ const ranges = [];
6671
+ for (const anchor of wanted) {
6672
+ const read = sources.get(anchor.file);
6673
+ if (!read?.ok) continue;
6674
+ const outcome = resolveAnchorSpan(read.source, anchor, resolvers);
6675
+ if (!outcome.ok) continue;
6676
+ ranges.push({
6677
+ file: anchor.file,
6678
+ symbol: anchor.symbol,
6679
+ startLine: outcome.span.startLine,
6680
+ endLine: outcome.span.endLine
6681
+ });
6682
+ }
6683
+ return ranges;
6684
+ }
6685
+ function strip(path) {
6686
+ return path.replace(/^\.\//, "");
6687
+ }
6688
+
6689
+ // src/commands/match/command.ts
6690
+ var matchCommand = define({
6691
+ name: "match",
6692
+ tool: "kb_match",
6693
+ usage: "match --git <base>..<head> | --stdin [--repo-root <path>] [--offline] [--include-non-current]",
6694
+ description: "Which records sit on each changed hunk: the anchored records per file range, current first, each with its standing and the anchor that matched. kb_load hands over a whole base; this narrows a diff. Symbol ranges resolve from repoRoot when omitted; non-current records need includeNonCurrent.",
6695
+ input: import_zod14.z.object({
6696
+ bundlePath,
6697
+ files: import_zod14.z.array(diffFileSchema).describe("The changed files, each with its post-change line ranges."),
6698
+ symbolRanges: import_zod14.z.array(symbolRangeSchema).optional().describe(
6699
+ "Symbol spans the caller already has. Resolved from repoRoot when omitted."
6700
+ ),
6701
+ repoRoot: REPO_ROOT,
6702
+ offline: import_zod14.z.boolean().optional().describe(
6703
+ "Resolve symbol ranges from what is already on disk, never fetching a grammar."
6704
+ ),
6705
+ includeNonCurrent: import_zod14.z.boolean().optional().describe(
6706
+ "Return superseded, rejected and unsettled records too, each carrying its standing."
6707
+ )
6708
+ }),
6709
+ fromArgv: async (argv, path, stdin) => {
6710
+ const repoRoot = argvFlag(argv, "--repo-root");
6711
+ const range = argvFlag(argv, "--git");
6712
+ const base2 = {
6713
+ bundlePath: path,
6714
+ ...repoRoot !== void 0 ? { repoRoot } : {},
6715
+ ...argv.includes("--offline") ? { offline: true } : {},
6716
+ ...argv.includes("--include-non-current") ? { includeNonCurrent: true } : {}
6717
+ };
6718
+ if (range !== void 0) {
6719
+ const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
6720
+ if (!diff.ok) {
6721
+ throw new KbMatchInputError(`--git ${range} ${REFUSED[diff.reason]}`);
6722
+ }
6723
+ return { ...base2, files: parseUnifiedDiff(diff.text) };
6724
+ }
6725
+ if (!argv.includes("--stdin")) {
6726
+ throw new KbMatchInputError(
6727
+ "pass --git <base>..<head>, or --stdin with { files } as JSON"
6728
+ );
6729
+ }
6730
+ return { ...base2, ...fromStdin(await stdin()) };
6731
+ },
6732
+ run: async ({ store }, {
6733
+ bundlePath: path,
6734
+ files,
6735
+ symbolRanges,
6736
+ repoRoot,
6737
+ offline,
6738
+ includeNonCurrent
6739
+ }) => {
6740
+ const records = await store.list(path);
6741
+ const ranges = symbolRanges ?? await resolveSymbolRanges(
6742
+ repoRoot ?? process.cwd(),
6743
+ files,
6744
+ records,
6745
+ offline === true
6746
+ );
6747
+ const index2 = symbolRangeIndex(ranges);
6748
+ return matchToDiff(files, records, { symbolRanges: ranges }).flatMap(
6749
+ (match) => project(match, index2, includeNonCurrent === true)
6750
+ );
6751
+ }
6752
+ });
6753
+ var REFUSED = {
6754
+ "bad-range": "is not a range git could read here \u2014 both halves of <base>..<head> are required",
6755
+ "too-large": "diffs to a patch past the output cap \u2014 narrow the range",
6756
+ timeout: "took longer to diff than the runner allows \u2014 narrow the range",
6757
+ "git-missing": "needs git on PATH, and there is none"
6758
+ };
6759
+ function fromStdin(text) {
6760
+ let payload;
6761
+ try {
6762
+ payload = JSON.parse(text);
6763
+ } catch {
6764
+ throw new KbMatchInputError("stdin is not JSON");
6765
+ }
6766
+ if (!Array.isArray(payload?.files)) {
6767
+ throw new KbMatchInputError("stdin needs a files array");
6768
+ }
6769
+ return {
6770
+ files: payload.files,
6771
+ ...payload.symbolRanges !== void 0 ? { symbolRanges: payload.symbolRanges } : {}
6772
+ };
6773
+ }
6774
+ function project(match, ranges, all) {
6775
+ const kept = all ? match.records : match.records.filter((hit) => hit.standing === "current");
6776
+ if (!kept.length) return [];
6777
+ const placed = kept.map((hit) => ({
6778
+ hit,
6779
+ at: placeOnHunk(hit.record, match.filePath, match.hunk, ranges)
6780
+ }));
6781
+ return [
6782
+ {
6783
+ filePath: match.filePath,
6784
+ hunk: match.hunk,
6785
+ // Over the records returned, not the ones matched: a hunk holding only
6786
+ // symbol-placed records is not `file` because a dropped one was.
6787
+ precision: placed.every(({ at: at2 }) => at2.kind === "symbol") ? "symbol" : "file",
6788
+ records: placed.map(({ hit, at: { anchor } }) => {
6789
+ const { frontmatter } = hit.record;
6790
+ return {
6791
+ conceptId: hit.record.conceptId,
6792
+ type: frontmatter.type,
6793
+ title: frontmatter.title ?? null,
6794
+ standing: hit.standing,
6795
+ status: frontmatter.strauss_status,
6796
+ supersededBy: hit.heads.map((head) => head.conceptId),
6797
+ ...frontmatter.strauss_materiality ? { materiality: frontmatter.strauss_materiality } : {},
6798
+ ...frontmatter.strauss_confidence ? { confidence: frontmatter.strauss_confidence } : {},
6799
+ ...frontmatter.tags?.length ? { tags: frontmatter.tags } : {},
6800
+ ...anchor ? { anchor } : {}
6801
+ };
6802
+ })
6803
+ }
6804
+ ];
6805
+ }
6806
+
6807
+ // src/commands/classify.ts
6808
+ var classifyFileSchema = diffFileSchema.extend({
6809
+ hunks: import_zod15.z.array(
6810
+ diffHunkSchema.extend({ lines: import_zod15.z.array(import_zod15.z.string()).optional() })
6811
+ ),
6812
+ renamedFrom: import_zod15.z.string().min(1).optional().describe("Where `git diff -M` says the path came from."),
6813
+ similarity: import_zod15.z.number().min(0).max(100).optional()
6814
+ });
6815
+ var classifyCommand = define({
6816
+ name: "classify",
6817
+ tool: "kb_classify",
6818
+ usage: "classify --git <base>..<head> | --stdin [--repo-root <path>] [--offline]",
6819
+ description: "What kind of change each file carries: test, config, ci, docs, lockfile, generated, boilerplate, rename or source, with the rule that decided it. Derived from the diff and never stored; a `review:generated`, `review:boilerplate` or `review:move` fact anchored on a file overrides the heuristic. kb_match says what sits on a hunk; this says whether to read it.",
6820
+ input: import_zod15.z.object({
6821
+ bundlePath,
6822
+ files: import_zod15.z.array(classifyFileSchema).describe("The changed files, each with its line ranges."),
6823
+ repoRoot: REPO_ROOT,
6824
+ offline: import_zod15.z.boolean().optional().describe(
6825
+ "Resolve symbol ranges from what is already on disk, never fetching a grammar."
6826
+ )
6827
+ }),
6828
+ fromArgv: async (argv, path, stdin) => {
6829
+ const repoRoot = argvFlag(argv, "--repo-root");
6830
+ const range = argvFlag(argv, "--git");
6831
+ const base2 = {
6832
+ bundlePath: path,
6833
+ ...repoRoot !== void 0 ? { repoRoot } : {},
6834
+ ...argv.includes("--offline") ? { offline: true } : {}
6835
+ };
6836
+ if (range !== void 0) {
6837
+ const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
6838
+ if (!diff.ok) {
6839
+ throw new KbClassifyInputError(
6840
+ `--git ${range} ${REFUSED2[diff.reason]}`
6841
+ );
6842
+ }
6843
+ return {
6844
+ ...base2,
6845
+ files: parseUnifiedDiff(diff.text, {
6846
+ keepEmpty: true,
6847
+ withLines: true
6848
+ })
6849
+ };
6850
+ }
6851
+ if (!argv.includes("--stdin")) {
6852
+ throw new KbClassifyInputError(
6853
+ "pass --git <base>..<head>, or --stdin with { files } as JSON"
6854
+ );
6855
+ }
6856
+ return { ...base2, files: fromStdin2(await stdin()) };
6857
+ },
6858
+ run: async ({ store }, { bundlePath: path, files, repoRoot, offline }) => {
6859
+ const records = await store.list(path);
6860
+ const root = repoRoot ?? process.cwd();
6861
+ const withHeaders = await mapLimit2(files, READERS, async (file) => ({
6862
+ ...file,
6863
+ header: await header(root, file)
6864
+ }));
6865
+ const symbolRanges = await resolveSymbolRanges(
6866
+ root,
6867
+ files,
6868
+ records,
6869
+ offline === true
6870
+ );
6871
+ return { files: classifyDiff(withHeaders, { records, symbolRanges }) };
6872
+ },
6873
+ render: (result) => renderClassify(result)
6874
+ });
6875
+ var HEADER_BYTES = 65536;
6876
+ var READERS = 16;
6877
+ async function header(root, file) {
6878
+ if (!filePathIsSafe(file.filePath)) return void 0;
6879
+ let handle;
6880
+ try {
6881
+ handle = await (0, import_promises10.open)((0, import_node_path12.join)(root, file.filePath), "r");
6882
+ const buffer = import_node_buffer.Buffer.alloc(HEADER_BYTES);
6883
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_BYTES, 0);
6884
+ return buffer.toString("utf8", 0, bytesRead).split("\n").slice(0, HEADER_LINES);
6885
+ } catch {
6886
+ return void 0;
6887
+ } finally {
6888
+ await handle?.close();
6889
+ }
6890
+ }
6891
+ async function mapLimit2(items, limit, run) {
6892
+ const out = Array.from({ length: items.length });
6893
+ let next = 0;
6894
+ const worker = async () => {
6895
+ while (next < items.length) {
6896
+ const at2 = next;
6897
+ next += 1;
6898
+ out[at2] = await run(items[at2]);
6899
+ }
6900
+ };
6901
+ await Promise.all(
6902
+ Array.from({ length: Math.min(limit, items.length) }, () => worker())
6903
+ );
6904
+ return out;
6905
+ }
6906
+ var REFUSED2 = {
6907
+ "bad-range": "is not a range git could read here \u2014 both halves of <base>..<head> are required",
6908
+ "too-large": "diffs to a patch past the output cap \u2014 narrow the range",
6909
+ timeout: "took longer to diff than the runner allows \u2014 narrow the range",
6910
+ "git-missing": "needs git on PATH, and there is none"
6911
+ };
6912
+ function fromStdin2(text) {
6913
+ let payload;
6914
+ try {
6915
+ payload = JSON.parse(text);
6916
+ } catch {
6917
+ throw new KbClassifyInputError("stdin is not JSON");
6918
+ }
6919
+ if (!Array.isArray(payload?.files)) {
6920
+ throw new KbClassifyInputError("stdin needs a files array");
6921
+ }
6922
+ return payload.files;
6923
+ }
6924
+ function renderClassify(result) {
6925
+ const width2 = Math.max(
6926
+ 0,
6927
+ ...result.files.map((file) => file.class.length)
6928
+ );
6929
+ return result.files.map(
6930
+ (file) => `${file.class.padEnd(width2)} ${file.filePath} (${file.reason})`
6931
+ ).join("\n");
6932
+ }
6933
+
6934
+ // src/commands/context.ts
6935
+ var import_zod16 = require("zod");
6936
+ var contextCommand = define({
6937
+ name: "context",
6938
+ tool: "kb_context",
6939
+ usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
6940
+ description: "Index block of pinned bases (ids, titles, standing) for injection at context birth. Takes no bundlePath \u2014 reads the workspace pin manifests. Empty when nothing is pinned; refuses over budget rather than truncating. Budget precedence: flags, then the manifest `context[profile]` over `context.default`, then the built-in profile, then package defaults.",
6941
+ input: import_zod16.z.object({
6942
+ budgetTokens: import_zod16.z.number().int().positive().optional().describe(
6943
+ "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
6944
+ ),
6945
+ fullUnderTokens: import_zod16.z.number().int().positive().optional().describe(
6946
+ "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."
6947
+ ),
6948
+ profile: import_zod16.z.string().optional().describe(
6949
+ "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."
6950
+ ),
6951
+ excludeTags: import_zod16.z.array(import_zod16.z.string().min(1)).optional().describe(
6952
+ "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
6953
+ ),
6954
+ format: import_zod16.z.enum(["markdown", "json"]).optional().describe(
6955
+ "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
6956
+ ),
6957
+ event: import_zod16.z.string().optional().describe(
6958
+ "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
6959
+ )
6960
+ }),
6961
+ fromArgv: (argv) => {
6962
+ const budget = argvFlag(argv, "--budget");
6963
+ const fullUnder = argvFlag(argv, "--full-under");
6964
+ const profile = argvFlag(argv, "--profile");
6965
+ const format = argvFlag(argv, "--format");
6966
+ const event = argvFlag(argv, "--event");
6967
+ const excludeTags = argvFlags(argv, "--exclude-tag");
6968
+ return {
6969
+ ...budget ? { budgetTokens: Number(budget) } : {},
6970
+ ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
6971
+ ...profile ? { profile } : {},
6972
+ ...excludeTags.length ? { excludeTags } : {},
6973
+ ...format ? { format } : {},
6974
+ ...event ? { event } : {}
6975
+ };
6976
+ },
6977
+ run: async ({ store }, { budgetTokens, fullUnderTokens, profile, excludeTags, format, event }) => {
6978
+ const result = await buildContext(store, process.cwd(), {
6979
+ ...budgetTokens ? { budgetTokens } : {},
6980
+ ...fullUnderTokens ? { fullUnderTokens } : {},
6981
+ ...profile ? { profile } : {},
6982
+ ...excludeTags ? { excludeTags } : {},
6983
+ // Degradations — a full pin that could not fit, a refused block — go
6984
+ // to stderr as well as into the block itself: stderr is diagnostics on
6985
+ // both surfaces (hooks discard it, MCP logs it), so an operator can
6986
+ // see budget pressure without reading injected context.
6987
+ warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
6988
+ `)
6989
+ });
6990
+ if (!result.block) return "";
6991
+ return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
6992
+ }
6993
+ });
6994
+
6995
+ // src/commands/doctor.ts
6996
+ var import_zod18 = require("zod");
6997
+
6998
+ // src/commands/reassess.ts
6999
+ var import_zod17 = require("zod");
7000
+ var reassessCommand = define({
7001
+ name: "reassess",
7002
+ tool: "kb_reassess",
7003
+ usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
7004
+ description: "One drifted record, as something to judge: its claim, each anchor's drift class, the old-vs-new span diff, and the records that depend on it. Formatting-only drift is dropped. Empty when there is nothing to reassess. Writes: relocates moved anchors, keeping their hash; never verifies, supersedes, or changes standing.",
7005
+ input: import_zod17.z.object({
7006
+ bundlePath,
7007
+ conceptId,
7008
+ repoRoot: REPO_ROOT,
7009
+ withDiff: import_zod17.z.boolean().optional().describe(
7010
+ "Recover each anchor's committed span and render the diff. Reads git history."
7011
+ )
7012
+ }),
7013
+ fromArgv: (argv, path) => {
7014
+ const repoRoot = argvFlag(argv, "--repo-root");
7015
+ return {
7016
+ bundlePath: path,
7017
+ conceptId: argv[1],
7018
+ ...repoRoot !== void 0 ? { repoRoot } : {},
7019
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
7020
+ };
7021
+ },
7022
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
7023
+ const root = repoRoot ?? process.cwd();
7024
+ const bundle = await store.list(path);
7025
+ const record = bundle.find((entry) => entry.conceptId === id);
7026
+ if (!record) throw new KbRecordNotFoundError(id);
7027
+ const drift = await store.detectDrift([record], repoRoot);
7028
+ const entries = drift?.get(id) ?? [];
7029
+ if (!entries.some((entry) => entry.state !== "match")) {
7030
+ return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
7031
+ }
7032
+ const standing = adjudicate(bundle, bundle).find(
7033
+ (hit) => hit.record.conceptId === id
7034
+ )?.standing;
7035
+ const impact2 = await store.impact(path, id);
7036
+ const { packet, classified } = await reassessPacket(root, record, entries, {
7037
+ ...withDiff ? { withDiff: true } : {},
5835
7038
  impact: impact2,
5836
7039
  ...standing ? { standing } : {}
5837
7040
  });
@@ -5846,7 +7049,10 @@ var reassessCommand = define({
5846
7049
  relocated.set(found.anchor, {
5847
7050
  ...found.anchor,
5848
7051
  file: to.file,
5849
- ...to.symbol ? { symbol: to.symbol } : {}
7052
+ ...to.symbol ? { symbol: to.symbol } : {},
7053
+ // A span is the anchor's whole address, so relocating it means
7054
+ // moving the line range the same code now occupies.
7055
+ ...found.anchor.span ? { span: { start: to.startLine, end: to.endLine } } : {}
5850
7056
  });
5851
7057
  rebaselined.push({
5852
7058
  file: found.anchor.file,
@@ -5945,13 +7151,13 @@ function at(file, symbol) {
5945
7151
  }
5946
7152
 
5947
7153
  // src/commands/doctor.ts
5948
- var days = (what, fallback) => import_zod15.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
7154
+ var days = (what, fallback) => import_zod18.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5949
7155
  var doctorCommand = define({
5950
7156
  name: "doctor",
5951
7157
  tool: "kb_doctor",
5952
7158
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
5953
7159
  description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. `drifted` narrows it to a reassessment packet per drifted record, `with_diff` adding each anchor's old-vs-new span.",
5954
- input: import_zod15.z.object({
7160
+ input: import_zod18.z.object({
5955
7161
  bundlePath,
5956
7162
  repoRoot: REPO_ROOT,
5957
7163
  expiringDays: days(
@@ -5966,16 +7172,16 @@ var doctorCommand = define({
5966
7172
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
5967
7173
  DEFAULT_AGING_DAYS
5968
7174
  ),
5969
- offline: import_zod15.z.boolean().optional().describe(
7175
+ offline: import_zod18.z.boolean().optional().describe(
5970
7176
  "Read foreign anchors from the local repo cache only, never fetching."
5971
7177
  ),
5972
- strict: import_zod15.z.boolean().optional().describe(
7178
+ strict: import_zod18.z.boolean().optional().describe(
5973
7179
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
5974
7180
  ),
5975
- drifted: import_zod15.z.boolean().optional().describe(
7181
+ drifted: import_zod18.z.boolean().optional().describe(
5976
7182
  "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
5977
7183
  ),
5978
- withDiff: import_zod15.z.boolean().optional().describe(
7184
+ withDiff: import_zod18.z.boolean().optional().describe(
5979
7185
  "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
5980
7186
  )
5981
7187
  }),
@@ -6031,7 +7237,7 @@ var doctorCommand = define({
6031
7237
  ...hints.length ? { hints } : {}
6032
7238
  };
6033
7239
  }
6034
- const standings = new Map(
7240
+ const standings2 = new Map(
6035
7241
  adjudicate(records, records, new Date(checkedAt)).map((hit) => [
6036
7242
  hit.record.conceptId,
6037
7243
  hit.standing
@@ -6045,7 +7251,7 @@ var doctorCommand = define({
6045
7251
  (entry) => entry.conceptId === found.conceptId
6046
7252
  );
6047
7253
  if (!record) continue;
6048
- const standing = standings.get(record.conceptId);
7254
+ const standing = standings2.get(record.conceptId);
6049
7255
  const built = await reassessPacket(
6050
7256
  repoRoot ?? process.cwd(),
6051
7257
  record,
@@ -6082,15 +7288,16 @@ var doctorCommand = define({
6082
7288
  });
6083
7289
  function render2(result) {
6084
7290
  if (result.packets) return renderPackets(result);
6085
- const { thresholds } = result;
7291
+ const { thresholds, anchorResolvers: counts } = result;
6086
7292
  const lines = [
6087
7293
  `# KB Doctor \u2014 ${result.bundlePath}`,
6088
7294
  `records: ${result.recordCount}`,
6089
7295
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
6090
7296
  `checked: ${result.checkedAt}`,
6091
- ...result.anchorResolvers.total ? [
6092
- `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
6093
- ] : [],
7297
+ ...counts.total ? [anchorLine(counts)] : [],
7298
+ // Its own line: an old-side anchor may name a whole file, which no
7299
+ // resolver bucket and no `total` counts.
7300
+ ...counts.oldSide ? [`old-side anchors: ${counts.oldSide}`] : [],
6094
7301
  ""
6095
7302
  ];
6096
7303
  const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
@@ -6115,6 +7322,14 @@ function render2(result) {
6115
7322
  );
6116
7323
  return lines.join("\n");
6117
7324
  }
7325
+ function anchorLine(counts) {
7326
+ const parts = [
7327
+ `${counts.treeSitter} tree-sitter`,
7328
+ `${counts.regex} regex`,
7329
+ ...counts.span ? [`${counts.span} span`] : []
7330
+ ];
7331
+ return `anchors: ${counts.total} hashed \u2014 ${parts.join(", ")}`;
7332
+ }
6118
7333
  function renderPackets(result) {
6119
7334
  const packets = result.packets ?? [];
6120
7335
  const lines = [
@@ -6140,20 +7355,155 @@ function renderPackets(result) {
6140
7355
  return lines.join("\n");
6141
7356
  }
6142
7357
 
7358
+ // src/commands/export.ts
7359
+ var import_promises11 = require("fs/promises");
7360
+ var import_node_path13 = require("path");
7361
+ var import_zod19 = require("zod");
7362
+ var NUMBERED = /^(\d{4})-(.+)\.md$/;
7363
+ var MARKER = "<!-- strauss-kb export: ";
7364
+ var exportCommand = define({
7365
+ name: "export",
7366
+ tool: "kb_export",
7367
+ usage: "export --format madr --to <dir>",
7368
+ description: "Write the base's decisions out as numbered MADR files, one per decision, for a repository that keeps ADRs of its own. Numbering is by slug, so a re-run rewrites its own files in place. A superseded decision is exported with what replaced it.",
7369
+ input: import_zod19.z.object({
7370
+ bundlePath,
7371
+ format: import_zod19.z.enum(["madr"]).describe("Output layout. `madr` is the only one so far."),
7372
+ to: import_zod19.z.string().min(1).describe("Directory the ADR files are written into.")
7373
+ }),
7374
+ fromArgv: (argv, path) => ({
7375
+ bundlePath: path,
7376
+ format: argvFlag(argv, "--format"),
7377
+ to: argvFlag(argv, "--to")
7378
+ }),
7379
+ run: async ({ store }, { bundlePath: path, to }) => {
7380
+ const bundle = await store.list(path);
7381
+ const decisions = selectDecisions(bundle).sort(
7382
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
7383
+ );
7384
+ const adjudicated = new Map(
7385
+ adjudicate(decisions, bundle).map((hit) => [hit.record.conceptId, hit])
7386
+ );
7387
+ await (0, import_promises11.mkdir)(to, { recursive: true });
7388
+ const taken = await existingFiles(to);
7389
+ let next = Math.max(0, ...[...taken.values()].map((row) => row.number)) + 1;
7390
+ const exported = [];
7391
+ const foreign = [];
7392
+ for (const record of decisions) {
7393
+ const slug = record.conceptId.slice(record.conceptId.indexOf(".") + 1);
7394
+ const held = taken.get(slug);
7395
+ if (held && !held.ours) {
7396
+ foreign.push({ conceptId: record.conceptId, file: held.file });
7397
+ continue;
7398
+ }
7399
+ const number = held?.number ?? next++;
7400
+ const file = `${String(number).padStart(4, "0")}-${slug}.md`;
7401
+ const status = statusLine(adjudicated.get(record.conceptId));
7402
+ await publish((0, import_node_path13.join)(to, file), renderMadr(record, status));
7403
+ exported.push({ conceptId: record.conceptId, file, status });
7404
+ }
7405
+ return { to, format: "madr", exported, foreign };
7406
+ },
7407
+ render: (result) => {
7408
+ const { exported, foreign, to } = result;
7409
+ return [
7410
+ `Wrote ${exported.length} MADR file${exported.length === 1 ? "" : "s"} to ${to}.`,
7411
+ ...exported.map(
7412
+ (entry) => `- ${entry.file} ${entry.conceptId} [${entry.status}]`
7413
+ ),
7414
+ ...foreign.map(
7415
+ (entry) => `- skipped ${entry.conceptId}: ${entry.file} was not written by export`
7416
+ )
7417
+ ].join("\n");
7418
+ }
7419
+ });
7420
+ async function publish(target, contents) {
7421
+ const staging = `${target}.${process.pid}.tmp`;
7422
+ await (0, import_promises11.writeFile)(staging, contents, "utf8");
7423
+ try {
7424
+ await (0, import_promises11.rename)(staging, target);
7425
+ } catch (error) {
7426
+ await (0, import_promises11.unlink)(staging).catch(() => void 0);
7427
+ throw error;
7428
+ }
7429
+ }
7430
+ async function existingFiles(to) {
7431
+ const names = await (0, import_promises11.readdir)(to).catch(() => []);
7432
+ const taken = /* @__PURE__ */ new Map();
7433
+ for (const name of names.sort()) {
7434
+ const [, number, slug] = NUMBERED.exec(name) ?? [];
7435
+ if (!number || !slug) continue;
7436
+ const text = await (0, import_promises11.readFile)((0, import_node_path13.join)(to, name), "utf8").catch(() => "");
7437
+ taken.set(slug, {
7438
+ file: name,
7439
+ number: Number(number),
7440
+ ours: text.includes(MARKER)
7441
+ });
7442
+ }
7443
+ return taken;
7444
+ }
7445
+ function statusLine(hit) {
7446
+ const status = hit?.record.frontmatter.strauss_status ?? "draft";
7447
+ if (status !== "superseded") return status;
7448
+ const by = (hit?.heads ?? []).map((head) => head.conceptId);
7449
+ return by.length ? `superseded by ${by.join(", ")}` : "superseded";
7450
+ }
7451
+ function renderMadr(record, status) {
7452
+ const sections = bodySections(record.body);
7453
+ const blocks = [
7454
+ `# ${record.frontmatter.title ?? record.conceptId}`,
7455
+ "## Status",
7456
+ status
7457
+ ];
7458
+ push(blocks, "Context and Problem Statement", record.frontmatter.description);
7459
+ push(blocks, "Considered Options", sections.get("Rejected"));
7460
+ push(blocks, "Decision Outcome", sections.get("Decision"));
7461
+ push(blocks, "Consequences", sections.get("Impact"));
7462
+ blocks.push(`${MARKER}${record.conceptId} -->`);
7463
+ return `${blocks.join("\n\n")}
7464
+ `;
7465
+ }
7466
+ function push(blocks, heading, text) {
7467
+ if (text?.trim()) blocks.push(`## ${heading}`, text.trim());
7468
+ }
7469
+ function bodySections(body) {
7470
+ const generated = new RegExp(
7471
+ `^(?:(?:${Object.values(LINK_RELS).map((spec) => spec.phrase).join("|")}) \\[[^\\]]+\\]\\([^)]+\\.md\\)\\.|\\[\\^[^\\]]+\\]: .*)$`
7472
+ );
7473
+ const sections = /* @__PURE__ */ new Map();
7474
+ let heading = null;
7475
+ let lines = [];
7476
+ const flush = () => {
7477
+ if (heading) sections.set(heading, lines.join("\n").trim());
7478
+ };
7479
+ for (const line of body.split("\n")) {
7480
+ const match = /^## (.+?)\s*$/.exec(line);
7481
+ if (match) {
7482
+ flush();
7483
+ heading = match[1] ?? null;
7484
+ lines = [];
7485
+ } else if (heading && !generated.test(line)) {
7486
+ lines.push(line);
7487
+ }
7488
+ }
7489
+ flush();
7490
+ return sections;
7491
+ }
7492
+
6143
7493
  // src/commands/impact.ts
6144
- var import_zod16 = require("zod");
7494
+ var import_zod20 = require("zod");
6145
7495
  var impactCommand = define({
6146
7496
  name: "impact",
6147
7497
  tool: "kb_impact",
6148
7498
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
6149
7499
  description: "What breaks if this record changes: its transitive set of dependants, each with its standing. Each rel declares which of its ends depends on the other, and the walk follows each rel in its own direction. Naming `related_to` or an unknown rel in `rels` is an error. kb_backlinks gives one flat hop.",
6150
- input: import_zod16.z.object({
7500
+ input: import_zod20.z.object({
6151
7501
  bundlePath,
6152
7502
  conceptId,
6153
- depth: import_zod16.z.number().int().positive().optional().describe(
7503
+ depth: import_zod20.z.number().int().positive().optional().describe(
6154
7504
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
6155
7505
  ),
6156
- rels: import_zod16.z.array(import_zod16.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
7506
+ rels: import_zod20.z.array(import_zod20.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
6157
7507
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
6158
7508
  )
6159
7509
  }),
@@ -6174,15 +7524,15 @@ var impactCommand = define({
6174
7524
  });
6175
7525
 
6176
7526
  // src/commands/list.ts
6177
- var import_zod17 = require("zod");
7527
+ var import_zod21 = require("zod");
6178
7528
  var listCommand = define({
6179
7529
  name: "list",
6180
7530
  tool: "kb_list",
6181
7531
  usage: "list [type] [--tag T]...",
6182
7532
  description: "Every record, optionally one type or tag. For enumerating; use kb_query for a question.",
6183
- input: import_zod17.z.object({
7533
+ input: import_zod21.z.object({
6184
7534
  bundlePath,
6185
- type: import_zod17.z.enum(KB_RECORD_TYPES).optional(),
7535
+ type: import_zod21.z.enum(KB_RECORD_TYPES).optional(),
6186
7536
  tags: TAGS
6187
7537
  }),
6188
7538
  fromArgv: (argv, path) => {
@@ -6206,17 +7556,17 @@ var listCommand = define({
6206
7556
  });
6207
7557
 
6208
7558
  // src/commands/load.ts
6209
- var import_zod18 = require("zod");
7559
+ var import_zod22 = require("zod");
6210
7560
  var loadCommand = define({
6211
7561
  name: "load",
6212
7562
  tool: "kb_load",
6213
7563
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
6214
7564
  description: "Load the whole base, each record with its standing \u2014 call it first, at the point of use, since compaction drops it. Superseded records arrive as stubs; kb_trace has the history. Over budget it refuses: kb_catalog, then kb_pack, or narrow with `type`; `all` bypasses. Never read record files directly \u2014 only kb_* tools resolve supersession. `digest` stamps the base's content, so hooks know when to reload.",
6215
- input: import_zod18.z.object({
7565
+ input: import_zod22.z.object({
6216
7566
  bundlePath,
6217
- type: import_zod18.z.enum(KB_RECORD_TYPES).optional(),
6218
- budgetTokens: import_zod18.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6219
- all: import_zod18.z.boolean().optional().describe(
7567
+ type: import_zod22.z.enum(KB_RECORD_TYPES).optional(),
7568
+ budgetTokens: import_zod22.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
7569
+ all: import_zod22.z.boolean().optional().describe(
6220
7570
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
6221
7571
  ),
6222
7572
  repoRoot: REPO_ROOT
@@ -6258,25 +7608,25 @@ var loadCommand = define({
6258
7608
  });
6259
7609
 
6260
7610
  // src/commands/log.ts
6261
- var import_zod19 = require("zod");
7611
+ var import_zod23 = require("zod");
6262
7612
  var logCommand = define({
6263
7613
  name: "log",
6264
7614
  tool: "kb_log",
6265
7615
  usage: "log",
6266
7616
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
6267
- input: import_zod19.z.object({ bundlePath }),
7617
+ input: import_zod23.z.object({ bundlePath }),
6268
7618
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6269
7619
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
6270
7620
  });
6271
7621
 
6272
7622
  // src/commands/no-decision.ts
6273
- var import_zod20 = require("zod");
7623
+ var import_zod24 = require("zod");
6274
7624
  var noDecisionCommand = define({
6275
7625
  name: "no-decision",
6276
7626
  tool: "kb_no_decision",
6277
7627
  usage: "no-decision <reason...>",
6278
7628
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
6279
- input: import_zod20.z.object({ bundlePath, reason: import_zod20.z.string().min(1) }),
7629
+ input: import_zod24.z.object({ bundlePath, reason: import_zod24.z.string().min(1) }),
6280
7630
  fromArgv: (argv, path) => ({
6281
7631
  bundlePath: path,
6282
7632
  reason: argv.slice(1).join(" ").trim()
@@ -6293,20 +7643,20 @@ var noDecisionCommand = define({
6293
7643
  });
6294
7644
 
6295
7645
  // src/commands/pack.ts
6296
- var import_zod21 = require("zod");
7646
+ var import_zod25 = require("zod");
6297
7647
  var packCommand = define({
6298
7648
  name: "pack",
6299
7649
  tool: "kb_pack",
6300
7650
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
6301
7651
  description: "Bounded neighbourhood around one record: within `hops`, ranked, cut to `maxNodes`, with every cut record named under Excluded. Use when the base is over kb_load's budget and the work centres on a record you can name. Refuses over budget rather than truncating. Everything below the header is byte-stable across runs. Resolves supersession like kb_load.",
6302
- input: import_zod21.z.object({
7652
+ input: import_zod25.z.object({
6303
7653
  bundlePath,
6304
7654
  conceptId,
6305
- hops: import_zod21.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6306
- maxNodes: import_zod21.z.number().int().positive().optional().describe(
7655
+ hops: import_zod25.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
7656
+ maxNodes: import_zod25.z.number().int().positive().optional().describe(
6307
7657
  "How many records the pack may hold, root included. Defaults to 20."
6308
7658
  ),
6309
- budgetTokens: import_zod21.z.number().int().positive().optional().describe(
7659
+ budgetTokens: import_zod25.z.number().int().positive().optional().describe(
6310
7660
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
6311
7661
  )
6312
7662
  }),
@@ -6393,22 +7743,22 @@ function warningLabel(warning) {
6393
7743
  }
6394
7744
 
6395
7745
  // src/commands/pin.ts
6396
- var import_zod22 = require("zod");
7746
+ var import_zod26 = require("zod");
6397
7747
  var pinCommand = define({
6398
7748
  name: "pin",
6399
7749
  tool: "kb_pin",
6400
7750
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
6401
7751
  description: "Pin a base into a workspace manifest so kb_context surfaces it. Layers, nearest wins: project `.strauss/kb-pins.json` (default), `--local` (personal, gitignored), `--user` (`~/.strauss`). Idempotent; `--mode full|index`, `--profiles`, `--frozen`/`--unfreeze` update only those fields. A path with no records pins with a warning. Never touches the base itself.",
6402
- input: import_zod22.z.object({
7752
+ input: import_zod26.z.object({
6403
7753
  bundlePath,
6404
- mode: import_zod22.z.enum(["full", "index"]).optional().describe(
7754
+ mode: import_zod26.z.enum(["full", "index"]).optional().describe(
6405
7755
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
6406
7756
  ),
6407
- profiles: import_zod22.z.array(import_zod22.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6408
- layer: import_zod22.z.enum(["project", "local", "user"]).optional().describe(
7757
+ profiles: import_zod26.z.array(import_zod26.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
7758
+ layer: import_zod26.z.enum(["project", "local", "user"]).optional().describe(
6409
7759
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
6410
7760
  ),
6411
- frozen: import_zod22.z.boolean().optional().describe(
7761
+ frozen: import_zod26.z.boolean().optional().describe(
6412
7762
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
6413
7763
  )
6414
7764
  }),
@@ -6437,29 +7787,305 @@ var pinCommand = define({
6437
7787
  });
6438
7788
 
6439
7789
  // src/commands/pins.ts
6440
- var import_zod23 = require("zod");
7790
+ var import_zod27 = require("zod");
6441
7791
  var pinsCommand = define({
6442
7792
  name: "pins",
6443
7793
  tool: "kb_pins",
6444
7794
  usage: "pins",
6445
7795
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
6446
- input: import_zod23.z.object({}),
7796
+ input: import_zod27.z.object({}),
6447
7797
  fromArgv: () => ({}),
6448
7798
  run: ({ store }) => listPins(store, process.cwd())
6449
7799
  });
6450
7800
 
7801
+ // src/commands/promote/command.ts
7802
+ var import_node_path14 = require("path");
7803
+
7804
+ // src/commands/promote/carry.ts
7805
+ var PROMOTION_SOURCE_ID = "promoted";
7806
+ var CARRIED_STATUS = {
7807
+ draft: "accepted",
7808
+ proposed: "accepted",
7809
+ accepted: "accepted",
7810
+ open: "open",
7811
+ resolved: "resolved",
7812
+ rejected: "rejected",
7813
+ superseded: "superseded"
7814
+ };
7815
+ function carry(record, promoted, source) {
7816
+ const {
7817
+ type: _type,
7818
+ // Both name records in the source base, and supersession in the target is
7819
+ // a separate question from whether this record belongs there at all.
7820
+ strauss_supersedes: _supersedes,
7821
+ strauss_superseded_by: _supersededBy,
7822
+ // A check run against the source repository, which the target never saw.
7823
+ verified: _verified,
7824
+ ...rest
7825
+ } = record.frontmatter;
7826
+ const links = rest.strauss_links ?? [];
7827
+ const kept = links.filter((link2) => promoted.has(link2.target));
7828
+ const dropped = links.filter((link2) => !promoted.has(link2.target));
7829
+ const tags = (rest.tags ?? []).filter((tag) => !isReviewTag(tag));
7830
+ const frontmatter = {
7831
+ ...rest,
7832
+ strauss_status: CARRIED_STATUS[rest.strauss_status]
7833
+ };
7834
+ setOrDrop(frontmatter, "tags", tags);
7835
+ setOrDrop(frontmatter, "strauss_links", kept);
7836
+ let body = withoutLinkSentences(record.body, dropped);
7837
+ if (source) {
7838
+ frontmatter.sources = [
7839
+ ...(rest.sources ?? []).filter(
7840
+ (entry) => entry.id !== PROMOTION_SOURCE_ID
7841
+ ),
7842
+ { id: PROMOTION_SOURCE_ID, resource: source }
7843
+ ];
7844
+ body = `${stripFootnote(body).trimEnd()}
7845
+
7846
+ [^${PROMOTION_SOURCE_ID}]: ${source}
7847
+ `;
7848
+ }
7849
+ return {
7850
+ frontmatter,
7851
+ body,
7852
+ droppedLinks: dropped.map(({ target, rel }) => ({ target, rel }))
7853
+ };
7854
+ }
7855
+ function isReviewTag(tag) {
7856
+ return tag === "review" || tag.startsWith("review:");
7857
+ }
7858
+ function withoutLinkSentences(body, dropped) {
7859
+ const sentences = new Set(
7860
+ dropped.filter((link2) => isKbLinkRel(link2.rel)).map(
7861
+ (link2) => `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
7862
+ )
7863
+ );
7864
+ if (!sentences.size) return body;
7865
+ return body.split("\n\n").filter((block) => !sentences.has(block.trim())).join("\n\n");
7866
+ }
7867
+ function stripFootnote(body) {
7868
+ return body.split("\n").filter((line) => !line.startsWith(`[^${PROMOTION_SOURCE_ID}]: `)).join("\n");
7869
+ }
7870
+ function setOrDrop(frontmatter, key2, value) {
7871
+ if (value.length) frontmatter[key2] = value;
7872
+ else delete frontmatter[key2];
7873
+ }
7874
+
7875
+ // src/commands/promote/standing.ts
7876
+ var WITHDRAWN = ["superseded", "rejected"];
7877
+ function standings(bundle) {
7878
+ return new Map(
7879
+ adjudicate(bundle, bundle).map((hit) => [
7880
+ hit.record.conceptId,
7881
+ hit.standing
7882
+ ])
7883
+ );
7884
+ }
7885
+ function isWithdrawn(standing) {
7886
+ return standing !== void 0 && WITHDRAWN.includes(standing);
7887
+ }
7888
+
7889
+ // src/commands/promote/candidates.ts
7890
+ var REVIEW_TAG = "review";
7891
+ var SETTLED = ["resolved"];
7892
+ function promoteCandidates(bundle) {
7893
+ const inbound = inboundIndex(bundle);
7894
+ const standing = standings(bundle);
7895
+ const rows = [];
7896
+ for (const record of bundle) {
7897
+ if (isWithdrawn(standing.get(record.conceptId))) continue;
7898
+ const why2 = candidateReason(record, inbound.get(record.conceptId) ?? []);
7899
+ if (!why2) continue;
7900
+ rows.push({
7901
+ conceptId: record.conceptId,
7902
+ type: recordType(record.conceptId),
7903
+ title: record.frontmatter.title ?? null,
7904
+ why: why2
7905
+ });
7906
+ }
7907
+ return rows;
7908
+ }
7909
+ function candidateReason(record, inbound) {
7910
+ const { strauss_status: status, tags } = record.frontmatter;
7911
+ switch (recordType(record.conceptId)) {
7912
+ case "decision":
7913
+ if (isNoDecisionRecord(record)) return null;
7914
+ return tags?.includes(REVIEW_TAG) ? null : "decision no longer under review";
7915
+ case "constraint":
7916
+ return status === "proposed" ? "constraint still proposed \u2014 the target base is where it settles" : null;
7917
+ case "contract":
7918
+ return "contract \u2014 it outlives the change that introduced it";
7919
+ case "requirement":
7920
+ return inbound.some((edge) => edge.rel === "satisfies") ? "requirement something in the base satisfies" : null;
7921
+ case "risk":
7922
+ return record.frontmatter.strauss_materiality === "blocking" && !SETTLED.includes(status) ? "blocking risk still open" : null;
7923
+ default:
7924
+ return null;
7925
+ }
7926
+ }
7927
+ function recordType(conceptId2) {
7928
+ return conceptId2.slice(0, conceptId2.indexOf("."));
7929
+ }
7930
+
7931
+ // src/commands/promote/model.ts
7932
+ var import_zod28 = require("zod");
7933
+ var promoteInputSchema = import_zod28.z.object({
7934
+ bundlePath,
7935
+ conceptIds: import_zod28.z.array(conceptId).max(64).optional().describe("Records to copy into the target base. Omit with `list`."),
7936
+ to: import_zod28.z.string().min(1).optional().describe("Absolute path to the base being promoted into."),
7937
+ source: import_zod28.z.string().min(1).optional().describe(
7938
+ "Where the promotion came from, usually the pull request URL. Recorded on each copy as a source."
7939
+ ),
7940
+ force: import_zod28.z.boolean().optional().describe("Overwrite a record the target base already holds."),
7941
+ list: import_zod28.z.boolean().optional().describe("List the source base's candidates instead of promoting.")
7942
+ }).refine((input) => input.list === true || input.to !== void 0, {
7943
+ message: "promote needs a target base \u2014 pass --to <bundle>, or --list",
7944
+ path: ["to"]
7945
+ }).refine(
7946
+ (input) => input.list === true || (input.conceptIds?.length ?? 0) > 0,
7947
+ {
7948
+ message: "name at least one concept id to promote, or pass --list",
7949
+ path: ["conceptIds"]
7950
+ }
7951
+ );
7952
+
7953
+ // src/commands/promote/command.ts
7954
+ var promoteCommand = define({
7955
+ name: "promote",
7956
+ tool: "kb_promote",
7957
+ usage: "promote <concept-id...> --to <bundle> [--source <url>] [--force] | --list",
7958
+ description: "Copy records into another base at the same slug, with the review tags dropped and a source naming where the promotion came from. Use at merge, to lift what a review base settled into the base that outlives it. `list` names the candidates instead. The originals stay put.",
7959
+ input: promoteInputSchema,
7960
+ fromArgv: (argv, path) => {
7961
+ const to = argvFlag(argv, "--to");
7962
+ const source = argvFlag(argv, "--source");
7963
+ const words = argv.slice(1);
7964
+ for (const flag of ["--to", "--source"]) {
7965
+ const at2 = words.indexOf(flag);
7966
+ if (at2 !== -1) words.splice(at2, 2);
7967
+ }
7968
+ const conceptIds = words.filter((word) => !word.startsWith("--"));
7969
+ return {
7970
+ bundlePath: path,
7971
+ ...conceptIds.length ? { conceptIds } : {},
7972
+ ...to !== void 0 ? { to } : {},
7973
+ ...source !== void 0 ? { source } : {},
7974
+ ...argv.includes("--force") ? { force: true } : {},
7975
+ ...argv.includes("--list") ? { list: true } : {}
7976
+ };
7977
+ },
7978
+ run: async ({ store, actor }, { bundlePath: path, conceptIds, to, source, force, list }) => {
7979
+ const from = (0, import_node_path14.resolve)(path);
7980
+ const bundle = await store.list(from);
7981
+ if (list) {
7982
+ return { mode: "list", candidates: promoteCandidates(bundle) };
7983
+ }
7984
+ const target = (0, import_node_path14.resolve)(to);
7985
+ if (target === from) throw new KbPromoteSelfError(target);
7986
+ const named = (conceptIds ?? []).map(namedRecord);
7987
+ const wanted = named.map(({ conceptId: conceptId2, type, slug }) => {
7988
+ const record = bundle.find((entry) => entry.conceptId === conceptId2);
7989
+ if (!record) throw new KbRecordNotFoundError(conceptId2);
7990
+ return { record, type, slug };
7991
+ });
7992
+ await assertBaseNotFrozen(process.cwd(), from);
7993
+ await assertBaseNotFrozen(process.cwd(), target);
7994
+ const standing = standings(bundle);
7995
+ for (const { record } of wanted) {
7996
+ const where = standing.get(record.conceptId);
7997
+ if (isWithdrawn(where)) {
7998
+ throw new KbPromoteStandingError(record.conceptId, where);
7999
+ }
8000
+ if (!force && await store.read(target, record.conceptId)) {
8001
+ throw new KbPromoteCollisionError(record.conceptId, target);
8002
+ }
8003
+ }
8004
+ const promotedIds = new Set(wanted.map(({ record }) => record.conceptId));
8005
+ const promoted = [];
8006
+ for (const { record, type, slug } of wanted) {
8007
+ const { frontmatter, body, droppedLinks } = carry(
8008
+ record,
8009
+ promotedIds,
8010
+ source
8011
+ );
8012
+ try {
8013
+ await store.write(
8014
+ target,
8015
+ { type, slug, frontmatter, body, overwrite: force === true },
8016
+ actor
8017
+ );
8018
+ } catch (error) {
8019
+ throw new KbPromoteStoppedError(
8020
+ record.conceptId,
8021
+ promoted.map((entry) => entry.conceptId),
8022
+ error instanceof Error ? error.message : "unknown"
8023
+ );
8024
+ }
8025
+ await store.note(target, {
8026
+ by: actor,
8027
+ operation: "promote-in",
8028
+ conceptId: record.conceptId,
8029
+ target: from
8030
+ });
8031
+ await store.note(from, {
8032
+ by: actor,
8033
+ operation: "promote-out",
8034
+ conceptId: record.conceptId,
8035
+ target
8036
+ });
8037
+ promoted.push({ conceptId: record.conceptId, droppedLinks });
8038
+ }
8039
+ return { mode: "promote", to: target, promoted };
8040
+ },
8041
+ render: (result) => renderPromote(result)
8042
+ });
8043
+ function namedRecord(conceptId2) {
8044
+ const at2 = conceptId2.indexOf(".");
8045
+ const type = at2 === -1 ? conceptId2 : conceptId2.slice(0, at2);
8046
+ const slug = at2 === -1 ? "" : conceptId2.slice(at2 + 1);
8047
+ if (!KB_SLUG_PATTERN.test(type) || !KB_SLUG_PATTERN.test(slug)) {
8048
+ throw new KbInvalidConceptIdError(
8049
+ "concept id must be <type>.<slug>, both kebab-case",
8050
+ { conceptId: conceptId2 }
8051
+ );
8052
+ }
8053
+ return { conceptId: conceptId2, type, slug };
8054
+ }
8055
+ function renderPromote(result) {
8056
+ if (result.mode === "list") {
8057
+ if (!result.candidates.length) return "No promotion candidates.";
8058
+ return result.candidates.flatMap((candidate) => [
8059
+ `${candidate.conceptId} [${candidate.type}]${candidate.title ? ` \u2014 ${candidate.title}` : ""}`,
8060
+ ` ${candidate.why}`
8061
+ ]).join("\n");
8062
+ }
8063
+ const lines = [
8064
+ `Promoted ${result.promoted.length} record${result.promoted.length === 1 ? "" : "s"} into ${result.to}.`
8065
+ ];
8066
+ for (const entry of result.promoted) {
8067
+ lines.push(`- ${entry.conceptId}`);
8068
+ for (const link2 of entry.droppedLinks) {
8069
+ lines.push(
8070
+ ` dropped ${link2.rel} \u2192 ${link2.target} (not promoted in this run)`
8071
+ );
8072
+ }
8073
+ }
8074
+ return lines.join("\n");
8075
+ }
8076
+
6451
8077
  // src/commands/query.ts
6452
- var import_zod24 = require("zod");
8078
+ var import_zod29 = require("zod");
6453
8079
  var queryCommand = define({
6454
8080
  name: "query",
6455
8081
  tool: "kb_query",
6456
8082
  usage: "query <text...> [--tag T]... [--repo-root PATH]",
6457
8083
  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.",
6458
- input: import_zod24.z.object({
8084
+ input: import_zod29.z.object({
6459
8085
  bundlePath,
6460
- text: import_zod24.z.string().optional(),
6461
- type: import_zod24.z.enum(KB_RECORD_TYPES).optional(),
6462
- includeNonCurrent: import_zod24.z.boolean().optional(),
8086
+ text: import_zod29.z.string().optional(),
8087
+ type: import_zod29.z.enum(KB_RECORD_TYPES).optional(),
8088
+ includeNonCurrent: import_zod29.z.boolean().optional(),
6463
8089
  tags: TAGS,
6464
8090
  repoRoot: REPO_ROOT
6465
8091
  }),
@@ -6493,43 +8119,43 @@ var queryCommand = define({
6493
8119
  });
6494
8120
 
6495
8121
  // src/commands/read-index.ts
6496
- var import_zod25 = require("zod");
8122
+ var import_zod30 = require("zod");
6497
8123
  var readIndexCommand = define({
6498
8124
  name: "index",
6499
8125
  tool: "kb_index",
6500
8126
  usage: "index",
6501
8127
  description: "The index \u2014 title, type, status, description per record \u2014 rebuilt if stale. Cheapest re-orientation after compaction: call it (or kb_context) first, then kb_load or fetch by id.",
6502
- input: import_zod25.z.object({ bundlePath }),
8128
+ input: import_zod30.z.object({ bundlePath }),
6503
8129
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6504
8130
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
6505
8131
  });
6506
8132
 
6507
8133
  // src/commands/schema.ts
6508
- var import_zod26 = require("zod");
8134
+ var import_zod31 = require("zod");
6509
8135
  var schemaCommand = define({
6510
8136
  name: "schema",
6511
8137
  tool: "kb_schema",
6512
8138
  usage: "schema",
6513
8139
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
6514
- input: import_zod26.z.object({}),
8140
+ input: import_zod31.z.object({}),
6515
8141
  fromArgv: () => ({}),
6516
8142
  run: () => Promise.resolve(kbJsonSchemas())
6517
8143
  });
6518
8144
 
6519
8145
  // src/commands/stamp.ts
6520
- var import_promises10 = require("fs/promises");
6521
- var import_zod27 = require("zod");
8146
+ var import_promises12 = require("fs/promises");
8147
+ var import_zod32 = require("zod");
6522
8148
  var DIGEST = /^[0-9a-f]{64}$/;
6523
8149
  var stampCommand = define({
6524
8150
  name: "stamp",
6525
8151
  tool: "kb_stamp",
6526
8152
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
6527
8153
  description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests, how many records have drifted anchors \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids. Reads, never writes.",
6528
- input: import_zod27.z.object({
6529
- bundlePath: import_zod27.z.string().min(1).optional().describe(
8154
+ input: import_zod32.z.object({
8155
+ bundlePath: import_zod32.z.string().min(1).optional().describe(
6530
8156
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
6531
8157
  ),
6532
- since: import_zod27.z.string().min(1).optional().describe(
8158
+ since: import_zod32.z.string().min(1).optional().describe(
6533
8159
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
6534
8160
  )
6535
8161
  }),
@@ -6591,7 +8217,7 @@ async function readBaseline(since) {
6591
8217
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
6592
8218
  let parsed;
6593
8219
  try {
6594
- parsed = JSON.parse(await (0, import_promises10.readFile)(since, "utf8"));
8220
+ parsed = JSON.parse(await (0, import_promises12.readFile)(since, "utf8"));
6595
8221
  } catch {
6596
8222
  throw new KbStampBaselineError(since);
6597
8223
  }
@@ -6615,16 +8241,16 @@ async function readBaseline(since) {
6615
8241
  }
6616
8242
 
6617
8243
  // src/commands/status.ts
6618
- var import_zod28 = require("zod");
8244
+ var import_zod33 = require("zod");
6619
8245
  var statusCommand = define({
6620
8246
  name: "status",
6621
8247
  tool: "kb_status",
6622
8248
  usage: "status <concept-id> <status>",
6623
8249
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
6624
- input: import_zod28.z.object({
8250
+ input: import_zod33.z.object({
6625
8251
  bundlePath,
6626
8252
  conceptId,
6627
- status: import_zod28.z.enum(KB_RECORD_STATUSES)
8253
+ status: import_zod33.z.enum(KB_RECORD_STATUSES)
6628
8254
  }),
6629
8255
  fromArgv: (argv, path) => ({
6630
8256
  bundlePath: path,
@@ -6639,13 +8265,13 @@ var statusCommand = define({
6639
8265
  });
6640
8266
 
6641
8267
  // src/commands/supersede.ts
6642
- var import_zod29 = require("zod");
8268
+ var import_zod34 = require("zod");
6643
8269
  var supersedeCommand = define({
6644
8270
  name: "supersede",
6645
8271
  tool: "kb_supersede",
6646
8272
  usage: "supersede <concept-id> <replacement-id>",
6647
8273
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
6648
- input: import_zod29.z.object({ bundlePath, conceptId, replacementId: conceptId }),
8274
+ input: import_zod34.z.object({ bundlePath, conceptId, replacementId: conceptId }),
6649
8275
  fromArgv: (argv, path) => ({
6650
8276
  bundlePath: path,
6651
8277
  conceptId: argv[1],
@@ -6658,17 +8284,153 @@ var supersedeCommand = define({
6658
8284
  }
6659
8285
  });
6660
8286
 
8287
+ // src/commands/sweep.ts
8288
+ var import_zod35 = require("zod");
8289
+ var TERMINAL = [
8290
+ "resolved",
8291
+ "rejected",
8292
+ "superseded"
8293
+ ];
8294
+ var sweepCommand = define({
8295
+ name: "sweep",
8296
+ tool: "kb_sweep",
8297
+ usage: "sweep --tag <tag> --terminal [--dry-run]",
8298
+ description: "Delete tagged records that are resolved, rejected or superseded. Refuses without --tag, keeps any record a surviving record still points at, and logs each deletion.",
8299
+ input: import_zod35.z.object({
8300
+ bundlePath,
8301
+ tag: import_zod35.z.string({ error: "sweep needs --tag: it never sweeps a whole base" }).min(1).describe("Only records carrying this tag are considered."),
8302
+ terminal: import_zod35.z.literal(true, {
8303
+ error: "sweep needs --terminal: it deletes only settled records"
8304
+ }).describe(
8305
+ "Required. Names the only scope sweep deletes: resolved, rejected and superseded records."
8306
+ ),
8307
+ dryRun: import_zod35.z.boolean().optional().describe("Report what would go, and delete nothing.")
8308
+ }),
8309
+ fromArgv: (argv, path) => ({
8310
+ bundlePath: path,
8311
+ tag: argvFlag(argv, "--tag"),
8312
+ ...argv.includes("--terminal") ? { terminal: true } : {},
8313
+ ...argv.includes("--dry-run") ? { dryRun: true } : {}
8314
+ }),
8315
+ run: async ({ store, actor }, { bundlePath: path, tag, dryRun }) => {
8316
+ const bundle = await store.list(path);
8317
+ const held = holderIndex(bundle);
8318
+ const candidates = adjudicate(bundle, bundle).filter(
8319
+ (hit) => sweepable(hit, tag)
8320
+ );
8321
+ const doomed = new Set(candidates.map((hit) => hit.record.conceptId));
8322
+ let changed = true;
8323
+ while (changed) {
8324
+ changed = false;
8325
+ for (const conceptId2 of [...doomed]) {
8326
+ if (survivorsHolding(conceptId2, held, doomed).length === 0) continue;
8327
+ doomed.delete(conceptId2);
8328
+ changed = true;
8329
+ }
8330
+ }
8331
+ const skipped = candidates.filter((hit) => !doomed.has(hit.record.conceptId)).map((hit) => ({
8332
+ conceptId: hit.record.conceptId,
8333
+ heldBy: survivorsHolding(hit.record.conceptId, held, doomed)
8334
+ }));
8335
+ const ordered = [...doomed].sort();
8336
+ if (dryRun) {
8337
+ return {
8338
+ tag,
8339
+ dryRun: true,
8340
+ deleted: [],
8341
+ candidates: ordered,
8342
+ skipped,
8343
+ failed: []
8344
+ };
8345
+ }
8346
+ await assertBaseNotFrozen(process.cwd(), path);
8347
+ const deleted = [];
8348
+ const failed = [];
8349
+ try {
8350
+ for (const conceptId2 of ordered) {
8351
+ try {
8352
+ const outcome = await store.deleteRecord(
8353
+ path,
8354
+ conceptId2,
8355
+ { tag, statuses: TERMINAL },
8356
+ actor
8357
+ );
8358
+ if (outcome === "deleted") deleted.push(conceptId2);
8359
+ else failed.push({ conceptId: conceptId2, reason: outcome });
8360
+ } catch (error) {
8361
+ failed.push({
8362
+ conceptId: conceptId2,
8363
+ reason: error instanceof Error ? error.message : "unknown"
8364
+ });
8365
+ }
8366
+ }
8367
+ } finally {
8368
+ await store.readIndex(path);
8369
+ await store.dropSearchIndex(path);
8370
+ }
8371
+ return {
8372
+ tag,
8373
+ dryRun: false,
8374
+ deleted,
8375
+ candidates: ordered,
8376
+ skipped,
8377
+ failed
8378
+ };
8379
+ },
8380
+ render: (result) => renderSweep(result)
8381
+ });
8382
+ function sweepable(hit, tag) {
8383
+ const { tags, strauss_status } = hit.record.frontmatter;
8384
+ return (tags ?? []).includes(tag) && // Supersession is a standing, settled against the whole base; the other
8385
+ // two are the record's own word for itself.
8386
+ (hit.standing === "superseded" || strauss_status === "resolved" || strauss_status === "rejected");
8387
+ }
8388
+ function holderIndex(bundle) {
8389
+ const byTarget = /* @__PURE__ */ new Map();
8390
+ const hold = (target, from) => {
8391
+ if (target === from) return;
8392
+ const holders = byTarget.get(target) ?? /* @__PURE__ */ new Set();
8393
+ holders.add(from);
8394
+ byTarget.set(target, holders);
8395
+ };
8396
+ for (const [target, edges] of inboundIndex(bundle)) {
8397
+ for (const edge of edges) hold(target, edge.from);
8398
+ }
8399
+ for (const record of bundle) {
8400
+ const { strauss_supersedes, strauss_superseded_by } = record.frontmatter;
8401
+ for (const old of strauss_supersedes ?? []) hold(old, record.conceptId);
8402
+ if (strauss_superseded_by) hold(strauss_superseded_by, record.conceptId);
8403
+ }
8404
+ return byTarget;
8405
+ }
8406
+ function survivorsHolding(conceptId2, held, doomed) {
8407
+ return [...held.get(conceptId2) ?? []].filter((from) => !doomed.has(from)).sort();
8408
+ }
8409
+ function renderSweep(result) {
8410
+ const shown = result.dryRun ? result.candidates : result.deleted;
8411
+ const verb = result.dryRun ? "would delete" : "deleted";
8412
+ const lines = [`${verb} ${shown.length} (tag: ${result.tag})`];
8413
+ for (const conceptId2 of shown) lines.push(`- ${conceptId2}`);
8414
+ for (const skip of result.skipped) {
8415
+ lines.push(`kept ${skip.conceptId} \u2014 held by ${skip.heldBy.join(", ")}`);
8416
+ }
8417
+ for (const failure of result.failed) {
8418
+ lines.push(`failed ${failure.conceptId} \u2014 ${failure.reason}`);
8419
+ }
8420
+ return lines.join("\n");
8421
+ }
8422
+
6661
8423
  // src/commands/sync-instructions.ts
6662
- var import_zod30 = require("zod");
8424
+ var import_zod36 = require("zod");
6663
8425
  var syncInstructionsCommand = define({
6664
8426
  name: "sync-instructions",
6665
8427
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
6666
8428
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
6667
- input: import_zod30.z.object({
6668
- file: import_zod30.z.string().min(1).describe("The instruction file to edit in place."),
6669
- budgetTokens: import_zod30.z.number().int().positive().optional(),
6670
- fullUnderTokens: import_zod30.z.number().int().positive().optional(),
6671
- profile: import_zod30.z.string().optional()
8429
+ input: import_zod36.z.object({
8430
+ file: import_zod36.z.string().min(1).describe("The instruction file to edit in place."),
8431
+ budgetTokens: import_zod36.z.number().int().positive().optional(),
8432
+ fullUnderTokens: import_zod36.z.number().int().positive().optional(),
8433
+ profile: import_zod36.z.string().optional()
6672
8434
  }),
6673
8435
  fromArgv: (argv) => {
6674
8436
  const budget = argvFlag(argv, "--budget");
@@ -6694,17 +8456,17 @@ var syncInstructionsCommand = define({
6694
8456
  });
6695
8457
 
6696
8458
  // src/commands/trace.ts
6697
- var import_zod31 = require("zod");
8459
+ var import_zod37 = require("zod");
6698
8460
  var traceCommand = define({
6699
8461
  name: "trace",
6700
8462
  tool: "kb_trace",
6701
8463
  usage: "trace <concept-id> [edges...]",
6702
8464
  description: 'Timeline of how a position was reached, ordered by write time, following supersession, shared anchors and shared sources. Includes rejected, draft and superseded records \u2014 in a history they are the content. For "why is it like this"; kb_load answers "what holds now".',
6703
- input: import_zod31.z.object({
8465
+ input: import_zod37.z.object({
6704
8466
  bundlePath,
6705
8467
  conceptId,
6706
- edges: import_zod31.z.array(import_zod31.z.enum(TRACE_EDGES)).optional(),
6707
- depth: import_zod31.z.number().int().positive().optional()
8468
+ edges: import_zod37.z.array(import_zod37.z.enum(TRACE_EDGES)).optional(),
8469
+ depth: import_zod37.z.number().int().positive().optional()
6708
8470
  }),
6709
8471
  fromArgv: (argv, path) => ({
6710
8472
  bundlePath: path,
@@ -6726,37 +8488,37 @@ var traceCommand = define({
6726
8488
  });
6727
8489
 
6728
8490
  // src/commands/types.ts
6729
- var import_zod32 = require("zod");
8491
+ var import_zod38 = require("zod");
6730
8492
  var typesCommand = define({
6731
8493
  name: "types",
6732
8494
  tool: "kb_types",
6733
8495
  usage: "types",
6734
8496
  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.",
6735
- input: import_zod32.z.object({}),
8497
+ input: import_zod38.z.object({}),
6736
8498
  fromArgv: () => ({}),
6737
8499
  run: () => Promise.resolve(RECORD_TYPES)
6738
8500
  });
6739
8501
 
6740
8502
  // src/commands/unpin.ts
6741
- var import_zod33 = require("zod");
8503
+ var import_zod39 = require("zod");
6742
8504
  var unpinCommand = define({
6743
8505
  name: "unpin",
6744
8506
  tool: "kb_unpin",
6745
8507
  usage: "unpin [bundle-path]",
6746
8508
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
6747
- input: import_zod33.z.object({ bundlePath }),
8509
+ input: import_zod39.z.object({ bundlePath }),
6748
8510
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
6749
8511
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
6750
8512
  });
6751
8513
 
6752
8514
  // src/commands/validate.ts
6753
- var import_zod34 = require("zod");
8515
+ var import_zod40 = require("zod");
6754
8516
  var validateCommand = define({
6755
8517
  name: "validate",
6756
8518
  tool: "kb_validate",
6757
8519
  usage: "validate",
6758
8520
  description: "Check pointers no single record can see: supersession links that disagree between the two records, typed causal links, and assumptions that cite sources. Each finding carries a severity: errors fail the exit code, warnings do not.",
6759
- input: import_zod34.z.object({ bundlePath }),
8521
+ input: import_zod40.z.object({ bundlePath }),
6760
8522
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6761
8523
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
6762
8524
  // Warnings never fail the exit code; every other severity does.
@@ -6766,16 +8528,16 @@ var validateCommand = define({
6766
8528
  });
6767
8529
 
6768
8530
  // src/commands/verify.ts
6769
- var import_zod35 = require("zod");
8531
+ var import_zod41 = require("zod");
6770
8532
  var verifyCommand = define({
6771
8533
  name: "verify",
6772
8534
  tool: "kb_verify",
6773
8535
  usage: "verify <concept-id> --note <text>",
6774
8536
  description: "Append a verified[] event: who checked, when, and what was found. Append-only. A record's own generator is refused unless the actor is `human:`-prefixed.",
6775
- input: import_zod35.z.object({
8537
+ input: import_zod41.z.object({
6776
8538
  bundlePath,
6777
8539
  conceptId,
6778
- note: import_zod35.z.string().refine((s) => s.trim().length > 0, {
8540
+ note: import_zod41.z.string().refine((s) => s.trim().length > 0, {
6779
8541
  message: "note must say what the check found"
6780
8542
  })
6781
8543
  }),
@@ -6795,15 +8557,15 @@ var verifyCommand = define({
6795
8557
  });
6796
8558
 
6797
8559
  // src/commands/write.ts
6798
- var import_zod36 = require("zod");
8560
+ var import_zod42 = require("zod");
6799
8561
  var writeCommand = define({
6800
8562
  name: "write",
6801
8563
  tool: "kb_write",
6802
8564
  usage: "write <type> < record.json",
6803
8565
  description: "Write one record. Search first \u2014 a duplicate concept id is rejected, not overwritten; kb_types lists each type's sections. An unsourced claim is an `assumption` with assumption: true, never a vague `fact`. Conflicting records get a `risk`, `open-question`, or superseding `decision`. Prefer a new short record over overloading one. Never delete; supersede.",
6804
- input: import_zod36.z.object({
8566
+ input: import_zod42.z.object({
6805
8567
  bundlePath,
6806
- type: import_zod36.z.enum(KB_RECORD_TYPES),
8568
+ type: import_zod42.z.enum(KB_RECORD_TYPES),
6807
8569
  input: composeInputSchema
6808
8570
  }),
6809
8571
  fromArgv: async (argv, path, stdin) => ({
@@ -6827,13 +8589,13 @@ var writeCommand = define({
6827
8589
  });
6828
8590
 
6829
8591
  // src/commands/write-decision.ts
6830
- var import_zod37 = require("zod");
8592
+ var import_zod43 = require("zod");
6831
8593
  var writeDecisionCommand = define({
6832
8594
  name: "write-decision",
6833
8595
  tool: "kb_write_decision",
6834
8596
  usage: "write-decision < decision.json",
6835
8597
  description: "Write a decision, with `alternative` (what was rejected and why) and `impact` as fields. Record one when a later reader would otherwise simplify the constraint away; skip when the diff already answers it. `sources` for material read, `anchors` for code, `relatedConceptIds` for records.",
6836
- input: import_zod37.z.object({ bundlePath, input: decisionInputSchema }),
8598
+ input: import_zod43.z.object({ bundlePath, input: decisionInputSchema }),
6837
8599
  fromArgv: async (_argv, path, stdin) => ({
6838
8600
  bundlePath: path,
6839
8601
  input: JSON.parse(await stdin())
@@ -6864,19 +8626,24 @@ var KB_COMMANDS = [
6864
8626
  verifyCommand,
6865
8627
  anchorResolveCommand,
6866
8628
  reassessCommand,
8629
+ promoteCommand,
6867
8630
  loadCommand,
6868
8631
  catalogCommand,
6869
8632
  packCommand,
8633
+ exportCommand,
6870
8634
  queryCommand,
6871
8635
  traceCommand,
6872
8636
  impactCommand,
6873
8637
  backlinksCommand,
8638
+ matchCommand,
8639
+ classifyCommand,
6874
8640
  listCommand,
6875
8641
  readIndexCommand,
6876
8642
  logCommand,
6877
8643
  stampCommand,
6878
8644
  validateCommand,
6879
8645
  doctorCommand,
8646
+ sweepCommand,
6880
8647
  schemaCommand,
6881
8648
  pinCommand,
6882
8649
  unpinCommand,
@@ -6894,7 +8661,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
6894
8661
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
6895
8662
 
6896
8663
  // src/version.ts
6897
- var VERSION = true ? "0.1.19" : "0.0.0-dev";
8664
+ var VERSION = true ? "0.1.21" : "0.0.0-dev";
6898
8665
 
6899
8666
  // src/mcp.ts
6900
8667
  function createKbMcpServer() {
@@ -6933,7 +8700,7 @@ async function runKbMcpServer() {
6933
8700
  }
6934
8701
 
6935
8702
  // src/cli.ts
6936
- var import_node_path12 = require("path");
8703
+ var import_node_path15 = require("path");
6937
8704
  async function runKbCli(argv) {
6938
8705
  const { flags, literal } = takeLiteral(argv);
6939
8706
  const {
@@ -6995,7 +8762,7 @@ function takeLiteral(argv) {
6995
8762
  function takeBundle(argv) {
6996
8763
  const at2 = argv.indexOf("--bundle");
6997
8764
  if (at2 === -1) {
6998
- return { bundle: (0, import_node_path12.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
8765
+ return { bundle: (0, import_node_path15.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
6999
8766
  }
7000
8767
  const bundle = argv[at2 + 1];
7001
8768
  if (!bundle) die("--bundle requires a path");
@@ -7006,11 +8773,11 @@ function takeBundle(argv) {
7006
8773
  };
7007
8774
  }
7008
8775
  function readStdin() {
7009
- return new Promise((resolve6, reject) => {
8776
+ return new Promise((resolve7, reject) => {
7010
8777
  let text = "";
7011
8778
  process.stdin.setEncoding("utf8");
7012
8779
  process.stdin.on("data", (chunk) => text += chunk);
7013
- process.stdin.on("end", () => resolve6(text));
8780
+ process.stdin.on("end", () => resolve7(text));
7014
8781
  process.stdin.on("error", reject);
7015
8782
  });
7016
8783
  }
@@ -7055,12 +8822,14 @@ function usage() {
7055
8822
  DEFAULT_LOAD_BUDGET,
7056
8823
  DEFAULT_PACK_HOPS,
7057
8824
  DEFAULT_PACK_MAX_NODES,
8825
+ DEFAULT_THRESHOLDS,
7058
8826
  DEFAULT_TYPED_LINK_RELS,
7059
8827
  DEFAULT_UNVERIFIED_DAYS,
7060
8828
  ErrorTypes,
7061
8829
  Fault,
7062
8830
  INDEX_FILE,
7063
8831
  KB_CAUSAL_LINK_RELS,
8832
+ KB_CLASSES,
7064
8833
  KB_COMMANDS,
7065
8834
  KB_COMMANDS_BY_NAME,
7066
8835
  KB_CONCEPT_ID_PATTERN,
@@ -7074,10 +8843,15 @@ function usage() {
7074
8843
  KB_RECORD_TYPES,
7075
8844
  KB_SLUG_PATTERN,
7076
8845
  KbBaseFrozenError,
8846
+ KbClassifyInputError,
7077
8847
  KbInvalidConceptIdError,
7078
8848
  KbMissingFlagValueError,
7079
8849
  KbPackBudgetExceededError,
7080
8850
  KbPinsMalformedError,
8851
+ KbPromoteCollisionError,
8852
+ KbPromoteSelfError,
8853
+ KbPromoteStandingError,
8854
+ KbPromoteStoppedError,
7081
8855
  KbRecordAlreadyExistsError,
7082
8856
  KbRecordNotFoundError,
7083
8857
  KbSelfVerificationError,
@@ -7090,16 +8864,20 @@ function usage() {
7090
8864
  PINS_FILE,
7091
8865
  PINS_LOCAL_FILE,
7092
8866
  PIN_LAYERS,
8867
+ PROMOTION_SOURCE_ID,
7093
8868
  RECORD_TYPES,
7094
8869
  SEARCH_INDEX_FILE,
7095
8870
  TRACE_EDGES,
7096
8871
  TreeSitterResolver,
7097
8872
  adjudicate,
7098
8873
  anchorFilePath,
8874
+ anchorOnHunk,
7099
8875
  assertBaseNotFrozen,
7100
8876
  backlinks,
7101
8877
  buildContext,
8878
+ carry,
7102
8879
  catalog,
8880
+ classifyDiff,
7103
8881
  classifyDrift,
7104
8882
  composeDecisionRecord,
7105
8883
  composeInputSchema,
@@ -7125,8 +8903,11 @@ function usage() {
7125
8903
  isKbLinkRel,
7126
8904
  isKbRecordType,
7127
8905
  isNoDecisionRecord,
8906
+ isReviewTag,
7128
8907
  kbActorStampSchema,
7129
8908
  kbAnchorSchema,
8909
+ kbAnchorSpanSchema,
8910
+ kbAnchorWriteSchema,
7130
8911
  kbConceptIdSchema,
7131
8912
  kbJsonSchemas,
7132
8913
  kbLinkSchema,
@@ -7146,6 +8927,8 @@ function usage() {
7146
8927
  parseMarkdownWithFrontmatter,
7147
8928
  pinBase,
7148
8929
  prepareResolvers,
8930
+ promoteCandidates,
8931
+ promoteInputSchema,
7149
8932
  readMergedPins,
7150
8933
  readPinsLayer,
7151
8934
  readRemoteAnchors,
@@ -7167,6 +8950,7 @@ function usage() {
7167
8950
  selectDecisions,
7168
8951
  splitMarkdownFrontmatter,
7169
8952
  stringifyMarkdownWithFrontmatter,
8953
+ symbolRangeIndex,
7170
8954
  syncInstructions,
7171
8955
  toHookJson,
7172
8956
  trace,