@saasontools/strauss-kb 0.1.19 → 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,
@@ -131,6 +145,8 @@ __export(index_exports, {
131
145
  parseMarkdownWithFrontmatter: () => parseMarkdownWithFrontmatter,
132
146
  pinBase: () => pinBase,
133
147
  prepareResolvers: () => prepareResolvers,
148
+ promoteCandidates: () => promoteCandidates,
149
+ promoteInputSchema: () => promoteInputSchema,
134
150
  readMergedPins: () => readMergedPins,
135
151
  readPinsLayer: () => readPinsLayer,
136
152
  readRemoteAnchors: () => readRemoteAnchors,
@@ -152,6 +168,7 @@ __export(index_exports, {
152
168
  selectDecisions: () => selectDecisions,
153
169
  splitMarkdownFrontmatter: () => splitMarkdownFrontmatter,
154
170
  stringifyMarkdownWithFrontmatter: () => stringifyMarkdownWithFrontmatter,
171
+ symbolRangeIndex: () => symbolRangeIndex,
155
172
  syncInstructions: () => syncInstructions,
156
173
  toHookJson: () => toHookJson,
157
174
  trace: () => trace,
@@ -242,9 +259,24 @@ var kbVerifiedEventSchema = kbActorStampSchema.extend({
242
259
  message: "note must say what the check found"
243
260
  })
244
261
  });
262
+ var kbAnchorSpanSchema = import_zod.z.object({
263
+ start: import_zod.z.number().int().positive(),
264
+ end: import_zod.z.number().int().positive()
265
+ }).strict();
245
266
  var kbAnchorSchema = import_zod.z.object({
246
267
  file: import_zod.z.string().min(1),
247
268
  symbol: import_zod.z.string().min(1).optional(),
269
+ /**
270
+ * The lines the concept names, when no symbol covers them — deleted code,
271
+ * YAML, SQL, Markdown. Alternative to `symbol`, never a refinement of it.
272
+ */
273
+ span: kbAnchorSpanSchema.optional(),
274
+ /**
275
+ * Which side of the change the anchor describes. `old` is code as it was
276
+ * committed at `ref`, which is the only way to anchor something deleted;
277
+ * absent means the working tree.
278
+ */
279
+ side: import_zod.z.enum(["old", "new"]).optional(),
248
280
  /**
249
281
  * Which repository the file lives in — a remote URL
250
282
  * (`https://github.com/org/name`) or a short name. Absent means the base's
@@ -283,8 +315,38 @@ var kbAnchorSchema = import_zod.z.object({
283
315
  * before resolvers were named, which is read as `regex` — the only one
284
316
  * there was. A hash from a different resolver is drift, not a match.
285
317
  */
286
- resolver: import_zod.z.enum(["tree-sitter", "regex"]).optional()
318
+ resolver: import_zod.z.enum(["tree-sitter", "regex", "span"]).optional()
287
319
  }).strict();
320
+ var kbAnchorWriteSchema = kbAnchorSchema.superRefine((anchor, ctx) => {
321
+ if (anchor.span && anchor.symbol) {
322
+ ctx.addIssue({
323
+ code: import_zod.z.ZodIssueCode.custom,
324
+ path: ["span"],
325
+ message: "an anchor names a symbol or a span, not both"
326
+ });
327
+ }
328
+ if (anchor.span && anchor.span.end < anchor.span.start) {
329
+ ctx.addIssue({
330
+ code: import_zod.z.ZodIssueCode.custom,
331
+ path: ["span", "end"],
332
+ message: "span end must not precede start"
333
+ });
334
+ }
335
+ if (anchor.span && anchor.hash_kind === "ast") {
336
+ ctx.addIssue({
337
+ code: import_zod.z.ZodIssueCode.custom,
338
+ path: ["hash_kind"],
339
+ message: "a span is hashed raw, never ast"
340
+ });
341
+ }
342
+ if (anchor.side === "old" && !anchor.ref) {
343
+ ctx.addIssue({
344
+ code: import_zod.z.ZodIssueCode.custom,
345
+ path: ["ref"],
346
+ message: 'side: "old" needs a ref \u2014 committed code has no other address'
347
+ });
348
+ }
349
+ });
288
350
  var kbLinkSchema = import_zod.z.object({
289
351
  target: import_zod.z.string().min(1),
290
352
  rel: import_zod.z.string().min(1)
@@ -371,9 +433,15 @@ var Fault = /* @__PURE__ */ ((Fault2) => {
371
433
  })(Fault || {});
372
434
  var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
373
435
  ErrorTypes2["KbRecordAlreadyExists"] = "KbRecordAlreadyExists";
436
+ ErrorTypes2["KbClassifyInput"] = "KbClassifyInput";
374
437
  ErrorTypes2["KbInvalidConceptId"] = "KbInvalidConceptId";
438
+ ErrorTypes2["KbMatchInput"] = "KbMatchInput";
375
439
  ErrorTypes2["KbMissingFlagValue"] = "KbMissingFlagValue";
376
440
  ErrorTypes2["KbPackBudgetExceeded"] = "KbPackBudgetExceeded";
441
+ ErrorTypes2["KbPromoteCollision"] = "KbPromoteCollision";
442
+ ErrorTypes2["KbPromoteSelf"] = "KbPromoteSelf";
443
+ ErrorTypes2["KbPromoteStanding"] = "KbPromoteStanding";
444
+ ErrorTypes2["KbPromoteStopped"] = "KbPromoteStopped";
377
445
  ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
378
446
  ErrorTypes2["KbSelfVerification"] = "KbSelfVerification";
379
447
  ErrorTypes2["KbStampBaselineUnreadable"] = "KbStampBaselineUnreadable";
@@ -519,6 +587,89 @@ var KbMissingFlagValueError = class extends BaseError {
519
587
  }
520
588
  flag;
521
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
+ };
522
673
  var KbInvalidConceptIdError = class extends BaseError {
523
674
  constructor(message, details) {
524
675
  super({
@@ -698,7 +849,9 @@ function revRef(rev) {
698
849
  var UNCHECKED_REASONS = [
699
850
  "remote-unreachable",
700
851
  "repo-unauthorized",
701
- "default-branch-unknown"
852
+ "default-branch-unknown",
853
+ /** Local, but the same finding: a shallow clone has no rev to read. */
854
+ "ref-unavailable"
702
855
  ];
703
856
  function isUncheckedReason(reason) {
704
857
  return reason !== void 0 && UNCHECKED_REASONS.includes(reason);
@@ -766,6 +919,11 @@ function refShapeIsSafe(ref) {
766
919
  if (ref.includes("..")) return false;
767
920
  return REF_SHAPE.test(ref);
768
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
+ }
769
927
  async function refIsWellFormed(ref) {
770
928
  if (!refShapeIsSafe(ref)) return false;
771
929
  const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
@@ -1164,6 +1322,142 @@ function bundleDigest(records, superseded) {
1164
1322
  return bundleStamp(records, superseded).digest;
1165
1323
  }
1166
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
+
1167
1461
  // src/anchor-resolver/read.ts
1168
1462
  var import_promises2 = require("fs/promises");
1169
1463
  var import_node_path2 = require("path");
@@ -1229,6 +1523,7 @@ function looksLikeWrongRepoRoot(drift) {
1229
1523
  for (const entries of drift.values()) {
1230
1524
  for (const entry of entries) {
1231
1525
  if (entry.repo !== void 0) continue;
1526
+ if (entry.side === "old") continue;
1232
1527
  checked += 1;
1233
1528
  if (entry.state !== "unresolved" || entry.reason !== "file-missing") {
1234
1529
  return false;
@@ -1364,7 +1659,7 @@ function size(bytes) {
1364
1659
  return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
1365
1660
  }
1366
1661
  function pause(ms) {
1367
- return new Promise((resolve6) => setTimeout(resolve6, ms));
1662
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
1368
1663
  }
1369
1664
 
1370
1665
  // src/grammars/manifest.ts
@@ -1936,8 +2231,8 @@ function captureBraceBlock(lines, matchLine) {
1936
2231
  }
1937
2232
  var PYTHON_HEADER = /^\s*(?:async\s+)?(?:def|class)\s+[A-Za-z_]\w*\s*[(:]/;
1938
2233
  function captureIndentedBlock(lines, matchLine) {
1939
- const header = lines[matchLine] ?? "";
1940
- const indent = header.length - header.trimStart().length;
2234
+ const header2 = lines[matchLine] ?? "";
2235
+ const indent = header2.length - header2.trimStart().length;
1941
2236
  let headerEnd = -1;
1942
2237
  for (let index2 = matchLine; index2 < lines.length && index2 <= matchLine + 20; index2++) {
1943
2238
  const code = stripLine(lines[index2] ?? "", CLEAN_STATE).code.trimEnd();
@@ -1958,42 +2253,55 @@ function captureIndentedBlock(lines, matchLine) {
1958
2253
  }
1959
2254
  return end === headerEnd ? null : span(lines, matchLine, end);
1960
2255
  }
1961
- var TIERS = [
1962
- (name) => new RegExp(
1963
- `(?:function|class|interface|type|enum|const|let|var|def)\\s+${name}\\b`
1964
- ),
1965
- (name) => new RegExp(`\\b${name}\\s*[:=]`),
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 = [
1966
2265
  (name) => new RegExp(`\\b${name}\\s*\\(`),
1967
2266
  (name) => new RegExp(`\\b${name}\\b`)
1968
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
+ }
1969
2296
  var regexResolver = {
1970
2297
  name: "regex",
1971
2298
  resolve(source, symbol) {
1972
- const segments = symbol.split(".");
1973
- const name = segments[segments.length - 1];
1974
- if (!name) return null;
1975
- const parent = segments.length > 1 ? segments[segments.length - 2] : void 0;
1976
- const escaped = escapeRegExp(name);
1977
- const parentPattern = parent ? new RegExp(`\\b${escapeRegExp(parent)}\\b`) : null;
1978
- const lines = source.split("\n");
1979
- for (const tier of TIERS) {
1980
- const pattern = tier(escaped);
1981
- let candidates = lines.map((line, index2) => ({ line, index: index2 })).filter((entry) => pattern.test(entry.line)).map((entry) => entry.index);
1982
- if (!candidates.length) continue;
1983
- if (parentPattern && candidates.length > 1) {
1984
- const distances = candidates.map(
1985
- (index2) => distanceToParent(lines, index2, parentPattern)
1986
- );
1987
- const nearest = Math.min(...distances);
1988
- if (Number.isFinite(nearest)) {
1989
- candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1990
- }
1991
- }
1992
- if (candidates.length !== 1) return null;
1993
- const matchLine = candidates[0];
1994
- return PYTHON_HEADER.test(lines[matchLine] ?? "") ? captureIndentedBlock(lines, matchLine) : captureBraceBlock(lines, matchLine);
1995
- }
1996
- return null;
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" };
1997
2305
  }
1998
2306
  };
1999
2307
  function escapeRegExp(value) {
@@ -2015,6 +2323,7 @@ function resolveAnchor(source, anchor, resolver = regexResolver) {
2015
2323
  }
2016
2324
  function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2017
2325
  const normalized = source.replace(/\r\n/g, "\n");
2326
+ if (anchor.span) return sliceSpan(normalized, anchor.span);
2018
2327
  if (!anchor.symbol) {
2019
2328
  const lines = normalized.split("\n");
2020
2329
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
@@ -2027,11 +2336,17 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2027
2336
  }
2028
2337
  };
2029
2338
  }
2339
+ let afterParsedMiss = false;
2030
2340
  for (const resolver of resolvers) {
2031
- 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);
2032
2344
  if (attempt.kind === "abstain") continue;
2033
2345
  if (attempt.kind === "unresolved") {
2034
- if (attempt.reason === "symbol-not-found") continue;
2346
+ if (attempt.reason === "symbol-not-found") {
2347
+ if (resolver.attempt) afterParsedMiss = true;
2348
+ continue;
2349
+ }
2035
2350
  return { ok: false, reason: attempt.reason };
2036
2351
  }
2037
2352
  const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
@@ -2044,12 +2359,28 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
2044
2359
  }
2045
2360
  return { ok: false, reason: "symbol-not-found" };
2046
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
+ }
2047
2378
  function fromResolve(resolver, source, symbol, file) {
2048
2379
  const span2 = resolver.resolve(source, symbol, file);
2049
2380
  return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
2050
2381
  }
2051
2382
  function isResolverName(name) {
2052
- return name === "tree-sitter" || name === "regex";
2383
+ return name === "tree-sitter" || name === "regex" || name === "span";
2053
2384
  }
2054
2385
  async function prepareResolvers(resolvers, files) {
2055
2386
  for (const resolver of resolvers) await resolver.prepare?.(files);
@@ -2068,6 +2399,9 @@ function resolverChanged(source, anchor, produced) {
2068
2399
  return before !== null && hashAnchorText(before.text) === anchor.hash;
2069
2400
  }
2070
2401
  function anchorHashOf(anchor, outcome) {
2402
+ if (outcome.resolver === "span") {
2403
+ return { hash: hashAnchorText(outcome.span.text), kind: "raw" };
2404
+ }
2071
2405
  const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
2072
2406
  const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
2073
2407
  return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
@@ -2101,37 +2435,60 @@ async function detectAnchorDrift(records, options = {}) {
2101
2435
  }
2102
2436
  }
2103
2437
  const files = [];
2438
+ const committedWants = [];
2104
2439
  const wants = [];
2105
2440
  for (const entries of planned.values()) {
2106
2441
  for (const { anchor, foreign } of entries) {
2107
- if (!foreign) files.push(anchor.file);
2108
- 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);
2109
2445
  }
2110
2446
  }
2111
- const [reads, remote] = await Promise.all([
2447
+ const [reads, committed, remote] = await Promise.all([
2112
2448
  readAnchorFiles(
2113
2449
  files,
2114
2450
  options.reader ?? anchorFileReader(repoRoot),
2115
2451
  options.concurrency ?? DEFAULT_IO_CONCURRENCY
2116
2452
  ),
2453
+ readCommitted(repoRoot, committedWants, options),
2117
2454
  (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
2118
2455
  ]);
2119
2456
  await prepareResolvers(resolvers, [
2120
2457
  ...files,
2458
+ ...committedWants.map((anchor) => anchor.file),
2121
2459
  ...wants.map((want) => want.file)
2122
2460
  ]);
2123
2461
  const drift = /* @__PURE__ */ new Map();
2124
2462
  for (const record of records) {
2125
2463
  const entries = [];
2126
2464
  for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
2127
- entries.push(
2128
- foreign ? remoteEntry(anchor, remote, resolvers) : localEntry(anchor, reads.get(anchor.file), resolvers)
2129
- );
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));
2130
2471
  }
2131
2472
  if (entries.length) drift.set(record.conceptId, entries);
2132
2473
  }
2133
2474
  return drift;
2134
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
+ }
2135
2492
  function remoteWants(anchor) {
2136
2493
  const repo = anchor.repo;
2137
2494
  const wants = [{ repo, file: anchor.file }];
@@ -2142,6 +2499,7 @@ function base(anchor) {
2142
2499
  return {
2143
2500
  file: anchor.file,
2144
2501
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
2502
+ ...anchor.side === "old" ? { side: "old" } : {},
2145
2503
  storedHash: anchor.hash
2146
2504
  };
2147
2505
  }
@@ -2155,9 +2513,15 @@ function unresolved(anchor, reason, repo) {
2155
2513
  ...classOf(reason)
2156
2514
  };
2157
2515
  }
2516
+ var GONE_REASONS = /* @__PURE__ */ new Set([
2517
+ "file-missing",
2518
+ "symbol-not-found",
2519
+ "span-out-of-range",
2520
+ "ref-unreadable"
2521
+ ]);
2158
2522
  function provisionalDriftClass(entry) {
2159
2523
  if (entry.state === "unresolved") {
2160
- return entry.reason === "file-missing" || entry.reason === "symbol-not-found" ? "gone" : void 0;
2524
+ return GONE_REASONS.has(entry.reason) ? "gone" : void 0;
2161
2525
  }
2162
2526
  return entry.state === "drifted" ? "changed" : void 0;
2163
2527
  }
@@ -2226,6 +2590,13 @@ function remoteEntry(anchor, remote, resolvers) {
2226
2590
  remoteState: "drifted-from-ref"
2227
2591
  });
2228
2592
  }
2593
+ if (anchor.side === "old") {
2594
+ return compared(anchor, current, {
2595
+ repo,
2596
+ ...extras,
2597
+ remoteState: "matches-ref"
2598
+ });
2599
+ }
2229
2600
  const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
2230
2601
  return head?.ok && head.current.hash !== anchor.hash ? {
2231
2602
  ...compared(anchor, head.current, {
@@ -2261,19 +2632,28 @@ var kbLogEntrySchema = import_zod3.z.object({
2261
2632
  by: import_zod3.z.string().min(1),
2262
2633
  operation: import_zod3.z.string().min(1),
2263
2634
  conceptId: import_zod3.z.string().min(1),
2264
- /** 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
+ */
2265
2639
  target: import_zod3.z.string().min(1).optional()
2266
2640
  }).strict();
2267
2641
  function renderLogEntry(entry) {
2268
2642
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
2269
2643
  `;
2270
2644
  }
2645
+ var CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})/;
2271
2646
  function parseLog(raw) {
2272
2647
  const entries = [];
2273
2648
  const malformed = [];
2274
2649
  const seen = /* @__PURE__ */ new Set();
2650
+ let conflicted = false;
2275
2651
  raw.split("\n").forEach((text, index2) => {
2276
2652
  if (!text.trim()) return;
2653
+ if (CONFLICT_MARKER.test(text)) {
2654
+ conflicted = true;
2655
+ return;
2656
+ }
2277
2657
  let value;
2278
2658
  try {
2279
2659
  value = JSON.parse(text);
@@ -2294,7 +2674,7 @@ function parseLog(raw) {
2294
2674
  entries.sort(
2295
2675
  (left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
2296
2676
  );
2297
- return { entries, malformed };
2677
+ return { entries, malformed, conflicted };
2298
2678
  }
2299
2679
 
2300
2680
  // src/search-index.ts
@@ -2343,8 +2723,8 @@ async function searchBase(bundlePath2, query, options = {}) {
2343
2723
  async function isStale(bundlePath2) {
2344
2724
  const indexAt = await (0, import_promises5.stat)((0, import_node_path6.join)(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
2345
2725
  if (!indexAt) return true;
2346
- const { readdir: readdir2 } = await import("fs/promises");
2347
- const names = (await readdir2(bundlePath2).catch(() => [])).filter(
2726
+ const { readdir: readdir3 } = await import("fs/promises");
2727
+ const names = (await readdir3(bundlePath2).catch(() => [])).filter(
2348
2728
  (name) => name.endsWith(".md") && name !== INDEX_FILE
2349
2729
  );
2350
2730
  let stale = false;
@@ -2759,14 +3139,14 @@ function catalog(bundle, options = {}) {
2759
3139
  supersededBy: hit.heads.map((head) => head.conceptId),
2760
3140
  stale: hit.warnings.some((warning) => warning.kind === "stale")
2761
3141
  })).sort(byTypeThenTitle);
2762
- const standings = { ...EMPTY_STANDINGS };
2763
- for (const entry of entries) standings[entry.standing] += 1;
3142
+ const standings2 = { ...EMPTY_STANDINGS };
3143
+ for (const entry of entries) standings2[entry.standing] += 1;
2764
3144
  return {
2765
3145
  entries,
2766
3146
  recordCount: entries.length,
2767
- standings,
2768
- currentCount: standings.current,
2769
- supersededCount: standings.superseded,
3147
+ standings: standings2,
3148
+ currentCount: standings2.current,
3149
+ supersededCount: standings2.superseded,
2770
3150
  staleCount: entries.filter((entry) => entry.stale).length
2771
3151
  };
2772
3152
  }
@@ -2922,9 +3302,13 @@ function hasEdge(edges, edge) {
2922
3302
  );
2923
3303
  }
2924
3304
 
3305
+ // src/kb-files.ts
3306
+ var STORE_OWNED_FILES = [INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE];
3307
+
2925
3308
  // src/kb-gitattributes.ts
2926
3309
  var GITATTRIBUTES_FILE = ".gitattributes";
2927
- 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`;
2928
3312
  function parseLine(line) {
2929
3313
  const trimmed = line.trim();
2930
3314
  if (!trimmed || trimmed.startsWith("#")) return null;
@@ -2932,23 +3316,39 @@ function parseLine(line) {
2932
3316
  return pattern === void 0 ? null : { pattern, attrs };
2933
3317
  }
2934
3318
  function hasMergeDeclaration(contents) {
3319
+ return declares(contents, LOG_FILE, "merge");
3320
+ }
3321
+ function declares(contents, pattern, attribute) {
2935
3322
  return contents.split("\n").some((line) => {
2936
3323
  const parsed = parseLine(line);
2937
- if (!parsed || parsed.pattern !== LOG_FILE) return false;
3324
+ if (!parsed || parsed.pattern !== pattern) return false;
2938
3325
  return parsed.attrs.some(
2939
- (attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
3326
+ (attr) => attr === attribute || attr === `-${attribute}` || attr.startsWith(`${attribute}=`)
2940
3327
  );
2941
3328
  });
2942
3329
  }
2943
- 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 "";
2944
3344
  const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
2945
- return `${separator}${UNION_MERGE_LINE}
3345
+ return `${separator}${lines.join("\n")}
2946
3346
  `;
2947
3347
  }
2948
3348
 
2949
3349
  // src/kb-store.ts
2950
3350
  var KB_DIR = (0, import_node_path7.join)(".strauss", "kb");
2951
- var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
3351
+ var STORE_OWNED = new Set(STORE_OWNED_FILES);
2952
3352
  var DEFAULT_LOAD_BUDGET = 25e3;
2953
3353
  var KbStore = class {
2954
3354
  constructor(logger = {}) {
@@ -3075,12 +3475,16 @@ var KbStore = class {
3075
3475
  * Wholesale rather than merged: the caller just resolved the anchors it is
3076
3476
  * writing, so it holds the complete current set, and a merge would keep
3077
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.
3078
3481
  */
3079
3482
  async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
3483
+ const checked = anchors.map((anchor) => kbAnchorWriteSchema.parse(anchor));
3080
3484
  return this.mutate(
3081
3485
  bundlePath2,
3082
3486
  conceptId2,
3083
- (frontmatter) => ({ ...frontmatter, strauss_anchors: anchors }),
3487
+ (frontmatter) => ({ ...frontmatter, strauss_anchors: checked }),
3084
3488
  { operation: "anchor-resolve", by: actor }
3085
3489
  );
3086
3490
  }
@@ -3166,6 +3570,35 @@ ${answer}
3166
3570
  `
3167
3571
  );
3168
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
+ }
3169
3602
  /**
3170
3603
  * Records matching a text query, each carrying its standing.
3171
3604
  *
@@ -3401,12 +3834,25 @@ ${answer}
3401
3834
  }
3402
3835
  return expected;
3403
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
+ }
3404
3849
  /**
3405
3850
  * The log, with unparseable lines reported rather than repaired.
3406
3851
  *
3407
3852
  * The log is the bundle's only artifact that cannot be reconstructed — the
3408
3853
  * records rebuild the index, and the code outlives both, but nothing else
3409
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.
3410
3856
  */
3411
3857
  async readLog(bundlePath2) {
3412
3858
  const raw = await (0, import_promises6.readFile)(
@@ -3414,6 +3860,13 @@ ${answer}
3414
3860
  "utf8"
3415
3861
  ).catch(() => "");
3416
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
+ }
3417
3870
  for (const bad of result.malformed) {
3418
3871
  this.logger.warn?.({
3419
3872
  operation: "kb.log.parse",
@@ -3423,6 +3876,14 @@ ${answer}
3423
3876
  }
3424
3877
  return result;
3425
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
+ }
3426
3887
  /**
3427
3888
  * `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
3428
3889
  * a missing target (a broken link, legal per compose.ts) or a CAS conflict
@@ -3513,7 +3974,8 @@ ${answer}
3513
3974
  /**
3514
3975
  * Declares union merge for the log, so two worktrees writing the same
3515
3976
  * bundle interleave their `log.jsonl` lines on merge rather than one
3516
- * 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.
3517
3979
  *
3518
3980
  * Called from `record` — every path that appends a log line, not just
3519
3981
  * `write` — so a bundle only ever mutated through `setStatus`/`verify`/
@@ -3526,10 +3988,9 @@ ${answer}
3526
3988
  * race and created the file between the `readFile` below and this call,
3527
3989
  * `wx` fails instead of truncating what that writer just wrote, and the
3528
3990
  * failure is swallowed by the catch below same as any other best-effort
3529
- * miss. A file that exists but declares no merge strategy for the log
3530
- * gets the line appended, never a wholesale rewrite; one that already
3531
- * declares any merge strategy — this one or a user's own — is left alone
3532
- * entirely (see `hasMergeDeclaration`).
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`).
3533
3994
  *
3534
3995
  * `readFile` failing is `existing === null` only for `ENOENT` — genuinely
3535
3996
  * missing. Any other error (a permission problem, a transient `EMFILE`,
@@ -3540,12 +4001,12 @@ ${answer}
3540
4001
  * therefore left untouched and reported as a failure like any other.
3541
4002
  *
3542
4003
  * Two processes racing the append branch — both read a file without the
3543
- * line, both append it — is possible and left unguarded: `appendFile` is
3544
- * `O_APPEND`, so the result is two copies of the same line rather than a
3545
- * torn write, and `hasMergeDeclaration` sees a duplicate declaration as
3546
- * "already declared" on the next call. A cheap-to-detect, harmless-to-
3547
- * leave residue, not a reason to add a cross-process lock (see
3548
- * `ARCHITECTURE.md`'s rejection of one for the same trade on records).
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).
3549
4010
  *
3550
4011
  * Best-effort, like the log append it precedes: failing to write this
3551
4012
  * file must not fail the mutation it guards.
@@ -3562,7 +4023,7 @@ ${answer}
3562
4023
  }
3563
4024
  if (existing === null) {
3564
4025
  try {
3565
- await (0, import_promises6.writeFile)(target, appendUnionMergeLine(""), {
4026
+ await (0, import_promises6.writeFile)(target, appendGitattributesLines(""), {
3566
4027
  encoding: "utf8",
3567
4028
  flag: "wx"
3568
4029
  });
@@ -3582,8 +4043,9 @@ ${answer}
3582
4043
  });
3583
4044
  return;
3584
4045
  }
3585
- if (!hasMergeDeclaration(existing)) {
3586
- 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");
3587
4049
  this.logger.info?.({
3588
4050
  operation: "kb.gitattributes.ensure",
3589
4051
  bundlePath: root,
@@ -3692,7 +4154,7 @@ var composeInputSchema = import_zod4.z.object({
3692
4154
  why: import_zod4.z.string().min(1),
3693
4155
  /** Keyed by section heading from the type's spec. Unknown keys rejected. */
3694
4156
  sections: import_zod4.z.record(import_zod4.z.string(), import_zod4.z.string().min(1)).optional(),
3695
- anchors: import_zod4.z.array(kbAnchorSchema).optional(),
4157
+ anchors: import_zod4.z.array(kbAnchorWriteSchema).optional(),
3696
4158
  sources: import_zod4.z.array(kbSourceSchema).optional(),
3697
4159
  /** No source exists, as a claim rather than a sentinel in `sources`. */
3698
4160
  assumption: import_zod4.z.boolean().optional(),
@@ -4352,7 +4814,7 @@ function kbJsonSchemas() {
4352
4814
 
4353
4815
  // src/match-diff.ts
4354
4816
  function matchToDiff(files, records, options = {}) {
4355
- const ranges = indexRanges(options.symbolRanges ?? []);
4817
+ const ranges = symbolRangeIndex(options.symbolRanges ?? []);
4356
4818
  const anchored = records.filter(
4357
4819
  (record) => (record.frontmatter.strauss_anchors ?? []).length > 0
4358
4820
  );
@@ -4370,8 +4832,8 @@ function matchToDiff(files, records, options = {}) {
4370
4832
  let precision = "symbol";
4371
4833
  for (const { record, anchors } of candidates) {
4372
4834
  const placement = place(anchors, file.filePath, hunk, ranges);
4373
- if (placement === "miss") continue;
4374
- if (placement === "file") precision = "file";
4835
+ if (placement.kind === "miss") continue;
4836
+ if (placement.kind === "file") precision = "file";
4375
4837
  hits.push(record);
4376
4838
  }
4377
4839
  if (!hits.length) continue;
@@ -4385,19 +4847,51 @@ function matchToDiff(files, records, options = {}) {
4385
4847
  }
4386
4848
  return matches3;
4387
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
+ }
4388
4865
  function place(anchors, filePath, hunk, ranges) {
4389
- let fallback = "miss";
4866
+ let fallback = { kind: "miss" };
4390
4867
  for (const anchor of anchors) {
4391
- if (!anchor.symbol) return "file";
4392
- 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
+ );
4393
4882
  if (!resolved?.length) {
4394
- fallback = "file";
4883
+ if (fallback.kind === "miss") fallback = { kind: "file", anchor };
4395
4884
  continue;
4396
4885
  }
4397
- if (resolved.some((range) => overlaps(range, hunk))) return "symbol";
4886
+ if (resolved.some((range) => overlaps(range, hunk))) {
4887
+ return { kind: "symbol", anchor };
4888
+ }
4398
4889
  }
4399
4890
  return fallback;
4400
4891
  }
4892
+ function side(value) {
4893
+ return value ?? "new";
4894
+ }
4401
4895
  function overlaps(range, hunk) {
4402
4896
  return range.startLine <= hunk.endLine && hunk.startLine <= range.endLine;
4403
4897
  }
@@ -4415,50 +4909,219 @@ function order(records) {
4415
4909
  )
4416
4910
  );
4417
4911
  }
4418
- function indexRanges(ranges) {
4912
+ function symbolRangeIndex(ranges) {
4419
4913
  const byKey = /* @__PURE__ */ new Map();
4420
4914
  for (const range of ranges) {
4421
- const id = key(range.file, range.symbol);
4915
+ const id = key(range.file, range.symbol, side(range.side));
4422
4916
  byKey.set(id, [...byKey.get(id) ?? [], range]);
4423
4917
  }
4424
4918
  return byKey;
4425
4919
  }
4426
- function key(file, symbol) {
4427
- return `${normalize(file)}#${symbol}`;
4920
+ function key(file, symbol, at2) {
4921
+ return `${normalize(file)}#${symbol}#${at2}`;
4428
4922
  }
4429
4923
  function normalize(path) {
4430
4924
  return path.replace(/^\.\//, "");
4431
4925
  }
4432
4926
 
4433
- // src/validate.ts
4434
- function validateBundle(records) {
4435
- const byId = new Map(records.map((record) => [record.conceptId, record]));
4436
- const problems = [];
4437
- const report = (check, conceptId2, note, severity = "error") => problems.push({ check, conceptId: conceptId2, note, severity });
4438
- for (const record of records) {
4439
- const { conceptId: conceptId2, frontmatter: fm } = record;
4440
- if (!isKbRecordType(fm.type)) {
4441
- report("type", conceptId2, `unrecognised type "${fm.type}"`);
4442
- }
4443
- if (fm.strauss_status === "superseded") {
4444
- const by = fm.strauss_superseded_by;
4445
- if (!by) {
4446
- report("superseded_by", conceptId2, "superseded with no replacement");
4447
- } else if (!byId.has(by)) {
4448
- report("superseded_by", conceptId2, `replacement ${by} is missing`);
4449
- } else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
4450
- report("backlink", by, `does not list ${conceptId2} in supersedes`);
4451
- }
4452
- }
4453
- for (const old of fm.strauss_supersedes ?? []) {
4454
- const previous = byId.get(old);
4455
- if (!previous) {
4456
- report("supersedes", conceptId2, `target ${old} is missing`);
4457
- } else if (previous.frontmatter.strauss_status !== "superseded") {
4458
- report("supersedes", conceptId2, `${old} is not marked superseded`);
4459
- }
4460
- }
4461
- for (const link2 of fm.strauss_links ?? []) {
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 ?? []) {
5117
+ const previous = byId.get(old);
5118
+ if (!previous) {
5119
+ report("supersedes", conceptId2, `target ${old} is missing`);
5120
+ } else if (previous.frontmatter.strauss_status !== "superseded") {
5121
+ report("supersedes", conceptId2, `${old} is not marked superseded`);
5122
+ }
5123
+ }
5124
+ for (const link2 of fm.strauss_links ?? []) {
4462
5125
  if (!isKbLinkRel(link2.rel)) {
4463
5126
  report(
4464
5127
  "link_rel",
@@ -4489,6 +5152,34 @@ function validateBundle(records) {
4489
5152
  }
4490
5153
  }
4491
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
+ }
4492
5183
  if (anchor.repo && !isCanonicalRepoUrl(anchor.repo)) {
4493
5184
  report(
4494
5185
  "anchor_repo",
@@ -4535,14 +5226,19 @@ var DAY_MS = 864e5;
4535
5226
  function anchorResolverCounts(bundle) {
4536
5227
  let treeSitter = 0;
4537
5228
  let regex = 0;
5229
+ let span2 = 0;
5230
+ let oldSide = 0;
4538
5231
  for (const record of bundle) {
4539
5232
  for (const anchor of record.frontmatter.strauss_anchors ?? []) {
4540
- if (!anchor.hash || !anchor.symbol) continue;
4541
- 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;
4542
5238
  else regex += 1;
4543
5239
  }
4544
5240
  }
4545
- return { total: treeSitter + regex, treeSitter, regex };
5241
+ return { total: treeSitter + regex + span2, treeSitter, regex, span: span2, oldSide };
4546
5242
  }
4547
5243
  function doctor(bundle, options = {}) {
4548
5244
  const thresholds = {
@@ -4552,7 +5248,7 @@ function doctor(bundle, options = {}) {
4552
5248
  };
4553
5249
  const now = options.now ?? /* @__PURE__ */ new Date();
4554
5250
  const adjudicated = adjudicate(bundle, bundle, now, options.anchorDrift);
4555
- const standings = new Map(
5251
+ const standings2 = new Map(
4556
5252
  adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
4557
5253
  );
4558
5254
  const inForce = adjudicated.filter(
@@ -4565,7 +5261,7 @@ function doctor(bundle, options = {}) {
4565
5261
  group("aging", aging(inForce, now, thresholds.agingDays)),
4566
5262
  group("orphaned", orphaned(bundle)),
4567
5263
  group("broken-supersession", brokenSupersession(bundle, adjudicated)),
4568
- group("superseded-but-cited", supersededButCited(bundle, standings)),
5264
+ group("superseded-but-cited", supersededButCited(bundle, standings2)),
4569
5265
  group("drifted", drifted(inForce)),
4570
5266
  group("unchecked", unchecked(inForce))
4571
5267
  ];
@@ -4731,14 +5427,14 @@ function brokenSupersession(bundle, adjudicated) {
4731
5427
  (left, right) => left.conceptId.localeCompare(right.conceptId)
4732
5428
  );
4733
5429
  }
4734
- function supersededButCited(bundle, standings) {
5430
+ function supersededButCited(bundle, standings2) {
4735
5431
  const byId = new Map(bundle.map((record) => [record.conceptId, record]));
4736
5432
  const findings = [];
4737
5433
  for (const record of bundle) {
4738
- const standing = standings.get(record.conceptId);
5434
+ const standing = standings2.get(record.conceptId);
4739
5435
  if (standing === "superseded" || standing === "rejected") continue;
4740
5436
  for (const target of edgeNeighbours(record, bundle, "body-link")) {
4741
- const targetStanding = standings.get(target.conceptId);
5437
+ const targetStanding = standings2.get(target.conceptId);
4742
5438
  if (targetStanding !== "superseded" && targetStanding !== "rejected") {
4743
5439
  continue;
4744
5440
  }
@@ -5004,6 +5700,7 @@ var anchorResolveCommand = define({
5004
5700
  const base2 = {
5005
5701
  file: anchor.file,
5006
5702
  ...anchor.symbol ? { symbol: anchor.symbol } : {},
5703
+ ...anchor.side === "old" ? { side: "old" } : {},
5007
5704
  // Carried onto unresolved findings too: an anchor that once hashed
5008
5705
  // and now resolves to nothing is a broken anchor, and the exit code
5009
5706
  // has to be able to tell it from one nobody ever stamped.
@@ -5180,12 +5877,18 @@ async function readSources(anchors, root, offline) {
5180
5877
  const foreign = new Map(
5181
5878
  anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
5182
5879
  );
5183
- 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
+ );
5184
5886
  const remote = anchors.filter((anchor) => foreign.get(anchor));
5185
5887
  const reads = await readAnchorFiles(
5186
5888
  local.map((anchor) => anchor.file),
5187
5889
  anchorFileReader(root)
5188
5890
  );
5891
+ const atRef = await readCommitted(root, committed);
5189
5892
  const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
5190
5893
  offline
5191
5894
  });
@@ -5197,6 +5900,13 @@ async function readSources(anchors, root, offline) {
5197
5900
  read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
5198
5901
  );
5199
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
+ }
5200
5910
  for (const anchor of remote) {
5201
5911
  const repo = anchor.repo;
5202
5912
  const key2 = normalizeRepoUrl(repo);
@@ -5329,141 +6039,12 @@ function count(value, noun) {
5329
6039
  return `${value} ${value === 1 ? noun : `${noun}s`}`;
5330
6040
  }
5331
6041
 
5332
- // src/commands/context.ts
5333
- var import_zod13 = require("zod");
5334
- var contextCommand = define({
5335
- name: "context",
5336
- tool: "kb_context",
5337
- usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
5338
- description: "Index block of pinned bases (ids, titles, standing) for injection at context birth. Takes no bundlePath \u2014 reads the workspace pin manifests. Empty when nothing is pinned; refuses over budget rather than truncating. Budget precedence: flags, then the manifest `context[profile]` over `context.default`, then the built-in profile, then package defaults.",
5339
- input: import_zod13.z.object({
5340
- budgetTokens: import_zod13.z.number().int().positive().optional().describe(
5341
- "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
5342
- ),
5343
- fullUnderTokens: import_zod13.z.number().int().positive().optional().describe(
5344
- "Per-base rendering threshold, applied before the budget: a base whose complete load fits under this arrives as full records instead of index lines, and the whole block still answers to budgetTokens. Off by default \u2014 index-only is the safe default at a context birth, because injected bodies outlive the qualifiers on them; the session-start profile opts tiny bases in at 1500."
5345
- ),
5346
- profile: import_zod13.z.string().optional().describe(
5347
- "Named budget set: built-ins are session-start (full-under 1500), compact and turn (budget 2500); the manifests' `context` tables override per repo. Unknown names fall through to defaults rather than failing."
5348
- ),
5349
- excludeTags: import_zod13.z.array(import_zod13.z.string().min(1)).optional().describe(
5350
- "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
5351
- ),
5352
- format: import_zod13.z.enum(["markdown", "json"]).optional().describe(
5353
- "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
5354
- ),
5355
- event: import_zod13.z.string().optional().describe(
5356
- "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
5357
- )
5358
- }),
5359
- fromArgv: (argv) => {
5360
- const budget = argvFlag(argv, "--budget");
5361
- const fullUnder = argvFlag(argv, "--full-under");
5362
- const profile = argvFlag(argv, "--profile");
5363
- const format = argvFlag(argv, "--format");
5364
- const event = argvFlag(argv, "--event");
5365
- const excludeTags = argvFlags(argv, "--exclude-tag");
5366
- return {
5367
- ...budget ? { budgetTokens: Number(budget) } : {},
5368
- ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
5369
- ...profile ? { profile } : {},
5370
- ...excludeTags.length ? { excludeTags } : {},
5371
- ...format ? { format } : {},
5372
- ...event ? { event } : {}
5373
- };
5374
- },
5375
- run: async ({ store }, { budgetTokens, fullUnderTokens, profile, excludeTags, format, event }) => {
5376
- const result = await buildContext(store, process.cwd(), {
5377
- ...budgetTokens ? { budgetTokens } : {},
5378
- ...fullUnderTokens ? { fullUnderTokens } : {},
5379
- ...profile ? { profile } : {},
5380
- ...excludeTags ? { excludeTags } : {},
5381
- // Degradations — a full pin that could not fit, a refused block — go
5382
- // to stderr as well as into the block itself: stderr is diagnostics on
5383
- // both surfaces (hooks discard it, MCP logs it), so an operator can
5384
- // see budget pressure without reading injected context.
5385
- warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
5386
- `)
5387
- });
5388
- if (!result.block) return "";
5389
- return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
5390
- }
5391
- });
5392
-
5393
- // src/commands/doctor.ts
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");
5394
6046
  var import_zod15 = require("zod");
5395
6047
 
5396
- // src/drift/git.ts
5397
- var import_node_child_process3 = require("child_process");
5398
- var import_node_util3 = require("util");
5399
- var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
5400
- var MAX_GIT_OUTPUT_BYTES = 1048576;
5401
- var GIT_TIMEOUT_MS = 5e3;
5402
- async function git2(cwd, args) {
5403
- const env = { ...process.env };
5404
- delete env["GIT_DIR"];
5405
- delete env["GIT_WORK_TREE"];
5406
- delete env["GIT_INDEX_FILE"];
5407
- try {
5408
- const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
5409
- timeout: GIT_TIMEOUT_MS,
5410
- maxBuffer: MAX_GIT_OUTPUT_BYTES,
5411
- env
5412
- });
5413
- return { ok: true, stdout };
5414
- } catch {
5415
- return { ok: false };
5416
- }
5417
- }
5418
- async function listRepoFiles(repoRoot) {
5419
- const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
5420
- if (!result.ok) return [];
5421
- return result.stdout.split("\0").filter(Boolean);
5422
- }
5423
- async function readOldSource(repoRoot, anchor) {
5424
- if (!filePathIsSafe(anchor.file))
5425
- return { ok: false, reason: "unrecoverable" };
5426
- if (anchor.ref && refShapeIsSafe(anchor.ref)) {
5427
- const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
5428
- if (shown2 !== null) {
5429
- return {
5430
- ok: true,
5431
- source: shown2,
5432
- origin: { kind: "ref", ref: anchor.ref }
5433
- };
5434
- }
5435
- }
5436
- const at2 = anchor.resolved_at;
5437
- if (!at2 || Number.isNaN(Date.parse(at2))) {
5438
- return { ok: false, reason: "unrecoverable" };
5439
- }
5440
- const found = await git2(repoRoot, [
5441
- "log",
5442
- "-1",
5443
- "--format=%H",
5444
- `--before=${at2}`,
5445
- "--end-of-options",
5446
- "HEAD",
5447
- "--",
5448
- anchor.file
5449
- ]);
5450
- const sha = found.ok ? found.stdout.trim() : "";
5451
- if (!sha || !refShapeIsSafe(sha))
5452
- return { ok: false, reason: "unrecoverable" };
5453
- const shown = await showFile(repoRoot, sha, anchor.file);
5454
- if (shown === null) return { ok: false, reason: "unrecoverable" };
5455
- return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
5456
- }
5457
- async function showFile(repoRoot, ref, file) {
5458
- const path = file.replace(/^\.\//, "");
5459
- const result = await git2(repoRoot, [
5460
- "show",
5461
- "--end-of-options",
5462
- `${ref}:${path}`
5463
- ]);
5464
- return result.ok ? result.stdout : null;
5465
- }
5466
-
5467
6048
  // src/drift/moved.ts
5468
6049
  var import_promises9 = require("fs/promises");
5469
6050
  var MAX_MOVED_SEARCH_FILES = 2e3;
@@ -5482,6 +6063,7 @@ function movedSearch(repoRoot, options = {}) {
5482
6063
  async find(anchor) {
5483
6064
  const stored = anchor.hash;
5484
6065
  if (!stored) return void 0;
6066
+ if (anchor.span) return sameFileWindow(anchor, read, stored);
5485
6067
  const language = languageForFile(anchor.file);
5486
6068
  if (!language) return sameFileWindow(anchor, read, stored);
5487
6069
  const candidates = await filesForLanguage(language);
@@ -5579,6 +6161,11 @@ async function classifyDrift(repoRoot, record, entries, options = {}) {
5579
6161
  );
5580
6162
  const out = [];
5581
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
+ }
5582
6169
  const movedTo = await search.find(anchor);
5583
6170
  if (movedTo) {
5584
6171
  out.push({
@@ -5652,8 +6239,8 @@ function unifiedDiff(before, after, options = {}) {
5652
6239
  }
5653
6240
  const truncated = body.length > max;
5654
6241
  const shown = truncated ? body.slice(0, max) : body;
5655
- const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
5656
- const lines = [header, ...shown];
6242
+ const header2 = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
6243
+ const lines = [header2, ...shown];
5657
6244
  if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
5658
6245
  return { text: lines.join("\n"), added, removed, truncated };
5659
6246
  }
@@ -5713,12 +6300,12 @@ async function reassessPacket(repoRoot, record, entries, options = {}) {
5713
6300
  ...options.search ? { search: options.search } : {},
5714
6301
  withHistory: options.withDiff !== false
5715
6302
  });
5716
- const open = classified.filter(
6303
+ const open2 = classified.filter(
5717
6304
  (found) => found.class === "changed" || found.class === "gone"
5718
6305
  );
5719
- if (!open.length) return { packet: null, classified };
5720
- const budget = diffBudget(open.length);
5721
- const anchors = open.map(
6306
+ if (!open2.length) return { packet: null, classified };
6307
+ const budget = diffBudget(open2.length);
6308
+ const anchors = open2.map(
5722
6309
  (found) => anchorPacket(found, options.withDiff === true, budget)
5723
6310
  );
5724
6311
  const type = record.frontmatter.type;
@@ -5792,45 +6379,632 @@ function claimOf(record) {
5792
6379
  return text ? { section, text } : null;
5793
6380
  }
5794
6381
 
5795
- // src/commands/reassess.ts
6382
+ // src/commands/match/command.ts
5796
6383
  var import_zod14 = require("zod");
5797
- var reassessCommand = define({
5798
- name: "reassess",
5799
- tool: "kb_reassess",
5800
- usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
5801
- description: "One drifted record, as something to judge: its claim, each anchor's drift class, the old-vs-new span diff, and the records that depend on it. Formatting-only drift is dropped. Empty when there is nothing to reassess. Writes: relocates moved anchors, keeping their hash; never verifies, supersedes, or changes standing.",
5802
- input: import_zod14.z.object({
5803
- bundlePath,
5804
- conceptId,
5805
- repoRoot: REPO_ROOT,
5806
- withDiff: import_zod14.z.boolean().optional().describe(
5807
- "Recover each anchor's committed span and render the diff. Reads git history."
5808
- )
5809
- }),
5810
- fromArgv: (argv, path) => {
5811
- const repoRoot = argvFlag(argv, "--repo-root");
5812
- return {
5813
- bundlePath: path,
5814
- conceptId: argv[1],
5815
- ...repoRoot !== void 0 ? { repoRoot } : {},
5816
- ...argv.includes("--with-diff") ? { withDiff: true } : {}
5817
- };
5818
- },
5819
- run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
5820
- const root = repoRoot ?? process.cwd();
5821
- const bundle = await store.list(path);
5822
- const record = bundle.find((entry) => entry.conceptId === id);
5823
- if (!record) throw new KbRecordNotFoundError(id);
5824
- const drift = await store.detectDrift([record], repoRoot);
5825
- const entries = drift?.get(id) ?? [];
5826
- if (!entries.some((entry) => entry.state !== "match")) {
5827
- return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
5828
- }
5829
- const standing = adjudicate(bundle, bundle).find(
5830
- (hit) => hit.record.conceptId === id
5831
- )?.standing;
5832
- const impact2 = await store.impact(path, id);
5833
- const { packet, classified } = await reassessPacket(root, record, entries, {
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) ?? [];
7000
+ if (!entries.some((entry) => entry.state !== "match")) {
7001
+ return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
7002
+ }
7003
+ const standing = adjudicate(bundle, bundle).find(
7004
+ (hit) => hit.record.conceptId === id
7005
+ )?.standing;
7006
+ const impact2 = await store.impact(path, id);
7007
+ const { packet, classified } = await reassessPacket(root, record, entries, {
5834
7008
  ...withDiff ? { withDiff: true } : {},
5835
7009
  impact: impact2,
5836
7010
  ...standing ? { standing } : {}
@@ -5846,7 +7020,10 @@ var reassessCommand = define({
5846
7020
  relocated.set(found.anchor, {
5847
7021
  ...found.anchor,
5848
7022
  file: to.file,
5849
- ...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 } } : {}
5850
7027
  });
5851
7028
  rebaselined.push({
5852
7029
  file: found.anchor.file,
@@ -5945,13 +7122,13 @@ function at(file, symbol) {
5945
7122
  }
5946
7123
 
5947
7124
  // src/commands/doctor.ts
5948
- 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}.`);
5949
7126
  var doctorCommand = define({
5950
7127
  name: "doctor",
5951
7128
  tool: "kb_doctor",
5952
7129
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
5953
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.",
5954
- input: import_zod15.z.object({
7131
+ input: import_zod18.z.object({
5955
7132
  bundlePath,
5956
7133
  repoRoot: REPO_ROOT,
5957
7134
  expiringDays: days(
@@ -5966,16 +7143,16 @@ var doctorCommand = define({
5966
7143
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
5967
7144
  DEFAULT_AGING_DAYS
5968
7145
  ),
5969
- offline: import_zod15.z.boolean().optional().describe(
7146
+ offline: import_zod18.z.boolean().optional().describe(
5970
7147
  "Read foreign anchors from the local repo cache only, never fetching."
5971
7148
  ),
5972
- strict: import_zod15.z.boolean().optional().describe(
7149
+ strict: import_zod18.z.boolean().optional().describe(
5973
7150
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
5974
7151
  ),
5975
- drifted: import_zod15.z.boolean().optional().describe(
7152
+ drifted: import_zod18.z.boolean().optional().describe(
5976
7153
  "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
5977
7154
  ),
5978
- withDiff: import_zod15.z.boolean().optional().describe(
7155
+ withDiff: import_zod18.z.boolean().optional().describe(
5979
7156
  "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
5980
7157
  )
5981
7158
  }),
@@ -6031,7 +7208,7 @@ var doctorCommand = define({
6031
7208
  ...hints.length ? { hints } : {}
6032
7209
  };
6033
7210
  }
6034
- const standings = new Map(
7211
+ const standings2 = new Map(
6035
7212
  adjudicate(records, records, new Date(checkedAt)).map((hit) => [
6036
7213
  hit.record.conceptId,
6037
7214
  hit.standing
@@ -6045,7 +7222,7 @@ var doctorCommand = define({
6045
7222
  (entry) => entry.conceptId === found.conceptId
6046
7223
  );
6047
7224
  if (!record) continue;
6048
- const standing = standings.get(record.conceptId);
7225
+ const standing = standings2.get(record.conceptId);
6049
7226
  const built = await reassessPacket(
6050
7227
  repoRoot ?? process.cwd(),
6051
7228
  record,
@@ -6082,15 +7259,16 @@ var doctorCommand = define({
6082
7259
  });
6083
7260
  function render2(result) {
6084
7261
  if (result.packets) return renderPackets(result);
6085
- const { thresholds } = result;
7262
+ const { thresholds, anchorResolvers: counts } = result;
6086
7263
  const lines = [
6087
7264
  `# KB Doctor \u2014 ${result.bundlePath}`,
6088
7265
  `records: ${result.recordCount}`,
6089
7266
  `thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
6090
7267
  `checked: ${result.checkedAt}`,
6091
- ...result.anchorResolvers.total ? [
6092
- `anchors: ${result.anchorResolvers.total} hashed \u2014 ${result.anchorResolvers.treeSitter} tree-sitter, ${result.anchorResolvers.regex} regex`
6093
- ] : [],
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}`] : [],
6094
7272
  ""
6095
7273
  ];
6096
7274
  const width2 = Math.max(...result.groups.map((group2) => group2.check.length));
@@ -6115,6 +7293,14 @@ function render2(result) {
6115
7293
  );
6116
7294
  return lines.join("\n");
6117
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
+ }
6118
7304
  function renderPackets(result) {
6119
7305
  const packets = result.packets ?? [];
6120
7306
  const lines = [
@@ -6140,20 +7326,155 @@ function renderPackets(result) {
6140
7326
  return lines.join("\n");
6141
7327
  }
6142
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
+
6143
7464
  // src/commands/impact.ts
6144
- var import_zod16 = require("zod");
7465
+ var import_zod20 = require("zod");
6145
7466
  var impactCommand = define({
6146
7467
  name: "impact",
6147
7468
  tool: "kb_impact",
6148
7469
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
6149
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.",
6150
- input: import_zod16.z.object({
7471
+ input: import_zod20.z.object({
6151
7472
  bundlePath,
6152
7473
  conceptId,
6153
- depth: import_zod16.z.number().int().positive().optional().describe(
7474
+ depth: import_zod20.z.number().int().positive().optional().describe(
6154
7475
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
6155
7476
  ),
6156
- 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(
6157
7478
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
6158
7479
  )
6159
7480
  }),
@@ -6174,15 +7495,15 @@ var impactCommand = define({
6174
7495
  });
6175
7496
 
6176
7497
  // src/commands/list.ts
6177
- var import_zod17 = require("zod");
7498
+ var import_zod21 = require("zod");
6178
7499
  var listCommand = define({
6179
7500
  name: "list",
6180
7501
  tool: "kb_list",
6181
7502
  usage: "list [type] [--tag T]...",
6182
7503
  description: "Every record, optionally one type or tag. For enumerating; use kb_query for a question.",
6183
- input: import_zod17.z.object({
7504
+ input: import_zod21.z.object({
6184
7505
  bundlePath,
6185
- type: import_zod17.z.enum(KB_RECORD_TYPES).optional(),
7506
+ type: import_zod21.z.enum(KB_RECORD_TYPES).optional(),
6186
7507
  tags: TAGS
6187
7508
  }),
6188
7509
  fromArgv: (argv, path) => {
@@ -6206,17 +7527,17 @@ var listCommand = define({
6206
7527
  });
6207
7528
 
6208
7529
  // src/commands/load.ts
6209
- var import_zod18 = require("zod");
7530
+ var import_zod22 = require("zod");
6210
7531
  var loadCommand = define({
6211
7532
  name: "load",
6212
7533
  tool: "kb_load",
6213
7534
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
6214
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.",
6215
- input: import_zod18.z.object({
7536
+ input: import_zod22.z.object({
6216
7537
  bundlePath,
6217
- type: import_zod18.z.enum(KB_RECORD_TYPES).optional(),
6218
- budgetTokens: import_zod18.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6219
- all: import_zod18.z.boolean().optional().describe(
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(
6220
7541
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
6221
7542
  ),
6222
7543
  repoRoot: REPO_ROOT
@@ -6258,25 +7579,25 @@ var loadCommand = define({
6258
7579
  });
6259
7580
 
6260
7581
  // src/commands/log.ts
6261
- var import_zod19 = require("zod");
7582
+ var import_zod23 = require("zod");
6262
7583
  var logCommand = define({
6263
7584
  name: "log",
6264
7585
  tool: "kb_log",
6265
7586
  usage: "log",
6266
7587
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
6267
- input: import_zod19.z.object({ bundlePath }),
7588
+ input: import_zod23.z.object({ bundlePath }),
6268
7589
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6269
7590
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
6270
7591
  });
6271
7592
 
6272
7593
  // src/commands/no-decision.ts
6273
- var import_zod20 = require("zod");
7594
+ var import_zod24 = require("zod");
6274
7595
  var noDecisionCommand = define({
6275
7596
  name: "no-decision",
6276
7597
  tool: "kb_no_decision",
6277
7598
  usage: "no-decision <reason...>",
6278
7599
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
6279
- input: import_zod20.z.object({ bundlePath, reason: import_zod20.z.string().min(1) }),
7600
+ input: import_zod24.z.object({ bundlePath, reason: import_zod24.z.string().min(1) }),
6280
7601
  fromArgv: (argv, path) => ({
6281
7602
  bundlePath: path,
6282
7603
  reason: argv.slice(1).join(" ").trim()
@@ -6293,20 +7614,20 @@ var noDecisionCommand = define({
6293
7614
  });
6294
7615
 
6295
7616
  // src/commands/pack.ts
6296
- var import_zod21 = require("zod");
7617
+ var import_zod25 = require("zod");
6297
7618
  var packCommand = define({
6298
7619
  name: "pack",
6299
7620
  tool: "kb_pack",
6300
7621
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
6301
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.",
6302
- input: import_zod21.z.object({
7623
+ input: import_zod25.z.object({
6303
7624
  bundlePath,
6304
7625
  conceptId,
6305
- hops: import_zod21.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6306
- maxNodes: import_zod21.z.number().int().positive().optional().describe(
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(
6307
7628
  "How many records the pack may hold, root included. Defaults to 20."
6308
7629
  ),
6309
- budgetTokens: import_zod21.z.number().int().positive().optional().describe(
7630
+ budgetTokens: import_zod25.z.number().int().positive().optional().describe(
6310
7631
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
6311
7632
  )
6312
7633
  }),
@@ -6393,22 +7714,22 @@ function warningLabel(warning) {
6393
7714
  }
6394
7715
 
6395
7716
  // src/commands/pin.ts
6396
- var import_zod22 = require("zod");
7717
+ var import_zod26 = require("zod");
6397
7718
  var pinCommand = define({
6398
7719
  name: "pin",
6399
7720
  tool: "kb_pin",
6400
7721
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
6401
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.",
6402
- input: import_zod22.z.object({
7723
+ input: import_zod26.z.object({
6403
7724
  bundlePath,
6404
- mode: import_zod22.z.enum(["full", "index"]).optional().describe(
7725
+ mode: import_zod26.z.enum(["full", "index"]).optional().describe(
6405
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."
6406
7727
  ),
6407
- profiles: import_zod22.z.array(import_zod22.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6408
- layer: import_zod22.z.enum(["project", "local", "user"]).optional().describe(
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(
6409
7730
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
6410
7731
  ),
6411
- frozen: import_zod22.z.boolean().optional().describe(
7732
+ frozen: import_zod26.z.boolean().optional().describe(
6412
7733
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
6413
7734
  )
6414
7735
  }),
@@ -6437,29 +7758,305 @@ var pinCommand = define({
6437
7758
  });
6438
7759
 
6439
7760
  // src/commands/pins.ts
6440
- var import_zod23 = require("zod");
7761
+ var import_zod27 = require("zod");
6441
7762
  var pinsCommand = define({
6442
7763
  name: "pins",
6443
7764
  tool: "kb_pins",
6444
7765
  usage: "pins",
6445
7766
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
6446
- input: import_zod23.z.object({}),
7767
+ input: import_zod27.z.object({}),
6447
7768
  fromArgv: () => ({}),
6448
7769
  run: ({ store }) => listPins(store, process.cwd())
6449
7770
  });
6450
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
+
6451
8048
  // src/commands/query.ts
6452
- var import_zod24 = require("zod");
8049
+ var import_zod29 = require("zod");
6453
8050
  var queryCommand = define({
6454
8051
  name: "query",
6455
8052
  tool: "kb_query",
6456
8053
  usage: "query <text...> [--tag T]... [--repo-root PATH]",
6457
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.",
6458
- input: import_zod24.z.object({
8055
+ input: import_zod29.z.object({
6459
8056
  bundlePath,
6460
- text: import_zod24.z.string().optional(),
6461
- type: import_zod24.z.enum(KB_RECORD_TYPES).optional(),
6462
- includeNonCurrent: import_zod24.z.boolean().optional(),
8057
+ text: import_zod29.z.string().optional(),
8058
+ type: import_zod29.z.enum(KB_RECORD_TYPES).optional(),
8059
+ includeNonCurrent: import_zod29.z.boolean().optional(),
6463
8060
  tags: TAGS,
6464
8061
  repoRoot: REPO_ROOT
6465
8062
  }),
@@ -6493,43 +8090,43 @@ var queryCommand = define({
6493
8090
  });
6494
8091
 
6495
8092
  // src/commands/read-index.ts
6496
- var import_zod25 = require("zod");
8093
+ var import_zod30 = require("zod");
6497
8094
  var readIndexCommand = define({
6498
8095
  name: "index",
6499
8096
  tool: "kb_index",
6500
8097
  usage: "index",
6501
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.",
6502
- input: import_zod25.z.object({ bundlePath }),
8099
+ input: import_zod30.z.object({ bundlePath }),
6503
8100
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6504
8101
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
6505
8102
  });
6506
8103
 
6507
8104
  // src/commands/schema.ts
6508
- var import_zod26 = require("zod");
8105
+ var import_zod31 = require("zod");
6509
8106
  var schemaCommand = define({
6510
8107
  name: "schema",
6511
8108
  tool: "kb_schema",
6512
8109
  usage: "schema",
6513
8110
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
6514
- input: import_zod26.z.object({}),
8111
+ input: import_zod31.z.object({}),
6515
8112
  fromArgv: () => ({}),
6516
8113
  run: () => Promise.resolve(kbJsonSchemas())
6517
8114
  });
6518
8115
 
6519
8116
  // src/commands/stamp.ts
6520
- var import_promises10 = require("fs/promises");
6521
- var import_zod27 = require("zod");
8117
+ var import_promises12 = require("fs/promises");
8118
+ var import_zod32 = require("zod");
6522
8119
  var DIGEST = /^[0-9a-f]{64}$/;
6523
8120
  var stampCommand = define({
6524
8121
  name: "stamp",
6525
8122
  tool: "kb_stamp",
6526
8123
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
6527
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.",
6528
- input: import_zod27.z.object({
6529
- 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(
6530
8127
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
6531
8128
  ),
6532
- since: import_zod27.z.string().min(1).optional().describe(
8129
+ since: import_zod32.z.string().min(1).optional().describe(
6533
8130
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
6534
8131
  )
6535
8132
  }),
@@ -6591,7 +8188,7 @@ async function readBaseline(since) {
6591
8188
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
6592
8189
  let parsed;
6593
8190
  try {
6594
- parsed = JSON.parse(await (0, import_promises10.readFile)(since, "utf8"));
8191
+ parsed = JSON.parse(await (0, import_promises12.readFile)(since, "utf8"));
6595
8192
  } catch {
6596
8193
  throw new KbStampBaselineError(since);
6597
8194
  }
@@ -6615,16 +8212,16 @@ async function readBaseline(since) {
6615
8212
  }
6616
8213
 
6617
8214
  // src/commands/status.ts
6618
- var import_zod28 = require("zod");
8215
+ var import_zod33 = require("zod");
6619
8216
  var statusCommand = define({
6620
8217
  name: "status",
6621
8218
  tool: "kb_status",
6622
8219
  usage: "status <concept-id> <status>",
6623
8220
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
6624
- input: import_zod28.z.object({
8221
+ input: import_zod33.z.object({
6625
8222
  bundlePath,
6626
8223
  conceptId,
6627
- status: import_zod28.z.enum(KB_RECORD_STATUSES)
8224
+ status: import_zod33.z.enum(KB_RECORD_STATUSES)
6628
8225
  }),
6629
8226
  fromArgv: (argv, path) => ({
6630
8227
  bundlePath: path,
@@ -6639,13 +8236,13 @@ var statusCommand = define({
6639
8236
  });
6640
8237
 
6641
8238
  // src/commands/supersede.ts
6642
- var import_zod29 = require("zod");
8239
+ var import_zod34 = require("zod");
6643
8240
  var supersedeCommand = define({
6644
8241
  name: "supersede",
6645
8242
  tool: "kb_supersede",
6646
8243
  usage: "supersede <concept-id> <replacement-id>",
6647
8244
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
6648
- input: import_zod29.z.object({ bundlePath, conceptId, replacementId: conceptId }),
8245
+ input: import_zod34.z.object({ bundlePath, conceptId, replacementId: conceptId }),
6649
8246
  fromArgv: (argv, path) => ({
6650
8247
  bundlePath: path,
6651
8248
  conceptId: argv[1],
@@ -6658,17 +8255,153 @@ var supersedeCommand = define({
6658
8255
  }
6659
8256
  });
6660
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
+
6661
8394
  // src/commands/sync-instructions.ts
6662
- var import_zod30 = require("zod");
8395
+ var import_zod36 = require("zod");
6663
8396
  var syncInstructionsCommand = define({
6664
8397
  name: "sync-instructions",
6665
8398
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
6666
8399
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
6667
- input: import_zod30.z.object({
6668
- file: import_zod30.z.string().min(1).describe("The instruction file to edit in place."),
6669
- budgetTokens: import_zod30.z.number().int().positive().optional(),
6670
- fullUnderTokens: import_zod30.z.number().int().positive().optional(),
6671
- profile: import_zod30.z.string().optional()
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()
6672
8405
  }),
6673
8406
  fromArgv: (argv) => {
6674
8407
  const budget = argvFlag(argv, "--budget");
@@ -6694,17 +8427,17 @@ var syncInstructionsCommand = define({
6694
8427
  });
6695
8428
 
6696
8429
  // src/commands/trace.ts
6697
- var import_zod31 = require("zod");
8430
+ var import_zod37 = require("zod");
6698
8431
  var traceCommand = define({
6699
8432
  name: "trace",
6700
8433
  tool: "kb_trace",
6701
8434
  usage: "trace <concept-id> [edges...]",
6702
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".',
6703
- input: import_zod31.z.object({
8436
+ input: import_zod37.z.object({
6704
8437
  bundlePath,
6705
8438
  conceptId,
6706
- edges: import_zod31.z.array(import_zod31.z.enum(TRACE_EDGES)).optional(),
6707
- 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()
6708
8441
  }),
6709
8442
  fromArgv: (argv, path) => ({
6710
8443
  bundlePath: path,
@@ -6726,37 +8459,37 @@ var traceCommand = define({
6726
8459
  });
6727
8460
 
6728
8461
  // src/commands/types.ts
6729
- var import_zod32 = require("zod");
8462
+ var import_zod38 = require("zod");
6730
8463
  var typesCommand = define({
6731
8464
  name: "types",
6732
8465
  tool: "kb_types",
6733
8466
  usage: "types",
6734
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.",
6735
- input: import_zod32.z.object({}),
8468
+ input: import_zod38.z.object({}),
6736
8469
  fromArgv: () => ({}),
6737
8470
  run: () => Promise.resolve(RECORD_TYPES)
6738
8471
  });
6739
8472
 
6740
8473
  // src/commands/unpin.ts
6741
- var import_zod33 = require("zod");
8474
+ var import_zod39 = require("zod");
6742
8475
  var unpinCommand = define({
6743
8476
  name: "unpin",
6744
8477
  tool: "kb_unpin",
6745
8478
  usage: "unpin [bundle-path]",
6746
8479
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
6747
- input: import_zod33.z.object({ bundlePath }),
8480
+ input: import_zod39.z.object({ bundlePath }),
6748
8481
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
6749
8482
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
6750
8483
  });
6751
8484
 
6752
8485
  // src/commands/validate.ts
6753
- var import_zod34 = require("zod");
8486
+ var import_zod40 = require("zod");
6754
8487
  var validateCommand = define({
6755
8488
  name: "validate",
6756
8489
  tool: "kb_validate",
6757
8490
  usage: "validate",
6758
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.",
6759
- input: import_zod34.z.object({ bundlePath }),
8492
+ input: import_zod40.z.object({ bundlePath }),
6760
8493
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6761
8494
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
6762
8495
  // Warnings never fail the exit code; every other severity does.
@@ -6766,16 +8499,16 @@ var validateCommand = define({
6766
8499
  });
6767
8500
 
6768
8501
  // src/commands/verify.ts
6769
- var import_zod35 = require("zod");
8502
+ var import_zod41 = require("zod");
6770
8503
  var verifyCommand = define({
6771
8504
  name: "verify",
6772
8505
  tool: "kb_verify",
6773
8506
  usage: "verify <concept-id> --note <text>",
6774
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.",
6775
- input: import_zod35.z.object({
8508
+ input: import_zod41.z.object({
6776
8509
  bundlePath,
6777
8510
  conceptId,
6778
- note: import_zod35.z.string().refine((s) => s.trim().length > 0, {
8511
+ note: import_zod41.z.string().refine((s) => s.trim().length > 0, {
6779
8512
  message: "note must say what the check found"
6780
8513
  })
6781
8514
  }),
@@ -6795,15 +8528,15 @@ var verifyCommand = define({
6795
8528
  });
6796
8529
 
6797
8530
  // src/commands/write.ts
6798
- var import_zod36 = require("zod");
8531
+ var import_zod42 = require("zod");
6799
8532
  var writeCommand = define({
6800
8533
  name: "write",
6801
8534
  tool: "kb_write",
6802
8535
  usage: "write <type> < record.json",
6803
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.",
6804
- input: import_zod36.z.object({
8537
+ input: import_zod42.z.object({
6805
8538
  bundlePath,
6806
- type: import_zod36.z.enum(KB_RECORD_TYPES),
8539
+ type: import_zod42.z.enum(KB_RECORD_TYPES),
6807
8540
  input: composeInputSchema
6808
8541
  }),
6809
8542
  fromArgv: async (argv, path, stdin) => ({
@@ -6827,13 +8560,13 @@ var writeCommand = define({
6827
8560
  });
6828
8561
 
6829
8562
  // src/commands/write-decision.ts
6830
- var import_zod37 = require("zod");
8563
+ var import_zod43 = require("zod");
6831
8564
  var writeDecisionCommand = define({
6832
8565
  name: "write-decision",
6833
8566
  tool: "kb_write_decision",
6834
8567
  usage: "write-decision < decision.json",
6835
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.",
6836
- input: import_zod37.z.object({ bundlePath, input: decisionInputSchema }),
8569
+ input: import_zod43.z.object({ bundlePath, input: decisionInputSchema }),
6837
8570
  fromArgv: async (_argv, path, stdin) => ({
6838
8571
  bundlePath: path,
6839
8572
  input: JSON.parse(await stdin())
@@ -6864,19 +8597,24 @@ var KB_COMMANDS = [
6864
8597
  verifyCommand,
6865
8598
  anchorResolveCommand,
6866
8599
  reassessCommand,
8600
+ promoteCommand,
6867
8601
  loadCommand,
6868
8602
  catalogCommand,
6869
8603
  packCommand,
8604
+ exportCommand,
6870
8605
  queryCommand,
6871
8606
  traceCommand,
6872
8607
  impactCommand,
6873
8608
  backlinksCommand,
8609
+ matchCommand,
8610
+ classifyCommand,
6874
8611
  listCommand,
6875
8612
  readIndexCommand,
6876
8613
  logCommand,
6877
8614
  stampCommand,
6878
8615
  validateCommand,
6879
8616
  doctorCommand,
8617
+ sweepCommand,
6880
8618
  schemaCommand,
6881
8619
  pinCommand,
6882
8620
  unpinCommand,
@@ -6894,7 +8632,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
6894
8632
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
6895
8633
 
6896
8634
  // src/version.ts
6897
- var VERSION = true ? "0.1.19" : "0.0.0-dev";
8635
+ var VERSION = true ? "0.1.20" : "0.0.0-dev";
6898
8636
 
6899
8637
  // src/mcp.ts
6900
8638
  function createKbMcpServer() {
@@ -6933,7 +8671,7 @@ async function runKbMcpServer() {
6933
8671
  }
6934
8672
 
6935
8673
  // src/cli.ts
6936
- var import_node_path12 = require("path");
8674
+ var import_node_path15 = require("path");
6937
8675
  async function runKbCli(argv) {
6938
8676
  const { flags, literal } = takeLiteral(argv);
6939
8677
  const {
@@ -6995,7 +8733,7 @@ function takeLiteral(argv) {
6995
8733
  function takeBundle(argv) {
6996
8734
  const at2 = argv.indexOf("--bundle");
6997
8735
  if (at2 === -1) {
6998
- 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 };
6999
8737
  }
7000
8738
  const bundle = argv[at2 + 1];
7001
8739
  if (!bundle) die("--bundle requires a path");
@@ -7006,11 +8744,11 @@ function takeBundle(argv) {
7006
8744
  };
7007
8745
  }
7008
8746
  function readStdin() {
7009
- return new Promise((resolve6, reject) => {
8747
+ return new Promise((resolve7, reject) => {
7010
8748
  let text = "";
7011
8749
  process.stdin.setEncoding("utf8");
7012
8750
  process.stdin.on("data", (chunk) => text += chunk);
7013
- process.stdin.on("end", () => resolve6(text));
8751
+ process.stdin.on("end", () => resolve7(text));
7014
8752
  process.stdin.on("error", reject);
7015
8753
  });
7016
8754
  }
@@ -7055,12 +8793,14 @@ function usage() {
7055
8793
  DEFAULT_LOAD_BUDGET,
7056
8794
  DEFAULT_PACK_HOPS,
7057
8795
  DEFAULT_PACK_MAX_NODES,
8796
+ DEFAULT_THRESHOLDS,
7058
8797
  DEFAULT_TYPED_LINK_RELS,
7059
8798
  DEFAULT_UNVERIFIED_DAYS,
7060
8799
  ErrorTypes,
7061
8800
  Fault,
7062
8801
  INDEX_FILE,
7063
8802
  KB_CAUSAL_LINK_RELS,
8803
+ KB_CLASSES,
7064
8804
  KB_COMMANDS,
7065
8805
  KB_COMMANDS_BY_NAME,
7066
8806
  KB_CONCEPT_ID_PATTERN,
@@ -7074,10 +8814,15 @@ function usage() {
7074
8814
  KB_RECORD_TYPES,
7075
8815
  KB_SLUG_PATTERN,
7076
8816
  KbBaseFrozenError,
8817
+ KbClassifyInputError,
7077
8818
  KbInvalidConceptIdError,
7078
8819
  KbMissingFlagValueError,
7079
8820
  KbPackBudgetExceededError,
7080
8821
  KbPinsMalformedError,
8822
+ KbPromoteCollisionError,
8823
+ KbPromoteSelfError,
8824
+ KbPromoteStandingError,
8825
+ KbPromoteStoppedError,
7081
8826
  KbRecordAlreadyExistsError,
7082
8827
  KbRecordNotFoundError,
7083
8828
  KbSelfVerificationError,
@@ -7090,16 +8835,20 @@ function usage() {
7090
8835
  PINS_FILE,
7091
8836
  PINS_LOCAL_FILE,
7092
8837
  PIN_LAYERS,
8838
+ PROMOTION_SOURCE_ID,
7093
8839
  RECORD_TYPES,
7094
8840
  SEARCH_INDEX_FILE,
7095
8841
  TRACE_EDGES,
7096
8842
  TreeSitterResolver,
7097
8843
  adjudicate,
7098
8844
  anchorFilePath,
8845
+ anchorOnHunk,
7099
8846
  assertBaseNotFrozen,
7100
8847
  backlinks,
7101
8848
  buildContext,
8849
+ carry,
7102
8850
  catalog,
8851
+ classifyDiff,
7103
8852
  classifyDrift,
7104
8853
  composeDecisionRecord,
7105
8854
  composeInputSchema,
@@ -7125,8 +8874,11 @@ function usage() {
7125
8874
  isKbLinkRel,
7126
8875
  isKbRecordType,
7127
8876
  isNoDecisionRecord,
8877
+ isReviewTag,
7128
8878
  kbActorStampSchema,
7129
8879
  kbAnchorSchema,
8880
+ kbAnchorSpanSchema,
8881
+ kbAnchorWriteSchema,
7130
8882
  kbConceptIdSchema,
7131
8883
  kbJsonSchemas,
7132
8884
  kbLinkSchema,
@@ -7146,6 +8898,8 @@ function usage() {
7146
8898
  parseMarkdownWithFrontmatter,
7147
8899
  pinBase,
7148
8900
  prepareResolvers,
8901
+ promoteCandidates,
8902
+ promoteInputSchema,
7149
8903
  readMergedPins,
7150
8904
  readPinsLayer,
7151
8905
  readRemoteAnchors,
@@ -7167,6 +8921,7 @@ function usage() {
7167
8921
  selectDecisions,
7168
8922
  splitMarkdownFrontmatter,
7169
8923
  stringifyMarkdownWithFrontmatter,
8924
+ symbolRangeIndex,
7170
8925
  syncInstructions,
7171
8926
  toHookJson,
7172
8927
  trace,