@saasontools/strauss-kb 0.1.18 → 0.1.20

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,
@@ -123,6 +137,7 @@ __export(index_exports, {
123
137
  listPins: () => listPins,
124
138
  loadQmd: () => loadQmd,
125
139
  matchToDiff: () => matchToDiff,
140
+ matchesTags: () => matchesTags,
126
141
  mergedContextBudgets: () => mergedContextBudgets,
127
142
  neighbours: () => neighbours,
128
143
  pack: () => pack,
@@ -130,6 +145,8 @@ __export(index_exports, {
130
145
  parseMarkdownWithFrontmatter: () => parseMarkdownWithFrontmatter,
131
146
  pinBase: () => pinBase,
132
147
  prepareResolvers: () => prepareResolvers,
148
+ promoteCandidates: () => promoteCandidates,
149
+ promoteInputSchema: () => promoteInputSchema,
133
150
  readMergedPins: () => readMergedPins,
134
151
  readPinsLayer: () => readPinsLayer,
135
152
  readRemoteAnchors: () => readRemoteAnchors,
@@ -151,6 +168,7 @@ __export(index_exports, {
151
168
  selectDecisions: () => selectDecisions,
152
169
  splitMarkdownFrontmatter: () => splitMarkdownFrontmatter,
153
170
  stringifyMarkdownWithFrontmatter: () => stringifyMarkdownWithFrontmatter,
171
+ symbolRangeIndex: () => symbolRangeIndex,
154
172
  syncInstructions: () => syncInstructions,
155
173
  toHookJson: () => toHookJson,
156
174
  trace: () => trace,
@@ -241,9 +259,24 @@ var kbVerifiedEventSchema = kbActorStampSchema.extend({
241
259
  message: "note must say what the check found"
242
260
  })
243
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();
244
266
  var kbAnchorSchema = import_zod.z.object({
245
267
  file: import_zod.z.string().min(1),
246
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(),
247
280
  /**
248
281
  * Which repository the file lives in — a remote URL
249
282
  * (`https://github.com/org/name`) or a short name. Absent means the base's
@@ -282,8 +315,38 @@ var kbAnchorSchema = import_zod.z.object({
282
315
  * before resolvers were named, which is read as `regex` — the only one
283
316
  * there was. A hash from a different resolver is drift, not a match.
284
317
  */
285
- resolver: import_zod.z.enum(["tree-sitter", "regex"]).optional()
318
+ resolver: import_zod.z.enum(["tree-sitter", "regex", "span"]).optional()
286
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
+ });
287
350
  var kbLinkSchema = import_zod.z.object({
288
351
  target: import_zod.z.string().min(1),
289
352
  rel: import_zod.z.string().min(1)
@@ -370,9 +433,15 @@ var Fault = /* @__PURE__ */ ((Fault2) => {
370
433
  })(Fault || {});
371
434
  var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
372
435
  ErrorTypes2["KbRecordAlreadyExists"] = "KbRecordAlreadyExists";
436
+ ErrorTypes2["KbClassifyInput"] = "KbClassifyInput";
373
437
  ErrorTypes2["KbInvalidConceptId"] = "KbInvalidConceptId";
438
+ ErrorTypes2["KbMatchInput"] = "KbMatchInput";
374
439
  ErrorTypes2["KbMissingFlagValue"] = "KbMissingFlagValue";
375
440
  ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
441
+ ErrorTypes2["KbPromoteCollision"] = "KbPromoteCollision";
442
+ ErrorTypes2["KbPromoteSelf"] = "KbPromoteSelf";
443
+ ErrorTypes2["KbPromoteStanding"] = "KbPromoteStanding";
444
+ ErrorTypes2["KbPromoteStopped"] = "KbPromoteStopped";
376
445
  ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
377
446
  ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
378
447
  ErrorTypes2["KbStampBaselineUnreadable"] = "KbStampBaselineUnreadable";
@@ -518,6 +587,89 @@ var KbMissingFlagValueError = class extends BaseError {
518
587
  }
519
588
  flag;
520
589
  };
590
+ var KbClassifyInputError = class extends BaseError {
591
+ constructor(reason) {
592
+ super({
593
+ message: `classify: ${reason}`,
594
+ errorType: "KbClassifyInput" /* KbClassifyInput */,
595
+ code: 400,
596
+ fault: "User" /* User */,
597
+ retriable: false,
598
+ reportToUser: true,
599
+ details: { reason }
600
+ });
601
+ this.reason = reason;
602
+ }
603
+ reason;
604
+ };
605
+ var KbPromoteCollisionError = class extends BaseError {
606
+ constructor(conceptId2, to) {
607
+ super({
608
+ message: `kb: ${to} already holds ${conceptId2} \u2014 re-run with force to overwrite it`,
609
+ errorType: "KbPromoteCollision" /* KbPromoteCollision */,
610
+ code: 409,
611
+ fault: "User" /* User */,
612
+ retriable: false,
613
+ reportToUser: true,
614
+ details: { conceptId: conceptId2, to, action: "refused" }
615
+ });
616
+ this.conceptId = conceptId2;
617
+ this.to = to;
618
+ }
619
+ conceptId;
620
+ to;
621
+ };
622
+ var KbPromoteStandingError = class extends BaseError {
623
+ constructor(conceptId2, standing) {
624
+ super({
625
+ message: `kb: ${conceptId2} is ${standing} \u2014 only a record that still stands can be promoted`,
626
+ errorType: "KbPromoteStanding" /* KbPromoteStanding */,
627
+ code: 409,
628
+ fault: "User" /* User */,
629
+ retriable: false,
630
+ reportToUser: true,
631
+ details: { conceptId: conceptId2, standing, action: "refused" }
632
+ });
633
+ this.conceptId = conceptId2;
634
+ this.standing = standing;
635
+ }
636
+ conceptId;
637
+ standing;
638
+ };
639
+ var KbPromoteSelfError = class extends BaseError {
640
+ constructor(to) {
641
+ super({
642
+ message: `kb: ${to} is the base being promoted from \u2014 name a different target`,
643
+ errorType: "KbPromoteSelf" /* KbPromoteSelf */,
644
+ code: 400,
645
+ fault: "User" /* User */,
646
+ retriable: false,
647
+ reportToUser: true,
648
+ details: { to, action: "refused" }
649
+ });
650
+ this.to = to;
651
+ }
652
+ to;
653
+ };
654
+ var KbPromoteStoppedError = class extends BaseError {
655
+ constructor(conceptId2, landed, reason) {
656
+ super({
657
+ message: `kb: promotion stopped at ${conceptId2} (${reason}) \u2014 landed: ${landed.length ? landed.join(", ") : "nothing"}`,
658
+ errorType: "KbPromoteStopped" /* KbPromoteStopped */,
659
+ code: 500,
660
+ fault: "System" /* System */,
661
+ retriable: false,
662
+ reportToUser: true,
663
+ details: { conceptId: conceptId2, landed, reason, action: "stopped" }
664
+ });
665
+ this.conceptId = conceptId2;
666
+ this.landed = landed;
667
+ this.reason = reason;
668
+ }
669
+ conceptId;
670
+ landed;
671
+ reason;
672
+ };
521
673
  var KbInvalidConceptIdError = class extends BaseError {
522
674
  constructor(message, details) {
523
675
  super({
@@ -697,7 +849,9 @@ function revRef(rev) {
697
849
  var UNCHECKED_REASONS = [
698
850
  "remote-unreachable",
699
851
  "repo-unauthorized",
700
- "default-branch-unknown"
852
+ "default-branch-unknown",
853
+ /** Local, but the same finding: a shallow clone has no rev to read. */
854
+ "ref-unavailable"
701
855
  ];
702
856
  function isUncheckedReason(reason) {
703
857
  return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
@@ -765,6 +919,11 @@ function refShapeIsSafe(ref) {
765
919
  if (ref.includes("..")) return false;
766
920
  return REF_SHAPE.test(ref);
767
921
  }
922
+ function localRevShapeIsSafe(rev) {
923
+ if (!rev || rev.length > MAX_REF_LENGTH) return false;
924
+ if (rev.includes("..")) return false;
925
+ return /^[A-Za-z0-9][A-Za-z0-9._/^~-]*$/.test(rev);
926
+ }
768
927
  async function refIsWellFormed(ref) {
769
928
  if (!refShapeIsSafe(ref)) return false;
770
929
  const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
@@ -1163,6 +1322,142 @@ function bundleDigest(records, superseded) {
1163
1322
  return bundleStamp(records, superseded).digest;
1164
1323
  }
1165
1324
 
1325
+ // src/drift/git.ts
1326
+ var import_node_child_process3 = require("child_process");
1327
+ var import_node_util3 = require("util");
1328
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
1329
+ var MAX_GIT_OUTPUT_BYTES = 1048576;
1330
+ var MAX_RANGE_DIFF_BYTES = 8 * 1048576;
1331
+ var GIT_TIMEOUT_MS = 5e3;
1332
+ var RANGE_DIFF_TIMEOUT_MS = 2e4;
1333
+ async function git2(cwd, args, limits = {}) {
1334
+ const env = { ...process.env };
1335
+ delete env["GIT_DIR"];
1336
+ delete env["GIT_WORK_TREE"];
1337
+ delete env["GIT_INDEX_FILE"];
1338
+ try {
1339
+ const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
1340
+ timeout: limits.timeoutMs ?? GIT_TIMEOUT_MS,
1341
+ maxBuffer: limits.maxBytes ?? MAX_GIT_OUTPUT_BYTES,
1342
+ env
1343
+ });
1344
+ return { ok: true, stdout };
1345
+ } catch (error) {
1346
+ return { ok: false, reason: failureOf(error) };
1347
+ }
1348
+ }
1349
+ function failureOf(error) {
1350
+ const { code, killed } = error;
1351
+ if (code === "ENOENT") return "git-missing";
1352
+ if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "too-large";
1353
+ if (killed === true) return "timeout";
1354
+ return "failed";
1355
+ }
1356
+ async function readFileAtRef(repoRoot, anchor) {
1357
+ if (!filePathIsSafe(anchor.file))
1358
+ return { ok: false, reason: "outside-repo" };
1359
+ if (!anchor.ref || !refShapeIsSafe(anchor.ref)) {
1360
+ return { ok: false, reason: "ref-unreadable" };
1361
+ }
1362
+ const blob = await catBlob(repoRoot, anchor.ref, anchor.file);
1363
+ if (blob !== null) return { ok: true, source: blob };
1364
+ return {
1365
+ ok: false,
1366
+ reason: await hasCommit(repoRoot, anchor.ref) ? "ref-unreadable" : "ref-unavailable"
1367
+ };
1368
+ }
1369
+ async function hasCommit(repoRoot, ref) {
1370
+ const found = await git2(repoRoot, [
1371
+ "cat-file",
1372
+ "-e",
1373
+ "--end-of-options",
1374
+ `${ref}^{commit}`
1375
+ ]);
1376
+ return found.ok;
1377
+ }
1378
+ async function listRepoFiles(repoRoot) {
1379
+ const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
1380
+ if (!result.ok) return [];
1381
+ return result.stdout.split("\0").filter(Boolean);
1382
+ }
1383
+ var DIFF_RANGE = /^(.+?)(\.{2,3})(.+)$/;
1384
+ async function readRangeDiff(repoRoot, range, maxBytes = MAX_RANGE_DIFF_BYTES) {
1385
+ const parts = DIFF_RANGE.exec(range);
1386
+ if (!parts) return { ok: false, reason: "bad-range" };
1387
+ const [, base2 = "", dots = "", head = ""] = parts;
1388
+ if (!localRevShapeIsSafe(base2) || !localRevShapeIsSafe(head)) {
1389
+ return { ok: false, reason: "bad-range" };
1390
+ }
1391
+ const result = await git2(
1392
+ repoRoot,
1393
+ [
1394
+ "-c",
1395
+ "core.quotePath=false",
1396
+ "diff",
1397
+ "--unified=0",
1398
+ "--no-color",
1399
+ "--no-ext-diff",
1400
+ "--no-textconv",
1401
+ "--find-renames",
1402
+ "--src-prefix=a/",
1403
+ "--dst-prefix=b/",
1404
+ "--end-of-options",
1405
+ `${base2}${dots}${head}`,
1406
+ "--"
1407
+ ],
1408
+ { maxBytes, timeoutMs: RANGE_DIFF_TIMEOUT_MS }
1409
+ );
1410
+ if (result.ok) return { ok: true, text: result.stdout };
1411
+ return {
1412
+ ok: false,
1413
+ reason: result.reason === "failed" ? "bad-range" : result.reason
1414
+ };
1415
+ }
1416
+ async function readOldSource(repoRoot, anchor) {
1417
+ if (!filePathIsSafe(anchor.file))
1418
+ return { ok: false, reason: "unrecoverable" };
1419
+ if (anchor.ref && refShapeIsSafe(anchor.ref)) {
1420
+ const shown2 = await catBlob(repoRoot, anchor.ref, anchor.file);
1421
+ if (shown2 !== null) {
1422
+ return {
1423
+ ok: true,
1424
+ source: shown2,
1425
+ origin: { kind: "ref", ref: anchor.ref }
1426
+ };
1427
+ }
1428
+ }
1429
+ const at2 = anchor.resolved_at;
1430
+ if (!at2 || Number.isNaN(Date.parse(at2))) {
1431
+ return { ok: false, reason: "unrecoverable" };
1432
+ }
1433
+ const found = await git2(repoRoot, [
1434
+ "log",
1435
+ "-1",
1436
+ "--format=%H",
1437
+ `--before=${at2}`,
1438
+ "--end-of-options",
1439
+ "HEAD",
1440
+ "--",
1441
+ anchor.file
1442
+ ]);
1443
+ const sha = found.ok ? found.stdout.trim() : "";
1444
+ if (!sha || !refShapeIsSafe(sha))
1445
+ return { ok: false, reason: "unrecoverable" };
1446
+ const shown = await catBlob(repoRoot, sha, anchor.file);
1447
+ if (shown === null) return { ok: false, reason: "unrecoverable" };
1448
+ return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
1449
+ }
1450
+ async function catBlob(repoRoot, ref, file) {
1451
+ const path = file.replace(/^\.\//, "");
1452
+ const result = await git2(repoRoot, [
1453
+ "cat-file",
1454
+ "blob",
1455
+ "--end-of-options",
1456
+ `${ref}:${path}`
1457
+ ]);
1458
+ return result.ok ? result.stdout : null;
1459
+ }
1460
+
1166
1461
  // src/anchor-resolver/read.ts
1167
1462
  var import_promises2 = require("fs/promises");
1168
1463
  var import_node_path2 = require("path");
@@ -1228,6 +1523,7 @@ function looksLikeWrongRepoRoot(drift) {
1228
1523
  for (const entries of drift.values()) {
1229
1524
  for (const entry of entries) {
1230
1525
  if (entry.repo !== void 0) continue;
1526
+ if (entry.side === "old") continue;
1231
1527
  checked += 1;
1232
1528
  if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
1233
1529
  return false;
@@ -1363,7 +1659,7 @@ function size(bytes) {
1363
1659
  return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1364
1660
  }
1365
1661
  function pause(ms) {
1366
- return new Promise((resolve6) => setTimeout(resolve6, ms));
1662
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
1367
1663
  }
1368
1664
 
1369
1665
  // src/grammars/manifest.ts
@@ -1935,8 +2231,8 @@ function captureBraceBlock(lines, matchLine) {
1935
2231
  }
1936
2232
  var PYTHON_HEADER = /^\s*(?:async\s+)?(?:def|class)\s+[A-Za-z_]\w*\s*[(:]/;
1937
2233
  function captureIndentedBlock(lines, matchLine) {
1938
- const header = lines[matchLine] ?? "";
1939
- const indent = header.length - header.trimStart().length;
2234
+ const header2 = lines[matchLine] ?? "";
2235
+ const indent = header2.length - header2.trimStart().length;
1940
2236
  let headerEnd = -1;
1941
2237
  for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1942
2238
  const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
@@ -1957,42 +2253,55 @@ function captureIndentedBlock(lines, matchLine) {
1957
2253
  }
1958
2254
  return end === headerEnd ? null : span(lines, matchLine, end);
1959
2255
  }
1960
- var TIERS = [
1961
- (name) => new RegExp(
1962
- `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
1963
- ),
1964
- (name) => new RegExp(`\\b${name}\\s*[:=]`),
2256
+ var declarationTier = (name) => new RegExp(
2257
+ `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
2258
+ );
2259
+ var assignmentTier = (name) => new RegExp(`\\b${name}\\s*[:=]`);
2260
+ var anchoredAssignmentTier = (name) => new RegExp(
2261
+ `^\\s*(?:export\\s+|readonly\\s+|pub\\s+|static\\s+|private\\s+|public\\s+|protected\\s+)*${name}\\s*[:=]`
2262
+ );
2263
+ var DEFINITION_TIERS = [declarationTier, anchoredAssignmentTier];
2264
+ var MENTION_TIERS = [
1965
2265
  (name) => new RegExp(`\\b${name}\\s*\\(`),
1966
2266
  (name) => new RegExp(`\\b${name}\\b`)
1967
2267
  ];
2268
+ var TIERS = [declarationTier, assignmentTier, ...MENTION_TIERS];
2269
+ function resolveWith(tiers, source, symbol) {
2270
+ const segments = symbol.split(".");
2271
+ const name = segments[segments.length - 1];
2272
+ if (!name) return null;
2273
+ const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
2274
+ const escaped = escapeRegExp(name);
2275
+ const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
2276
+ const lines = source.split("\n");
2277
+ for (const tier of tiers) {
2278
+ const pattern = tier(escaped);
2279
+ let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
2280
+ if (!candidates.length) continue;
2281
+ if (parentPattern && candidates.length > 1) {
2282
+ const distances = candidates.map(
2283
+ (index2) => distanceToParent(lines, index2, parentPattern)
2284
+ );
2285
+ const nearest = Math.min(...distances);
2286
+ if (Number.isFinite(nearest)) {
2287
+ candidates = candidates.filter((_, at2) => distances[at2] === nearest);
2288
+ }
2289
+ }
2290
+ if (candidates.length !== 1) return null;
2291
+ const matchLine = candidates[0];
2292
+ return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
2293
+ }
2294
+ return null;
2295
+ }
1968
2296
  var regexResolver = {
1969
2297
  name: "regex",
1970
2298
  resolve(source, symbol) {
1971
- const segments = symbol.split(".");
1972
- const name = segments[segments.length - 1];
1973
- if (!name) return null;
1974
- const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
1975
- const escaped = escapeRegExp(name);
1976
- const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
1977
- const lines = source.split("\n");
1978
- for (const tier of TIERS) {
1979
- const pattern = tier(escaped);
1980
- let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1981
- if (!candidates.length) continue;
1982
- if (parentPattern && candidates.length > 1) {
1983
- const distances = candidates.map(
1984
- (index2) => distanceToParent(lines, index2, parentPattern)
1985
- );
1986
- const nearest = Math.min(...distances);
1987
- if (Number.isFinite(nearest)) {
1988
- candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1989
- }
1990
- }
1991
- if (candidates.length !== 1) return null;
1992
- const matchLine = candidates[0];
1993
- return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
1994
- }
1995
- return null;
2299
+ return resolveWith(TIERS, source, symbol);
2300
+ },
2301
+ attempt(source, symbol, _file, options) {
2302
+ const tiers = options?.afterParsedMiss ? DEFINITION_TIERS : TIERS;
2303
+ const span2 = resolveWith(tiers, source, symbol);
2304
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1996
2305
  }
1997
2306
  };
1998
2307
  function escapeRegExp(value) {
@@ -2014,6 +2323,7 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
2014
2323
  }
2015
2324
  function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2016
2325
  const normalized = source.replace(/\r\n/g, "\n");
2326
+ if (anchor.span) return sliceSpan(normalized, anchor.span);
2017
2327
  if (!anchor.symbol) {
2018
2328
  const lines = normalized.split("\n");
2019
2329
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
@@ -2026,11 +2336,17 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2026
2336
  }
2027
2337
  };
2028
2338
  }
2339
+ let afterParsedMiss = false;
2029
2340
  for (const resolver of resolvers) {
2030
- const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
2341
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file, {
2342
+ afterParsedMiss
2343
+ }) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
2031
2344
  if (attempt.kind === "abstain") continue;
2032
2345
  if (attempt.kind === "unresolved") {
2033
- if (attempt.reason === "symbol-not-found") continue;
2346
+ if (attempt.reason === "symbol-not-found") {
2347
+ if (resolver.attempt) afterParsedMiss = true;
2348
+ continue;
2349
+ }
2034
2350
  return { ok: false, reason: attempt.reason };
2035
2351
  }
2036
2352
  const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
@@ -2043,12 +2359,28 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2043
2359
  }
2044
2360
  return { ok: false, reason: "symbol-not-found" };
2045
2361
  }
2362
+ function sliceSpan(source, range) {
2363
+ const lines = source.split("\n");
2364
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
2365
+ if (range.end > lines.length) {
2366
+ return { ok: false, reason: "span-out-of-range" };
2367
+ }
2368
+ return {
2369
+ ok: true,
2370
+ span: {
2371
+ text: lines.slice(range.start - 1, range.end).join("\n"),
2372
+ startLine: range.start,
2373
+ endLine: range.end
2374
+ },
2375
+ resolver: "span"
2376
+ };
2377
+ }
2046
2378
  function fromResolve(resolver, source, symbol, file) {
2047
2379
  const span2 = resolver.resolve(source, symbol, file);
2048
2380
  return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
2049
2381
  }
2050
2382
  function isResolverName(name) {
2051
- return name === "tree-sitter" || name === "regex";
2383
+ return name === "tree-sitter" || name === "regex" || name === "span";
2052
2384
  }
2053
2385
  async function prepareResolvers(resolvers, files) {
2054
2386
  for (const resolver of resolvers) await resolver.prepare?.(files);
@@ -2067,6 +2399,9 @@ function resolverChanged(source, anchor, produced) {
2067
2399
  return before !== null && hashAnchorText(before.text) === anchor.hash;
2068
2400
  }
2069
2401
  function anchorHashOf(anchor, outcome) {
2402
+ if (outcome.resolver === "span") {
2403
+ return { hash: hashAnchorText(outcome.span.text), kind: "raw" };
2404
+ }
2070
2405
  const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
2071
2406
  const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
2072
2407
  return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
@@ -2100,37 +2435,60 @@ async function detectAnchorDrift(records, options = {}) {
2100
2435
  }
2101
2436
  }
2102
2437
  const files = [];
2438
+ const committedWants = [];
2103
2439
  const wants = [];
2104
2440
  for (const entries of planned.values()) {
2105
2441
  for (const { anchor, foreign } of entries) {
2106
- if (!foreign) files.push(anchor.file);
2107
- else wants.push(...remoteWants(anchor));
2442
+ if (foreign) wants.push(...remoteWants(anchor));
2443
+ else if (anchor.side === "old") committedWants.push(anchor);
2444
+ else files.push(anchor.file);
2108
2445
  }
2109
2446
  }
2110
- const [reads, remote] = await Promise.all([
2447
+ const [reads, committed, remote] = await Promise.all([
2111
2448
  readAnchorFiles(
2112
2449
  files,
2113
2450
  options.reader ?? anchorFileReader(repoRoot),
2114
2451
  options.concurrency ?? DEFAULT_IO_CONCURRENCY
2115
2452
  ),
2453
+ readCommitted(repoRoot, committedWants, options),
2116
2454
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
2117
2455
  ]);
2118
2456
  await prepareResolvers(resolvers, [
2119
2457
  ...files,
2458
+ ...committedWants.map((anchor) => anchor.file),
2120
2459
  ...wants.map((want) => want.file)
2121
2460
  ]);
2122
2461
  const drift = /* @__PURE__ */ new Map();
2123
2462
  for (const record of records) {
2124
2463
  const entries = [];
2125
2464
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
2126
- entries.push(
2127
- foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
2128
- );
2465
+ if (foreign) {
2466
+ entries.push(remoteEntry(anchor, remote, resolvers));
2467
+ continue;
2468
+ }
2469
+ const read = anchor.side === "old" ? committed.get(atRefKey(anchor)) : reads.get(anchor.file);
2470
+ entries.push(localEntry(anchor, read, resolvers));
2129
2471
  }
2130
2472
  if (entries.length) drift.set(record.conceptId, entries);
2131
2473
  }
2132
2474
  return drift;
2133
2475
  }
2476
+ function atRefKey(anchor) {
2477
+ return `${anchor.ref ?? ""}\0${anchor.file}`;
2478
+ }
2479
+ async function readCommitted(repoRoot, anchors, options = {}) {
2480
+ if (!anchors.length) return /* @__PURE__ */ new Map();
2481
+ const read = options.readAtRef ?? readFileAtRef;
2482
+ const byKey = /* @__PURE__ */ new Map();
2483
+ for (const anchor of anchors) byKey.set(atRefKey(anchor), anchor);
2484
+ const keys = [...byKey.keys()];
2485
+ const results = await mapLimit(
2486
+ keys,
2487
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY,
2488
+ (key2) => read(repoRoot, byKey.get(key2))
2489
+ );
2490
+ return new Map(keys.map((key2, at2) => [key2, results[at2]]));
2491
+ }
2134
2492
  function remoteWants(anchor) {
2135
2493
  const repo = anchor.repo;
2136
2494
  const wants = [{ repo, file: anchor.file }];
@@ -2141,6 +2499,7 @@ function base(anchor) {
2141
2499
  return {
2142
2500
  file: anchor.file,
2143
2501
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
2502
+ ...anchor.side === "old" ? { side: "old" } : {},
2144
2503
  storedHash: anchor.hash
2145
2504
  };
2146
2505
  }
@@ -2154,9 +2513,15 @@ function unresolved(anchor, reason, repo) {
2154
2513
  ...classOf(reason)
2155
2514
  };
2156
2515
  }
2516
+ var GONE_REASONS = /* @__PURE__ */ new Set([
2517
+ "file-missing",
2518
+ "symbol-not-found",
2519
+ "span-out-of-range",
2520
+ "ref-unreadable"
2521
+ ]);
2157
2522
  function provisionalDriftClass(entry) {
2158
2523
  if (entry.state === "unresolved") {
2159
- return entry.reason === "file-missing" || entry.reason === "symbol-not-found" ? "gone" : void 0;
2524
+ return GONE_REASONS.has(entry.reason) ? "gone" : void 0;
2160
2525
  }
2161
2526
  return entry.state === "drifted" ? "changed" : void 0;
2162
2527
  }
@@ -2225,6 +2590,13 @@ function remoteEntry(anchor, remote, resolvers) {
2225
2590
  remoteState: "drifted-from-ref"
2226
2591
  });
2227
2592
  }
2593
+ if (anchor.side === "old") {
2594
+ return compared(anchor, current, {
2595
+ repo,
2596
+ ...extras,
2597
+ remoteState: "matches-ref"
2598
+ });
2599
+ }
2228
2600
  const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
2229
2601
  return head?.ok && head.current.hash !== anchor.hash ? {
2230
2602
  ...compared(anchor, head.current, {
@@ -2260,19 +2632,28 @@ var kbLogEntrySchema = import_zod3.z.object({
2260
2632
  by: import_zod3.z.string().min(1),
2261
2633
  operation: import_zod3.z.string().min(1),
2262
2634
  conceptId: import_zod3.z.string().min(1),
2263
- /** Second concept id, where the operation relates two — supersession. */
2635
+ /**
2636
+ * The operation's other end, where it has one: a second concept id for
2637
+ * supersession, the other base's path for promotion.
2638
+ */
2264
2639
  target: import_zod3.z.string().min(1).optional()
2265
2640
  }).strict();
2266
2641
  function renderLogEntry(entry) {
2267
2642
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
2268
2643
  `;
2269
2644
  }
2645
+ var CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})/;
2270
2646
  function parseLog(raw) {
2271
2647
  const entries = [];
2272
2648
  const malformed = [];
2273
2649
  const seen = /* @__PURE__ */ new Set();
2650
+ let conflicted = false;
2274
2651
  raw.split("\n").forEach((text, index2) => {
2275
2652
  if (!text.trim()) return;
2653
+ if (CONFLICT_MARKER.test(text)) {
2654
+ conflicted = true;
2655
+ return;
2656
+ }
2276
2657
  let value;
2277
2658
  try {
2278
2659
  value = JSON.parse(text);
@@ -2293,7 +2674,7 @@ function parseLog(raw) {
2293
2674
  entries.sort(
2294
2675
  (left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
2295
2676
  );
2296
- return { entries, malformed };
2677
+ return { entries, malformed, conflicted };
2297
2678
  }
2298
2679
 
2299
2680
  // src/search-index.ts
@@ -2342,8 +2723,8 @@ async function searchBase(bundlePath2, query, options = {}) {
2342
2723
  async function isStale(bundlePath2) {
2343
2724
  const indexAt = await (0, import_promises5.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
2344
2725
  if (!indexAt) return true;
2345
- const { readdir: readdir2 } = await import("fs/promises");
2346
- const names = (await readdir2(bundlePath2).catch(() => [])).filter(
2726
+ const { readdir: readdir3 } = await import("fs/promises");
2727
+ const names = (await readdir3(bundlePath2).catch(() => [])).filter(
2347
2728
  (name) => name.endsWith(".md") && name !== INDEX_FILE
2348
2729
  );
2349
2730
  let stale = false;
@@ -2733,6 +3114,13 @@ function typeRank(record) {
2733
3114
  return index2 === -1 ? TYPE_PRIORITY.length : index2;
2734
3115
  }
2735
3116
 
3117
+ // src/kb-tags.ts
3118
+ function matchesTags(record, filter) {
3119
+ if (!filter.tags?.length && !filter.excludeTags?.length) return true;
3120
+ const carried = new Set(record.frontmatter.tags ?? []);
3121
+ return (filter.tags ?? []).every((tag) => carried.has(tag)) && !(filter.excludeTags ?? []).some((tag) => carried.has(tag));
3122
+ }
3123
+
2736
3124
  // src/catalog.ts
2737
3125
  var EMPTY_STANDINGS = {
2738
3126
  current: 0,
@@ -2743,7 +3131,7 @@ var EMPTY_STANDINGS = {
2743
3131
  };
2744
3132
  function catalog(bundle, options = {}) {
2745
3133
  const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
2746
- const entries = adjudicate(wanted, bundle, options.now ?? /* @__PURE__ */ new Date()).map((hit) => ({
3134
+ const entries = adjudicate(wanted, bundle, options.now ?? /* @__PURE__ */ new Date()).filter((hit) => matchesTags(hit.record, options)).map((hit) => ({
2747
3135
  conceptId: hit.record.conceptId,
2748
3136
  type: hit.record.frontmatter.type,
2749
3137
  title: hit.record.frontmatter.title ?? null,
@@ -2751,14 +3139,14 @@ function catalog(bundle, options = {}) {
2751
3139
  supersededBy: hit.heads.map((head) => head.conceptId),
2752
3140
  stale: hit.warnings.some((warning) => warning.kind === "stale")
2753
3141
  })).sort(byTypeThenTitle);
2754
- const standings = { ...EMPTY_STANDINGS };
2755
- for (const entry of entries) standings[entry.standing] += 1;
3142
+ const standings2 = { ...EMPTY_STANDINGS };
3143
+ for (const entry of entries) standings2[entry.standing] += 1;
2756
3144
  return {
2757
3145
  entries,
2758
3146
  recordCount: entries.length,
2759
- standings,
2760
- currentCount: standings.current,
2761
- supersededCount: standings.superseded,
3147
+ standings: standings2,
3148
+ currentCount: standings2.current,
3149
+ supersededCount: standings2.superseded,
2762
3150
  staleCount: entries.filter((entry) => entry.stale).length
2763
3151
  };
2764
3152
  }
@@ -2914,9 +3302,13 @@ function hasEdge(edges, edge) {
2914
3302
  );
2915
3303
  }
2916
3304
 
3305
+ // src/kb-files.ts
3306
+ var STORE_OWNED_FILES = [INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE];
3307
+
2917
3308
  // src/kb-gitattributes.ts
2918
3309
  var GITATTRIBUTES_FILE = ".gitattributes";
2919
- var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
3310
+ var GENERATED = "linguist-generated";
3311
+ var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union ${GENERATED}=true`;
2920
3312
  function parseLine(line) {
2921
3313
  const trimmed = line.trim();
2922
3314
  if (!trimmed || trimmed.startsWith("#")) return null;
@@ -2924,23 +3316,39 @@ function parseLine(line) {
2924
3316
  return pattern === void 0 ? null : { pattern, attrs };
2925
3317
  }
2926
3318
  function hasMergeDeclaration(contents) {
3319
+ return declares(contents, LOG_FILE, "merge");
3320
+ }
3321
+ function declares(contents, pattern, attribute) {
2927
3322
  return contents.split("\n").some((line) => {
2928
3323
  const parsed = parseLine(line);
2929
- if (!parsed || parsed.pattern !== LOG_FILE) return false;
3324
+ if (!parsed || parsed.pattern !== pattern) return false;
2930
3325
  return parsed.attrs.some(
2931
- (attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
3326
+ (attr) => attr === attribute || attr === `-${attribute}` || attr.startsWith(`${attribute}=`)
2932
3327
  );
2933
3328
  });
2934
3329
  }
2935
- function appendUnionMergeLine(contents) {
3330
+ function missingGitattributesLines(contents) {
3331
+ const needsMerge = !hasMergeDeclaration(contents);
3332
+ const generated = STORE_OWNED_FILES.filter(
3333
+ // The union-merge line carries the log's `linguist-generated` too, so the
3334
+ // log needs its own line only where that line is already there without it.
3335
+ (file) => !(needsMerge && file === LOG_FILE)
3336
+ ).filter((file) => !declares(contents, file, GENERATED)).map((file) => `${file} ${GENERATED}=true`);
3337
+ return needsMerge ? [UNION_MERGE_LINE, ...generated] : generated;
3338
+ }
3339
+ var GITATTRIBUTES_BLOCK = `${missingGitattributesLines("").join("\n")}
3340
+ `;
3341
+ function appendGitattributesLines(contents) {
3342
+ const lines = missingGitattributesLines(contents);
3343
+ if (lines.length === 0) return "";
2936
3344
  const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
2937
- return `${separator}${UNION_MERGE_LINE}
3345
+ return `${separator}${lines.join("\n")}
2938
3346
  `;
2939
3347
  }
2940
3348
 
2941
3349
  // src/kb-store.ts
2942
3350
  var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
2943
- var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
3351
+ var STORE_OWNED = new Set(STORE_OWNED_FILES);
2944
3352
  var DEFAULT_LOAD_BUDGET = 25e3;
2945
3353
  var KbStore = class {
2946
3354
  constructor(logger = {}) {
@@ -3016,13 +3424,16 @@ var KbStore = class {
3016
3424
  return this.parse(conceptId2, raw);
3017
3425
  }
3018
3426
  /**
3019
- * Every record in the bundle, optionally narrowed to one type.
3427
+ * Every record in the bundle, optionally narrowed to one type and to the
3428
+ * records carrying every tag in `filter.tags`. Selection only — `excludeTags`
3429
+ * is not taken here, because `query`, `catalog` and `load` read through this
3430
+ * and must adjudicate over the whole base.
3020
3431
  *
3021
3432
  * A file that fails to parse is skipped and logged rather than thrown: one
3022
3433
  * malformed record — hand-edited, or written by a producer we don't know —
3023
3434
  * must not make the whole bundle unreadable.
3024
3435
  */
3025
- async list(bundlePath2, type) {
3436
+ async list(bundlePath2, type, filter = {}) {
3026
3437
  const root = this.root(bundlePath2);
3027
3438
  let names;
3028
3439
  try {
@@ -3036,7 +3447,9 @@ var KbStore = class {
3036
3447
  DEFAULT_IO_CONCURRENCY,
3037
3448
  async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await (0, import_promises6.readFile)((0, import_node_path7.join)(root, name), "utf8"))
3038
3449
  );
3039
- return records.filter((record) => record !== null);
3450
+ return records.filter(
3451
+ (record) => record !== null && matchesTags(record, filter)
3452
+ );
3040
3453
  }
3041
3454
  /**
3042
3455
  * Moves a record's status, preserving everything else.
@@ -3062,12 +3475,16 @@ var KbStore = class {
3062
3475
  * Wholesale rather than merged: the caller just resolved the anchors it is
3063
3476
  * writing, so it holds the complete current set, and a merge would keep
3064
3477
  * stale entries the resolution pass deliberately dropped.
3478
+ *
3479
+ * Through the write schema: this is a write, and a defect a hand-edit put in
3480
+ * the frontmatter must not be published back out under an actor stamp.
3065
3481
  */
3066
3482
  async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
3483
+ const checked = anchors.map((anchor) => kbAnchorWriteSchema.parse(anchor));
3067
3484
  return this.mutate(
3068
3485
  bundlePath2,
3069
3486
  conceptId2,
3070
- (frontmatter) => ({ ...frontmatter, strauss_anchors: anchors }),
3487
+ (frontmatter) => ({ ...frontmatter, strauss_anchors: checked }),
3071
3488
  { operation: "anchor-resolve", by: actor }
3072
3489
  );
3073
3490
  }
@@ -3153,6 +3570,35 @@ ${answer}
3153
3570
  `
3154
3571
  );
3155
3572
  }
3573
+ /**
3574
+ * Removes one record, logged as `sweep`. The only path in this store that
3575
+ * deletes — see the specification for the scope that makes it safe.
3576
+ *
3577
+ * `expected` is re-read and re-checked immediately before the unlink, the
3578
+ * compare-and-swap `mutate` makes: a record retagged or moved out of a
3579
+ * terminal status since the caller listed it is reported, not removed.
3580
+ */
3581
+ async deleteRecord(bundlePath2, conceptId2, expected, actor = "unknown") {
3582
+ const target = this.recordPath(bundlePath2, conceptId2);
3583
+ const witness = await this.read(bundlePath2, conceptId2);
3584
+ if (!witness) throw new KbRecordNotFoundError(conceptId2);
3585
+ const { tags, strauss_status } = witness.frontmatter;
3586
+ if (!(tags ?? []).includes(expected.tag) || !expected.statuses.includes(strauss_status)) {
3587
+ return "changed-since-listing";
3588
+ }
3589
+ try {
3590
+ await (0, import_promises6.unlink)(target);
3591
+ } catch (error) {
3592
+ if (error.code !== "ENOENT") throw error;
3593
+ throw new KbRecordNotFoundError(conceptId2);
3594
+ }
3595
+ await this.record(this.root(bundlePath2), {
3596
+ operation: "sweep",
3597
+ by: actor,
3598
+ conceptId: conceptId2
3599
+ });
3600
+ return "deleted";
3601
+ }
3156
3602
  /**
3157
3603
  * Records matching a text query, each carrying its standing.
3158
3604
  *
@@ -3176,9 +3622,10 @@ ${answer}
3176
3622
  /* @__PURE__ */ new Date(),
3177
3623
  await this.detectDrift(narrowed, options.repoRoot)
3178
3624
  );
3179
- if (options.includeNonCurrent) return adjudicated;
3180
- const present = new Set(adjudicated.map((hit) => hit.record.conceptId));
3181
- return adjudicated.filter(
3625
+ const kept = adjudicated.filter((hit) => matchesTags(hit.record, options));
3626
+ if (options.includeNonCurrent) return kept;
3627
+ const present = new Set(kept.map((hit) => hit.record.conceptId));
3628
+ return kept.filter(
3182
3629
  (hit) => hit.standing !== "superseded" || !hit.heads.some((head) => present.has(head.conceptId))
3183
3630
  );
3184
3631
  }
@@ -3281,14 +3728,17 @@ ${answer}
3281
3728
  /* @__PURE__ */ new Date(),
3282
3729
  await this.detectDrift(wanted, options.repoRoot)
3283
3730
  );
3284
- const records = adjudicated.filter((hit) => hit.standing !== "superseded");
3285
- const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
3731
+ const kept = adjudicated.filter(
3732
+ (hit) => matchesTags(hit.record, { excludeTags: options.excludeTags })
3733
+ );
3734
+ const records = kept.filter((hit) => hit.standing !== "superseded");
3735
+ const superseded = kept.filter((hit) => hit.standing === "superseded").map(stub);
3286
3736
  const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
3287
3737
  const bundleDigestValue = bundleDigest(records, superseded);
3288
3738
  if (!options.all && approxTokens2 > budgetTokens) {
3289
3739
  return {
3290
3740
  loaded: false,
3291
- recordCount: wanted.length,
3741
+ recordCount: kept.length,
3292
3742
  approxTokens: approxTokens2,
3293
3743
  budgetTokens,
3294
3744
  message: refusalMessage({
@@ -3301,7 +3751,7 @@ ${answer}
3301
3751
  }
3302
3752
  return {
3303
3753
  loaded: true,
3304
- recordCount: wanted.length,
3754
+ recordCount: kept.length,
3305
3755
  tokensLoaded: approxTokens2,
3306
3756
  budgetTokens: options.all ? null : budgetTokens,
3307
3757
  records,
@@ -3384,12 +3834,25 @@ ${answer}
3384
3834
  }
3385
3835
  return expected;
3386
3836
  }
3837
+ /**
3838
+ * Drops the derived search index, so the next search rebuilds it.
3839
+ *
3840
+ * `searchBase` re-indexes when a record is newer than the index, which no
3841
+ * deletion makes true — a swept record would stay findable until some other
3842
+ * record was written.
3843
+ */
3844
+ async dropSearchIndex(bundlePath2) {
3845
+ await (0, import_promises6.unlink)((0, import_node_path7.join)(this.root(bundlePath2), SEARCH_INDEX_FILE)).catch(
3846
+ () => void 0
3847
+ );
3848
+ }
3387
3849
  /**
3388
3850
  * The log, with unparseable lines reported rather than repaired.
3389
3851
  *
3390
3852
  * The log is the bundle's only artifact that cannot be reconstructed — the
3391
3853
  * records rebuild the index, and the code outlives both, but nothing else
3392
3854
  * knows which agent touched what. So a bad line is surfaced and left alone.
3855
+ * Conflict markers are read past rather than reported per line.
3393
3856
  */
3394
3857
  async readLog(bundlePath2) {
3395
3858
  const raw = await (0, import_promises6.readFile)(
@@ -3397,6 +3860,13 @@ ${answer}
3397
3860
  "utf8"
3398
3861
  ).catch(() => "");
3399
3862
  const result = parseLog(raw);
3863
+ if (result.conflicted) {
3864
+ this.logger.warn?.({
3865
+ operation: "kb.log.parse",
3866
+ bundlePath: this.root(bundlePath2),
3867
+ outcome: "conflicted"
3868
+ });
3869
+ }
3400
3870
  for (const bad of result.malformed) {
3401
3871
  this.logger.warn?.({
3402
3872
  operation: "kb.log.parse",
@@ -3406,6 +3876,14 @@ ${answer}
3406
3876
  }
3407
3877
  return result;
3408
3878
  }
3879
+ /**
3880
+ * Appends one log entry for a move the store cannot see from one base.
3881
+ * Promotion writes into a target base and has to be legible from the source
3882
+ * base too, where nothing was written.
3883
+ */
3884
+ async note(bundlePath2, entry) {
3885
+ await this.record(this.root(bundlePath2), entry);
3886
+ }
3409
3887
  /**
3410
3888
  * `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
3411
3889
  * a missing target (a broken link, legal per compose.ts) or a CAS conflict
@@ -3496,7 +3974,8 @@ ${answer}
3496
3974
  /**
3497
3975
  * Declares union merge for the log, so two worktrees writing the same
3498
3976
  * bundle interleave their `log.jsonl` lines on merge rather than one
3499
- * side's appends silently losing to git's ordinary line-level merge.
3977
+ * side's appends silently losing to git's ordinary line-level merge — and
3978
+ * marks every store-owned file generated, so GitHub collapses it in a diff.
3500
3979
  *
3501
3980
  * Called from `record` — every path that appends a log line, not just
3502
3981
  * `write` — so a bundle only ever mutated through `setStatus`/`verify`/
@@ -3509,10 +3988,9 @@ ${answer}
3509
3988
  * race and created the file between the `readFile` below and this call,
3510
3989
  * `wx` fails instead of truncating what that writer just wrote, and the
3511
3990
  * failure is swallowed by the catch below same as any other best-effort
3512
- * miss. A file that exists but declares no merge strategy for the log
3513
- * gets the line appended, never a wholesale rewrite; one that already
3514
- * declares any merge strategy — this one or a user's own — is left alone
3515
- * entirely (see `hasMergeDeclaration`).
3991
+ * miss. A file that exists gets only the lines it lacks appended, never a
3992
+ * wholesale rewrite; an attribute it already sets this one's value or a
3993
+ * user's own — is left alone (see `missingGitattributesLines`).
3516
3994
  *
3517
3995
  * `readFile` failing is `existing === null` only for `ENOENT` — genuinely
3518
3996
  * missing. Any other error (a permission problem, a transient `EMFILE`,
@@ -3523,12 +4001,12 @@ ${answer}
3523
4001
  * therefore left untouched and reported as a failure like any other.
3524
4002
  *
3525
4003
  * Two processes racing the append branch — both read a file without the
3526
- * line, both append it — is possible and left unguarded: `appendFile` is
3527
- * `O_APPEND`, so the result is two copies of the same line rather than a
3528
- * torn write, and `hasMergeDeclaration` sees a duplicate declaration as
3529
- * "already declared" on the next call. A cheap-to-detect, harmless-to-
3530
- * leave residue, not a reason to add a cross-process lock (see
3531
- * `ARCHITECTURE.md`'s rejection of one for the same trade on records).
4004
+ * lines, both append them — is possible and left unguarded: `appendFile` is
4005
+ * `O_APPEND`, so the result is two copies of the same lines rather than a
4006
+ * torn write, and the next call sees a duplicate declaration as "already
4007
+ * declared". A cheap-to-detect, harmless-to-leave residue, not a reason to
4008
+ * add a cross-process lock (see `ARCHITECTURE.md`'s rejection of one for
4009
+ * the same trade on records).
3532
4010
  *
3533
4011
  * Best-effort, like the log append it precedes: failing to write this
3534
4012
  * file must not fail the mutation it guards.
@@ -3545,7 +4023,7 @@ ${answer}
3545
4023
  }
3546
4024
  if (existing === null) {
3547
4025
  try {
3548
- await (0, import_promises6.writeFile)(target, appendUnionMergeLine(""), {
4026
+ await (0, import_promises6.writeFile)(target, appendGitattributesLines(""), {
3549
4027
  encoding: "utf8",
3550
4028
  flag: "wx"
3551
4029
  });
@@ -3565,8 +4043,9 @@ ${answer}
3565
4043
  });
3566
4044
  return;
3567
4045
  }
3568
- if (!hasMergeDeclaration(existing)) {
3569
- await (0, import_promises6.appendFile)(target, appendUnionMergeLine(existing), "utf8");
4046
+ const addition = appendGitattributesLines(existing);
4047
+ if (addition) {
4048
+ await (0, import_promises6.appendFile)(target, addition, "utf8");
3570
4049
  this.logger.info?.({
3571
4050
  operation: "kb.gitattributes.ensure",
3572
4051
  bundlePath: root,
@@ -3675,7 +4154,7 @@ var composeInputSchema = import_zod4.z.object({
3675
4154
  why: import_zod4.z.string().min(1),
3676
4155
  /** Keyed by section heading from the type's spec. Unknown keys rejected. */
3677
4156
  sections: import_zod4.z.record(import_zod4.z.string(), import_zod4.z.string().min(1)).optional(),
3678
- anchors: import_zod4.z.array(kbAnchorSchema).optional(),
4157
+ anchors: import_zod4.z.array(kbAnchorWriteSchema).optional(),
3679
4158
  sources: import_zod4.z.array(kbSourceSchema).optional(),
3680
4159
  /** No source exists, as a claim rather than a sentinel in `sources`. */
3681
4160
  assumption: import_zod4.z.boolean().optional(),
@@ -3788,14 +4267,17 @@ function asBudgets(value) {
3788
4267
  if (value === null || typeof value !== "object") return {};
3789
4268
  const table2 = value;
3790
4269
  const pick = (key2, min) => {
3791
- const raw = table2[key2];
3792
- return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
4270
+ const raw2 = table2[key2];
4271
+ return typeof raw2 === "number" && Number.isInteger(raw2) && raw2 >= min ? raw2 : void 0;
3793
4272
  };
3794
4273
  const budgetTokens = pick("budgetTokens", 1);
3795
4274
  const fullUnderTokens = pick("fullUnderTokens", 0);
4275
+ const raw = table2["excludeTags"];
4276
+ const excludeTags = Array.isArray(raw) ? raw.filter((tag) => typeof tag === "string" && tag !== "") : void 0;
3796
4277
  return {
3797
4278
  ...budgetTokens ? { budgetTokens } : {},
3798
- ...fullUnderTokens !== void 0 ? { fullUnderTokens } : {}
4279
+ ...fullUnderTokens !== void 0 ? { fullUnderTokens } : {},
4280
+ ...excludeTags ? { excludeTags } : {}
3799
4281
  };
3800
4282
  }
3801
4283
  function contextProfileBudgets(manifest, profile) {
@@ -4111,7 +4593,7 @@ function preamble() {
4111
4593
  "tokens."
4112
4594
  ].join("\n");
4113
4595
  }
4114
- async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens) {
4596
+ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens, excludeTags) {
4115
4597
  const bundle = await store.list(absolutePath);
4116
4598
  if (bundle.length === 0) {
4117
4599
  return {
@@ -4125,7 +4607,8 @@ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, b
4125
4607
  let degradedFrom;
4126
4608
  if (fullCap > 0) {
4127
4609
  const full = await store.load(absolutePath, {
4128
- budgetTokens: fullCap
4610
+ budgetTokens: fullCap,
4611
+ excludeTags
4129
4612
  });
4130
4613
  if (!full.loaded && pinMode === "full") {
4131
4614
  degradedFrom = { approxTokens: full.approxTokens };
@@ -4155,7 +4638,9 @@ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, b
4155
4638
  };
4156
4639
  }
4157
4640
  }
4158
- const adjudicated = adjudicate(bundle, bundle);
4641
+ const adjudicated = adjudicate(bundle, bundle).filter(
4642
+ (hit) => matchesTags(hit.record, { excludeTags })
4643
+ );
4159
4644
  const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
4160
4645
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
4161
4646
  (hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
@@ -4176,6 +4661,7 @@ async function buildContext(store, workspaceDir, options = {}) {
4176
4661
  const fromManifest = mergedContextBudgets(merged, options.profile);
4177
4662
  budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
4178
4663
  fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
4664
+ const excludeTags = options.excludeTags ?? fromManifest.excludeTags ?? builtin.excludeTags ?? [];
4179
4665
  const pins = merged.pins.filter(
4180
4666
  (pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
4181
4667
  );
@@ -4196,7 +4682,8 @@ async function buildContext(store, workspaceDir, options = {}) {
4196
4682
  pin.absolutePath,
4197
4683
  fullUnderTokens,
4198
4684
  pin.mode,
4199
- budgetTokens
4685
+ budgetTokens,
4686
+ excludeTags
4200
4687
  ),
4201
4688
  frozen: pin.frozen === true
4202
4689
  }))
@@ -4327,7 +4814,7 @@ function kbJsonSchemas() {
4327
4814
 
4328
4815
  // src/match-diff.ts
4329
4816
  function matchToDiff(files, records, options = {}) {
4330
- const ranges = indexRanges(options.symbolRanges ?? []);
4817
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
4331
4818
  const anchored = records.filter(
4332
4819
  (record) => (record.frontmatter.strauss_anchors ?? []).length > 0
4333
4820
  );
@@ -4345,8 +4832,8 @@ function matchToDiff(files, records, options = {}) {
4345
4832
  let precision = "symbol";
4346
4833
  for (const { record, anchors } of candidates) {
4347
4834
  const placement = place(anchors, file.filePath, hunk, ranges);
4348
- if (placement === "miss") continue;
4349
- if (placement === "file") precision = "file";
4835
+ if (placement.kind === "miss") continue;
4836
+ if (placement.kind === "file") precision = "file";
4350
4837
  hits.push(record);
4351
4838
  }
4352
4839
  if (!hits.length) continue;
@@ -4360,19 +4847,51 @@ function matchToDiff(files, records, options = {}) {
4360
4847
  }
4361
4848
  return matches3;
4362
4849
  }
4850
+ function placeOnHunk(record, filePath, hunk, symbolRanges = []) {
4851
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
4852
+ (anchor) => normalize(anchor.file) === normalize(filePath)
4853
+ );
4854
+ return place(anchors, filePath, hunk, asIndex(symbolRanges));
4855
+ }
4856
+ function asIndex(ranges) {
4857
+ return isIndex(ranges) ? ranges : symbolRangeIndex(ranges);
4858
+ }
4859
+ function isIndex(ranges) {
4860
+ return !Array.isArray(ranges);
4861
+ }
4862
+ function anchorOnHunk(record, filePath, hunk, symbolRanges = []) {
4863
+ return placeOnHunk(record, filePath, hunk, symbolRanges).anchor;
4864
+ }
4363
4865
  function place(anchors, filePath, hunk, ranges) {
4364
- let fallback = "miss";
4866
+ let fallback = { kind: "miss" };
4365
4867
  for (const anchor of anchors) {
4366
- if (!anchor.symbol) return "file";
4367
- const resolved = ranges.get(key(filePath, anchor.symbol));
4868
+ if (side(anchor.side) !== side(hunk.side)) continue;
4869
+ if (anchor.span) {
4870
+ if (overlaps(
4871
+ { startLine: anchor.span.start, endLine: anchor.span.end },
4872
+ hunk
4873
+ )) {
4874
+ return { kind: "symbol", anchor };
4875
+ }
4876
+ continue;
4877
+ }
4878
+ if (!anchor.symbol) return { kind: "file", anchor };
4879
+ const resolved = ranges.get(
4880
+ key(filePath, anchor.symbol, side(anchor.side))
4881
+ );
4368
4882
  if (!resolved?.length) {
4369
- fallback = "file";
4883
+ if (fallback.kind === "miss") fallback = { kind: "file", anchor };
4370
4884
  continue;
4371
4885
  }
4372
- if (resolved.some((range) => overlaps(range, hunk))) return "symbol";
4886
+ if (resolved.some((range) => overlaps(range, hunk))) {
4887
+ return { kind: "symbol", anchor };
4888
+ }
4373
4889
  }
4374
4890
  return fallback;
4375
4891
  }
4892
+ function side(value) {
4893
+ return value ?? "new";
4894
+ }
4376
4895
  function overlaps(range, hunk) {
4377
4896
  return range.startLine <= hunk.endLine && hunk.startLine <= range.endLine;
4378
4897
  }
@@ -4390,42 +4909,211 @@ function order(records) {
4390
4909
  )
4391
4910
  );
4392
4911
  }
4393
- function indexRanges(ranges) {
4912
+ function symbolRangeIndex(ranges) {
4394
4913
  const byKey = /* @__PURE__ */ new Map();
4395
4914
  for (const range of ranges) {
4396
- const id = key(range.file, range.symbol);
4915
+ const id = key(range.file, range.symbol, side(range.side));
4397
4916
  byKey.set(id, [...byKey.get(id) ?? [], range]);
4398
4917
  }
4399
4918
  return byKey;
4400
4919
  }
4401
- function key(file, symbol) {
4402
- return `${normalize(file)}#${symbol}`;
4920
+ function key(file, symbol, at2) {
4921
+ return `${normalize(file)}#${symbol}#${at2}`;
4403
4922
  }
4404
4923
  function normalize(path) {
4405
4924
  return path.replace(/^\.\//, "");
4406
4925
  }
4407
4926
 
4408
- // src/validate.ts
4409
- function validateBundle(records) {
4410
- const byId = new Map(records.map((record) => [record.conceptId, record]));
4411
- const problems = [];
4412
- const report = (check, conceptId2, note, severity = "error") => problems.push({ check, conceptId: conceptId2, note, severity });
4413
- for (const record of records) {
4414
- const { conceptId: conceptId2, frontmatter: fm } = record;
4415
- if (!isKbRecordType(fm.type)) {
4416
- report("type", conceptId2, `unrecognised type "${fm.type}"`);
4417
- }
4418
- if (fm.strauss_status === "superseded") {
4419
- const by = fm.strauss_superseded_by;
4420
- if (!by) {
4421
- report("superseded_by", conceptId2, "superseded with no replacement");
4422
- } else if (!byId.has(by)) {
4423
- report("superseded_by", conceptId2, `replacement ${by} is missing`);
4424
- } else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
4425
- report("backlink", by, `does not list ${conceptId2} in supersedes`);
4426
- }
4427
- }
4428
- for (const old of fm.strauss_supersedes ?? []) {
4927
+ // src/classify/model.ts
4928
+ var KB_CLASSES = [
4929
+ "test",
4930
+ "config",
4931
+ "ci",
4932
+ "docs",
4933
+ "lockfile",
4934
+ "generated",
4935
+ "boilerplate",
4936
+ "rename",
4937
+ "source"
4938
+ ];
4939
+ var DEFAULT_THRESHOLDS = {
4940
+ boilerplate: 0.8,
4941
+ rename: 90
4942
+ };
4943
+
4944
+ // src/classify/rules.ts
4945
+ var PATH_RULES = [
4946
+ {
4947
+ name: "test-path",
4948
+ class: "test",
4949
+ test: /(^|\/)(__tests__|__mocks__|tests?)\/|\.(spec|test)\.[^/]+$/
4950
+ },
4951
+ {
4952
+ name: "ci-path",
4953
+ class: "ci",
4954
+ test: /(^|\/)\.github\/|(^|\/)(\.circleci|\.buildkite|\.gitlab|ci)\/[^/]*\.ya?ml$|(^|\/)Dockerfile(\.[^/]*)?$|\.tf$/
4955
+ },
4956
+ {
4957
+ name: "docs-path",
4958
+ class: "docs",
4959
+ test: /\.md$|(^|\/)docs\/|(^|\/)LICENSE(\.(md|txt|rst))?$/
4960
+ },
4961
+ {
4962
+ name: "lockfile-path",
4963
+ class: "lockfile",
4964
+ test: /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|Cargo\.lock|go\.sum)$/
4965
+ },
4966
+ {
4967
+ name: "config-path",
4968
+ class: "config",
4969
+ // `.jsonl` rides with `.json`: an append-only log of JSON is configuration
4970
+ // data too, and calling it source would send a reviewer to read it. Every
4971
+ // arm is anchored at both ends: `src/tsconfig-loader.ts` and `report.env.ts`
4972
+ // are source, not config.
4973
+ test: /\.(jsonc?|jsonl|ya?ml|toml|ini)$|(^|\/)\.env(?![^/]*\.[cm]?[jt]sx?$)([.-][^/]*)?$|[^/]+\.env$|(^|\/)tsconfig[^/]*\.json$|\.config\.[^/]+$|(^|\/)\.(eslintrc|prettierrc)[^/]*$/
4974
+ }
4975
+ ];
4976
+ var HEADER_LINES = 20;
4977
+ var GENERATED_MARKERS = [
4978
+ /@generated\b/i,
4979
+ /\bdo not edit\b/i,
4980
+ /\bcode generated by\b/i,
4981
+ /\bthis file was automatically generated\b/i
4982
+ ];
4983
+ var BOILERPLATE_SHAPES = [
4984
+ { name: "import", test: /^import\b|^\}\s*from\s+["']/ },
4985
+ { name: "re-export", test: /^export\s+(\*|\{|type\s*[{*])/ },
4986
+ {
4987
+ name: "class-shell",
4988
+ test: /^(export\s+)?(default\s+)?(abstract\s+)?class\s+[\w$]+[^{]*\{\s*\}$/
4989
+ },
4990
+ { name: "punctuation", test: /^[{}()[\],;]+$/ }
4991
+ ];
4992
+ function generatedMarker(lines) {
4993
+ for (const line of lines.slice(0, HEADER_LINES)) {
4994
+ const hit = GENERATED_MARKERS.find((marker) => marker.test(line));
4995
+ if (hit) return hit.source.replaceAll("\\b", "");
4996
+ }
4997
+ return void 0;
4998
+ }
4999
+ function isBoilerplateLine(line) {
5000
+ return BOILERPLATE_SHAPES.some((shape) => shape.test.test(line));
5001
+ }
5002
+
5003
+ // src/classify/classify.ts
5004
+ function classifyDiff(files, options = {}) {
5005
+ const overrides = currentOverrides(options.records ?? [], options.now);
5006
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
5007
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds };
5008
+ return files.map((file) => classifyFile(file, overrides, ranges, thresholds));
5009
+ }
5010
+ var OVERRIDE_CLASS = /* @__PURE__ */ new Map([
5011
+ ["review:generated", "generated"],
5012
+ ["review:boilerplate", "boilerplate"],
5013
+ ["review:move", "rename"]
5014
+ ]);
5015
+ var WHOLE_FILE = {
5016
+ startLine: 1,
5017
+ endLine: Number.MAX_SAFE_INTEGER
5018
+ };
5019
+ function classifyFile(file, overrides, ranges, thresholds) {
5020
+ const whole = overrides.find(
5021
+ ({ record }) => placeOnHunk(record, file.filePath, WHOLE_FILE, ranges).kind === "file"
5022
+ );
5023
+ const verdict = whole ? verdictOf(whole) : heuristic(file, thresholds);
5024
+ const hunks = file.hunks.map((hunk) => {
5025
+ const hit = whole ?? overrides.find(
5026
+ ({ record }) => placeOnHunk(record, file.filePath, hunk, ranges).kind !== "miss"
5027
+ );
5028
+ return {
5029
+ startLine: hunk.startLine,
5030
+ endLine: hunk.endLine,
5031
+ ...hit ? verdictOf(hit) : verdict
5032
+ };
5033
+ });
5034
+ return {
5035
+ filePath: file.filePath,
5036
+ ...verdict,
5037
+ ...file.renamedFrom ? { renamedFrom: file.renamedFrom } : {},
5038
+ ...hunks.some((hunk) => hunk.class !== verdict.class) ? { hunks } : {}
5039
+ };
5040
+ }
5041
+ function verdictOf(override) {
5042
+ return {
5043
+ class: override.class,
5044
+ reason: `kb-override ${override.record.conceptId}`
5045
+ };
5046
+ }
5047
+ function heuristic(file, thresholds) {
5048
+ const marker = generatedMarker(file.header ?? headOfDiff(file));
5049
+ if (marker)
5050
+ return { class: "generated", reason: `generated-header ${marker}` };
5051
+ const rule = PATH_RULES.find((entry) => entry.test.test(file.filePath));
5052
+ if (rule) return { class: rule.class, reason: rule.name };
5053
+ if (file.renamedFrom && !file.hunks.length && (file.similarity ?? 100) >= thresholds.rename) {
5054
+ return { class: "rename", reason: `rename ${file.renamedFrom}` };
5055
+ }
5056
+ const share = boilerplateShare(file);
5057
+ if (share !== void 0 && share >= thresholds.boilerplate) {
5058
+ return {
5059
+ class: "boilerplate",
5060
+ reason: `boilerplate ${Math.round(share * 100)}%`
5061
+ };
5062
+ }
5063
+ return { class: "source", reason: "default" };
5064
+ }
5065
+ function headOfDiff(file) {
5066
+ return file.hunks.flatMap(
5067
+ (hunk) => (hunk.side ?? "new") === "new" && hunk.startLine <= HEADER_LINES ? (hunk.lines ?? []).slice(0, HEADER_LINES - hunk.startLine + 1) : []
5068
+ );
5069
+ }
5070
+ function boilerplateShare(file) {
5071
+ const lines = file.hunks.flatMap((hunk) => hunk.lines ?? []).map((line) => line.trim()).filter(Boolean);
5072
+ if (!lines.length) return void 0;
5073
+ return lines.filter(isBoilerplateLine).length / lines.length;
5074
+ }
5075
+ function currentOverrides(records, now) {
5076
+ const tagged = records.flatMap((record) => {
5077
+ if (record.frontmatter.type !== "fact") return [];
5078
+ const tag = (record.frontmatter.tags ?? []).find(
5079
+ (entry) => OVERRIDE_CLASS.has(entry)
5080
+ );
5081
+ const asserted = tag && OVERRIDE_CLASS.get(tag);
5082
+ return asserted ? [{ record, class: asserted }] : [];
5083
+ });
5084
+ const current = new Set(
5085
+ adjudicate(
5086
+ tagged.map(({ record }) => record),
5087
+ records,
5088
+ now
5089
+ ).filter((entry) => entry.standing === "current").map((entry) => entry.record.conceptId)
5090
+ );
5091
+ return tagged.filter(({ record }) => current.has(record.conceptId)).sort(
5092
+ (left, right) => left.record.conceptId.localeCompare(right.record.conceptId)
5093
+ );
5094
+ }
5095
+
5096
+ // src/validate.ts
5097
+ function validateBundle(records) {
5098
+ const byId = new Map(records.map((record) => [record.conceptId, record]));
5099
+ const problems = [];
5100
+ const report = (check, conceptId2, note, severity = "error") => problems.push({ check, conceptId: conceptId2, note, severity });
5101
+ for (const record of records) {
5102
+ const { conceptId: conceptId2, frontmatter: fm } = record;
5103
+ if (!isKbRecordType(fm.type)) {
5104
+ report("type", conceptId2, `unrecognised type "${fm.type}"`);
5105
+ }
5106
+ if (fm.strauss_status === "superseded") {
5107
+ const by = fm.strauss_superseded_by;
5108
+ if (!by) {
5109
+ report("superseded_by", conceptId2, "superseded with no replacement");
5110
+ } else if (!byId.has(by)) {
5111
+ report("superseded_by", conceptId2, `replacement ${by} is missing`);
5112
+ } else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
5113
+ report("backlink", by, `does not list ${conceptId2} in supersedes`);
5114
+ }
5115
+ }
5116
+ for (const old of fm.strauss_supersedes ?? []) {
4429
5117
  const previous = byId.get(old);
4430
5118
  if (!previous) {
4431
5119
  report("supersedes", conceptId2, `target ${old} is missing`);
@@ -4464,6 +5152,34 @@ function validateBundle(records) {
4464
5152
  }
4465
5153
  }
4466
5154
  for (const anchor of fm.strauss_anchors ?? []) {
5155
+ if (anchor.span && anchor.symbol) {
5156
+ report(
5157
+ "anchor_span",
5158
+ conceptId2,
5159
+ `anchor ${anchor.file} names both a symbol and a span \u2014 one or the other`
5160
+ );
5161
+ }
5162
+ if (anchor.span && anchor.span.end < anchor.span.start) {
5163
+ report(
5164
+ "anchor_span",
5165
+ conceptId2,
5166
+ `anchor ${anchor.file} span ${anchor.span.start}-${anchor.span.end} ends before it starts`
5167
+ );
5168
+ }
5169
+ if (anchor.span && anchor.hash_kind === "ast") {
5170
+ report(
5171
+ "anchor_span",
5172
+ conceptId2,
5173
+ `anchor ${anchor.file} is a span with hash_kind: "ast" \u2014 a span is hashed raw`
5174
+ );
5175
+ }
5176
+ if (anchor.side === "old" && !anchor.ref) {
5177
+ report(
5178
+ "anchor_side",
5179
+ conceptId2,
5180
+ `anchor ${anchor.file} is side: "old" with no ref`
5181
+ );
5182
+ }
4467
5183
  if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
4468
5184
  report(
4469
5185
  "anchor_repo",
@@ -4510,14 +5226,19 @@ var DAY_MS = 864e5;
4510
5226
  function anchorResolverCounts(bundle) {
4511
5227
  let treeSitter = 0;
4512
5228
  let regex = 0;
5229
+ let span2 = 0;
5230
+ let oldSide = 0;
4513
5231
  for (const record of bundle) {
4514
5232
  for (const anchor of record.frontmatter.strauss_anchors ?? []) {
4515
- if (!anchor.hash || !anchor.symbol) continue;
4516
- if (anchor.resolver === "tree-sitter") treeSitter += 1;
5233
+ if (!anchor.hash) continue;
5234
+ if (anchor.side === "old") oldSide += 1;
5235
+ if (anchor.span) span2 += 1;
5236
+ else if (!anchor.symbol) continue;
5237
+ else if (anchor.resolver === "tree-sitter") treeSitter += 1;
4517
5238
  else regex += 1;
4518
5239
  }
4519
5240
  }
4520
- return { total: treeSitter + regex, treeSitter, regex };
5241
+ return { total: treeSitter + regex + span2, treeSitter, regex, span: span2, oldSide };
4521
5242
  }
4522
5243
  function doctor(bundle, options = {}) {
4523
5244
  const thresholds = {
@@ -4527,7 +5248,7 @@ function doctor(bundle, options = {}) {
4527
5248
  };
4528
5249
  const now = options.now ?? /* @__PURE__ */ new Date();
4529
5250
  const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
4530
- const standings = new Map(
5251
+ const standings2 = new Map(
4531
5252
  adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
4532
5253
  );
4533
5254
  const inForce = adjudicated.filter(
@@ -4540,7 +5261,7 @@ function doctor(bundle, options = {}) {
4540
5261
  group("aging", aging(inForce, now, thresholds.agingDays)),
4541
5262
  group("orphaned", orphaned(bundle)),
4542
5263
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
4543
- group("superseded-but-cited", supersededButCited(bundle, standings)),
5264
+ group("superseded-but-cited", supersededButCited(bundle, standings2)),
4544
5265
  group("drifted", drifted(inForce)),
4545
5266
  group("unchecked", unchecked(inForce))
4546
5267
  ];
@@ -4706,14 +5427,14 @@ function brokenSupersession(bundle, adjudicated) {
4706
5427
  (left, right) => left.conceptId.localeCompare(right.conceptId)
4707
5428
  );
4708
5429
  }
4709
- function supersededButCited(bundle, standings) {
5430
+ function supersededButCited(bundle, standings2) {
4710
5431
  const byId = new Map(bundle.map((record) => [record.conceptId, record]));
4711
5432
  const findings = [];
4712
5433
  for (const record of bundle) {
4713
- const standing = standings.get(record.conceptId);
5434
+ const standing = standings2.get(record.conceptId);
4714
5435
  if (standing === "superseded" || standing === "rejected") continue;
4715
5436
  for (const target of edgeNeighbours(record, bundle, "body-link")) {
4716
- const targetStanding = standings.get(target.conceptId);
5437
+ const targetStanding = standings2.get(target.conceptId);
4717
5438
  if (targetStanding !== "superseded" && targetStanding !== "rejected") {
4718
5439
  continue;
4719
5440
  }
@@ -4857,6 +5578,9 @@ var import_zod9 = require("zod");
4857
5578
  var import_zod8 = require("zod");
4858
5579
  var bundlePath = import_zod8.z.string().min(1).describe("Absolute path to the knowledge base directory.");
4859
5580
  var conceptId = import_zod8.z.string().min(1).describe("e.g. decision.cursor-v2");
5581
+ var TAGS = import_zod8.z.array(import_zod8.z.string().min(1)).optional().describe(
5582
+ "Keep only records carrying every one of these frontmatter tags. Matched exactly."
5583
+ );
4860
5584
  var REPO_ROOT = import_zod8.z.string().min(1).optional().describe(
4861
5585
  "Where the anchored source lives, for the drift check. Defaults to the working directory."
4862
5586
  );
@@ -4878,6 +5602,41 @@ function argvFlag(argv, name) {
4878
5602
  }
4879
5603
  return value;
4880
5604
  }
5605
+ function argvFlags(argv, name) {
5606
+ const values = [];
5607
+ for (const [at2, arg] of argv.entries()) {
5608
+ if (arg.startsWith(`${name}=`)) {
5609
+ const value = arg.slice(name.length + 1);
5610
+ if (!value) throw new KbMissingFlagValueError(name);
5611
+ values.push(value);
5612
+ } else if (arg === name) {
5613
+ const value = argv[at2 + 1];
5614
+ if (value === void 0 || value.startsWith("--")) {
5615
+ throw new KbMissingFlagValueError(name);
5616
+ }
5617
+ values.push(value);
5618
+ }
5619
+ }
5620
+ return values;
5621
+ }
5622
+ function argvWithout(argv, ...names) {
5623
+ const kept = [];
5624
+ for (let at2 = 0; at2 < argv.length; at2 += 1) {
5625
+ const arg = argv[at2];
5626
+ if (names.some((name) => arg.startsWith(`${name}=`))) continue;
5627
+ if (names.includes(arg)) {
5628
+ at2 += 1;
5629
+ continue;
5630
+ }
5631
+ kept.push(arg);
5632
+ }
5633
+ return kept;
5634
+ }
5635
+ function argvPositional(argv, ...names) {
5636
+ return argvWithout(argv.slice(1), ...names).find(
5637
+ (arg) => !arg.startsWith("--")
5638
+ );
5639
+ }
4881
5640
 
4882
5641
  // src/commands/anchor-resolve.ts
4883
5642
  function resolverSummary(results) {
@@ -4941,6 +5700,7 @@ var anchorResolveCommand = define({
4941
5700
  const base2 = {
4942
5701
  file: anchor.file,
4943
5702
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
5703
+ ...anchor.side === "old" ? { side: "old" } : {},
4944
5704
  // Carried onto unresolved findings too: an anchor that once hashed
4945
5705
  // and now resolves to nothing is a broken anchor, and the exit code
4946
5706
  // has to be able to tell it from one nobody ever stamped.
@@ -5117,12 +5877,18 @@ async function readSources(anchors, root, offline) {
5117
5877
  const foreign = new Map(
5118
5878
  anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
5119
5879
  );
5120
- const local = anchors.filter((anchor) => !foreign.get(anchor));
5880
+ const local = anchors.filter(
5881
+ (anchor) => !foreign.get(anchor) && anchor.side !== "old"
5882
+ );
5883
+ const committed = anchors.filter(
5884
+ (anchor) => !foreign.get(anchor) && anchor.side === "old"
5885
+ );
5121
5886
  const remote = anchors.filter((anchor) => foreign.get(anchor));
5122
5887
  const reads = await readAnchorFiles(
5123
5888
  local.map((anchor) => anchor.file),
5124
5889
  anchorFileReader(root)
5125
5890
  );
5891
+ const atRef = await readCommitted(root, committed);
5126
5892
  const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
5127
5893
  offline
5128
5894
  });
@@ -5134,6 +5900,13 @@ async function readSources(anchors, root, offline) {
5134
5900
  read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
5135
5901
  );
5136
5902
  }
5903
+ for (const anchor of committed) {
5904
+ const read = atRef.get(atRefKey(anchor));
5905
+ sources.set(
5906
+ anchor,
5907
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
5908
+ );
5909
+ }
5137
5910
  for (const anchor of remote) {
5138
5911
  const repo = anchor.repo;
5139
5912
  const key2 = normalizeRepoUrl(repo);
@@ -5194,25 +5967,39 @@ var import_zod12 = require("zod");
5194
5967
  var catalogCommand = define({
5195
5968
  name: "catalog",
5196
5969
  tool: "kb_catalog",
5197
- usage: "catalog [type]",
5970
+ usage: "catalog [type] [--tag T]...",
5198
5971
  description: "Lists every record as one line \u2014 concept id, type, title, standing, and a stale flag \u2014 at roughly thirty tokens each. Pick this over kb_load once kb_load refuses: kb_catalog never refuses. Superseded records show only their replacement; fetch bodies with kb_load, kb_pack, kb_query, or kb_trace.",
5199
5972
  input: import_zod12.z.object({
5200
5973
  bundlePath,
5201
- type: import_zod12.z.enum(KB_RECORD_TYPES).optional()
5974
+ type: import_zod12.z.enum(KB_RECORD_TYPES).optional(),
5975
+ tags: TAGS
5202
5976
  }),
5203
- fromArgv: (argv, path) => ({
5204
- bundlePath: path,
5205
- ...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {}
5206
- }),
5207
- run: async ({ store }, { bundlePath: path, type }) => render(
5208
- await store.catalog(path, { ...type ? { type } : {} }),
5977
+ fromArgv: (argv, path) => {
5978
+ const tags = argvFlags(argv, "--tag");
5979
+ const type = argvPositional(argv, "--tag");
5980
+ return {
5981
+ bundlePath: path,
5982
+ ...type ? { type } : {},
5983
+ ...tags.length ? { tags } : {}
5984
+ };
5985
+ },
5986
+ run: async ({ store }, { bundlePath: path, type, tags }) => render(
5987
+ await store.catalog(path, {
5988
+ ...type ? { type } : {},
5989
+ ...tags ? { tags } : {}
5990
+ }),
5209
5991
  path,
5210
- type
5992
+ type,
5993
+ tags
5211
5994
  )
5212
5995
  });
5213
- function render(result, bundle, type) {
5996
+ function render(result, bundle, type, tags) {
5997
+ const narrowed = [
5998
+ ...type ? [type] : [],
5999
+ ...tags?.length ? [`tags: ${tags.join(", ")}`] : []
6000
+ ].join(" \xB7 ");
5214
6001
  const lines = [
5215
- `# KB Catalog${type ? ` \u2014 ${type}` : ""}`,
6002
+ `# KB Catalog${narrowed ? ` \u2014 ${narrowed}` : ""}`,
5216
6003
  `bundle: ${bundle}`,
5217
6004
  `${count(result.recordCount, "record")}: ${standingCounts(result)}`
5218
6005
  ];
@@ -5224,7 +6011,7 @@ function render(result, bundle, type) {
5224
6011
  lines.push("");
5225
6012
  if (!result.entries.length) {
5226
6013
  lines.push(
5227
- type ? `(no records of type ${type})` : "(no records \u2014 this base is empty)"
6014
+ narrowed ? `(no records matching ${narrowed})` : "(no records \u2014 this base is empty)"
5228
6015
  );
5229
6016
  } else {
5230
6017
  for (const entry of result.entries) lines.push(renderCatalogLine(entry));
@@ -5252,135 +6039,12 @@ function count(value, noun) {
5252
6039
  return `${value} ${value === 1 ? noun : `${noun}s`}`;
5253
6040
  }
5254
6041
 
5255
- // src/commands/context.ts
5256
- var import_zod13 = require("zod");
5257
- var contextCommand = define({
5258
- name: "context",
5259
- tool: "kb_context",
5260
- usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
5261
- 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.",
5262
- input: import_zod13.z.object({
5263
- budgetTokens: import_zod13.z.number().int().positive().optional().describe(
5264
- "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
5265
- ),
5266
- fullUnderTokens: import_zod13.z.number().int().positive().optional().describe(
5267
- "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."
5268
- ),
5269
- profile: import_zod13.z.string().optional().describe(
5270
- "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."
5271
- ),
5272
- format: import_zod13.z.enum(["markdown", "json"]).optional().describe(
5273
- "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
5274
- ),
5275
- event: import_zod13.z.string().optional().describe(
5276
- "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
5277
- )
5278
- }),
5279
- fromArgv: (argv) => {
5280
- const budget = argvFlag(argv, "--budget");
5281
- const fullUnder = argvFlag(argv, "--full-under");
5282
- const profile = argvFlag(argv, "--profile");
5283
- const format = argvFlag(argv, "--format");
5284
- const event = argvFlag(argv, "--event");
5285
- return {
5286
- ...budget ? { budgetTokens: Number(budget) } : {},
5287
- ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
5288
- ...profile ? { profile } : {},
5289
- ...format ? { format } : {},
5290
- ...event ? { event } : {}
5291
- };
5292
- },
5293
- run: async ({ store }, { budgetTokens, fullUnderTokens, profile, format, event }) => {
5294
- const result = await buildContext(store, process.cwd(), {
5295
- ...budgetTokens ? { budgetTokens } : {},
5296
- ...fullUnderTokens ? { fullUnderTokens } : {},
5297
- ...profile ? { profile } : {},
5298
- // Degradations — a full pin that could not fit, a refused block — go
5299
- // to stderr as well as into the block itself: stderr is diagnostics on
5300
- // both surfaces (hooks discard it, MCP logs it), so an operator can
5301
- // see budget pressure without reading injected context.
5302
- warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
5303
- `)
5304
- });
5305
- if (!result.block) return "";
5306
- return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
5307
- }
5308
- });
5309
-
5310
- // src/commands/doctor.ts
6042
+ // src/commands/classify.ts
6043
+ var import_node_buffer = require("buffer");
6044
+ var import_promises10 = require("fs/promises");
6045
+ var import_node_path12 = require("path");
5311
6046
  var import_zod15 = require("zod");
5312
6047
 
5313
- // src/drift/git.ts
5314
- var import_node_child_process3 = require("child_process");
5315
- var import_node_util3 = require("util");
5316
- var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
5317
- var MAX_GIT_OUTPUT_BYTES = 1048576;
5318
- var GIT_TIMEOUT_MS = 5e3;
5319
- async function git2(cwd, args) {
5320
- const env = { ...process.env };
5321
- delete env["GIT_DIR"];
5322
- delete env["GIT_WORK_TREE"];
5323
- delete env["GIT_INDEX_FILE"];
5324
- try {
5325
- const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
5326
- timeout: GIT_TIMEOUT_MS,
5327
- maxBuffer: MAX_GIT_OUTPUT_BYTES,
5328
- env
5329
- });
5330
- return { ok: true, stdout };
5331
- } catch {
5332
- return { ok: false };
5333
- }
5334
- }
5335
- async function listRepoFiles(repoRoot) {
5336
- const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
5337
- if (!result.ok) return [];
5338
- return result.stdout.split("\0").filter(Boolean);
5339
- }
5340
- async function readOldSource(repoRoot, anchor) {
5341
- if (!filePathIsSafe(anchor.file))
5342
- return { ok: false, reason: "unrecoverable" };
5343
- if (anchor.ref && refShapeIsSafe(anchor.ref)) {
5344
- const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
5345
- if (shown2 !== null) {
5346
- return {
5347
- ok: true,
5348
- source: shown2,
5349
- origin: { kind: "ref", ref: anchor.ref }
5350
- };
5351
- }
5352
- }
5353
- const at2 = anchor.resolved_at;
5354
- if (!at2 || Number.isNaN(Date.parse(at2))) {
5355
- return { ok: false, reason: "unrecoverable" };
5356
- }
5357
- const found = await git2(repoRoot, [
5358
- "log",
5359
- "-1",
5360
- "--format=%H",
5361
- `--before=${at2}`,
5362
- "--end-of-options",
5363
- "HEAD",
5364
- "--",
5365
- anchor.file
5366
- ]);
5367
- const sha = found.ok ? found.stdout.trim() : "";
5368
- if (!sha || !refShapeIsSafe(sha))
5369
- return { ok: false, reason: "unrecoverable" };
5370
- const shown = await showFile(repoRoot, sha, anchor.file);
5371
- if (shown === null) return { ok: false, reason: "unrecoverable" };
5372
- return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
5373
- }
5374
- async function showFile(repoRoot, ref, file) {
5375
- const path = file.replace(/^\.\//, "");
5376
- const result = await git2(repoRoot, [
5377
- "show",
5378
- "--end-of-options",
5379
- `${ref}:${path}`
5380
- ]);
5381
- return result.ok ? result.stdout : null;
5382
- }
5383
-
5384
6048
  // src/drift/moved.ts
5385
6049
  var import_promises9 = require("fs/promises");
5386
6050
  var MAX_MOVED_SEARCH_FILES = 2e3;
@@ -5399,6 +6063,7 @@ function movedSearch(repoRoot, options = {}) {
5399
6063
  async find(anchor) {
5400
6064
  const stored = anchor.hash;
5401
6065
  if (!stored) return void 0;
6066
+ if (anchor.span) return sameFileWindow(anchor, read, stored);
5402
6067
  const language = languageForFile(anchor.file);
5403
6068
  if (!language) return sameFileWindow(anchor, read, stored);
5404
6069
  const candidates = await filesForLanguage(language);
@@ -5496,6 +6161,11 @@ async function classifyDrift(repoRoot, record, entries, options = {}) {
5496
6161
  );
5497
6162
  const out = [];
5498
6163
  for (const { anchor, entry } of wanted) {
6164
+ if (anchor.side === "old") {
6165
+ const settled2 = entry.class ?? "changed";
6166
+ out.push({ anchor, entry: { ...entry, class: settled2 }, class: settled2 });
6167
+ continue;
6168
+ }
5499
6169
  const movedTo = await search.find(anchor);
5500
6170
  if (movedTo) {
5501
6171
  out.push({
@@ -5569,8 +6239,8 @@ function unifiedDiff(before, after, options = {}) {
5569
6239
  }
5570
6240
  const truncated = body.length > max;
5571
6241
  const shown = truncated ? body.slice(0, max) : body;
5572
- const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
5573
- const lines = [header, ...shown];
6242
+ const header2 = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
6243
+ const lines = [header2, ...shown];
5574
6244
  if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
5575
6245
  return { text: lines.join("\n"), added, removed, truncated };
5576
6246
  }
@@ -5630,12 +6300,12 @@ async function reassessPacket(repoRoot, record, entries, options = {}) {
5630
6300
  ...options.search ? { search: options.search } : {},
5631
6301
  withHistory: options.withDiff !== false
5632
6302
  });
5633
- const open = classified.filter(
6303
+ const open2 = classified.filter(
5634
6304
  (found) => found.class === "changed" || found.class === "gone"
5635
6305
  );
5636
- if (!open.length) return { packet: null, classified };
5637
- const budget = diffBudget(open.length);
5638
- const anchors = open.map(
6306
+ if (!open2.length) return { packet: null, classified };
6307
+ const budget = diffBudget(open2.length);
6308
+ const anchors = open2.map(
5639
6309
  (found) => anchorPacket(found, options.withDiff === true, budget)
5640
6310
  );
5641
6311
  const type = record.frontmatter.type;
@@ -5709,37 +6379,624 @@ function claimOf(record) {
5709
6379
  return text ? { section, text } : null;
5710
6380
  }
5711
6381
 
5712
- // src/commands/reassess.ts
6382
+ // src/commands/match/command.ts
5713
6383
  var import_zod14 = require("zod");
5714
- var reassessCommand = define({
5715
- name: "reassess",
5716
- tool: "kb_reassess",
5717
- usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
5718
- 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.",
5719
- input: import_zod14.z.object({
5720
- bundlePath,
5721
- conceptId,
5722
- repoRoot: REPO_ROOT,
5723
- withDiff: import_zod14.z.boolean().optional().describe(
5724
- "Recover each anchor's committed span and render the diff. Reads git history."
5725
- )
5726
- }),
5727
- fromArgv: (argv, path) => {
5728
- const repoRoot = argvFlag(argv, "--repo-root");
5729
- return {
5730
- bundlePath: path,
5731
- conceptId: argv[1],
5732
- ...repoRoot !== void 0 ? { repoRoot } : {},
5733
- ...argv.includes("--with-diff") ? { withDiff: true } : {}
5734
- };
5735
- },
5736
- run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
5737
- const root = repoRoot ?? process.cwd();
5738
- const bundle = await store.list(path);
5739
- const record = bundle.find((entry) => entry.conceptId === id);
5740
- if (!record) throw new KbRecordNotFoundError(id);
5741
- const drift = await store.detectDrift([record], repoRoot);
5742
- const entries = drift?.get(id) ?? [];
6384
+
6385
+ // src/commands/match/errors.ts
6386
+ var KbMatchInputError = class extends BaseError {
6387
+ constructor(reason) {
6388
+ super({
6389
+ message: `match: ${reason}`,
6390
+ errorType: "KbMatchInput" /* KbMatchInput */,
6391
+ code: 400,
6392
+ fault: "User" /* User */,
6393
+ retriable: false,
6394
+ reportToUser: true,
6395
+ details: { reason }
6396
+ });
6397
+ this.reason = reason;
6398
+ }
6399
+ reason;
6400
+ };
6401
+
6402
+ // src/commands/match/model.ts
6403
+ var import_zod13 = require("zod");
6404
+ var diffHunkSchema = import_zod13.z.object({
6405
+ startLine: import_zod13.z.number().int().positive(),
6406
+ endLine: import_zod13.z.number().int().positive(),
6407
+ side: import_zod13.z.enum(["old", "new"]).optional()
6408
+ }).passthrough();
6409
+ var diffFileSchema = import_zod13.z.object({
6410
+ filePath: import_zod13.z.string().min(1).describe("Repo-relative, spelled the way anchors are."),
6411
+ hunks: import_zod13.z.array(diffHunkSchema)
6412
+ });
6413
+ var symbolRangeSchema = import_zod13.z.object({
6414
+ file: import_zod13.z.string().min(1),
6415
+ symbol: import_zod13.z.string().min(1),
6416
+ startLine: import_zod13.z.number().int().positive(),
6417
+ endLine: import_zod13.z.number().int().positive()
6418
+ });
6419
+
6420
+ // src/commands/match/parse-unified-diff.ts
6421
+ var FILE_HEADER = /^diff --git (.+)$/;
6422
+ var HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
6423
+ var SIMILARITY = /^similarity index (\d+)%$/;
6424
+ var KNOWN_PREFIX = /^[ab]\//;
6425
+ function parseUnifiedDiff(patch, options = {}) {
6426
+ const files = [];
6427
+ let current;
6428
+ let listed = false;
6429
+ let shared;
6430
+ let oldPath;
6431
+ let rename4 = {};
6432
+ let inHeader = false;
6433
+ let added;
6434
+ let removed;
6435
+ const list = () => {
6436
+ if (!current || listed) return;
6437
+ files.push(current);
6438
+ listed = true;
6439
+ };
6440
+ const open2 = (filePath) => {
6441
+ current = { filePath, hunks: [], ...rename4 };
6442
+ listed = false;
6443
+ };
6444
+ const amend = () => {
6445
+ if (current) Object.assign(current, rename4);
6446
+ };
6447
+ const close = () => {
6448
+ if (options.keepEmpty) list();
6449
+ current = void 0;
6450
+ listed = false;
6451
+ added = void 0;
6452
+ removed = void 0;
6453
+ };
6454
+ for (const raw of patch.split("\n")) {
6455
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
6456
+ const start = FILE_HEADER.exec(line);
6457
+ if (start) {
6458
+ close();
6459
+ oldPath = void 0;
6460
+ rename4 = {};
6461
+ shared = sharedHeaderPath(start[1]);
6462
+ inHeader = true;
6463
+ if (shared) open2(shared);
6464
+ continue;
6465
+ }
6466
+ if (inHeader) {
6467
+ const similarity = SIMILARITY.exec(line);
6468
+ if (similarity) {
6469
+ rename4.similarity = Number(similarity[1]);
6470
+ amend();
6471
+ continue;
6472
+ }
6473
+ if (line.startsWith("rename from ")) {
6474
+ rename4.renamedFrom = unquote(line.slice(12).trim());
6475
+ amend();
6476
+ continue;
6477
+ }
6478
+ if (line.startsWith("rename to ")) {
6479
+ open2(unquote(line.slice(10).trim()));
6480
+ continue;
6481
+ }
6482
+ if (line.startsWith("--- ")) {
6483
+ oldPath = sidePath(line.slice(4), shared);
6484
+ continue;
6485
+ }
6486
+ if (line.startsWith("+++ ")) {
6487
+ const path = sidePath(line.slice(4), shared) ?? oldPath;
6488
+ if (path) open2(path);
6489
+ else current = void 0;
6490
+ continue;
6491
+ }
6492
+ }
6493
+ const hunk = HUNK.exec(line);
6494
+ if (!hunk) {
6495
+ if (!options.withLines || inHeader) continue;
6496
+ if (line.startsWith("+")) added?.lines?.push(line.slice(1));
6497
+ else if (line.startsWith("-")) removed?.lines?.push(line.slice(1));
6498
+ continue;
6499
+ }
6500
+ inHeader = false;
6501
+ added = void 0;
6502
+ removed = void 0;
6503
+ if (!current) continue;
6504
+ const [next, before] = hunksOf(hunk, options.withLines === true);
6505
+ added = next;
6506
+ removed = before;
6507
+ list();
6508
+ current.hunks.push(...before ? [next, before] : [next]);
6509
+ }
6510
+ close();
6511
+ return files;
6512
+ }
6513
+ function hunksOf(hunk, withLines) {
6514
+ const oldStart = Number(hunk[1]);
6515
+ const oldCount = hunk[2] === void 0 ? 1 : Number(hunk[2]);
6516
+ const newStart = Number(hunk[3]);
6517
+ const newCount = hunk[4] === void 0 ? 1 : Number(hunk[4]);
6518
+ const lines = withLines ? { lines: [] } : {};
6519
+ const added = newCount === 0 ? { ...point(newStart), ...lines } : { startLine: newStart, endLine: newStart + newCount - 1, ...lines };
6520
+ if (oldCount === 0) return [added];
6521
+ return [
6522
+ added,
6523
+ {
6524
+ startLine: oldStart,
6525
+ endLine: oldStart + oldCount - 1,
6526
+ side: "old",
6527
+ ...withLines ? { lines: [] } : {}
6528
+ }
6529
+ ];
6530
+ }
6531
+ function point(start) {
6532
+ const at2 = Math.max(1, start);
6533
+ return { startLine: at2, endLine: at2 };
6534
+ }
6535
+ function sidePath(raw, shared) {
6536
+ const text = unquote(raw.replace(/\t.*$/, "").trim());
6537
+ if (text === "/dev/null") return void 0;
6538
+ if (KNOWN_PREFIX.test(text)) return text.slice(2);
6539
+ return shared ?? text;
6540
+ }
6541
+ function sharedHeaderPath(rest) {
6542
+ const pair = splitHeaderPair(rest);
6543
+ if (!pair || pair[0] === pair[1]) return void 0;
6544
+ const from = unquote(pair[0]).split("/");
6545
+ const to = unquote(pair[1]).split("/");
6546
+ const shared = [];
6547
+ while (from.length > 1 && to.length > 1 && from.at(-1) === to.at(-1)) {
6548
+ shared.unshift(from.pop());
6549
+ to.pop();
6550
+ }
6551
+ return shared.length ? shared.join("/") : void 0;
6552
+ }
6553
+ function splitHeaderPair(rest) {
6554
+ if (rest.startsWith('"')) {
6555
+ const end = endOfQuoted(rest);
6556
+ if (end < 0 || rest[end + 1] !== " ") return void 0;
6557
+ return [rest.slice(0, end + 1), rest.slice(end + 2)];
6558
+ }
6559
+ const mid = (rest.length - 1) / 2;
6560
+ if (Number.isInteger(mid) && rest[mid] === " ") {
6561
+ return [rest.slice(0, mid), rest.slice(mid + 1)];
6562
+ }
6563
+ const at2 = rest.indexOf(" ");
6564
+ return at2 === -1 ? void 0 : [rest.slice(0, at2), rest.slice(at2 + 1)];
6565
+ }
6566
+ function endOfQuoted(text) {
6567
+ for (let at2 = 1; at2 < text.length; at2 += 1) {
6568
+ if (text[at2] === "\\") {
6569
+ at2 += 1;
6570
+ continue;
6571
+ }
6572
+ if (text[at2] === '"') return at2;
6573
+ }
6574
+ return -1;
6575
+ }
6576
+ var ESCAPES = {
6577
+ a: 7,
6578
+ b: 8,
6579
+ f: 12,
6580
+ n: 10,
6581
+ r: 13,
6582
+ t: 9,
6583
+ v: 11,
6584
+ '"': 34,
6585
+ "\\": 92
6586
+ };
6587
+ var OCTAL = /^[0-7]{3}/;
6588
+ var utf8 = new TextEncoder();
6589
+ function unquote(text) {
6590
+ if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) {
6591
+ return text;
6592
+ }
6593
+ const body = text.slice(1, -1);
6594
+ const bytes = [];
6595
+ for (let at2 = 0; at2 < body.length; ) {
6596
+ const slash = body.indexOf("\\", at2);
6597
+ if (slash < 0) {
6598
+ bytes.push(...utf8.encode(body.slice(at2)));
6599
+ break;
6600
+ }
6601
+ if (slash > at2) bytes.push(...utf8.encode(body.slice(at2, slash)));
6602
+ const octal = OCTAL.exec(body.slice(slash + 1, slash + 4));
6603
+ if (octal) {
6604
+ bytes.push(Number.parseInt(octal[0], 8));
6605
+ at2 = slash + 4;
6606
+ continue;
6607
+ }
6608
+ const next = body[slash + 1];
6609
+ if (next === void 0) {
6610
+ bytes.push(ESCAPES["\\"]);
6611
+ break;
6612
+ }
6613
+ const mapped = ESCAPES[next];
6614
+ if (mapped === void 0) bytes.push(...utf8.encode(next));
6615
+ else bytes.push(mapped);
6616
+ at2 = slash + 2;
6617
+ }
6618
+ return new TextDecoder().decode(Uint8Array.from(bytes));
6619
+ }
6620
+
6621
+ // src/commands/match/symbol-ranges.ts
6622
+ async function resolveSymbolRanges(repoRoot, files, records, offline = false) {
6623
+ const changed = new Set(files.map((file) => strip(file.filePath)));
6624
+ const wanted = [];
6625
+ const seen = /* @__PURE__ */ new Set();
6626
+ for (const record of records) {
6627
+ for (const anchor of record.frontmatter.strauss_anchors ?? []) {
6628
+ if (!anchor.symbol || anchor.repo) continue;
6629
+ if (!changed.has(strip(anchor.file))) continue;
6630
+ const key2 = `${strip(anchor.file)}#${anchor.symbol}`;
6631
+ if (seen.has(key2)) continue;
6632
+ seen.add(key2);
6633
+ wanted.push(anchor);
6634
+ }
6635
+ }
6636
+ if (!wanted.length) return [];
6637
+ const paths = [...new Set(wanted.map((anchor) => anchor.file))];
6638
+ const sources = await readAnchorFiles(paths, anchorFileReader(repoRoot));
6639
+ const resolvers = defaultAnchorResolvers({ offline });
6640
+ await prepareResolvers(resolvers, paths);
6641
+ const ranges = [];
6642
+ for (const anchor of wanted) {
6643
+ const read = sources.get(anchor.file);
6644
+ if (!read?.ok) continue;
6645
+ const outcome = resolveAnchorSpan(read.source, anchor, resolvers);
6646
+ if (!outcome.ok) continue;
6647
+ ranges.push({
6648
+ file: anchor.file,
6649
+ symbol: anchor.symbol,
6650
+ startLine: outcome.span.startLine,
6651
+ endLine: outcome.span.endLine
6652
+ });
6653
+ }
6654
+ return ranges;
6655
+ }
6656
+ function strip(path) {
6657
+ return path.replace(/^\.\//, "");
6658
+ }
6659
+
6660
+ // src/commands/match/command.ts
6661
+ var matchCommand = define({
6662
+ name: "match",
6663
+ tool: "kb_match",
6664
+ usage: "match --git <base>..<head> | --stdin [--repo-root <path>] [--offline] [--include-non-current]",
6665
+ 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.",
6666
+ input: import_zod14.z.object({
6667
+ bundlePath,
6668
+ files: import_zod14.z.array(diffFileSchema).describe("The changed files, each with its post-change line ranges."),
6669
+ symbolRanges: import_zod14.z.array(symbolRangeSchema).optional().describe(
6670
+ "Symbol spans the caller already has. Resolved from repoRoot when omitted."
6671
+ ),
6672
+ repoRoot: REPO_ROOT,
6673
+ offline: import_zod14.z.boolean().optional().describe(
6674
+ "Resolve symbol ranges from what is already on disk, never fetching a grammar."
6675
+ ),
6676
+ includeNonCurrent: import_zod14.z.boolean().optional().describe(
6677
+ "Return superseded, rejected and unsettled records too, each carrying its standing."
6678
+ )
6679
+ }),
6680
+ fromArgv: async (argv, path, stdin) => {
6681
+ const repoRoot = argvFlag(argv, "--repo-root");
6682
+ const range = argvFlag(argv, "--git");
6683
+ const base2 = {
6684
+ bundlePath: path,
6685
+ ...repoRoot !== void 0 ? { repoRoot } : {},
6686
+ ...argv.includes("--offline") ? { offline: true } : {},
6687
+ ...argv.includes("--include-non-current") ? { includeNonCurrent: true } : {}
6688
+ };
6689
+ if (range !== void 0) {
6690
+ const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
6691
+ if (!diff.ok) {
6692
+ throw new KbMatchInputError(`--git ${range} ${REFUSED[diff.reason]}`);
6693
+ }
6694
+ return { ...base2, files: parseUnifiedDiff(diff.text) };
6695
+ }
6696
+ if (!argv.includes("--stdin")) {
6697
+ throw new KbMatchInputError(
6698
+ "pass --git <base>..<head>, or --stdin with { files } as JSON"
6699
+ );
6700
+ }
6701
+ return { ...base2, ...fromStdin(await stdin()) };
6702
+ },
6703
+ run: async ({ store }, {
6704
+ bundlePath: path,
6705
+ files,
6706
+ symbolRanges,
6707
+ repoRoot,
6708
+ offline,
6709
+ includeNonCurrent
6710
+ }) => {
6711
+ const records = await store.list(path);
6712
+ const ranges = symbolRanges ?? await resolveSymbolRanges(
6713
+ repoRoot ?? process.cwd(),
6714
+ files,
6715
+ records,
6716
+ offline === true
6717
+ );
6718
+ const index2 = symbolRangeIndex(ranges);
6719
+ return matchToDiff(files, records, { symbolRanges: ranges }).flatMap(
6720
+ (match) => project(match, index2, includeNonCurrent === true)
6721
+ );
6722
+ }
6723
+ });
6724
+ var REFUSED = {
6725
+ "bad-range": "is not a range git could read here \u2014 both halves of <base>..<head> are required",
6726
+ "too-large": "diffs to a patch past the output cap \u2014 narrow the range",
6727
+ timeout: "took longer to diff than the runner allows \u2014 narrow the range",
6728
+ "git-missing": "needs git on PATH, and there is none"
6729
+ };
6730
+ function fromStdin(text) {
6731
+ let payload;
6732
+ try {
6733
+ payload = JSON.parse(text);
6734
+ } catch {
6735
+ throw new KbMatchInputError("stdin is not JSON");
6736
+ }
6737
+ if (!Array.isArray(payload?.files)) {
6738
+ throw new KbMatchInputError("stdin needs a files array");
6739
+ }
6740
+ return {
6741
+ files: payload.files,
6742
+ ...payload.symbolRanges !== void 0 ? { symbolRanges: payload.symbolRanges } : {}
6743
+ };
6744
+ }
6745
+ function project(match, ranges, all) {
6746
+ const kept = all ? match.records : match.records.filter((hit) => hit.standing === "current");
6747
+ if (!kept.length) return [];
6748
+ const placed = kept.map((hit) => ({
6749
+ hit,
6750
+ at: placeOnHunk(hit.record, match.filePath, match.hunk, ranges)
6751
+ }));
6752
+ return [
6753
+ {
6754
+ filePath: match.filePath,
6755
+ hunk: match.hunk,
6756
+ // Over the records returned, not the ones matched: a hunk holding only
6757
+ // symbol-placed records is not `file` because a dropped one was.
6758
+ precision: placed.every(({ at: at2 }) => at2.kind === "symbol") ? "symbol" : "file",
6759
+ records: placed.map(({ hit, at: { anchor } }) => {
6760
+ const { frontmatter } = hit.record;
6761
+ return {
6762
+ conceptId: hit.record.conceptId,
6763
+ type: frontmatter.type,
6764
+ title: frontmatter.title ?? null,
6765
+ standing: hit.standing,
6766
+ status: frontmatter.strauss_status,
6767
+ supersededBy: hit.heads.map((head) => head.conceptId),
6768
+ ...frontmatter.strauss_materiality ? { materiality: frontmatter.strauss_materiality } : {},
6769
+ ...frontmatter.strauss_confidence ? { confidence: frontmatter.strauss_confidence } : {},
6770
+ ...frontmatter.tags?.length ? { tags: frontmatter.tags } : {},
6771
+ ...anchor ? { anchor } : {}
6772
+ };
6773
+ })
6774
+ }
6775
+ ];
6776
+ }
6777
+
6778
+ // src/commands/classify.ts
6779
+ var classifyFileSchema = diffFileSchema.extend({
6780
+ hunks: import_zod15.z.array(
6781
+ diffHunkSchema.extend({ lines: import_zod15.z.array(import_zod15.z.string()).optional() })
6782
+ ),
6783
+ renamedFrom: import_zod15.z.string().min(1).optional().describe("Where `git diff -M` says the path came from."),
6784
+ similarity: import_zod15.z.number().min(0).max(100).optional()
6785
+ });
6786
+ var classifyCommand = define({
6787
+ name: "classify",
6788
+ tool: "kb_classify",
6789
+ usage: "classify --git <base>..<head> | --stdin [--repo-root <path>] [--offline]",
6790
+ 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.",
6791
+ input: import_zod15.z.object({
6792
+ bundlePath,
6793
+ files: import_zod15.z.array(classifyFileSchema).describe("The changed files, each with its line ranges."),
6794
+ repoRoot: REPO_ROOT,
6795
+ offline: import_zod15.z.boolean().optional().describe(
6796
+ "Resolve symbol ranges from what is already on disk, never fetching a grammar."
6797
+ )
6798
+ }),
6799
+ fromArgv: async (argv, path, stdin) => {
6800
+ const repoRoot = argvFlag(argv, "--repo-root");
6801
+ const range = argvFlag(argv, "--git");
6802
+ const base2 = {
6803
+ bundlePath: path,
6804
+ ...repoRoot !== void 0 ? { repoRoot } : {},
6805
+ ...argv.includes("--offline") ? { offline: true } : {}
6806
+ };
6807
+ if (range !== void 0) {
6808
+ const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
6809
+ if (!diff.ok) {
6810
+ throw new KbClassifyInputError(
6811
+ `--git ${range} ${REFUSED2[diff.reason]}`
6812
+ );
6813
+ }
6814
+ return {
6815
+ ...base2,
6816
+ files: parseUnifiedDiff(diff.text, {
6817
+ keepEmpty: true,
6818
+ withLines: true
6819
+ })
6820
+ };
6821
+ }
6822
+ if (!argv.includes("--stdin")) {
6823
+ throw new KbClassifyInputError(
6824
+ "pass --git <base>..<head>, or --stdin with { files } as JSON"
6825
+ );
6826
+ }
6827
+ return { ...base2, files: fromStdin2(await stdin()) };
6828
+ },
6829
+ run: async ({ store }, { bundlePath: path, files, repoRoot, offline }) => {
6830
+ const records = await store.list(path);
6831
+ const root = repoRoot ?? process.cwd();
6832
+ const withHeaders = await mapLimit2(files, READERS, async (file) => ({
6833
+ ...file,
6834
+ header: await header(root, file)
6835
+ }));
6836
+ const symbolRanges = await resolveSymbolRanges(
6837
+ root,
6838
+ files,
6839
+ records,
6840
+ offline === true
6841
+ );
6842
+ return { files: classifyDiff(withHeaders, { records, symbolRanges }) };
6843
+ },
6844
+ render: (result) => renderClassify(result)
6845
+ });
6846
+ var HEADER_BYTES = 65536;
6847
+ var READERS = 16;
6848
+ async function header(root, file) {
6849
+ if (!filePathIsSafe(file.filePath)) return void 0;
6850
+ let handle;
6851
+ try {
6852
+ handle = await (0, import_promises10.open)((0, import_node_path12.join)(root, file.filePath), "r");
6853
+ const buffer = import_node_buffer.Buffer.alloc(HEADER_BYTES);
6854
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_BYTES, 0);
6855
+ return buffer.toString("utf8", 0, bytesRead).split("\n").slice(0, HEADER_LINES);
6856
+ } catch {
6857
+ return void 0;
6858
+ } finally {
6859
+ await handle?.close();
6860
+ }
6861
+ }
6862
+ async function mapLimit2(items, limit, run) {
6863
+ const out = Array.from({ length: items.length });
6864
+ let next = 0;
6865
+ const worker = async () => {
6866
+ while (next < items.length) {
6867
+ const at2 = next;
6868
+ next += 1;
6869
+ out[at2] = await run(items[at2]);
6870
+ }
6871
+ };
6872
+ await Promise.all(
6873
+ Array.from({ length: Math.min(limit, items.length) }, () => worker())
6874
+ );
6875
+ return out;
6876
+ }
6877
+ var REFUSED2 = {
6878
+ "bad-range": "is not a range git could read here \u2014 both halves of <base>..<head> are required",
6879
+ "too-large": "diffs to a patch past the output cap \u2014 narrow the range",
6880
+ timeout: "took longer to diff than the runner allows \u2014 narrow the range",
6881
+ "git-missing": "needs git on PATH, and there is none"
6882
+ };
6883
+ function fromStdin2(text) {
6884
+ let payload;
6885
+ try {
6886
+ payload = JSON.parse(text);
6887
+ } catch {
6888
+ throw new KbClassifyInputError("stdin is not JSON");
6889
+ }
6890
+ if (!Array.isArray(payload?.files)) {
6891
+ throw new KbClassifyInputError("stdin needs a files array");
6892
+ }
6893
+ return payload.files;
6894
+ }
6895
+ function renderClassify(result) {
6896
+ const width2 = Math.max(
6897
+ 0,
6898
+ ...result.files.map((file) => file.class.length)
6899
+ );
6900
+ return result.files.map(
6901
+ (file) => `${file.class.padEnd(width2)} ${file.filePath} (${file.reason})`
6902
+ ).join("\n");
6903
+ }
6904
+
6905
+ // src/commands/context.ts
6906
+ var import_zod16 = require("zod");
6907
+ var contextCommand = define({
6908
+ name: "context",
6909
+ tool: "kb_context",
6910
+ usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
6911
+ 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.",
6912
+ input: import_zod16.z.object({
6913
+ budgetTokens: import_zod16.z.number().int().positive().optional().describe(
6914
+ "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
6915
+ ),
6916
+ fullUnderTokens: import_zod16.z.number().int().positive().optional().describe(
6917
+ "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."
6918
+ ),
6919
+ profile: import_zod16.z.string().optional().describe(
6920
+ "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."
6921
+ ),
6922
+ excludeTags: import_zod16.z.array(import_zod16.z.string().min(1)).optional().describe(
6923
+ "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
6924
+ ),
6925
+ format: import_zod16.z.enum(["markdown", "json"]).optional().describe(
6926
+ "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
6927
+ ),
6928
+ event: import_zod16.z.string().optional().describe(
6929
+ "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
6930
+ )
6931
+ }),
6932
+ fromArgv: (argv) => {
6933
+ const budget = argvFlag(argv, "--budget");
6934
+ const fullUnder = argvFlag(argv, "--full-under");
6935
+ const profile = argvFlag(argv, "--profile");
6936
+ const format = argvFlag(argv, "--format");
6937
+ const event = argvFlag(argv, "--event");
6938
+ const excludeTags = argvFlags(argv, "--exclude-tag");
6939
+ return {
6940
+ ...budget ? { budgetTokens: Number(budget) } : {},
6941
+ ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
6942
+ ...profile ? { profile } : {},
6943
+ ...excludeTags.length ? { excludeTags } : {},
6944
+ ...format ? { format } : {},
6945
+ ...event ? { event } : {}
6946
+ };
6947
+ },
6948
+ run: async ({ store }, { budgetTokens, fullUnderTokens, profile, excludeTags, format, event }) => {
6949
+ const result = await buildContext(store, process.cwd(), {
6950
+ ...budgetTokens ? { budgetTokens } : {},
6951
+ ...fullUnderTokens ? { fullUnderTokens } : {},
6952
+ ...profile ? { profile } : {},
6953
+ ...excludeTags ? { excludeTags } : {},
6954
+ // Degradations — a full pin that could not fit, a refused block — go
6955
+ // to stderr as well as into the block itself: stderr is diagnostics on
6956
+ // both surfaces (hooks discard it, MCP logs it), so an operator can
6957
+ // see budget pressure without reading injected context.
6958
+ warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
6959
+ `)
6960
+ });
6961
+ if (!result.block) return "";
6962
+ return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
6963
+ }
6964
+ });
6965
+
6966
+ // src/commands/doctor.ts
6967
+ var import_zod18 = require("zod");
6968
+
6969
+ // src/commands/reassess.ts
6970
+ var import_zod17 = require("zod");
6971
+ var reassessCommand = define({
6972
+ name: "reassess",
6973
+ tool: "kb_reassess",
6974
+ usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
6975
+ 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.",
6976
+ input: import_zod17.z.object({
6977
+ bundlePath,
6978
+ conceptId,
6979
+ repoRoot: REPO_ROOT,
6980
+ withDiff: import_zod17.z.boolean().optional().describe(
6981
+ "Recover each anchor's committed span and render the diff. Reads git history."
6982
+ )
6983
+ }),
6984
+ fromArgv: (argv, path) => {
6985
+ const repoRoot = argvFlag(argv, "--repo-root");
6986
+ return {
6987
+ bundlePath: path,
6988
+ conceptId: argv[1],
6989
+ ...repoRoot !== void 0 ? { repoRoot } : {},
6990
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
6991
+ };
6992
+ },
6993
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
6994
+ const root = repoRoot ?? process.cwd();
6995
+ const bundle = await store.list(path);
6996
+ const record = bundle.find((entry) => entry.conceptId === id);
6997
+ if (!record) throw new KbRecordNotFoundError(id);
6998
+ const drift = await store.detectDrift([record], repoRoot);
6999
+ const entries = drift?.get(id) ?? [];
5743
7000
  if (!entries.some((entry) => entry.state !== "match")) {
5744
7001
  return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
5745
7002
  }
@@ -5763,7 +7020,10 @@ var reassessCommand = define({
5763
7020
  relocated.set(found.anchor, {
5764
7021
  ...found.anchor,
5765
7022
  file: to.file,
5766
- ...to.symbol ? { symbol: to.symbol } : {}
7023
+ ...to.symbol ? { symbol: to.symbol } : {},
7024
+ // A span is the anchor's whole address, so relocating it means
7025
+ // moving the line range the same code now occupies.
7026
+ ...found.anchor.span ? { span: { start: to.startLine, end: to.endLine } } : {}
5767
7027
  });
5768
7028
  rebaselined.push({
5769
7029
  file: found.anchor.file,
@@ -5862,13 +7122,13 @@ function at(file, symbol) {
5862
7122
  }
5863
7123
 
5864
7124
  // src/commands/doctor.ts
5865
- var days = (what, fallback) => import_zod15.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
7125
+ var days = (what, fallback) => import_zod18.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5866
7126
  var doctorCommand = define({
5867
7127
  name: "doctor",
5868
7128
  tool: "kb_doctor",
5869
7129
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
5870
7130
  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.",
5871
- input: import_zod15.z.object({
7131
+ input: import_zod18.z.object({
5872
7132
  bundlePath,
5873
7133
  repoRoot: REPO_ROOT,
5874
7134
  expiringDays: days(
@@ -5883,16 +7143,16 @@ var doctorCommand = define({
5883
7143
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
5884
7144
  DEFAULT_AGING_DAYS
5885
7145
  ),
5886
- offline: import_zod15.z.boolean().optional().describe(
7146
+ offline: import_zod18.z.boolean().optional().describe(
5887
7147
  "Read foreign anchors from the local repo cache only, never fetching."
5888
7148
  ),
5889
- strict: import_zod15.z.boolean().optional().describe(
7149
+ strict: import_zod18.z.boolean().optional().describe(
5890
7150
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
5891
7151
  ),
5892
- drifted: import_zod15.z.boolean().optional().describe(
7152
+ drifted: import_zod18.z.boolean().optional().describe(
5893
7153
  "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
5894
7154
  ),
5895
- withDiff: import_zod15.z.boolean().optional().describe(
7155
+ withDiff: import_zod18.z.boolean().optional().describe(
5896
7156
  "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
5897
7157
  )
5898
7158
  }),
@@ -5948,7 +7208,7 @@ var doctorCommand = define({
5948
7208
  ...hints.length ? { hints } : {}
5949
7209
  };
5950
7210
  }
5951
- const standings = new Map(
7211
+ const standings2 = new Map(
5952
7212
  adjudicate(records, records, new Date(checkedAt)).map((hit) => [
5953
7213
  hit.record.conceptId,
5954
7214
  hit.standing
@@ -5962,7 +7222,7 @@ var doctorCommand = define({
5962
7222
  (entry) => entry.conceptId === found.conceptId
5963
7223
  );
5964
7224
  if (!record) continue;
5965
- const standing = standings.get(record.conceptId);
7225
+ const standing = standings2.get(record.conceptId);
5966
7226
  const built = await reassessPacket(
5967
7227
  repoRoot ?? process.cwd(),
5968
7228
  record,
@@ -5999,15 +7259,16 @@ var doctorCommand = define({
5999
7259
  });
6000
7260
  function render2(result) {
6001
7261
  if (result.packets) return renderPackets(result);
6002
- const { thresholds } = result;
7262
+ const { thresholds, anchorResolvers: counts } = result;
6003
7263
  const lines = [
6004
7264
  `# KB Doctor \u2014 ${result.bundlePath}`,
6005
7265
  `records: ${result.recordCount}`,
6006
7266
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
6007
7267
  `checked: ${result.checkedAt}`,
6008
- ...result.anchorResolvers.total ? [
6009
- `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
6010
- ] : [],
7268
+ ...counts.total ? [anchorLine(counts)] : [],
7269
+ // Its own line: an old-side anchor may name a whole file, which no
7270
+ // resolver bucket and no `total` counts.
7271
+ ...counts.oldSide ? [`old-side anchors: ${counts.oldSide}`] : [],
6011
7272
  ""
6012
7273
  ];
6013
7274
  const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
@@ -6032,6 +7293,14 @@ function render2(result) {
6032
7293
  );
6033
7294
  return lines.join("\n");
6034
7295
  }
7296
+ function anchorLine(counts) {
7297
+ const parts = [
7298
+ `${counts.treeSitter} tree-sitter`,
7299
+ `${counts.regex} regex`,
7300
+ ...counts.span ? [`${counts.span} span`] : []
7301
+ ];
7302
+ return `anchors: ${counts.total} hashed \u2014 ${parts.join(", ")}`;
7303
+ }
6035
7304
  function renderPackets(result) {
6036
7305
  const packets = result.packets ?? [];
6037
7306
  const lines = [
@@ -6057,20 +7326,155 @@ function renderPackets(result) {
6057
7326
  return lines.join("\n");
6058
7327
  }
6059
7328
 
7329
+ // src/commands/export.ts
7330
+ var import_promises11 = require("fs/promises");
7331
+ var import_node_path13 = require("path");
7332
+ var import_zod19 = require("zod");
7333
+ var NUMBERED = /^(\d{4})-(.+)\.md$/;
7334
+ var MARKER = "<!-- strauss-kb export: ";
7335
+ var exportCommand = define({
7336
+ name: "export",
7337
+ tool: "kb_export",
7338
+ usage: "export --format madr --to <dir>",
7339
+ 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.",
7340
+ input: import_zod19.z.object({
7341
+ bundlePath,
7342
+ format: import_zod19.z.enum(["madr"]).describe("Output layout. `madr` is the only one so far."),
7343
+ to: import_zod19.z.string().min(1).describe("Directory the ADR files are written into.")
7344
+ }),
7345
+ fromArgv: (argv, path) => ({
7346
+ bundlePath: path,
7347
+ format: argvFlag(argv, "--format"),
7348
+ to: argvFlag(argv, "--to")
7349
+ }),
7350
+ run: async ({ store }, { bundlePath: path, to }) => {
7351
+ const bundle = await store.list(path);
7352
+ const decisions = selectDecisions(bundle).sort(
7353
+ (left, right) => left.conceptId.localeCompare(right.conceptId)
7354
+ );
7355
+ const adjudicated = new Map(
7356
+ adjudicate(decisions, bundle).map((hit) => [hit.record.conceptId, hit])
7357
+ );
7358
+ await (0, import_promises11.mkdir)(to, { recursive: true });
7359
+ const taken = await existingFiles(to);
7360
+ let next = Math.max(0, ...[...taken.values()].map((row) => row.number)) + 1;
7361
+ const exported = [];
7362
+ const foreign = [];
7363
+ for (const record of decisions) {
7364
+ const slug = record.conceptId.slice(record.conceptId.indexOf(".") + 1);
7365
+ const held = taken.get(slug);
7366
+ if (held && !held.ours) {
7367
+ foreign.push({ conceptId: record.conceptId, file: held.file });
7368
+ continue;
7369
+ }
7370
+ const number = held?.number ?? next++;
7371
+ const file = `${String(number).padStart(4, "0")}-${slug}.md`;
7372
+ const status = statusLine(adjudicated.get(record.conceptId));
7373
+ await publish((0, import_node_path13.join)(to, file), renderMadr(record, status));
7374
+ exported.push({ conceptId: record.conceptId, file, status });
7375
+ }
7376
+ return { to, format: "madr", exported, foreign };
7377
+ },
7378
+ render: (result) => {
7379
+ const { exported, foreign, to } = result;
7380
+ return [
7381
+ `Wrote ${exported.length} MADR file${exported.length === 1 ? "" : "s"} to ${to}.`,
7382
+ ...exported.map(
7383
+ (entry) => `- ${entry.file} ${entry.conceptId} [${entry.status}]`
7384
+ ),
7385
+ ...foreign.map(
7386
+ (entry) => `- skipped ${entry.conceptId}: ${entry.file} was not written by export`
7387
+ )
7388
+ ].join("\n");
7389
+ }
7390
+ });
7391
+ async function publish(target, contents) {
7392
+ const staging = `${target}.${process.pid}.tmp`;
7393
+ await (0, import_promises11.writeFile)(staging, contents, "utf8");
7394
+ try {
7395
+ await (0, import_promises11.rename)(staging, target);
7396
+ } catch (error) {
7397
+ await (0, import_promises11.unlink)(staging).catch(() => void 0);
7398
+ throw error;
7399
+ }
7400
+ }
7401
+ async function existingFiles(to) {
7402
+ const names = await (0, import_promises11.readdir)(to).catch(() => []);
7403
+ const taken = /* @__PURE__ */ new Map();
7404
+ for (const name of names.sort()) {
7405
+ const [, number, slug] = NUMBERED.exec(name) ?? [];
7406
+ if (!number || !slug) continue;
7407
+ const text = await (0, import_promises11.readFile)((0, import_node_path13.join)(to, name), "utf8").catch(() => "");
7408
+ taken.set(slug, {
7409
+ file: name,
7410
+ number: Number(number),
7411
+ ours: text.includes(MARKER)
7412
+ });
7413
+ }
7414
+ return taken;
7415
+ }
7416
+ function statusLine(hit) {
7417
+ const status = hit?.record.frontmatter.strauss_status ?? "draft";
7418
+ if (status !== "superseded") return status;
7419
+ const by = (hit?.heads ?? []).map((head) => head.conceptId);
7420
+ return by.length ? `superseded by ${by.join(", ")}` : "superseded";
7421
+ }
7422
+ function renderMadr(record, status) {
7423
+ const sections = bodySections(record.body);
7424
+ const blocks = [
7425
+ `# ${record.frontmatter.title ?? record.conceptId}`,
7426
+ "## Status",
7427
+ status
7428
+ ];
7429
+ push(blocks, "Context and Problem Statement", record.frontmatter.description);
7430
+ push(blocks, "Considered Options", sections.get("Rejected"));
7431
+ push(blocks, "Decision Outcome", sections.get("Decision"));
7432
+ push(blocks, "Consequences", sections.get("Impact"));
7433
+ blocks.push(`${MARKER}${record.conceptId} -->`);
7434
+ return `${blocks.join("\n\n")}
7435
+ `;
7436
+ }
7437
+ function push(blocks, heading, text) {
7438
+ if (text?.trim()) blocks.push(`## ${heading}`, text.trim());
7439
+ }
7440
+ function bodySections(body) {
7441
+ const generated = new RegExp(
7442
+ `^(?:(?:${Object.values(LINK_RELS).map((spec) => spec.phrase).join("|")}) \\[[^\\]]+\\]\\([^)]+\\.md\\)\\.|\\[\\^[^\\]]+\\]: .*)$`
7443
+ );
7444
+ const sections = /* @__PURE__ */ new Map();
7445
+ let heading = null;
7446
+ let lines = [];
7447
+ const flush = () => {
7448
+ if (heading) sections.set(heading, lines.join("\n").trim());
7449
+ };
7450
+ for (const line of body.split("\n")) {
7451
+ const match = /^## (.+?)\s*$/.exec(line);
7452
+ if (match) {
7453
+ flush();
7454
+ heading = match[1] ?? null;
7455
+ lines = [];
7456
+ } else if (heading && !generated.test(line)) {
7457
+ lines.push(line);
7458
+ }
7459
+ }
7460
+ flush();
7461
+ return sections;
7462
+ }
7463
+
6060
7464
  // src/commands/impact.ts
6061
- var import_zod16 = require("zod");
7465
+ var import_zod20 = require("zod");
6062
7466
  var impactCommand = define({
6063
7467
  name: "impact",
6064
7468
  tool: "kb_impact",
6065
7469
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
6066
7470
  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.",
6067
- input: import_zod16.z.object({
7471
+ input: import_zod20.z.object({
6068
7472
  bundlePath,
6069
7473
  conceptId,
6070
- depth: import_zod16.z.number().int().positive().optional().describe(
7474
+ depth: import_zod20.z.number().int().positive().optional().describe(
6071
7475
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
6072
7476
  ),
6073
- rels: import_zod16.z.array(import_zod16.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
7477
+ rels: import_zod20.z.array(import_zod20.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
6074
7478
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
6075
7479
  )
6076
7480
  }),
@@ -6091,35 +7495,49 @@ var impactCommand = define({
6091
7495
  });
6092
7496
 
6093
7497
  // src/commands/list.ts
6094
- var import_zod17 = require("zod");
7498
+ var import_zod21 = require("zod");
6095
7499
  var listCommand = define({
6096
7500
  name: "list",
6097
7501
  tool: "kb_list",
6098
- usage: "list [type]",
6099
- description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
6100
- input: import_zod17.z.object({ bundlePath, type: import_zod17.z.enum(KB_RECORD_TYPES).optional() }),
6101
- fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
6102
- run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
6103
- conceptId: record.conceptId,
6104
- title: record.frontmatter.title ?? null,
6105
- description: record.frontmatter.description ?? null,
6106
- status: record.frontmatter.strauss_status,
6107
- anchors: record.frontmatter.strauss_anchors ?? []
6108
- }))
7502
+ usage: "list [type] [--tag T]...",
7503
+ description: "Every record, optionally one type or tag. For enumerating; use kb_query for a question.",
7504
+ input: import_zod21.z.object({
7505
+ bundlePath,
7506
+ type: import_zod21.z.enum(KB_RECORD_TYPES).optional(),
7507
+ tags: TAGS
7508
+ }),
7509
+ fromArgv: (argv, path) => {
7510
+ const tags = argvFlags(argv, "--tag");
7511
+ const type = argvPositional(argv, "--tag");
7512
+ return {
7513
+ bundlePath: path,
7514
+ ...type ? { type } : {},
7515
+ ...tags.length ? { tags } : {}
7516
+ };
7517
+ },
7518
+ run: async ({ store }, { bundlePath: path, type, tags }) => (await store.list(path, type, { ...tags ? { tags } : {} })).map(
7519
+ (record) => ({
7520
+ conceptId: record.conceptId,
7521
+ title: record.frontmatter.title ?? null,
7522
+ description: record.frontmatter.description ?? null,
7523
+ status: record.frontmatter.strauss_status,
7524
+ anchors: record.frontmatter.strauss_anchors ?? []
7525
+ })
7526
+ )
6109
7527
  });
6110
7528
 
6111
7529
  // src/commands/load.ts
6112
- var import_zod18 = require("zod");
7530
+ var import_zod22 = require("zod");
6113
7531
  var loadCommand = define({
6114
7532
  name: "load",
6115
7533
  tool: "kb_load",
6116
7534
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
6117
7535
  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.",
6118
- input: import_zod18.z.object({
7536
+ input: import_zod22.z.object({
6119
7537
  bundlePath,
6120
- type: import_zod18.z.enum(KB_RECORD_TYPES).optional(),
6121
- budgetTokens: import_zod18.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6122
- all: import_zod18.z.boolean().optional().describe(
7538
+ type: import_zod22.z.enum(KB_RECORD_TYPES).optional(),
7539
+ budgetTokens: import_zod22.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
7540
+ all: import_zod22.z.boolean().optional().describe(
6123
7541
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
6124
7542
  ),
6125
7543
  repoRoot: REPO_ROOT
@@ -6161,25 +7579,25 @@ var loadCommand = define({
6161
7579
  });
6162
7580
 
6163
7581
  // src/commands/log.ts
6164
- var import_zod19 = require("zod");
7582
+ var import_zod23 = require("zod");
6165
7583
  var logCommand = define({
6166
7584
  name: "log",
6167
7585
  tool: "kb_log",
6168
7586
  usage: "log",
6169
7587
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
6170
- input: import_zod19.z.object({ bundlePath }),
7588
+ input: import_zod23.z.object({ bundlePath }),
6171
7589
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6172
7590
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
6173
7591
  });
6174
7592
 
6175
7593
  // src/commands/no-decision.ts
6176
- var import_zod20 = require("zod");
7594
+ var import_zod24 = require("zod");
6177
7595
  var noDecisionCommand = define({
6178
7596
  name: "no-decision",
6179
7597
  tool: "kb_no_decision",
6180
7598
  usage: "no-decision <reason...>",
6181
7599
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
6182
- input: import_zod20.z.object({ bundlePath, reason: import_zod20.z.string().min(1) }),
7600
+ input: import_zod24.z.object({ bundlePath, reason: import_zod24.z.string().min(1) }),
6183
7601
  fromArgv: (argv, path) => ({
6184
7602
  bundlePath: path,
6185
7603
  reason: argv.slice(1).join(" ").trim()
@@ -6196,20 +7614,20 @@ var noDecisionCommand = define({
6196
7614
  });
6197
7615
 
6198
7616
  // src/commands/pack.ts
6199
- var import_zod21 = require("zod");
7617
+ var import_zod25 = require("zod");
6200
7618
  var packCommand = define({
6201
7619
  name: "pack",
6202
7620
  tool: "kb_pack",
6203
7621
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
6204
7622
  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.",
6205
- input: import_zod21.z.object({
7623
+ input: import_zod25.z.object({
6206
7624
  bundlePath,
6207
7625
  conceptId,
6208
- hops: import_zod21.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6209
- maxNodes: import_zod21.z.number().int().positive().optional().describe(
7626
+ hops: import_zod25.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
7627
+ maxNodes: import_zod25.z.number().int().positive().optional().describe(
6210
7628
  "How many records the pack may hold, root included. Defaults to 20."
6211
7629
  ),
6212
- budgetTokens: import_zod21.z.number().int().positive().optional().describe(
7630
+ budgetTokens: import_zod25.z.number().int().positive().optional().describe(
6213
7631
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
6214
7632
  )
6215
7633
  }),
@@ -6296,22 +7714,22 @@ function warningLabel(warning) {
6296
7714
  }
6297
7715
 
6298
7716
  // src/commands/pin.ts
6299
- var import_zod22 = require("zod");
7717
+ var import_zod26 = require("zod");
6300
7718
  var pinCommand = define({
6301
7719
  name: "pin",
6302
7720
  tool: "kb_pin",
6303
7721
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
6304
7722
  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.",
6305
- input: import_zod22.z.object({
7723
+ input: import_zod26.z.object({
6306
7724
  bundlePath,
6307
- mode: import_zod22.z.enum(["full", "index"]).optional().describe(
7725
+ mode: import_zod26.z.enum(["full", "index"]).optional().describe(
6308
7726
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
6309
7727
  ),
6310
- profiles: import_zod22.z.array(import_zod22.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6311
- layer: import_zod22.z.enum(["project", "local", "user"]).optional().describe(
7728
+ profiles: import_zod26.z.array(import_zod26.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
7729
+ layer: import_zod26.z.enum(["project", "local", "user"]).optional().describe(
6312
7730
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
6313
7731
  ),
6314
- frozen: import_zod22.z.boolean().optional().describe(
7732
+ frozen: import_zod26.z.boolean().optional().describe(
6315
7733
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
6316
7734
  )
6317
7735
  }),
@@ -6340,47 +7758,325 @@ var pinCommand = define({
6340
7758
  });
6341
7759
 
6342
7760
  // src/commands/pins.ts
6343
- var import_zod23 = require("zod");
7761
+ var import_zod27 = require("zod");
6344
7762
  var pinsCommand = define({
6345
7763
  name: "pins",
6346
7764
  tool: "kb_pins",
6347
7765
  usage: "pins",
6348
7766
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
6349
- input: import_zod23.z.object({}),
7767
+ input: import_zod27.z.object({}),
6350
7768
  fromArgv: () => ({}),
6351
7769
  run: ({ store }) => listPins(store, process.cwd())
6352
7770
  });
6353
7771
 
7772
+ // src/commands/promote/command.ts
7773
+ var import_node_path14 = require("path");
7774
+
7775
+ // src/commands/promote/carry.ts
7776
+ var PROMOTION_SOURCE_ID = "promoted";
7777
+ var CARRIED_STATUS = {
7778
+ draft: "accepted",
7779
+ proposed: "accepted",
7780
+ accepted: "accepted",
7781
+ open: "open",
7782
+ resolved: "resolved",
7783
+ rejected: "rejected",
7784
+ superseded: "superseded"
7785
+ };
7786
+ function carry(record, promoted, source) {
7787
+ const {
7788
+ type: _type,
7789
+ // Both name records in the source base, and supersession in the target is
7790
+ // a separate question from whether this record belongs there at all.
7791
+ strauss_supersedes: _supersedes,
7792
+ strauss_superseded_by: _supersededBy,
7793
+ // A check run against the source repository, which the target never saw.
7794
+ verified: _verified,
7795
+ ...rest
7796
+ } = record.frontmatter;
7797
+ const links = rest.strauss_links ?? [];
7798
+ const kept = links.filter((link2) => promoted.has(link2.target));
7799
+ const dropped = links.filter((link2) => !promoted.has(link2.target));
7800
+ const tags = (rest.tags ?? []).filter((tag) => !isReviewTag(tag));
7801
+ const frontmatter = {
7802
+ ...rest,
7803
+ strauss_status: CARRIED_STATUS[rest.strauss_status]
7804
+ };
7805
+ setOrDrop(frontmatter, "tags", tags);
7806
+ setOrDrop(frontmatter, "strauss_links", kept);
7807
+ let body = withoutLinkSentences(record.body, dropped);
7808
+ if (source) {
7809
+ frontmatter.sources = [
7810
+ ...(rest.sources ?? []).filter(
7811
+ (entry) => entry.id !== PROMOTION_SOURCE_ID
7812
+ ),
7813
+ { id: PROMOTION_SOURCE_ID, resource: source }
7814
+ ];
7815
+ body = `${stripFootnote(body).trimEnd()}
7816
+
7817
+ [^${PROMOTION_SOURCE_ID}]: ${source}
7818
+ `;
7819
+ }
7820
+ return {
7821
+ frontmatter,
7822
+ body,
7823
+ droppedLinks: dropped.map(({ target, rel }) => ({ target, rel }))
7824
+ };
7825
+ }
7826
+ function isReviewTag(tag) {
7827
+ return tag === "review" || tag.startsWith("review:");
7828
+ }
7829
+ function withoutLinkSentences(body, dropped) {
7830
+ const sentences = new Set(
7831
+ dropped.filter((link2) => isKbLinkRel(link2.rel)).map(
7832
+ (link2) => `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
7833
+ )
7834
+ );
7835
+ if (!sentences.size) return body;
7836
+ return body.split("\n\n").filter((block) => !sentences.has(block.trim())).join("\n\n");
7837
+ }
7838
+ function stripFootnote(body) {
7839
+ return body.split("\n").filter((line) => !line.startsWith(`[^${PROMOTION_SOURCE_ID}]: `)).join("\n");
7840
+ }
7841
+ function setOrDrop(frontmatter, key2, value) {
7842
+ if (value.length) frontmatter[key2] = value;
7843
+ else delete frontmatter[key2];
7844
+ }
7845
+
7846
+ // src/commands/promote/standing.ts
7847
+ var WITHDRAWN = ["superseded", "rejected"];
7848
+ function standings(bundle) {
7849
+ return new Map(
7850
+ adjudicate(bundle, bundle).map((hit) => [
7851
+ hit.record.conceptId,
7852
+ hit.standing
7853
+ ])
7854
+ );
7855
+ }
7856
+ function isWithdrawn(standing) {
7857
+ return standing !== void 0 && WITHDRAWN.includes(standing);
7858
+ }
7859
+
7860
+ // src/commands/promote/candidates.ts
7861
+ var REVIEW_TAG = "review";
7862
+ var SETTLED = ["resolved"];
7863
+ function promoteCandidates(bundle) {
7864
+ const inbound = inboundIndex(bundle);
7865
+ const standing = standings(bundle);
7866
+ const rows = [];
7867
+ for (const record of bundle) {
7868
+ if (isWithdrawn(standing.get(record.conceptId))) continue;
7869
+ const why2 = candidateReason(record, inbound.get(record.conceptId) ?? []);
7870
+ if (!why2) continue;
7871
+ rows.push({
7872
+ conceptId: record.conceptId,
7873
+ type: recordType(record.conceptId),
7874
+ title: record.frontmatter.title ?? null,
7875
+ why: why2
7876
+ });
7877
+ }
7878
+ return rows;
7879
+ }
7880
+ function candidateReason(record, inbound) {
7881
+ const { strauss_status: status, tags } = record.frontmatter;
7882
+ switch (recordType(record.conceptId)) {
7883
+ case "decision":
7884
+ if (isNoDecisionRecord(record)) return null;
7885
+ return tags?.includes(REVIEW_TAG) ? null : "decision no longer under review";
7886
+ case "constraint":
7887
+ return status === "proposed" ? "constraint still proposed \u2014 the target base is where it settles" : null;
7888
+ case "contract":
7889
+ return "contract \u2014 it outlives the change that introduced it";
7890
+ case "requirement":
7891
+ return inbound.some((edge) => edge.rel === "satisfies") ? "requirement something in the base satisfies" : null;
7892
+ case "risk":
7893
+ return record.frontmatter.strauss_materiality === "blocking" && !SETTLED.includes(status) ? "blocking risk still open" : null;
7894
+ default:
7895
+ return null;
7896
+ }
7897
+ }
7898
+ function recordType(conceptId2) {
7899
+ return conceptId2.slice(0, conceptId2.indexOf("."));
7900
+ }
7901
+
7902
+ // src/commands/promote/model.ts
7903
+ var import_zod28 = require("zod");
7904
+ var promoteInputSchema = import_zod28.z.object({
7905
+ bundlePath,
7906
+ conceptIds: import_zod28.z.array(conceptId).max(64).optional().describe("Records to copy into the target base. Omit with `list`."),
7907
+ to: import_zod28.z.string().min(1).optional().describe("Absolute path to the base being promoted into."),
7908
+ source: import_zod28.z.string().min(1).optional().describe(
7909
+ "Where the promotion came from, usually the pull request URL. Recorded on each copy as a source."
7910
+ ),
7911
+ force: import_zod28.z.boolean().optional().describe("Overwrite a record the target base already holds."),
7912
+ list: import_zod28.z.boolean().optional().describe("List the source base's candidates instead of promoting.")
7913
+ }).refine((input) => input.list === true || input.to !== void 0, {
7914
+ message: "promote needs a target base \u2014 pass --to <bundle>, or --list",
7915
+ path: ["to"]
7916
+ }).refine(
7917
+ (input) => input.list === true || (input.conceptIds?.length ?? 0) > 0,
7918
+ {
7919
+ message: "name at least one concept id to promote, or pass --list",
7920
+ path: ["conceptIds"]
7921
+ }
7922
+ );
7923
+
7924
+ // src/commands/promote/command.ts
7925
+ var promoteCommand = define({
7926
+ name: "promote",
7927
+ tool: "kb_promote",
7928
+ usage: "promote <concept-id...> --to <bundle> [--source <url>] [--force] | --list",
7929
+ 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.",
7930
+ input: promoteInputSchema,
7931
+ fromArgv: (argv, path) => {
7932
+ const to = argvFlag(argv, "--to");
7933
+ const source = argvFlag(argv, "--source");
7934
+ const words = argv.slice(1);
7935
+ for (const flag of ["--to", "--source"]) {
7936
+ const at2 = words.indexOf(flag);
7937
+ if (at2 !== -1) words.splice(at2, 2);
7938
+ }
7939
+ const conceptIds = words.filter((word) => !word.startsWith("--"));
7940
+ return {
7941
+ bundlePath: path,
7942
+ ...conceptIds.length ? { conceptIds } : {},
7943
+ ...to !== void 0 ? { to } : {},
7944
+ ...source !== void 0 ? { source } : {},
7945
+ ...argv.includes("--force") ? { force: true } : {},
7946
+ ...argv.includes("--list") ? { list: true } : {}
7947
+ };
7948
+ },
7949
+ run: async ({ store, actor }, { bundlePath: path, conceptIds, to, source, force, list }) => {
7950
+ const from = (0, import_node_path14.resolve)(path);
7951
+ const bundle = await store.list(from);
7952
+ if (list) {
7953
+ return { mode: "list", candidates: promoteCandidates(bundle) };
7954
+ }
7955
+ const target = (0, import_node_path14.resolve)(to);
7956
+ if (target === from) throw new KbPromoteSelfError(target);
7957
+ const named = (conceptIds ?? []).map(namedRecord);
7958
+ const wanted = named.map(({ conceptId: conceptId2, type, slug }) => {
7959
+ const record = bundle.find((entry) => entry.conceptId === conceptId2);
7960
+ if (!record) throw new KbRecordNotFoundError(conceptId2);
7961
+ return { record, type, slug };
7962
+ });
7963
+ await assertBaseNotFrozen(process.cwd(), from);
7964
+ await assertBaseNotFrozen(process.cwd(), target);
7965
+ const standing = standings(bundle);
7966
+ for (const { record } of wanted) {
7967
+ const where = standing.get(record.conceptId);
7968
+ if (isWithdrawn(where)) {
7969
+ throw new KbPromoteStandingError(record.conceptId, where);
7970
+ }
7971
+ if (!force && await store.read(target, record.conceptId)) {
7972
+ throw new KbPromoteCollisionError(record.conceptId, target);
7973
+ }
7974
+ }
7975
+ const promotedIds = new Set(wanted.map(({ record }) => record.conceptId));
7976
+ const promoted = [];
7977
+ for (const { record, type, slug } of wanted) {
7978
+ const { frontmatter, body, droppedLinks } = carry(
7979
+ record,
7980
+ promotedIds,
7981
+ source
7982
+ );
7983
+ try {
7984
+ await store.write(
7985
+ target,
7986
+ { type, slug, frontmatter, body, overwrite: force === true },
7987
+ actor
7988
+ );
7989
+ } catch (error) {
7990
+ throw new KbPromoteStoppedError(
7991
+ record.conceptId,
7992
+ promoted.map((entry) => entry.conceptId),
7993
+ error instanceof Error ? error.message : "unknown"
7994
+ );
7995
+ }
7996
+ await store.note(target, {
7997
+ by: actor,
7998
+ operation: "promote-in",
7999
+ conceptId: record.conceptId,
8000
+ target: from
8001
+ });
8002
+ await store.note(from, {
8003
+ by: actor,
8004
+ operation: "promote-out",
8005
+ conceptId: record.conceptId,
8006
+ target
8007
+ });
8008
+ promoted.push({ conceptId: record.conceptId, droppedLinks });
8009
+ }
8010
+ return { mode: "promote", to: target, promoted };
8011
+ },
8012
+ render: (result) => renderPromote(result)
8013
+ });
8014
+ function namedRecord(conceptId2) {
8015
+ const at2 = conceptId2.indexOf(".");
8016
+ const type = at2 === -1 ? conceptId2 : conceptId2.slice(0, at2);
8017
+ const slug = at2 === -1 ? "" : conceptId2.slice(at2 + 1);
8018
+ if (!KB_SLUG_PATTERN.test(type) || !KB_SLUG_PATTERN.test(slug)) {
8019
+ throw new KbInvalidConceptIdError(
8020
+ "concept id must be <type>.<slug>, both kebab-case",
8021
+ { conceptId: conceptId2 }
8022
+ );
8023
+ }
8024
+ return { conceptId: conceptId2, type, slug };
8025
+ }
8026
+ function renderPromote(result) {
8027
+ if (result.mode === "list") {
8028
+ if (!result.candidates.length) return "No promotion candidates.";
8029
+ return result.candidates.flatMap((candidate) => [
8030
+ `${candidate.conceptId} [${candidate.type}]${candidate.title ? ` \u2014 ${candidate.title}` : ""}`,
8031
+ ` ${candidate.why}`
8032
+ ]).join("\n");
8033
+ }
8034
+ const lines = [
8035
+ `Promoted ${result.promoted.length} record${result.promoted.length === 1 ? "" : "s"} into ${result.to}.`
8036
+ ];
8037
+ for (const entry of result.promoted) {
8038
+ lines.push(`- ${entry.conceptId}`);
8039
+ for (const link2 of entry.droppedLinks) {
8040
+ lines.push(
8041
+ ` dropped ${link2.rel} \u2192 ${link2.target} (not promoted in this run)`
8042
+ );
8043
+ }
8044
+ }
8045
+ return lines.join("\n");
8046
+ }
8047
+
6354
8048
  // src/commands/query.ts
6355
- var import_zod24 = require("zod");
8049
+ var import_zod29 = require("zod");
6356
8050
  var queryCommand = define({
6357
8051
  name: "query",
6358
8052
  tool: "kb_query",
6359
- usage: "query <text...> [--repo-root PATH]",
8053
+ usage: "query <text...> [--tag T]... [--repo-root PATH]",
6360
8054
  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.",
6361
- input: import_zod24.z.object({
8055
+ input: import_zod29.z.object({
6362
8056
  bundlePath,
6363
- text: import_zod24.z.string().optional(),
6364
- type: import_zod24.z.enum(KB_RECORD_TYPES).optional(),
6365
- includeNonCurrent: import_zod24.z.boolean().optional(),
8057
+ text: import_zod29.z.string().optional(),
8058
+ type: import_zod29.z.enum(KB_RECORD_TYPES).optional(),
8059
+ includeNonCurrent: import_zod29.z.boolean().optional(),
8060
+ tags: TAGS,
6366
8061
  repoRoot: REPO_ROOT
6367
8062
  }),
6368
- // `--repo-root` is a flag, so its value must not fall into the search text.
8063
+ // Both are flags, so neither's value may fall into the search text.
6369
8064
  fromArgv: (argv, path) => {
6370
8065
  const repoRoot = argvFlag(argv, "--repo-root");
6371
- const words = argv.slice(1);
6372
- const flag = words.indexOf("--repo-root");
6373
- if (flag !== -1) words.splice(flag, 2);
8066
+ const tags = argvFlags(argv, "--tag");
8067
+ const words = argvWithout(argv.slice(1), "--repo-root", "--tag");
6374
8068
  return {
6375
8069
  bundlePath: path,
6376
8070
  text: words.join(" ").trim(),
6377
8071
  includeNonCurrent: true,
8072
+ ...tags.length ? { tags } : {},
6378
8073
  ...repoRoot !== void 0 ? { repoRoot } : {}
6379
8074
  };
6380
8075
  },
6381
- run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent, repoRoot }) => (await store.query(path, text ?? "", {
8076
+ run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent, tags, repoRoot }) => (await store.query(path, text ?? "", {
6382
8077
  ...type ? { type } : {},
6383
8078
  includeNonCurrent: includeNonCurrent === true,
8079
+ ...tags ? { tags } : {},
6384
8080
  ...repoRoot !== void 0 ? { repoRoot } : {}
6385
8081
  })).map((hit) => ({
6386
8082
  conceptId: hit.record.conceptId,
@@ -6394,43 +8090,43 @@ var queryCommand = define({
6394
8090
  });
6395
8091
 
6396
8092
  // src/commands/read-index.ts
6397
- var import_zod25 = require("zod");
8093
+ var import_zod30 = require("zod");
6398
8094
  var readIndexCommand = define({
6399
8095
  name: "index",
6400
8096
  tool: "kb_index",
6401
8097
  usage: "index",
6402
8098
  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.",
6403
- input: import_zod25.z.object({ bundlePath }),
8099
+ input: import_zod30.z.object({ bundlePath }),
6404
8100
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6405
8101
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
6406
8102
  });
6407
8103
 
6408
8104
  // src/commands/schema.ts
6409
- var import_zod26 = require("zod");
8105
+ var import_zod31 = require("zod");
6410
8106
  var schemaCommand = define({
6411
8107
  name: "schema",
6412
8108
  tool: "kb_schema",
6413
8109
  usage: "schema",
6414
8110
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
6415
- input: import_zod26.z.object({}),
8111
+ input: import_zod31.z.object({}),
6416
8112
  fromArgv: () => ({}),
6417
8113
  run: () => Promise.resolve(kbJsonSchemas())
6418
8114
  });
6419
8115
 
6420
8116
  // src/commands/stamp.ts
6421
- var import_promises10 = require("fs/promises");
6422
- var import_zod27 = require("zod");
8117
+ var import_promises12 = require("fs/promises");
8118
+ var import_zod32 = require("zod");
6423
8119
  var DIGEST = /^[0-9a-f]{64}$/;
6424
8120
  var stampCommand = define({
6425
8121
  name: "stamp",
6426
8122
  tool: "kb_stamp",
6427
8123
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
6428
8124
  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.",
6429
- input: import_zod27.z.object({
6430
- bundlePath: import_zod27.z.string().min(1).optional().describe(
8125
+ input: import_zod32.z.object({
8126
+ bundlePath: import_zod32.z.string().min(1).optional().describe(
6431
8127
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
6432
8128
  ),
6433
- since: import_zod27.z.string().min(1).optional().describe(
8129
+ since: import_zod32.z.string().min(1).optional().describe(
6434
8130
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
6435
8131
  )
6436
8132
  }),
@@ -6492,7 +8188,7 @@ async function readBaseline(since) {
6492
8188
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
6493
8189
  let parsed;
6494
8190
  try {
6495
- parsed = JSON.parse(await (0, import_promises10.readFile)(since, "utf8"));
8191
+ parsed = JSON.parse(await (0, import_promises12.readFile)(since, "utf8"));
6496
8192
  } catch {
6497
8193
  throw new KbStampBaselineError(since);
6498
8194
  }
@@ -6516,16 +8212,16 @@ async function readBaseline(since) {
6516
8212
  }
6517
8213
 
6518
8214
  // src/commands/status.ts
6519
- var import_zod28 = require("zod");
8215
+ var import_zod33 = require("zod");
6520
8216
  var statusCommand = define({
6521
8217
  name: "status",
6522
8218
  tool: "kb_status",
6523
8219
  usage: "status <concept-id> <status>",
6524
8220
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
6525
- input: import_zod28.z.object({
8221
+ input: import_zod33.z.object({
6526
8222
  bundlePath,
6527
8223
  conceptId,
6528
- status: import_zod28.z.enum(KB_RECORD_STATUSES)
8224
+ status: import_zod33.z.enum(KB_RECORD_STATUSES)
6529
8225
  }),
6530
8226
  fromArgv: (argv, path) => ({
6531
8227
  bundlePath: path,
@@ -6540,13 +8236,13 @@ var statusCommand = define({
6540
8236
  });
6541
8237
 
6542
8238
  // src/commands/supersede.ts
6543
- var import_zod29 = require("zod");
8239
+ var import_zod34 = require("zod");
6544
8240
  var supersedeCommand = define({
6545
8241
  name: "supersede",
6546
8242
  tool: "kb_supersede",
6547
8243
  usage: "supersede <concept-id> <replacement-id>",
6548
8244
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
6549
- input: import_zod29.z.object({ bundlePath, conceptId, replacementId: conceptId }),
8245
+ input: import_zod34.z.object({ bundlePath, conceptId, replacementId: conceptId }),
6550
8246
  fromArgv: (argv, path) => ({
6551
8247
  bundlePath: path,
6552
8248
  conceptId: argv[1],
@@ -6559,17 +8255,153 @@ var supersedeCommand = define({
6559
8255
  }
6560
8256
  });
6561
8257
 
8258
+ // src/commands/sweep.ts
8259
+ var import_zod35 = require("zod");
8260
+ var TERMINAL = [
8261
+ "resolved",
8262
+ "rejected",
8263
+ "superseded"
8264
+ ];
8265
+ var sweepCommand = define({
8266
+ name: "sweep",
8267
+ tool: "kb_sweep",
8268
+ usage: "sweep --tag <tag> --terminal [--dry-run]",
8269
+ 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.",
8270
+ input: import_zod35.z.object({
8271
+ bundlePath,
8272
+ 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."),
8273
+ terminal: import_zod35.z.literal(true, {
8274
+ error: "sweep needs --terminal: it deletes only settled records"
8275
+ }).describe(
8276
+ "Required. Names the only scope sweep deletes: resolved, rejected and superseded records."
8277
+ ),
8278
+ dryRun: import_zod35.z.boolean().optional().describe("Report what would go, and delete nothing.")
8279
+ }),
8280
+ fromArgv: (argv, path) => ({
8281
+ bundlePath: path,
8282
+ tag: argvFlag(argv, "--tag"),
8283
+ ...argv.includes("--terminal") ? { terminal: true } : {},
8284
+ ...argv.includes("--dry-run") ? { dryRun: true } : {}
8285
+ }),
8286
+ run: async ({ store, actor }, { bundlePath: path, tag, dryRun }) => {
8287
+ const bundle = await store.list(path);
8288
+ const held = holderIndex(bundle);
8289
+ const candidates = adjudicate(bundle, bundle).filter(
8290
+ (hit) => sweepable(hit, tag)
8291
+ );
8292
+ const doomed = new Set(candidates.map((hit) => hit.record.conceptId));
8293
+ let changed = true;
8294
+ while (changed) {
8295
+ changed = false;
8296
+ for (const conceptId2 of [...doomed]) {
8297
+ if (survivorsHolding(conceptId2, held, doomed).length === 0) continue;
8298
+ doomed.delete(conceptId2);
8299
+ changed = true;
8300
+ }
8301
+ }
8302
+ const skipped = candidates.filter((hit) => !doomed.has(hit.record.conceptId)).map((hit) => ({
8303
+ conceptId: hit.record.conceptId,
8304
+ heldBy: survivorsHolding(hit.record.conceptId, held, doomed)
8305
+ }));
8306
+ const ordered = [...doomed].sort();
8307
+ if (dryRun) {
8308
+ return {
8309
+ tag,
8310
+ dryRun: true,
8311
+ deleted: [],
8312
+ candidates: ordered,
8313
+ skipped,
8314
+ failed: []
8315
+ };
8316
+ }
8317
+ await assertBaseNotFrozen(process.cwd(), path);
8318
+ const deleted = [];
8319
+ const failed = [];
8320
+ try {
8321
+ for (const conceptId2 of ordered) {
8322
+ try {
8323
+ const outcome = await store.deleteRecord(
8324
+ path,
8325
+ conceptId2,
8326
+ { tag, statuses: TERMINAL },
8327
+ actor
8328
+ );
8329
+ if (outcome === "deleted") deleted.push(conceptId2);
8330
+ else failed.push({ conceptId: conceptId2, reason: outcome });
8331
+ } catch (error) {
8332
+ failed.push({
8333
+ conceptId: conceptId2,
8334
+ reason: error instanceof Error ? error.message : "unknown"
8335
+ });
8336
+ }
8337
+ }
8338
+ } finally {
8339
+ await store.readIndex(path);
8340
+ await store.dropSearchIndex(path);
8341
+ }
8342
+ return {
8343
+ tag,
8344
+ dryRun: false,
8345
+ deleted,
8346
+ candidates: ordered,
8347
+ skipped,
8348
+ failed
8349
+ };
8350
+ },
8351
+ render: (result) => renderSweep(result)
8352
+ });
8353
+ function sweepable(hit, tag) {
8354
+ const { tags, strauss_status } = hit.record.frontmatter;
8355
+ return (tags ?? []).includes(tag) && // Supersession is a standing, settled against the whole base; the other
8356
+ // two are the record's own word for itself.
8357
+ (hit.standing === "superseded" || strauss_status === "resolved" || strauss_status === "rejected");
8358
+ }
8359
+ function holderIndex(bundle) {
8360
+ const byTarget = /* @__PURE__ */ new Map();
8361
+ const hold = (target, from) => {
8362
+ if (target === from) return;
8363
+ const holders = byTarget.get(target) ?? /* @__PURE__ */ new Set();
8364
+ holders.add(from);
8365
+ byTarget.set(target, holders);
8366
+ };
8367
+ for (const [target, edges] of inboundIndex(bundle)) {
8368
+ for (const edge of edges) hold(target, edge.from);
8369
+ }
8370
+ for (const record of bundle) {
8371
+ const { strauss_supersedes, strauss_superseded_by } = record.frontmatter;
8372
+ for (const old of strauss_supersedes ?? []) hold(old, record.conceptId);
8373
+ if (strauss_superseded_by) hold(strauss_superseded_by, record.conceptId);
8374
+ }
8375
+ return byTarget;
8376
+ }
8377
+ function survivorsHolding(conceptId2, held, doomed) {
8378
+ return [...held.get(conceptId2) ?? []].filter((from) => !doomed.has(from)).sort();
8379
+ }
8380
+ function renderSweep(result) {
8381
+ const shown = result.dryRun ? result.candidates : result.deleted;
8382
+ const verb = result.dryRun ? "would delete" : "deleted";
8383
+ const lines = [`${verb} ${shown.length} (tag: ${result.tag})`];
8384
+ for (const conceptId2 of shown) lines.push(`- ${conceptId2}`);
8385
+ for (const skip of result.skipped) {
8386
+ lines.push(`kept ${skip.conceptId} \u2014 held by ${skip.heldBy.join(", ")}`);
8387
+ }
8388
+ for (const failure of result.failed) {
8389
+ lines.push(`failed ${failure.conceptId} \u2014 ${failure.reason}`);
8390
+ }
8391
+ return lines.join("\n");
8392
+ }
8393
+
6562
8394
  // src/commands/sync-instructions.ts
6563
- var import_zod30 = require("zod");
8395
+ var import_zod36 = require("zod");
6564
8396
  var syncInstructionsCommand = define({
6565
8397
  name: "sync-instructions",
6566
8398
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
6567
8399
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
6568
- input: import_zod30.z.object({
6569
- file: import_zod30.z.string().min(1).describe("The instruction file to edit in place."),
6570
- budgetTokens: import_zod30.z.number().int().positive().optional(),
6571
- fullUnderTokens: import_zod30.z.number().int().positive().optional(),
6572
- profile: import_zod30.z.string().optional()
8400
+ input: import_zod36.z.object({
8401
+ file: import_zod36.z.string().min(1).describe("The instruction file to edit in place."),
8402
+ budgetTokens: import_zod36.z.number().int().positive().optional(),
8403
+ fullUnderTokens: import_zod36.z.number().int().positive().optional(),
8404
+ profile: import_zod36.z.string().optional()
6573
8405
  }),
6574
8406
  fromArgv: (argv) => {
6575
8407
  const budget = argvFlag(argv, "--budget");
@@ -6595,17 +8427,17 @@ var syncInstructionsCommand = define({
6595
8427
  });
6596
8428
 
6597
8429
  // src/commands/trace.ts
6598
- var import_zod31 = require("zod");
8430
+ var import_zod37 = require("zod");
6599
8431
  var traceCommand = define({
6600
8432
  name: "trace",
6601
8433
  tool: "kb_trace",
6602
8434
  usage: "trace <concept-id> [edges...]",
6603
8435
  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".',
6604
- input: import_zod31.z.object({
8436
+ input: import_zod37.z.object({
6605
8437
  bundlePath,
6606
8438
  conceptId,
6607
- edges: import_zod31.z.array(import_zod31.z.enum(TRACE_EDGES)).optional(),
6608
- depth: import_zod31.z.number().int().positive().optional()
8439
+ edges: import_zod37.z.array(import_zod37.z.enum(TRACE_EDGES)).optional(),
8440
+ depth: import_zod37.z.number().int().positive().optional()
6609
8441
  }),
6610
8442
  fromArgv: (argv, path) => ({
6611
8443
  bundlePath: path,
@@ -6627,37 +8459,37 @@ var traceCommand = define({
6627
8459
  });
6628
8460
 
6629
8461
  // src/commands/types.ts
6630
- var import_zod32 = require("zod");
8462
+ var import_zod38 = require("zod");
6631
8463
  var typesCommand = define({
6632
8464
  name: "types",
6633
8465
  tool: "kb_types",
6634
8466
  usage: "types",
6635
8467
  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.",
6636
- input: import_zod32.z.object({}),
8468
+ input: import_zod38.z.object({}),
6637
8469
  fromArgv: () => ({}),
6638
8470
  run: () => Promise.resolve(RECORD_TYPES)
6639
8471
  });
6640
8472
 
6641
8473
  // src/commands/unpin.ts
6642
- var import_zod33 = require("zod");
8474
+ var import_zod39 = require("zod");
6643
8475
  var unpinCommand = define({
6644
8476
  name: "unpin",
6645
8477
  tool: "kb_unpin",
6646
8478
  usage: "unpin [bundle-path]",
6647
8479
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
6648
- input: import_zod33.z.object({ bundlePath }),
8480
+ input: import_zod39.z.object({ bundlePath }),
6649
8481
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
6650
8482
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
6651
8483
  });
6652
8484
 
6653
8485
  // src/commands/validate.ts
6654
- var import_zod34 = require("zod");
8486
+ var import_zod40 = require("zod");
6655
8487
  var validateCommand = define({
6656
8488
  name: "validate",
6657
8489
  tool: "kb_validate",
6658
8490
  usage: "validate",
6659
8491
  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.",
6660
- input: import_zod34.z.object({ bundlePath }),
8492
+ input: import_zod40.z.object({ bundlePath }),
6661
8493
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6662
8494
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
6663
8495
  // Warnings never fail the exit code; every other severity does.
@@ -6667,16 +8499,16 @@ var validateCommand = define({
6667
8499
  });
6668
8500
 
6669
8501
  // src/commands/verify.ts
6670
- var import_zod35 = require("zod");
8502
+ var import_zod41 = require("zod");
6671
8503
  var verifyCommand = define({
6672
8504
  name: "verify",
6673
8505
  tool: "kb_verify",
6674
8506
  usage: "verify <concept-id> --note <text>",
6675
8507
  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.",
6676
- input: import_zod35.z.object({
8508
+ input: import_zod41.z.object({
6677
8509
  bundlePath,
6678
8510
  conceptId,
6679
- note: import_zod35.z.string().refine((s) => s.trim().length > 0, {
8511
+ note: import_zod41.z.string().refine((s) => s.trim().length > 0, {
6680
8512
  message: "note must say what the check found"
6681
8513
  })
6682
8514
  }),
@@ -6696,15 +8528,15 @@ var verifyCommand = define({
6696
8528
  });
6697
8529
 
6698
8530
  // src/commands/write.ts
6699
- var import_zod36 = require("zod");
8531
+ var import_zod42 = require("zod");
6700
8532
  var writeCommand = define({
6701
8533
  name: "write",
6702
8534
  tool: "kb_write",
6703
8535
  usage: "write <type> < record.json",
6704
8536
  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.",
6705
- input: import_zod36.z.object({
8537
+ input: import_zod42.z.object({
6706
8538
  bundlePath,
6707
- type: import_zod36.z.enum(KB_RECORD_TYPES),
8539
+ type: import_zod42.z.enum(KB_RECORD_TYPES),
6708
8540
  input: composeInputSchema
6709
8541
  }),
6710
8542
  fromArgv: async (argv, path, stdin) => ({
@@ -6728,13 +8560,13 @@ var writeCommand = define({
6728
8560
  });
6729
8561
 
6730
8562
  // src/commands/write-decision.ts
6731
- var import_zod37 = require("zod");
8563
+ var import_zod43 = require("zod");
6732
8564
  var writeDecisionCommand = define({
6733
8565
  name: "write-decision",
6734
8566
  tool: "kb_write_decision",
6735
8567
  usage: "write-decision < decision.json",
6736
8568
  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.",
6737
- input: import_zod37.z.object({ bundlePath, input: decisionInputSchema }),
8569
+ input: import_zod43.z.object({ bundlePath, input: decisionInputSchema }),
6738
8570
  fromArgv: async (_argv, path, stdin) => ({
6739
8571
  bundlePath: path,
6740
8572
  input: JSON.parse(await stdin())
@@ -6765,19 +8597,24 @@ var KB_COMMANDS = [
6765
8597
  verifyCommand,
6766
8598
  anchorResolveCommand,
6767
8599
  reassessCommand,
8600
+ promoteCommand,
6768
8601
  loadCommand,
6769
8602
  catalogCommand,
6770
8603
  packCommand,
8604
+ exportCommand,
6771
8605
  queryCommand,
6772
8606
  traceCommand,
6773
8607
  impactCommand,
6774
8608
  backlinksCommand,
8609
+ matchCommand,
8610
+ classifyCommand,
6775
8611
  listCommand,
6776
8612
  readIndexCommand,
6777
8613
  logCommand,
6778
8614
  stampCommand,
6779
8615
  validateCommand,
6780
8616
  doctorCommand,
8617
+ sweepCommand,
6781
8618
  schemaCommand,
6782
8619
  pinCommand,
6783
8620
  unpinCommand,
@@ -6795,7 +8632,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
6795
8632
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
6796
8633
 
6797
8634
  // src/version.ts
6798
- var VERSION = true ? "0.1.18" : "0.0.0-dev";
8635
+ var VERSION = true ? "0.1.20" : "0.0.0-dev";
6799
8636
 
6800
8637
  // src/mcp.ts
6801
8638
  function createKbMcpServer() {
@@ -6834,7 +8671,7 @@ async function runKbMcpServer() {
6834
8671
  }
6835
8672
 
6836
8673
  // src/cli.ts
6837
- var import_node_path12 = require("path");
8674
+ var import_node_path15 = require("path");
6838
8675
  async function runKbCli(argv) {
6839
8676
  const { flags, literal } = takeLiteral(argv);
6840
8677
  const {
@@ -6896,7 +8733,7 @@ function takeLiteral(argv) {
6896
8733
  function takeBundle(argv) {
6897
8734
  const at2 = argv.indexOf("--bundle");
6898
8735
  if (at2 === -1) {
6899
- return { bundle: (0, import_node_path12.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
8736
+ return { bundle: (0, import_node_path15.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
6900
8737
  }
6901
8738
  const bundle = argv[at2 + 1];
6902
8739
  if (!bundle) die("--bundle requires a path");
@@ -6907,11 +8744,11 @@ function takeBundle(argv) {
6907
8744
  };
6908
8745
  }
6909
8746
  function readStdin() {
6910
- return new Promise((resolve6, reject) => {
8747
+ return new Promise((resolve7, reject) => {
6911
8748
  let text = "";
6912
8749
  process.stdin.setEncoding("utf8");
6913
8750
  process.stdin.on("data", (chunk) => text += chunk);
6914
- process.stdin.on("end", () => resolve6(text));
8751
+ process.stdin.on("end", () => resolve7(text));
6915
8752
  process.stdin.on("error", reject);
6916
8753
  });
6917
8754
  }
@@ -6956,12 +8793,14 @@ function usage() {
6956
8793
  DEFAULT_LOAD_BUDGET,
6957
8794
  DEFAULT_PACK_HOPS,
6958
8795
  DEFAULT_PACK_MAX_NODES,
8796
+ DEFAULT_THRESHOLDS,
6959
8797
  DEFAULT_TYPED_LINK_RELS,
6960
8798
  DEFAULT_UNVERIFIED_DAYS,
6961
8799
  ErrorTypes,
6962
8800
  Fault,
6963
8801
  INDEX_FILE,
6964
8802
  KB_CAUSAL_LINK_RELS,
8803
+ KB_CLASSES,
6965
8804
  KB_COMMANDS,
6966
8805
  KB_COMMANDS_BY_NAME,
6967
8806
  KB_CONCEPT_ID_PATTERN,
@@ -6975,10 +8814,15 @@ function usage() {
6975
8814
  KB_RECORD_TYPES,
6976
8815
  KB_SLUG_PATTERN,
6977
8816
  KbBaseFrozenError,
8817
+ KbClassifyInputError,
6978
8818
  KbInvalidConceptIdError,
6979
8819
  KbMissingFlagValueError,
6980
8820
  KbPackBudgetExceededError,
6981
8821
  KbPinsMalformedError,
8822
+ KbPromoteCollisionError,
8823
+ KbPromoteSelfError,
8824
+ KbPromoteStandingError,
8825
+ KbPromoteStoppedError,
6982
8826
  KbRecordAlreadyExistsError,
6983
8827
  KbRecordNotFoundError,
6984
8828
  KbSelfVerificationError,
@@ -6991,16 +8835,20 @@ function usage() {
6991
8835
  PINS_FILE,
6992
8836
  PINS_LOCAL_FILE,
6993
8837
  PIN_LAYERS,
8838
+ PROMOTION_SOURCE_ID,
6994
8839
  RECORD_TYPES,
6995
8840
  SEARCH_INDEX_FILE,
6996
8841
  TRACE_EDGES,
6997
8842
  TreeSitterResolver,
6998
8843
  adjudicate,
6999
8844
  anchorFilePath,
8845
+ anchorOnHunk,
7000
8846
  assertBaseNotFrozen,
7001
8847
  backlinks,
7002
8848
  buildContext,
8849
+ carry,
7003
8850
  catalog,
8851
+ classifyDiff,
7004
8852
  classifyDrift,
7005
8853
  composeDecisionRecord,
7006
8854
  composeInputSchema,
@@ -7026,8 +8874,11 @@ function usage() {
7026
8874
  isKbLinkRel,
7027
8875
  isKbRecordType,
7028
8876
  isNoDecisionRecord,
8877
+ isReviewTag,
7029
8878
  kbActorStampSchema,
7030
8879
  kbAnchorSchema,
8880
+ kbAnchorSpanSchema,
8881
+ kbAnchorWriteSchema,
7031
8882
  kbConceptIdSchema,
7032
8883
  kbJsonSchemas,
7033
8884
  kbLinkSchema,
@@ -7039,6 +8890,7 @@ function usage() {
7039
8890
  listPins,
7040
8891
  loadQmd,
7041
8892
  matchToDiff,
8893
+ matchesTags,
7042
8894
  mergedContextBudgets,
7043
8895
  neighbours,
7044
8896
  pack,
@@ -7046,6 +8898,8 @@ function usage() {
7046
8898
  parseMarkdownWithFrontmatter,
7047
8899
  pinBase,
7048
8900
  prepareResolvers,
8901
+ promoteCandidates,
8902
+ promoteInputSchema,
7049
8903
  readMergedPins,
7050
8904
  readPinsLayer,
7051
8905
  readRemoteAnchors,
@@ -7067,6 +8921,7 @@ function usage() {
7067
8921
  selectDecisions,
7068
8922
  splitMarkdownFrontmatter,
7069
8923
  stringifyMarkdownWithFrontmatter,
8924
+ symbolRangeIndex,
7070
8925
  syncInstructions,
7071
8926
  toHookJson,
7072
8927
  trace,