@saasontools/strauss-kb 0.1.17 → 0.1.18

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
@@ -85,6 +85,7 @@ __export(index_exports, {
85
85
  backlinks: () => backlinks,
86
86
  buildContext: () => buildContext,
87
87
  catalog: () => catalog,
88
+ classifyDrift: () => classifyDrift,
88
89
  composeDecisionRecord: () => composeDecisionRecord,
89
90
  composeInputSchema: () => composeInputSchema,
90
91
  composeLinkSchema: () => composeLinkSchema,
@@ -132,6 +133,7 @@ __export(index_exports, {
132
133
  readMergedPins: () => readMergedPins,
133
134
  readPinsLayer: () => readPinsLayer,
134
135
  readRemoteAnchors: () => readRemoteAnchors,
136
+ reassessPacket: () => reassessPacket,
135
137
  regexResolver: () => regexResolver,
136
138
  renderCatalogLine: () => renderCatalogLine,
137
139
  renderIndex: () => renderIndex,
@@ -153,6 +155,7 @@ __export(index_exports, {
153
155
  toHookJson: () => toHookJson,
154
156
  trace: () => trace,
155
157
  treeSitterLanguages: () => treeSitterLanguages,
158
+ unifiedDiff: () => unifiedDiff,
156
159
  unpinBase: () => unpinBase,
157
160
  validateBundle: () => validateBundle
158
161
  });
@@ -181,9 +184,9 @@ async function mapLimit(items, limit, fn) {
181
184
  { length: Math.min(limit, items.length) },
182
185
  async () => {
183
186
  while (!failed && next < items.length) {
184
- const at = next++;
187
+ const at2 = next++;
185
188
  try {
186
- out[at] = await fn(items[at], at);
189
+ out[at2] = await fn(items[at2], at2);
187
190
  } catch (error) {
188
191
  failed = true;
189
192
  throw error;
@@ -262,6 +265,14 @@ var kbAnchorSchema = import_zod.z.object({
262
265
  hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
263
266
  message: "hash must be sha256:<64 hex chars>"
264
267
  }).optional(),
268
+ /**
269
+ * What `hash` was taken over: the span's raw text, or the normalised token
270
+ * stream a parser sees (`ast`). Absent means `raw`, which is what every
271
+ * anchor stamped before this field carries, so old hashes keep comparing
272
+ * the way they were written. An `ast` hash is blind to whitespace and
273
+ * comments, so reformatting the anchored code is not drift.
274
+ */
275
+ hash_kind: import_zod.z.enum(["raw", "ast"]).optional(),
265
276
  /** ISO 8601 timestamp of the last successful resolution. */
266
277
  resolved_at: import_zod.z.string().min(1).optional(),
267
278
  /** Line count of the text the hash was taken over. */
@@ -676,8 +687,8 @@ function safeSegment(value) {
676
687
  function revRef(rev) {
677
688
  const safe = rev.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64);
678
689
  let hash = 5381;
679
- for (let at = 0; at < rev.length; at++) {
680
- hash = (hash * 33 ^ rev.charCodeAt(at)) >>> 0;
690
+ for (let at2 = 0; at2 < rev.length; at2++) {
691
+ hash = (hash * 33 ^ rev.charCodeAt(at2)) >>> 0;
681
692
  }
682
693
  return `refs/strauss/${safe}-${hash.toString(16)}`;
683
694
  }
@@ -786,8 +797,8 @@ function repoUrlIsSafe(repo) {
786
797
  if (!scheme?.[1]) return false;
787
798
  if (!allowed.includes(scheme[1].toLowerCase())) return false;
788
799
  const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
789
- const at = authority.lastIndexOf("@");
790
- return at < 0 || !authority.slice(0, at).includes(":");
800
+ const at2 = authority.lastIndexOf("@");
801
+ return at2 < 0 || !authority.slice(0, at2).includes(":");
791
802
  }
792
803
  function protocolArgs() {
793
804
  const allowed = allowedProtocols();
@@ -878,7 +889,7 @@ async function readOneRepo(repo, url, declared, context) {
878
889
  return new Map([
879
890
  ...rejected2,
880
891
  ...wants.map(
881
- (want, at) => [wantKey(repo, want.ref, want.file), reads[at]]
892
+ (want, at2) => [wantKey(repo, want.ref, want.file), reads[at2]]
882
893
  )
883
894
  ]);
884
895
  }
@@ -1067,7 +1078,8 @@ function warningAnchor(entry) {
1067
1078
  diffSize,
1068
1079
  ...reason !== void 0 ? { reason } : {},
1069
1080
  ...repo !== void 0 ? { repo } : {},
1070
- ...remoteState !== void 0 ? { remoteState } : {}
1081
+ ...remoteState !== void 0 ? { remoteState } : {},
1082
+ ...entry.class !== void 0 ? { class: entry.class } : {}
1071
1083
  };
1072
1084
  }
1073
1085
  function resolveHeads(from, byId) {
@@ -1238,7 +1250,7 @@ async function readAnchorFiles(files, read, concurrency = DEFAULT_IO_CONCURRENCY
1238
1250
  return { ok: false, reason: "file-unreadable" };
1239
1251
  }
1240
1252
  });
1241
- return new Map(wanted.map((file, at) => [file, results[at]]));
1253
+ return new Map(wanted.map((file, at2) => [file, results[at2]]));
1242
1254
  }
1243
1255
 
1244
1256
  // src/anchor-resolver/resolver.ts
@@ -1428,8 +1440,8 @@ async function ensureGrammar(language, options = {}) {
1428
1440
  return miss(language, `grammar tree-sitter-${language}`, grammar);
1429
1441
  const parts = [];
1430
1442
  const total = pack2.tags.length;
1431
- for (const [at, part] of pack2.tags.entries()) {
1432
- const name = `${language} tags${total > 1 ? ` part ${at + 1}/${total}` : ""}`;
1443
+ for (const [at2, part] of pack2.tags.entries()) {
1444
+ const name = `${language} tags${total > 1 ? ` part ${at2 + 1}/${total}` : ""}`;
1433
1445
  const path = grammarCachePath(root, language, part.sha256, "scm");
1434
1446
  const held = await ensurePart(path, name, part, options);
1435
1447
  if (held !== true) return miss(language, name, held);
@@ -1577,8 +1589,8 @@ function typeNameIn(receiver) {
1577
1589
  while (stack.length) {
1578
1590
  const node = stack.pop();
1579
1591
  if (node.type === "type_identifier") return node.text;
1580
- for (let at = 0; at < node.childCount; at++) {
1581
- const child = node.child(at);
1592
+ for (let at2 = 0; at2 < node.childCount; at2++) {
1593
+ const child = node.child(at2);
1582
1594
  if (child) stack.push(child);
1583
1595
  }
1584
1596
  }
@@ -1587,7 +1599,7 @@ function typeNameIn(receiver) {
1587
1599
  function endsWith(chain, wanted) {
1588
1600
  if (wanted.length > chain.length) return false;
1589
1601
  const offset = chain.length - wanted.length;
1590
- return wanted.every((segment, at) => chain[offset + at] === segment);
1602
+ return wanted.every((segment, at2) => chain[offset + at2] === segment);
1591
1603
  }
1592
1604
  function width(node) {
1593
1605
  return node.endIndex - node.startIndex;
@@ -1613,6 +1625,26 @@ function spanOf(definition, source) {
1613
1625
  };
1614
1626
  }
1615
1627
 
1628
+ // src/tree-sitter-resolver/tokens.ts
1629
+ function tokens(root) {
1630
+ const out = [];
1631
+ const stack = [root];
1632
+ while (stack.length) {
1633
+ const node = stack.pop();
1634
+ if (node.type.includes("comment")) continue;
1635
+ if (node.childCount === 0) {
1636
+ const text = node.text.trim();
1637
+ if (text) out.push(text);
1638
+ continue;
1639
+ }
1640
+ for (let at2 = node.childCount - 1; at2 >= 0; at2--) {
1641
+ const child = node.child(at2);
1642
+ if (child) stack.push(child);
1643
+ }
1644
+ }
1645
+ return out;
1646
+ }
1647
+
1616
1648
  // src/tree-sitter-resolver/resolver.ts
1617
1649
  var TREE_CACHE_LIMIT = 32;
1618
1650
  var TreeSitterResolver = class {
@@ -1658,7 +1690,7 @@ var TreeSitterResolver = class {
1658
1690
  (language) => this.load(language)
1659
1691
  );
1660
1692
  languages.forEach(
1661
- (language, at) => this.loaded.set(language, loaded[at] ?? null)
1693
+ (language, at2) => this.loaded.set(language, loaded[at2] ?? null)
1662
1694
  );
1663
1695
  }
1664
1696
  /**
@@ -1746,6 +1778,60 @@ var TreeSitterResolver = class {
1746
1778
  this.trees.set(key2, parsed);
1747
1779
  return parsed;
1748
1780
  }
1781
+ /**
1782
+ * Every definition this file declares, as dotted symbol and span.
1783
+ *
1784
+ * The inverse of `attempt`: that asks "where is this name", this asks "what
1785
+ * names are here". `moved` needs the second — the stored hash has to be
1786
+ * looked for at every definition in the repository, and there is no name to
1787
+ * ask about, since the whole question is which name now carries that code.
1788
+ */
1789
+ spans(source, file) {
1790
+ const language = languageForFile(file);
1791
+ if (!language) return [];
1792
+ const loaded = this.loaded.get(language);
1793
+ if (!loaded) return [];
1794
+ const parsed = this.parse(language, loaded, source);
1795
+ if (!parsed) return [];
1796
+ return parsed.definitions.filter((definition) => definition.target).map((definition) => ({
1797
+ symbol: chainOf(definition, parsed.byNodeId).join("."),
1798
+ span: spanOf(definition, source)
1799
+ }));
1800
+ }
1801
+ /**
1802
+ * The token stream of a span: every leaf the parser sees, comments dropped,
1803
+ * joined by single spaces.
1804
+ *
1805
+ * This is what makes a reformat not be drift. Hashing it rather than the raw
1806
+ * text means indentation, line breaks, trailing commas the formatter moved,
1807
+ * and every comment above or inside the definition are outside the hash —
1808
+ * and a renamed identifier or a changed literal is still inside it, because
1809
+ * those are leaves.
1810
+ *
1811
+ * `null` when the file has no grammar, the grammar would not load, or the
1812
+ * text will not parse: no normalisation is better than a guessed one.
1813
+ */
1814
+ normalize(text, file) {
1815
+ const language = file ? languageForFile(file) : void 0;
1816
+ if (!language) return null;
1817
+ const loaded = this.loaded.get(language);
1818
+ if (!loaded) return null;
1819
+ const parser = this.parser;
1820
+ if (!parser) return null;
1821
+ let tree;
1822
+ try {
1823
+ parser.setLanguage(loaded.language);
1824
+ tree = parser.parse(text);
1825
+ } catch {
1826
+ return null;
1827
+ }
1828
+ if (!tree) return null;
1829
+ try {
1830
+ return tokens(tree.rootNode).join(" ");
1831
+ } finally {
1832
+ tree.delete();
1833
+ }
1834
+ }
1749
1835
  /** Drops cached trees. Grammars stay loaded — they are immutable. */
1750
1836
  reset() {
1751
1837
  for (const parsed of this.trees.values()) parsed.tree.delete();
@@ -1899,7 +1985,7 @@ var regexResolver = {
1899
1985
  );
1900
1986
  const nearest = Math.min(...distances);
1901
1987
  if (Number.isFinite(nearest)) {
1902
- candidates = candidates.filter((_, at) => distances[at] === nearest);
1988
+ candidates = candidates.filter((_, at2) => distances[at2] === nearest);
1903
1989
  }
1904
1990
  }
1905
1991
  if (candidates.length !== 1) return null;
@@ -1914,8 +2000,8 @@ function escapeRegExp(value) {
1914
2000
  }
1915
2001
  function distanceToParent(lines, index2, parent) {
1916
2002
  const floor = Math.max(0, index2 - PARENT_SCOPE_LINES);
1917
- for (let at = index2; at >= floor; at--) {
1918
- if (parent.test(lines[at] ?? "")) return index2 - at;
2003
+ for (let at2 = index2; at2 >= floor; at2--) {
2004
+ if (parent.test(lines[at2] ?? "")) return index2 - at2;
1919
2005
  }
1920
2006
  return Number.POSITIVE_INFINITY;
1921
2007
  }
@@ -1947,10 +2033,12 @@ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1947
2033
  if (attempt.reason === "symbol-not-found") continue;
1948
2034
  return { ok: false, reason: attempt.reason };
1949
2035
  }
2036
+ const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
1950
2037
  return {
1951
2038
  ok: true,
1952
2039
  span: attempt.span,
1953
- ...isResolverName(resolver.name) ? { resolver: resolver.name } : {}
2040
+ ...isResolverName(resolver.name) ? { resolver: resolver.name } : {},
2041
+ ...tokens2 ? { normalized: tokens2 } : {}
1954
2042
  };
1955
2043
  }
1956
2044
  return { ok: false, reason: "symbol-not-found" };
@@ -1978,6 +2066,11 @@ function resolverChanged(source, anchor, produced) {
1978
2066
  );
1979
2067
  return before !== null && hashAnchorText(before.text) === anchor.hash;
1980
2068
  }
2069
+ function anchorHashOf(anchor, outcome) {
2070
+ const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
2071
+ const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
2072
+ return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
2073
+ }
1981
2074
 
1982
2075
  // src/anchor-resolver/drift.ts
1983
2076
  async function detectAnchorDrift(records, options = {}) {
@@ -2057,16 +2150,29 @@ function unresolved(anchor, reason, repo) {
2057
2150
  state: "unresolved",
2058
2151
  diffSize: null,
2059
2152
  ...reason ? { reason } : {},
2060
- ...repo ? { repo } : {}
2153
+ ...repo ? { repo } : {},
2154
+ ...classOf(reason)
2061
2155
  };
2062
2156
  }
2157
+ function provisionalDriftClass(entry) {
2158
+ if (entry.state === "unresolved") {
2159
+ return entry.reason === "file-missing" || entry.reason === "symbol-not-found" ? "gone" : void 0;
2160
+ }
2161
+ return entry.state === "drifted" ? "changed" : void 0;
2162
+ }
2163
+ function classOf(reason) {
2164
+ const settled = provisionalDriftClass({ state: "unresolved", reason });
2165
+ return settled ? { class: settled } : {};
2166
+ }
2063
2167
  function hashIn(source, anchor, resolvers) {
2064
2168
  const outcome = resolveAnchorSpan(source, anchor, resolvers);
2065
2169
  if (!outcome.ok) return { ok: false, reason: outcome.reason };
2170
+ const { hash, kind } = anchorHashOf(anchor, outcome);
2066
2171
  return {
2067
2172
  ok: true,
2068
2173
  current: {
2069
- hash: hashAnchorText(outcome.span.text),
2174
+ hash,
2175
+ kind,
2070
2176
  lines: outcome.span.endLine - outcome.span.startLine + 1,
2071
2177
  ...outcome.resolver ? { resolver: outcome.resolver } : {}
2072
2178
  }
@@ -2079,11 +2185,14 @@ function resolverExtras(source, anchor, current) {
2079
2185
  };
2080
2186
  }
2081
2187
  function compared(anchor, current, extra = {}) {
2188
+ const matched = current.hash === anchor.hash;
2082
2189
  return {
2083
2190
  ...base(anchor),
2084
- state: current.hash === anchor.hash ? "match" : "drifted",
2191
+ state: matched ? "match" : "drifted",
2085
2192
  currentHash: current.hash,
2193
+ hashKind: current.kind,
2086
2194
  diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
2195
+ ...matched ? {} : { class: "changed" },
2087
2196
  ...extra
2088
2197
  };
2089
2198
  }
@@ -2240,8 +2349,8 @@ async function isStale(bundlePath2) {
2240
2349
  let stale = false;
2241
2350
  await mapLimit(names, DEFAULT_IO_CONCURRENCY, async (name) => {
2242
2351
  if (stale) return;
2243
- const at = await (0, import_promises5.stat)((0, import_node_path6.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
2244
- if (at > indexAt) stale = true;
2352
+ const at2 = await (0, import_promises5.stat)((0, import_node_path6.join)(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
2353
+ if (at2 > indexAt) stale = true;
2245
2354
  });
2246
2355
  return stale;
2247
2356
  }
@@ -2536,8 +2645,8 @@ function trace(seedId, bundle, options = {}) {
2536
2645
  return [...reached.values()].sort(byGeneratedAt);
2537
2646
  }
2538
2647
  function byGeneratedAt(left, right) {
2539
- const at = (step) => step.record.frontmatter.generated?.at ?? "";
2540
- return at(left).localeCompare(at(right)) || left.depth - right.depth;
2648
+ const at2 = (step) => step.record.frontmatter.generated?.at ?? "";
2649
+ return at2(left).localeCompare(at2(right)) || left.depth - right.depth;
2541
2650
  }
2542
2651
 
2543
2652
  // src/pack.ts
@@ -2973,8 +3082,8 @@ var KbStore = class {
2973
3082
  * and the refusal is logged under its own operation name — `mutate` only
2974
3083
  * logs what it publishes.
2975
3084
  */
2976
- async verify(bundlePath2, conceptId2, note, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
2977
- const event = kbVerifiedEventSchema.parse({ by: actor, at, note });
3085
+ async verify(bundlePath2, conceptId2, note, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
3086
+ const event = kbVerifiedEventSchema.parse({ by: actor, at: at2, note });
2978
3087
  const existing = await this.read(bundlePath2, conceptId2);
2979
3088
  if (!existing) throw new KbRecordNotFoundError(conceptId2);
2980
3089
  const generatedBy = existing.frontmatter.generated?.by;
@@ -3026,14 +3135,14 @@ var KbStore = class {
3026
3135
  return superseded;
3027
3136
  }
3028
3137
  /** Resolves an open question, stamping who answered and when. */
3029
- async answer(bundlePath2, conceptId2, answer, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
3138
+ async answer(bundlePath2, conceptId2, answer, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
3030
3139
  return this.mutate(
3031
3140
  bundlePath2,
3032
3141
  conceptId2,
3033
3142
  (frontmatter) => ({
3034
3143
  ...frontmatter,
3035
3144
  strauss_status: "resolved",
3036
- strauss_answered: { by: actor, at }
3145
+ strauss_answered: { by: actor, at: at2 }
3037
3146
  }),
3038
3147
  { operation: "answer", by: actor },
3039
3148
  (body) => `${body.trimEnd()}
@@ -3202,24 +3311,34 @@ ${answer}
3202
3311
  }
3203
3312
  /**
3204
3313
  * `load`'s digest without `load`'s bodies — the same records, adjudicated
3205
- * the same way, handed back as a stamp. Skips the anchor drift pass, which
3206
- * reads source files and only ever adds warnings: no warning reaches the
3207
- * digest, so the value is identical to the one `load` returns.
3314
+ * the same way, handed back as a stamp.
3315
+ *
3316
+ * Drift is counted but kept out of the digest, which is what lets the reload
3317
+ * hook ask one question and get two answers: whether the base moved, and
3318
+ * whether the code under it did. A `load` and a `stamp` of the same base
3319
+ * still agree on the digest, because no warning has ever reached it.
3208
3320
  */
3209
- async stamp(bundlePath2) {
3321
+ async stamp(bundlePath2, options = {}) {
3210
3322
  const bundle = await this.list(bundlePath2);
3211
3323
  const adjudicated = adjudicate(bundle, bundle, /* @__PURE__ */ new Date());
3212
3324
  const current = adjudicated.filter((hit) => hit.standing !== "superseded");
3213
3325
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
3214
3326
  const stamped = bundleStamp(current, superseded);
3215
- const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at) => typeof at === "string").sort();
3327
+ const dates = bundle.map((record) => record.frontmatter.generated?.at ?? null).filter((at2) => typeof at2 === "string").sort();
3328
+ const drift = await this.detectDrift(bundle, options.repoRoot);
3329
+ const drifted2 = drift === void 0 ? null : [...drift.values()].filter(
3330
+ (entries) => entries.some(
3331
+ (entry) => entry.state !== "match" && !isUncheckedReason(entry.reason)
3332
+ )
3333
+ ).length;
3216
3334
  return {
3217
3335
  path: bundlePath2,
3218
3336
  digest: stamped.digest,
3219
3337
  recordCount: bundle.length,
3220
3338
  superseded: superseded.length,
3221
3339
  newestAt: dates.at(-1) ?? null,
3222
- records: stamped.records
3340
+ records: stamped.records,
3341
+ drifted: drifted2
3223
3342
  };
3224
3343
  }
3225
3344
  /** How a position was arrived at, as a timeline. See `trace.ts`. */
@@ -3876,7 +3995,7 @@ async function listPins(store, workspaceDir) {
3876
3995
  }
3877
3996
 
3878
3997
  // src/kb-pins/pin.ts
3879
- async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
3998
+ async function pinBase(store, workspaceDir, bundlePath2, at2, options = {}) {
3880
3999
  const layer = options.layer ?? "project";
3881
4000
  const root = layerRoot(workspaceDir, layer);
3882
4001
  const manifest = await readPinsLayer(workspaceDir, layer);
@@ -3904,7 +4023,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
3904
4023
  return {
3905
4024
  path: existing.path,
3906
4025
  layer,
3907
- pinnedAt: existing.pinnedAt ?? at,
4026
+ pinnedAt: existing.pinnedAt ?? at2,
3908
4027
  alreadyPinned: true,
3909
4028
  ...updated.mode ? { mode: updated.mode } : {},
3910
4029
  ...updated.profiles ? { profiles: updated.profiles } : {},
@@ -3914,7 +4033,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
3914
4033
  }
3915
4034
  const entry = {
3916
4035
  path: storablePath(root, bundlePath2),
3917
- pinnedAt: at,
4036
+ pinnedAt: at2,
3918
4037
  ...fields
3919
4038
  };
3920
4039
  await writePinsLayer(workspaceDir, layer, {
@@ -3924,7 +4043,7 @@ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
3924
4043
  return {
3925
4044
  path: entry.path,
3926
4045
  layer,
3927
- pinnedAt: at,
4046
+ pinnedAt: at2,
3928
4047
  alreadyPinned: false,
3929
4048
  ...fields,
3930
4049
  ...warning ? { warning } : {}
@@ -4452,18 +4571,18 @@ function expired(hits, now) {
4452
4571
  for (const hit of hits) {
4453
4572
  const raw = hit.record.frontmatter.stale_after;
4454
4573
  if (!raw) continue;
4455
- const at = Date.parse(raw);
4456
- if (Number.isNaN(at)) {
4574
+ const at2 = Date.parse(raw);
4575
+ if (Number.isNaN(at2)) {
4457
4576
  findings.push(
4458
4577
  finding(hit.record, `stale_after "${raw}" is not a readable date`)
4459
4578
  );
4460
4579
  continue;
4461
4580
  }
4462
- if (at < now.getTime()) {
4581
+ if (at2 < now.getTime()) {
4463
4582
  findings.push(
4464
4583
  finding(
4465
4584
  hit.record,
4466
- `stale since ${raw} (${daysBetween(at, now.getTime())} days ago)`
4585
+ `stale since ${raw} (${daysBetween(at2, now.getTime())} days ago)`
4467
4586
  )
4468
4587
  );
4469
4588
  }
@@ -4476,12 +4595,12 @@ function expiring(hits, now, withinDays) {
4476
4595
  for (const hit of hits) {
4477
4596
  const raw = hit.record.frontmatter.stale_after;
4478
4597
  if (!raw) continue;
4479
- const at = Date.parse(raw);
4480
- if (Number.isNaN(at) || at < now.getTime() || at > horizon) continue;
4598
+ const at2 = Date.parse(raw);
4599
+ if (Number.isNaN(at2) || at2 < now.getTime() || at2 > horizon) continue;
4481
4600
  findings.push(
4482
4601
  finding(
4483
4602
  hit.record,
4484
- `goes stale ${raw} (in ${daysBetween(now.getTime(), at)} days)`
4603
+ `goes stale ${raw} (in ${daysBetween(now.getTime(), at2)} days)`
4485
4604
  )
4486
4605
  );
4487
4606
  }
@@ -4651,13 +4770,16 @@ function anchorFindings(hits, kind, headline) {
4651
4770
  );
4652
4771
  }
4653
4772
  function describeAnchor(anchor) {
4654
- const at = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
4655
- if (anchor.reason) return `${at} (${anchor.reason})`;
4773
+ const at2 = anchor.symbol ? `${anchor.file}:${anchor.symbol}` : anchor.file;
4774
+ if (anchor.class === "gone") {
4775
+ return `${at2} gone${anchor.reason ? ` (${anchor.reason})` : ""}`;
4776
+ }
4777
+ if (anchor.reason) return `${at2} (${anchor.reason})`;
4656
4778
  if (anchor.remoteState === "drifted-on-default") {
4657
- return `${at} (matches ref, moved on the default branch)`;
4779
+ return `${at2} (matches ref, moved on the default branch)`;
4658
4780
  }
4659
- if (anchor.diffSize === null) return `${at} (changed, size unrecorded)`;
4660
- return anchor.diffSize === 0 ? `${at} (content changed, same line count)` : `${at} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
4781
+ if (anchor.diffSize === null) return `${at2} (changed, size unrecorded)`;
4782
+ return anchor.diffSize === 0 ? `${at2} (content changed, same line count)` : `${at2} (${anchor.diffSize} line${anchor.diffSize === 1 ? "" : "s"} apart)`;
4661
4783
  }
4662
4784
  function replaces(later, earlier) {
4663
4785
  return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
@@ -4674,9 +4796,9 @@ function daysBetween(from, to) {
4674
4796
  return Math.max(0, Math.floor((to - from) / DAY_MS));
4675
4797
  }
4676
4798
  function ageInDays(record, now) {
4677
- const at = record.frontmatter.generated?.at;
4678
- if (!at) return null;
4679
- const written = Date.parse(at);
4799
+ const at2 = record.frontmatter.generated?.at;
4800
+ if (!at2) return null;
4801
+ const written = Date.parse(at2);
4680
4802
  if (Number.isNaN(written)) return null;
4681
4803
  return daysBetween(written, now.getTime());
4682
4804
  }
@@ -4748,9 +4870,9 @@ function argvFlag(argv, name) {
4748
4870
  if (!value2) throw new KbMissingFlagValueError(name);
4749
4871
  return value2;
4750
4872
  }
4751
- const at = argv.indexOf(name);
4752
- if (at === -1) return void 0;
4753
- const value = argv[at + 1];
4873
+ const at2 = argv.indexOf(name);
4874
+ if (at2 === -1) return void 0;
4875
+ const value = argv[at2 + 1];
4754
4876
  if (value === void 0 || value.startsWith("--")) {
4755
4877
  throw new KbMissingFlagValueError(name);
4756
4878
  }
@@ -4843,11 +4965,14 @@ var anchorResolveCommand = define({
4843
4965
  }
4844
4966
  const resolved = outcome.span;
4845
4967
  const producedBy = outcome.resolver;
4846
- const currentHash = hashAnchorText(resolved.text);
4968
+ const { hash: currentHash, kind } = anchorHashOf(anchor, outcome);
4847
4969
  const currentLines = resolved.endLine - resolved.startLine + 1;
4970
+ const stampedKind = outcome.normalized ? "ast" : "raw";
4971
+ const stampedHash = outcome.normalized ? anchorHashOf({ ...anchor, hash: void 0 }, outcome).hash : currentHash;
4848
4972
  const stamped = {
4849
4973
  ...anchor,
4850
- hash: currentHash,
4974
+ hash: stampedHash,
4975
+ hash_kind: stampedKind,
4851
4976
  lines: currentLines,
4852
4977
  resolved_at: now(),
4853
4978
  ...producedBy ? { resolver: producedBy } : {}
@@ -4857,7 +4982,8 @@ var anchorResolveCommand = define({
4857
4982
  results.push({
4858
4983
  ...base2,
4859
4984
  state: "stamped",
4860
- currentHash,
4985
+ currentHash: stampedHash,
4986
+ hashKind: stampedKind,
4861
4987
  ...producedBy ? { resolver: producedBy } : {}
4862
4988
  });
4863
4989
  updated.push(stamped);
@@ -4869,6 +4995,7 @@ var anchorResolveCommand = define({
4869
4995
  ...base2,
4870
4996
  state: "drifted",
4871
4997
  currentHash,
4998
+ hashKind: kind,
4872
4999
  diffSize: lineDelta(anchor, currentLines),
4873
5000
  ...producedBy ? { resolver: producedBy } : {},
4874
5001
  // A regex-stamped anchor re-read by tree-sitter drifts because the
@@ -4897,6 +5024,7 @@ var anchorResolveCommand = define({
4897
5024
  ...base2,
4898
5025
  state: "match",
4899
5026
  currentHash,
5027
+ hashKind: kind,
4900
5028
  ...producedBy ? { resolver: producedBy } : {},
4901
5029
  ...pinned ? { remoteState: "matches-ref" } : {}
4902
5030
  });
@@ -5180,14 +5308,567 @@ var contextCommand = define({
5180
5308
  });
5181
5309
 
5182
5310
  // src/commands/doctor.ts
5311
+ var import_zod15 = require("zod");
5312
+
5313
+ // src/drift/git.ts
5314
+ var import_node_child_process3 = require("child_process");
5315
+ var import_node_util3 = require("util");
5316
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
5317
+ var MAX_GIT_OUTPUT_BYTES = 1048576;
5318
+ var GIT_TIMEOUT_MS = 5e3;
5319
+ async function git2(cwd, args) {
5320
+ const env = { ...process.env };
5321
+ delete env["GIT_DIR"];
5322
+ delete env["GIT_WORK_TREE"];
5323
+ delete env["GIT_INDEX_FILE"];
5324
+ try {
5325
+ const { stdout } = await execFileAsync3("git", ["-C", cwd, ...args], {
5326
+ timeout: GIT_TIMEOUT_MS,
5327
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
5328
+ env
5329
+ });
5330
+ return { ok: true, stdout };
5331
+ } catch {
5332
+ return { ok: false };
5333
+ }
5334
+ }
5335
+ async function listRepoFiles(repoRoot) {
5336
+ const result = await git2(repoRoot, ["ls-files", "-z", "--cached"]);
5337
+ if (!result.ok) return [];
5338
+ return result.stdout.split("\0").filter(Boolean);
5339
+ }
5340
+ async function readOldSource(repoRoot, anchor) {
5341
+ if (!filePathIsSafe(anchor.file))
5342
+ return { ok: false, reason: "unrecoverable" };
5343
+ if (anchor.ref && refShapeIsSafe(anchor.ref)) {
5344
+ const shown2 = await showFile(repoRoot, anchor.ref, anchor.file);
5345
+ if (shown2 !== null) {
5346
+ return {
5347
+ ok: true,
5348
+ source: shown2,
5349
+ origin: { kind: "ref", ref: anchor.ref }
5350
+ };
5351
+ }
5352
+ }
5353
+ const at2 = anchor.resolved_at;
5354
+ if (!at2 || Number.isNaN(Date.parse(at2))) {
5355
+ return { ok: false, reason: "unrecoverable" };
5356
+ }
5357
+ const found = await git2(repoRoot, [
5358
+ "log",
5359
+ "-1",
5360
+ "--format=%H",
5361
+ `--before=${at2}`,
5362
+ "--end-of-options",
5363
+ "HEAD",
5364
+ "--",
5365
+ anchor.file
5366
+ ]);
5367
+ const sha = found.ok ? found.stdout.trim() : "";
5368
+ if (!sha || !refShapeIsSafe(sha))
5369
+ return { ok: false, reason: "unrecoverable" };
5370
+ const shown = await showFile(repoRoot, sha, anchor.file);
5371
+ if (shown === null) return { ok: false, reason: "unrecoverable" };
5372
+ return { ok: true, source: shown, origin: { kind: "history", ref: sha } };
5373
+ }
5374
+ async function showFile(repoRoot, ref, file) {
5375
+ const path = file.replace(/^\.\//, "");
5376
+ const result = await git2(repoRoot, [
5377
+ "show",
5378
+ "--end-of-options",
5379
+ `${ref}:${path}`
5380
+ ]);
5381
+ return result.ok ? result.stdout : null;
5382
+ }
5383
+
5384
+ // src/drift/moved.ts
5385
+ var import_promises9 = require("fs/promises");
5386
+ var MAX_MOVED_SEARCH_FILES = 2e3;
5387
+ var SEARCH_BATCH = 64;
5388
+ function movedSearch(repoRoot, options = {}) {
5389
+ const read = options.reader ?? anchorFileReader(repoRoot);
5390
+ const sizeOf = options.sizeOf ?? diskSize(repoRoot);
5391
+ const resolver = new TreeSitterResolver();
5392
+ let repoFiles;
5393
+ const prepared = /* @__PURE__ */ new Set();
5394
+ const filesForLanguage = async (language) => {
5395
+ repoFiles ??= listRepoFiles(repoRoot);
5396
+ return (await repoFiles).filter((file) => languageForFile(file) === language).slice(0, MAX_MOVED_SEARCH_FILES);
5397
+ };
5398
+ return {
5399
+ async find(anchor) {
5400
+ const stored = anchor.hash;
5401
+ if (!stored) return void 0;
5402
+ const language = languageForFile(anchor.file);
5403
+ if (!language) return sameFileWindow(anchor, read, stored);
5404
+ const candidates = await filesForLanguage(language);
5405
+ if (!prepared.has(language)) {
5406
+ await resolver.prepare(candidates.length ? candidates : [anchor.file]);
5407
+ prepared.add(language);
5408
+ }
5409
+ const floor = anchor.lines ?? 0;
5410
+ for (let at2 = 0; at2 < candidates.length; at2 += SEARCH_BATCH) {
5411
+ const batch = candidates.slice(at2, at2 + SEARCH_BATCH);
5412
+ const hits = await mapLimit(
5413
+ batch,
5414
+ DEFAULT_IO_CONCURRENCY,
5415
+ async (file) => {
5416
+ const size2 = await sizeOf(file);
5417
+ if (size2 !== null && size2 < floor) return void 0;
5418
+ return matchIn(resolver, read, anchor, stored, file);
5419
+ }
5420
+ );
5421
+ const found = hits.find((hit) => hit !== void 0);
5422
+ if (found) return found;
5423
+ }
5424
+ return void 0;
5425
+ }
5426
+ };
5427
+ }
5428
+ async function matchIn(resolver, read, anchor, stored, file) {
5429
+ const source = await read(file);
5430
+ if (!source.ok) return void 0;
5431
+ const normalized = source.source.replace(/\r\n/g, "\n");
5432
+ for (const found of resolver.spans(normalized, file)) {
5433
+ const text = anchor.hash_kind === "ast" ? resolver.normalize(found.span.text, file) : found.span.text;
5434
+ if (text === null || hashAnchorText(text) !== stored) continue;
5435
+ if (file === anchor.file && found.symbol === anchor.symbol) continue;
5436
+ return {
5437
+ file,
5438
+ symbol: found.symbol,
5439
+ startLine: found.span.startLine,
5440
+ endLine: found.span.endLine
5441
+ };
5442
+ }
5443
+ return void 0;
5444
+ }
5445
+ function diskSize(repoRoot) {
5446
+ return async (file) => {
5447
+ const path = anchorFilePath(repoRoot, file);
5448
+ if (path === null) return null;
5449
+ try {
5450
+ return (await (0, import_promises9.stat)(path)).size;
5451
+ } catch {
5452
+ return null;
5453
+ }
5454
+ };
5455
+ }
5456
+ async function sameFileWindow(anchor, read, stored) {
5457
+ const height = anchor.lines;
5458
+ if (!height || anchor.hash_kind === "ast") return void 0;
5459
+ const source = await read(anchor.file);
5460
+ if (!source.ok) return void 0;
5461
+ const lines = source.source.replace(/\r\n/g, "\n").split("\n");
5462
+ for (let at2 = 0; at2 + height <= lines.length; at2++) {
5463
+ if (hashAnchorText(lines.slice(at2, at2 + height).join("\n")) !== stored) {
5464
+ continue;
5465
+ }
5466
+ return {
5467
+ file: anchor.file,
5468
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
5469
+ startLine: at2 + 1,
5470
+ endLine: at2 + height
5471
+ };
5472
+ }
5473
+ return void 0;
5474
+ }
5475
+
5476
+ // src/drift/classify.ts
5477
+ async function classifyDrift(repoRoot, record, entries, options = {}) {
5478
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
5479
+ (anchor) => anchor.hash
5480
+ );
5481
+ const reader = options.reader ?? anchorFileReader(repoRoot);
5482
+ const treeSitter = new TreeSitterResolver();
5483
+ const resolvers = [treeSitter, regexResolver];
5484
+ const search = options.search ?? movedSearch(repoRoot, { ...options.reader ? { reader } : {} });
5485
+ const wanted = [];
5486
+ entries.forEach((entry, at2) => {
5487
+ const anchor = anchors[at2];
5488
+ if (!anchor) return;
5489
+ if (entry.state === "match" || isUncheckedReason(entry.reason)) return;
5490
+ wanted.push({ anchor, entry });
5491
+ });
5492
+ if (!wanted.length) return [];
5493
+ await prepareResolvers(
5494
+ resolvers,
5495
+ wanted.map(({ anchor }) => anchor.file)
5496
+ );
5497
+ const out = [];
5498
+ for (const { anchor, entry } of wanted) {
5499
+ const movedTo = await search.find(anchor);
5500
+ if (movedTo) {
5501
+ out.push({
5502
+ anchor,
5503
+ entry: { ...entry, class: "moved", movedTo },
5504
+ class: "moved"
5505
+ });
5506
+ continue;
5507
+ }
5508
+ const newText = await currentText(reader, anchor, resolvers);
5509
+ const old = options.withHistory === false ? { ok: false, reason: "unrecoverable" } : await readOldSource(repoRoot, anchor);
5510
+ const oldText = old.ok ? spanIn(old.source, anchor, resolvers) : void 0;
5511
+ const settled = newText !== void 0 && oldText !== void 0 && sameTokens(treeSitter, anchor.file, oldText, newText) ? "cosmetic" : entry.class ?? "changed";
5512
+ out.push({
5513
+ anchor,
5514
+ entry: { ...entry, class: settled },
5515
+ class: settled,
5516
+ ...newText !== void 0 ? { newText } : {},
5517
+ ...oldText !== void 0 ? { oldText } : {},
5518
+ ...old.ok ? { oldOrigin: old.origin } : {}
5519
+ });
5520
+ }
5521
+ return out;
5522
+ }
5523
+ function sameTokens(resolver, file, before, after) {
5524
+ if (before === after) return false;
5525
+ const left = resolver.normalize(before, file);
5526
+ const right = resolver.normalize(after, file);
5527
+ return left !== null && left === right;
5528
+ }
5529
+ async function currentText(reader, anchor, resolvers) {
5530
+ const read = await reader(anchor.file);
5531
+ if (!read.ok) return void 0;
5532
+ return spanIn(read.source, anchor, resolvers);
5533
+ }
5534
+ function spanIn(source, anchor, resolvers) {
5535
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
5536
+ return outcome.ok ? outcome.span.text : void 0;
5537
+ }
5538
+
5539
+ // src/drift/diff.ts
5540
+ var MAX_ANCHOR_DIFF_LINES = 200;
5541
+ var PACKET_DIFF_LINE_BUDGET = 200;
5542
+ var MIN_ANCHOR_DIFF_LINES = 12;
5543
+ function diffBudget(anchors) {
5544
+ if (anchors <= 0) return MAX_ANCHOR_DIFF_LINES;
5545
+ return Math.min(
5546
+ MAX_ANCHOR_DIFF_LINES,
5547
+ Math.max(
5548
+ MIN_ANCHOR_DIFF_LINES,
5549
+ Math.floor(PACKET_DIFF_LINE_BUDGET / anchors)
5550
+ )
5551
+ );
5552
+ }
5553
+ function unifiedDiff(before, after, options = {}) {
5554
+ const max = options.maxLines ?? MAX_ANCHOR_DIFF_LINES;
5555
+ const left = before.replace(/\r\n/g, "\n").split("\n");
5556
+ const right = after.replace(/\r\n/g, "\n").split("\n");
5557
+ const body = [];
5558
+ let added = 0;
5559
+ let removed = 0;
5560
+ for (const edit of edits(left, right)) {
5561
+ if (edit.kind === "same") body.push(` ${edit.line}`);
5562
+ else if (edit.kind === "remove") {
5563
+ body.push(`-${edit.line}`);
5564
+ removed += 1;
5565
+ } else {
5566
+ body.push(`+${edit.line}`);
5567
+ added += 1;
5568
+ }
5569
+ }
5570
+ const truncated = body.length > max;
5571
+ const shown = truncated ? body.slice(0, max) : body;
5572
+ const header = `@@ -1,${left.length} +1,${right.length} @@${options.oldLabel ? ` ${options.oldLabel} \u2192 ${options.newLabel ?? ""}`.trimEnd() : ""}`;
5573
+ const lines = [header, ...shown];
5574
+ if (truncated) lines.push(`\u2026 ${body.length - max} more diff lines`);
5575
+ return { text: lines.join("\n"), added, removed, truncated };
5576
+ }
5577
+ function edits(left, right) {
5578
+ const rows = left.length;
5579
+ const cols = right.length;
5580
+ const table2 = Array.from(
5581
+ { length: rows + 1 },
5582
+ () => new Array(cols + 1).fill(0)
5583
+ );
5584
+ for (let row2 = rows - 1; row2 >= 0; row2--) {
5585
+ for (let col2 = cols - 1; col2 >= 0; col2--) {
5586
+ table2[row2][col2] = left[row2] === right[col2] ? table2[row2 + 1][col2 + 1] + 1 : Math.max(
5587
+ table2[row2 + 1][col2],
5588
+ table2[row2][col2 + 1]
5589
+ );
5590
+ }
5591
+ }
5592
+ const out = [];
5593
+ let row = 0;
5594
+ let col = 0;
5595
+ while (row < rows && col < cols) {
5596
+ if (left[row] === right[col]) {
5597
+ out.push({ kind: "same", line: left[row] });
5598
+ row += 1;
5599
+ col += 1;
5600
+ } else if (table2[row + 1][col] >= table2[row][col + 1]) {
5601
+ out.push({ kind: "remove", line: left[row] });
5602
+ row += 1;
5603
+ } else {
5604
+ out.push({ kind: "add", line: right[col] });
5605
+ col += 1;
5606
+ }
5607
+ }
5608
+ for (; row < rows; row++)
5609
+ out.push({ kind: "remove", line: left[row] });
5610
+ for (; col < cols; col++)
5611
+ out.push({ kind: "add", line: right[col] });
5612
+ return out;
5613
+ }
5614
+
5615
+ // src/drift/packet.ts
5616
+ var PRESUMED_INVALID = [
5617
+ "fact",
5618
+ "constraint",
5619
+ "contract"
5620
+ ];
5621
+ var RATIONALE_SURVIVES = ["decision", "risk"];
5622
+ var DEFAULT_NOTES = {
5623
+ "presumed-invalidated": "the code this claim was taken from changed; presume it no longer holds until re-read",
5624
+ "rationale-may-survive": "the reasoning may outlive the code that implemented it; check whether it does",
5625
+ review: "re-read the record against the new code"
5626
+ };
5627
+ async function reassessPacket(repoRoot, record, entries, options = {}) {
5628
+ const classified = await classifyDrift(repoRoot, record, entries, {
5629
+ ...options.reader ? { reader: options.reader } : {},
5630
+ ...options.search ? { search: options.search } : {},
5631
+ withHistory: options.withDiff !== false
5632
+ });
5633
+ const open = classified.filter(
5634
+ (found) => found.class === "changed" || found.class === "gone"
5635
+ );
5636
+ if (!open.length) return { packet: null, classified };
5637
+ const budget = diffBudget(open.length);
5638
+ const anchors = open.map(
5639
+ (found) => anchorPacket(found, options.withDiff === true, budget)
5640
+ );
5641
+ const type = record.frontmatter.type;
5642
+ const fallback = isKbRecordType(type) ? PRESUMED_INVALID.includes(type) ? "presumed-invalidated" : RATIONALE_SURVIVES.includes(type) ? "rationale-may-survive" : "review" : "review";
5643
+ return {
5644
+ classified,
5645
+ packet: {
5646
+ conceptId: record.conceptId,
5647
+ title: record.frontmatter.title ?? null,
5648
+ type,
5649
+ standing: options.standing ?? "unsettled",
5650
+ why: record.frontmatter.description ?? null,
5651
+ claim: claimOf(record),
5652
+ anchors,
5653
+ impact: (options.impact?.impacted ?? []).map((entry) => ({
5654
+ conceptId: entry.conceptId,
5655
+ title: entry.title,
5656
+ standing: entry.standing,
5657
+ depth: entry.depth
5658
+ })),
5659
+ impactTruncated: options.impact?.truncated ?? false,
5660
+ default: fallback,
5661
+ defaultNote: DEFAULT_NOTES[fallback]
5662
+ }
5663
+ };
5664
+ }
5665
+ function anchorPacket(found, withDiff, maxLines) {
5666
+ const { entry } = found;
5667
+ const base2 = {
5668
+ file: entry.file,
5669
+ ...entry.symbol ? { symbol: entry.symbol } : {},
5670
+ class: found.class,
5671
+ ...entry.reason ? { reason: entry.reason } : {},
5672
+ storedHash: entry.storedHash,
5673
+ ...entry.currentHash ? { currentHash: entry.currentHash } : {},
5674
+ diffSize: entry.diffSize,
5675
+ ...entry.movedTo ? { movedTo: entry.movedTo } : {}
5676
+ };
5677
+ if (!withDiff) return base2;
5678
+ if (found.oldText === void 0 || !found.oldOrigin) {
5679
+ return { ...base2, diff: { status: "unrecoverable" } };
5680
+ }
5681
+ const rendered = unifiedDiff(found.oldText, found.newText ?? "", {
5682
+ maxLines
5683
+ });
5684
+ return {
5685
+ ...base2,
5686
+ diff: {
5687
+ status: "ok",
5688
+ source: found.oldOrigin.kind,
5689
+ ref: found.oldOrigin.ref,
5690
+ unified: rendered.text,
5691
+ added: rendered.added,
5692
+ removed: rendered.removed,
5693
+ truncated: rendered.truncated
5694
+ }
5695
+ };
5696
+ }
5697
+ function claimOf(record) {
5698
+ const type = record.frontmatter.type;
5699
+ const section = isKbRecordType(type) ? RECORD_TYPES[type].sections[0] : void 0;
5700
+ if (!section) return null;
5701
+ const lines = record.body.replace(/\r\n/g, "\n").split("\n");
5702
+ const start = lines.findIndex(
5703
+ (line) => line.trim().toLowerCase() === `## ${section}`.toLowerCase()
5704
+ );
5705
+ if (start < 0) return null;
5706
+ const rest = lines.slice(start + 1);
5707
+ const end = rest.findIndex((line) => line.startsWith("## "));
5708
+ const text = (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
5709
+ return text ? { section, text } : null;
5710
+ }
5711
+
5712
+ // src/commands/reassess.ts
5183
5713
  var import_zod14 = require("zod");
5184
- var days = (what, fallback) => import_zod14.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5714
+ var reassessCommand = define({
5715
+ name: "reassess",
5716
+ tool: "kb_reassess",
5717
+ usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
5718
+ description: "One drifted record, as something to judge: its claim, each anchor's drift class, the old-vs-new span diff, and the records that depend on it. Formatting-only drift is dropped. Empty when there is nothing to reassess. Writes: relocates moved anchors, keeping their hash; never verifies, supersedes, or changes standing.",
5719
+ input: import_zod14.z.object({
5720
+ bundlePath,
5721
+ conceptId,
5722
+ repoRoot: REPO_ROOT,
5723
+ withDiff: import_zod14.z.boolean().optional().describe(
5724
+ "Recover each anchor's committed span and render the diff. Reads git history."
5725
+ )
5726
+ }),
5727
+ fromArgv: (argv, path) => {
5728
+ const repoRoot = argvFlag(argv, "--repo-root");
5729
+ return {
5730
+ bundlePath: path,
5731
+ conceptId: argv[1],
5732
+ ...repoRoot !== void 0 ? { repoRoot } : {},
5733
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
5734
+ };
5735
+ },
5736
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, repoRoot, withDiff }) => {
5737
+ const root = repoRoot ?? process.cwd();
5738
+ const bundle = await store.list(path);
5739
+ const record = bundle.find((entry) => entry.conceptId === id);
5740
+ if (!record) throw new KbRecordNotFoundError(id);
5741
+ const drift = await store.detectDrift([record], repoRoot);
5742
+ const entries = drift?.get(id) ?? [];
5743
+ if (!entries.some((entry) => entry.state !== "match")) {
5744
+ return { conceptId: id, packet: null, rebaselined: [], cosmetic: 0 };
5745
+ }
5746
+ const standing = adjudicate(bundle, bundle).find(
5747
+ (hit) => hit.record.conceptId === id
5748
+ )?.standing;
5749
+ const impact2 = await store.impact(path, id);
5750
+ const { packet, classified } = await reassessPacket(root, record, entries, {
5751
+ ...withDiff ? { withDiff: true } : {},
5752
+ impact: impact2,
5753
+ ...standing ? { standing } : {}
5754
+ });
5755
+ const moves = classified.filter((found) => found.class === "moved");
5756
+ let frozen = false;
5757
+ const rebaselined = [];
5758
+ if (moves.length) {
5759
+ const relocated = /* @__PURE__ */ new Map();
5760
+ for (const found of moves) {
5761
+ const to = found.entry.movedTo;
5762
+ if (!to) continue;
5763
+ relocated.set(found.anchor, {
5764
+ ...found.anchor,
5765
+ file: to.file,
5766
+ ...to.symbol ? { symbol: to.symbol } : {}
5767
+ });
5768
+ rebaselined.push({
5769
+ file: found.anchor.file,
5770
+ ...found.anchor.symbol ? { symbol: found.anchor.symbol } : {},
5771
+ toFile: to.file,
5772
+ ...to.symbol ? { toSymbol: to.symbol } : {}
5773
+ });
5774
+ }
5775
+ try {
5776
+ await assertBaseNotFrozen(process.cwd(), path);
5777
+ } catch (error) {
5778
+ if (!(error instanceof KbBaseFrozenError)) throw error;
5779
+ frozen = true;
5780
+ }
5781
+ if (!frozen) {
5782
+ await store.updateAnchors(
5783
+ path,
5784
+ id,
5785
+ (record.frontmatter.strauss_anchors ?? []).map(
5786
+ (anchor) => relocated.get(anchor) ?? anchor
5787
+ ),
5788
+ actor
5789
+ );
5790
+ }
5791
+ }
5792
+ return {
5793
+ conceptId: id,
5794
+ packet,
5795
+ rebaselined: frozen ? [] : rebaselined,
5796
+ cosmetic: classified.filter((found) => found.class === "cosmetic").length,
5797
+ ...frozen ? {
5798
+ frozen: true,
5799
+ note: "base is frozen: nothing was rebaselined"
5800
+ } : {}
5801
+ };
5802
+ },
5803
+ render: (result) => renderReassess(result)
5804
+ });
5805
+ function renderReassess(result) {
5806
+ const lines = [];
5807
+ for (const move of result.rebaselined) {
5808
+ lines.push(
5809
+ `rebaselined: ${at(move.file, move.symbol)} \u2192 ${at(move.toFile, move.toSymbol)} (same code, new address)`
5810
+ );
5811
+ }
5812
+ if (result.cosmetic) {
5813
+ lines.push(
5814
+ `${result.cosmetic} anchor${result.cosmetic === 1 ? "" : "s"} changed formatting only.`
5815
+ );
5816
+ }
5817
+ if (result.note) lines.push(result.note);
5818
+ const packet = result.packet;
5819
+ if (!packet) {
5820
+ lines.push(`${result.conceptId}: nothing to reassess.`);
5821
+ return lines.join("\n");
5822
+ }
5823
+ lines.push(
5824
+ "",
5825
+ `# ${packet.conceptId}${packet.title ? ` \u2014 ${packet.title}` : ""}`,
5826
+ `type: ${packet.type} standing: ${packet.standing}`,
5827
+ ...packet.why ? [`why: ${packet.why}`] : [],
5828
+ ...packet.claim ? ["", `## ${packet.claim.section}`, packet.claim.text] : [],
5829
+ "",
5830
+ `## Anchors (${packet.anchors.length})`
5831
+ );
5832
+ for (const anchor of packet.anchors) {
5833
+ lines.push(
5834
+ `- ${at(anchor.file, anchor.symbol)} \u2014 ${anchor.class}${anchor.reason ? ` (${anchor.reason})` : ""}`
5835
+ );
5836
+ if (!anchor.diff) continue;
5837
+ if (anchor.diff.status === "unrecoverable") {
5838
+ lines.push(
5839
+ " diff: unrecoverable \u2014 no committed span to compare against"
5840
+ );
5841
+ continue;
5842
+ }
5843
+ lines.push(
5844
+ ` diff vs ${anchor.diff.ref} (${anchor.diff.source}): +${anchor.diff.added} \u2212${anchor.diff.removed}`,
5845
+ ...anchor.diff.unified.split("\n").map((line) => ` ${line}`)
5846
+ );
5847
+ }
5848
+ if (packet.impact.length) {
5849
+ lines.push("", `## Impact (${packet.impact.length})`);
5850
+ for (const entry of packet.impact) {
5851
+ lines.push(
5852
+ `- ${entry.conceptId} [${entry.standing}]${entry.title ? ` \u2014 ${entry.title}` : ""}`
5853
+ );
5854
+ }
5855
+ if (packet.impactTruncated) lines.push("- \u2026 walk truncated");
5856
+ }
5857
+ lines.push("", `Default: ${packet.default} \u2014 ${packet.defaultNote}.`);
5858
+ return lines.join("\n");
5859
+ }
5860
+ function at(file, symbol) {
5861
+ return symbol ? `${file}:${symbol}` : file;
5862
+ }
5863
+
5864
+ // src/commands/doctor.ts
5865
+ var days = (what, fallback) => import_zod15.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5185
5866
  var doctorCommand = define({
5186
5867
  name: "doctor",
5187
5868
  tool: "kb_doctor",
5188
- usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict]",
5189
- 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. Use it when picking up a base you have not touched in a while; kb_validate only checks that pointers between records agree.",
5190
- input: import_zod14.z.object({
5869
+ usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
5870
+ description: "Read-only health sweep: expired, expiring, unverified, aging, orphaned, broken-supersession, superseded-but-cited, drifted and unchecked anchors. Every group is reported even when empty; nothing is written or re-stamped. `drifted` narrows it to a reassessment packet per drifted record, `with_diff` adding each anchor's old-vs-new span.",
5871
+ input: import_zod15.z.object({
5191
5872
  bundlePath,
5192
5873
  repoRoot: REPO_ROOT,
5193
5874
  expiringDays: days(
@@ -5202,11 +5883,17 @@ var doctorCommand = define({
5202
5883
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
5203
5884
  DEFAULT_AGING_DAYS
5204
5885
  ),
5205
- offline: import_zod14.z.boolean().optional().describe(
5886
+ offline: import_zod15.z.boolean().optional().describe(
5206
5887
  "Read foreign anchors from the local repo cache only, never fetching."
5207
5888
  ),
5208
- strict: import_zod14.z.boolean().optional().describe(
5889
+ strict: import_zod15.z.boolean().optional().describe(
5209
5890
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
5891
+ ),
5892
+ drifted: import_zod15.z.boolean().optional().describe(
5893
+ "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
5894
+ ),
5895
+ withDiff: import_zod15.z.boolean().optional().describe(
5896
+ "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
5210
5897
  )
5211
5898
  }),
5212
5899
  // Presence, not truthiness: `--expiring-days ""` is a caller who meant
@@ -5225,7 +5912,9 @@ var doctorCommand = define({
5225
5912
  ...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
5226
5913
  ...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
5227
5914
  ...argv.includes("--offline") ? { offline: true } : {},
5228
- ...argv.includes("--strict") ? { strict: true } : {}
5915
+ ...argv.includes("--strict") ? { strict: true } : {},
5916
+ ...argv.includes("--drifted") ? { drifted: true } : {},
5917
+ ...argv.includes("--with-diff") ? { withDiff: true } : {}
5229
5918
  };
5230
5919
  },
5231
5920
  run: async ({ store, now }, {
@@ -5234,7 +5923,9 @@ var doctorCommand = define({
5234
5923
  unverifiedDays,
5235
5924
  agingDays,
5236
5925
  repoRoot,
5237
- offline
5926
+ offline,
5927
+ drifted: drifted2,
5928
+ withDiff
5238
5929
  }) => {
5239
5930
  const checkedAt = now();
5240
5931
  const records = await store.list(path);
@@ -5249,10 +5940,51 @@ var doctorCommand = define({
5249
5940
  now: new Date(checkedAt)
5250
5941
  });
5251
5942
  const hints = grammarHints();
5943
+ if (!drifted2) {
5944
+ return {
5945
+ bundlePath: path,
5946
+ checkedAt,
5947
+ ...report,
5948
+ ...hints.length ? { hints } : {}
5949
+ };
5950
+ }
5951
+ const standings = new Map(
5952
+ adjudicate(records, records, new Date(checkedAt)).map((hit) => [
5953
+ hit.record.conceptId,
5954
+ hit.standing
5955
+ ])
5956
+ );
5957
+ const packets = [];
5958
+ const rebaselinable = [];
5959
+ const search = movedSearch(repoRoot ?? process.cwd());
5960
+ for (const found of report.groups.find((g) => g.check === "drifted")?.findings ?? []) {
5961
+ const record = records.find(
5962
+ (entry) => entry.conceptId === found.conceptId
5963
+ );
5964
+ if (!record) continue;
5965
+ const standing = standings.get(record.conceptId);
5966
+ const built = await reassessPacket(
5967
+ repoRoot ?? process.cwd(),
5968
+ record,
5969
+ anchorDrift?.get(record.conceptId) ?? [],
5970
+ {
5971
+ ...withDiff ? { withDiff: true } : {},
5972
+ impact: await store.impact(path, record.conceptId),
5973
+ ...standing ? { standing } : {},
5974
+ search
5975
+ }
5976
+ );
5977
+ if (built.packet) packets.push(built.packet);
5978
+ if (built.classified.some((entry) => entry.class === "moved")) {
5979
+ rebaselinable.push(record.conceptId);
5980
+ }
5981
+ }
5252
5982
  return {
5253
5983
  bundlePath: path,
5254
5984
  checkedAt,
5255
5985
  ...report,
5986
+ packets,
5987
+ rebaselinable,
5256
5988
  ...hints.length ? { hints } : {}
5257
5989
  };
5258
5990
  },
@@ -5266,6 +5998,7 @@ var doctorCommand = define({
5266
5998
  failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
5267
5999
  });
5268
6000
  function render2(result) {
6001
+ if (result.packets) return renderPackets(result);
5269
6002
  const { thresholds } = result;
5270
6003
  const lines = [
5271
6004
  `# KB Doctor \u2014 ${result.bundlePath}`,
@@ -5299,21 +6032,45 @@ function render2(result) {
5299
6032
  );
5300
6033
  return lines.join("\n");
5301
6034
  }
6035
+ function renderPackets(result) {
6036
+ const packets = result.packets ?? [];
6037
+ const lines = [
6038
+ `# KB Drift \u2014 ${result.bundlePath}`,
6039
+ `checked: ${result.checkedAt}`,
6040
+ `${packets.length} record${packets.length === 1 ? "" : "s"} need a reading; ${result.counts.drifted} drifted in all.`
6041
+ ];
6042
+ if (result.rebaselinable?.length) {
6043
+ lines.push(
6044
+ `moved, rebaseline with \`kb_reassess\`: ${result.rebaselinable.join(", ")}`
6045
+ );
6046
+ }
6047
+ for (const packet of packets) {
6048
+ lines.push(
6049
+ renderReassess({
6050
+ conceptId: packet.conceptId,
6051
+ packet,
6052
+ rebaselined: [],
6053
+ cosmetic: 0
6054
+ })
6055
+ );
6056
+ }
6057
+ return lines.join("\n");
6058
+ }
5302
6059
 
5303
6060
  // src/commands/impact.ts
5304
- var import_zod15 = require("zod");
6061
+ var import_zod16 = require("zod");
5305
6062
  var impactCommand = define({
5306
6063
  name: "impact",
5307
6064
  tool: "kb_impact",
5308
6065
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
5309
6066
  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.",
5310
- input: import_zod15.z.object({
6067
+ input: import_zod16.z.object({
5311
6068
  bundlePath,
5312
6069
  conceptId,
5313
- depth: import_zod15.z.number().int().positive().optional().describe(
6070
+ depth: import_zod16.z.number().int().positive().optional().describe(
5314
6071
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
5315
6072
  ),
5316
- rels: import_zod15.z.array(import_zod15.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
6073
+ rels: import_zod16.z.array(import_zod16.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
5317
6074
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
5318
6075
  )
5319
6076
  }),
@@ -5334,13 +6091,13 @@ var impactCommand = define({
5334
6091
  });
5335
6092
 
5336
6093
  // src/commands/list.ts
5337
- var import_zod16 = require("zod");
6094
+ var import_zod17 = require("zod");
5338
6095
  var listCommand = define({
5339
6096
  name: "list",
5340
6097
  tool: "kb_list",
5341
6098
  usage: "list [type]",
5342
6099
  description: "Every record, optionally one type. For enumerating; use kb_query for a question.",
5343
- input: import_zod16.z.object({ bundlePath, type: import_zod16.z.enum(KB_RECORD_TYPES).optional() }),
6100
+ input: import_zod17.z.object({ bundlePath, type: import_zod17.z.enum(KB_RECORD_TYPES).optional() }),
5344
6101
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
5345
6102
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
5346
6103
  conceptId: record.conceptId,
@@ -5352,17 +6109,17 @@ var listCommand = define({
5352
6109
  });
5353
6110
 
5354
6111
  // src/commands/load.ts
5355
- var import_zod17 = require("zod");
6112
+ var import_zod18 = require("zod");
5356
6113
  var loadCommand = define({
5357
6114
  name: "load",
5358
6115
  tool: "kb_load",
5359
6116
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
5360
6117
  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.",
5361
- input: import_zod17.z.object({
6118
+ input: import_zod18.z.object({
5362
6119
  bundlePath,
5363
- type: import_zod17.z.enum(KB_RECORD_TYPES).optional(),
5364
- budgetTokens: import_zod17.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
5365
- all: import_zod17.z.boolean().optional().describe(
6120
+ type: import_zod18.z.enum(KB_RECORD_TYPES).optional(),
6121
+ budgetTokens: import_zod18.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6122
+ all: import_zod18.z.boolean().optional().describe(
5366
6123
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
5367
6124
  ),
5368
6125
  repoRoot: REPO_ROOT
@@ -5404,25 +6161,25 @@ var loadCommand = define({
5404
6161
  });
5405
6162
 
5406
6163
  // src/commands/log.ts
5407
- var import_zod18 = require("zod");
6164
+ var import_zod19 = require("zod");
5408
6165
  var logCommand = define({
5409
6166
  name: "log",
5410
6167
  tool: "kb_log",
5411
6168
  usage: "log",
5412
6169
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
5413
- input: import_zod18.z.object({ bundlePath }),
6170
+ input: import_zod19.z.object({ bundlePath }),
5414
6171
  fromArgv: (_argv, path) => ({ bundlePath: path }),
5415
6172
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
5416
6173
  });
5417
6174
 
5418
6175
  // src/commands/no-decision.ts
5419
- var import_zod19 = require("zod");
6176
+ var import_zod20 = require("zod");
5420
6177
  var noDecisionCommand = define({
5421
6178
  name: "no-decision",
5422
6179
  tool: "kb_no_decision",
5423
6180
  usage: "no-decision <reason...>",
5424
6181
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
5425
- input: import_zod19.z.object({ bundlePath, reason: import_zod19.z.string().min(1) }),
6182
+ input: import_zod20.z.object({ bundlePath, reason: import_zod20.z.string().min(1) }),
5426
6183
  fromArgv: (argv, path) => ({
5427
6184
  bundlePath: path,
5428
6185
  reason: argv.slice(1).join(" ").trim()
@@ -5439,20 +6196,20 @@ var noDecisionCommand = define({
5439
6196
  });
5440
6197
 
5441
6198
  // src/commands/pack.ts
5442
- var import_zod20 = require("zod");
6199
+ var import_zod21 = require("zod");
5443
6200
  var packCommand = define({
5444
6201
  name: "pack",
5445
6202
  tool: "kb_pack",
5446
6203
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
5447
6204
  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.",
5448
- input: import_zod20.z.object({
6205
+ input: import_zod21.z.object({
5449
6206
  bundlePath,
5450
6207
  conceptId,
5451
- hops: import_zod20.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
5452
- maxNodes: import_zod20.z.number().int().positive().optional().describe(
6208
+ hops: import_zod21.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6209
+ maxNodes: import_zod21.z.number().int().positive().optional().describe(
5453
6210
  "How many records the pack may hold, root included. Defaults to 20."
5454
6211
  ),
5455
- budgetTokens: import_zod20.z.number().int().positive().optional().describe(
6212
+ budgetTokens: import_zod21.z.number().int().positive().optional().describe(
5456
6213
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
5457
6214
  )
5458
6215
  }),
@@ -5477,12 +6234,12 @@ var packCommand = define({
5477
6234
  return render3(result, path, now());
5478
6235
  }
5479
6236
  });
5480
- function render3(result, bundle, at) {
6237
+ function render3(result, bundle, at2) {
5481
6238
  const lines = [
5482
6239
  `# KB Pack \u2014 ${result.root}`,
5483
6240
  `bundle: ${bundle}`,
5484
6241
  `budget: ~${result.tokensLoaded} of ${result.budgetTokens} tokens, ${result.recordCount} records`,
5485
- `packed: ${at}`,
6242
+ `packed: ${at2}`,
5486
6243
  "",
5487
6244
  `## Records (${result.records.length})`
5488
6245
  ];
@@ -5539,22 +6296,22 @@ function warningLabel(warning) {
5539
6296
  }
5540
6297
 
5541
6298
  // src/commands/pin.ts
5542
- var import_zod21 = require("zod");
6299
+ var import_zod22 = require("zod");
5543
6300
  var pinCommand = define({
5544
6301
  name: "pin",
5545
6302
  tool: "kb_pin",
5546
6303
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
5547
6304
  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.",
5548
- input: import_zod21.z.object({
6305
+ input: import_zod22.z.object({
5549
6306
  bundlePath,
5550
- mode: import_zod21.z.enum(["full", "index"]).optional().describe(
6307
+ mode: import_zod22.z.enum(["full", "index"]).optional().describe(
5551
6308
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
5552
6309
  ),
5553
- profiles: import_zod21.z.array(import_zod21.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
5554
- layer: import_zod21.z.enum(["project", "local", "user"]).optional().describe(
6310
+ profiles: import_zod22.z.array(import_zod22.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6311
+ layer: import_zod22.z.enum(["project", "local", "user"]).optional().describe(
5555
6312
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
5556
6313
  ),
5557
- frozen: import_zod21.z.boolean().optional().describe(
6314
+ frozen: import_zod22.z.boolean().optional().describe(
5558
6315
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
5559
6316
  )
5560
6317
  }),
@@ -5583,29 +6340,29 @@ var pinCommand = define({
5583
6340
  });
5584
6341
 
5585
6342
  // src/commands/pins.ts
5586
- var import_zod22 = require("zod");
6343
+ var import_zod23 = require("zod");
5587
6344
  var pinsCommand = define({
5588
6345
  name: "pins",
5589
6346
  tool: "kb_pins",
5590
6347
  usage: "pins",
5591
6348
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
5592
- input: import_zod22.z.object({}),
6349
+ input: import_zod23.z.object({}),
5593
6350
  fromArgv: () => ({}),
5594
6351
  run: ({ store }) => listPins(store, process.cwd())
5595
6352
  });
5596
6353
 
5597
6354
  // src/commands/query.ts
5598
- var import_zod23 = require("zod");
6355
+ var import_zod24 = require("zod");
5599
6356
  var queryCommand = define({
5600
6357
  name: "query",
5601
6358
  tool: "kb_query",
5602
6359
  usage: "query <text...> [--repo-root PATH]",
5603
6360
  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.",
5604
- input: import_zod23.z.object({
6361
+ input: import_zod24.z.object({
5605
6362
  bundlePath,
5606
- text: import_zod23.z.string().optional(),
5607
- type: import_zod23.z.enum(KB_RECORD_TYPES).optional(),
5608
- includeNonCurrent: import_zod23.z.boolean().optional(),
6363
+ text: import_zod24.z.string().optional(),
6364
+ type: import_zod24.z.enum(KB_RECORD_TYPES).optional(),
6365
+ includeNonCurrent: import_zod24.z.boolean().optional(),
5609
6366
  repoRoot: REPO_ROOT
5610
6367
  }),
5611
6368
  // `--repo-root` is a flag, so its value must not fall into the search text.
@@ -5637,43 +6394,43 @@ var queryCommand = define({
5637
6394
  });
5638
6395
 
5639
6396
  // src/commands/read-index.ts
5640
- var import_zod24 = require("zod");
6397
+ var import_zod25 = require("zod");
5641
6398
  var readIndexCommand = define({
5642
6399
  name: "index",
5643
6400
  tool: "kb_index",
5644
6401
  usage: "index",
5645
6402
  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.",
5646
- input: import_zod24.z.object({ bundlePath }),
6403
+ input: import_zod25.z.object({ bundlePath }),
5647
6404
  fromArgv: (_argv, path) => ({ bundlePath: path }),
5648
6405
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
5649
6406
  });
5650
6407
 
5651
6408
  // src/commands/schema.ts
5652
- var import_zod25 = require("zod");
6409
+ var import_zod26 = require("zod");
5653
6410
  var schemaCommand = define({
5654
6411
  name: "schema",
5655
6412
  tool: "kb_schema",
5656
6413
  usage: "schema",
5657
6414
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
5658
- input: import_zod25.z.object({}),
6415
+ input: import_zod26.z.object({}),
5659
6416
  fromArgv: () => ({}),
5660
6417
  run: () => Promise.resolve(kbJsonSchemas())
5661
6418
  });
5662
6419
 
5663
6420
  // src/commands/stamp.ts
5664
- var import_promises9 = require("fs/promises");
5665
- var import_zod26 = require("zod");
6421
+ var import_promises10 = require("fs/promises");
6422
+ var import_zod27 = require("zod");
5666
6423
  var DIGEST = /^[0-9a-f]{64}$/;
5667
6424
  var stampCommand = define({
5668
6425
  name: "stamp",
5669
6426
  tool: "kb_stamp",
5670
6427
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
5671
- description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids when the baseline is a prior stamp; silent when nothing changed. Reads, never writes.",
5672
- input: import_zod26.z.object({
5673
- bundlePath: import_zod26.z.string().min(1).optional().describe(
6428
+ description: "Content stamp of a base \u2014 `load`'s digest, record counts, per-record digests, how many records have drifted anchors \u2014 without any bodies. Takes no bundlePath to stamp every pinned base. With `since`, reports only the bases that moved, naming the changed ids. Reads, never writes.",
6429
+ input: import_zod27.z.object({
6430
+ bundlePath: import_zod27.z.string().min(1).optional().describe(
5674
6431
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
5675
6432
  ),
5676
- since: import_zod26.z.string().min(1).optional().describe(
6433
+ since: import_zod27.z.string().min(1).optional().describe(
5677
6434
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
5678
6435
  )
5679
6436
  }),
@@ -5712,7 +6469,7 @@ var stampCommand = define({
5712
6469
  return reports;
5713
6470
  },
5714
6471
  render: (result) => result.map((report) => {
5715
- const counts = `${report.recordCount} record(s), ${report.superseded} superseded`;
6472
+ const counts = `${report.recordCount} record(s), ${report.superseded} superseded${report.drifted ? `, ${report.drifted} drifted` : ""}`;
5716
6473
  const head = `${report.path} ${report.digest} ${counts}${report.newestAt ? ` newest ${report.newestAt}` : ""}`;
5717
6474
  return report.changed?.length ? `${head}
5718
6475
  changed: ${report.changed.join(", ")}` : head;
@@ -5735,7 +6492,7 @@ async function readBaseline(since) {
5735
6492
  if (DIGEST.test(since)) return { digest: since, byPath: /* @__PURE__ */ new Map() };
5736
6493
  let parsed;
5737
6494
  try {
5738
- parsed = JSON.parse(await (0, import_promises9.readFile)(since, "utf8"));
6495
+ parsed = JSON.parse(await (0, import_promises10.readFile)(since, "utf8"));
5739
6496
  } catch {
5740
6497
  throw new KbStampBaselineError(since);
5741
6498
  }
@@ -5759,16 +6516,16 @@ async function readBaseline(since) {
5759
6516
  }
5760
6517
 
5761
6518
  // src/commands/status.ts
5762
- var import_zod27 = require("zod");
6519
+ var import_zod28 = require("zod");
5763
6520
  var statusCommand = define({
5764
6521
  name: "status",
5765
6522
  tool: "kb_status",
5766
6523
  usage: "status <concept-id> <status>",
5767
6524
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
5768
- input: import_zod27.z.object({
6525
+ input: import_zod28.z.object({
5769
6526
  bundlePath,
5770
6527
  conceptId,
5771
- status: import_zod27.z.enum(KB_RECORD_STATUSES)
6528
+ status: import_zod28.z.enum(KB_RECORD_STATUSES)
5772
6529
  }),
5773
6530
  fromArgv: (argv, path) => ({
5774
6531
  bundlePath: path,
@@ -5783,13 +6540,13 @@ var statusCommand = define({
5783
6540
  });
5784
6541
 
5785
6542
  // src/commands/supersede.ts
5786
- var import_zod28 = require("zod");
6543
+ var import_zod29 = require("zod");
5787
6544
  var supersedeCommand = define({
5788
6545
  name: "supersede",
5789
6546
  tool: "kb_supersede",
5790
6547
  usage: "supersede <concept-id> <replacement-id>",
5791
6548
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
5792
- input: import_zod28.z.object({ bundlePath, conceptId, replacementId: conceptId }),
6549
+ input: import_zod29.z.object({ bundlePath, conceptId, replacementId: conceptId }),
5793
6550
  fromArgv: (argv, path) => ({
5794
6551
  bundlePath: path,
5795
6552
  conceptId: argv[1],
@@ -5803,16 +6560,16 @@ var supersedeCommand = define({
5803
6560
  });
5804
6561
 
5805
6562
  // src/commands/sync-instructions.ts
5806
- var import_zod29 = require("zod");
6563
+ var import_zod30 = require("zod");
5807
6564
  var syncInstructionsCommand = define({
5808
6565
  name: "sync-instructions",
5809
6566
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
5810
6567
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
5811
- input: import_zod29.z.object({
5812
- file: import_zod29.z.string().min(1).describe("The instruction file to edit in place."),
5813
- budgetTokens: import_zod29.z.number().int().positive().optional(),
5814
- fullUnderTokens: import_zod29.z.number().int().positive().optional(),
5815
- profile: import_zod29.z.string().optional()
6568
+ input: import_zod30.z.object({
6569
+ file: import_zod30.z.string().min(1).describe("The instruction file to edit in place."),
6570
+ budgetTokens: import_zod30.z.number().int().positive().optional(),
6571
+ fullUnderTokens: import_zod30.z.number().int().positive().optional(),
6572
+ profile: import_zod30.z.string().optional()
5816
6573
  }),
5817
6574
  fromArgv: (argv) => {
5818
6575
  const budget = argvFlag(argv, "--budget");
@@ -5838,17 +6595,17 @@ var syncInstructionsCommand = define({
5838
6595
  });
5839
6596
 
5840
6597
  // src/commands/trace.ts
5841
- var import_zod30 = require("zod");
6598
+ var import_zod31 = require("zod");
5842
6599
  var traceCommand = define({
5843
6600
  name: "trace",
5844
6601
  tool: "kb_trace",
5845
6602
  usage: "trace <concept-id> [edges...]",
5846
6603
  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".',
5847
- input: import_zod30.z.object({
6604
+ input: import_zod31.z.object({
5848
6605
  bundlePath,
5849
6606
  conceptId,
5850
- edges: import_zod30.z.array(import_zod30.z.enum(TRACE_EDGES)).optional(),
5851
- depth: import_zod30.z.number().int().positive().optional()
6607
+ edges: import_zod31.z.array(import_zod31.z.enum(TRACE_EDGES)).optional(),
6608
+ depth: import_zod31.z.number().int().positive().optional()
5852
6609
  }),
5853
6610
  fromArgv: (argv, path) => ({
5854
6611
  bundlePath: path,
@@ -5870,37 +6627,37 @@ var traceCommand = define({
5870
6627
  });
5871
6628
 
5872
6629
  // src/commands/types.ts
5873
- var import_zod31 = require("zod");
6630
+ var import_zod32 = require("zod");
5874
6631
  var typesCommand = define({
5875
6632
  name: "types",
5876
6633
  tool: "kb_types",
5877
6634
  usage: "types",
5878
6635
  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.",
5879
- input: import_zod31.z.object({}),
6636
+ input: import_zod32.z.object({}),
5880
6637
  fromArgv: () => ({}),
5881
6638
  run: () => Promise.resolve(RECORD_TYPES)
5882
6639
  });
5883
6640
 
5884
6641
  // src/commands/unpin.ts
5885
- var import_zod32 = require("zod");
6642
+ var import_zod33 = require("zod");
5886
6643
  var unpinCommand = define({
5887
6644
  name: "unpin",
5888
6645
  tool: "kb_unpin",
5889
6646
  usage: "unpin [bundle-path]",
5890
6647
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
5891
- input: import_zod32.z.object({ bundlePath }),
6648
+ input: import_zod33.z.object({ bundlePath }),
5892
6649
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
5893
6650
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
5894
6651
  });
5895
6652
 
5896
6653
  // src/commands/validate.ts
5897
- var import_zod33 = require("zod");
6654
+ var import_zod34 = require("zod");
5898
6655
  var validateCommand = define({
5899
6656
  name: "validate",
5900
6657
  tool: "kb_validate",
5901
6658
  usage: "validate",
5902
6659
  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.",
5903
- input: import_zod33.z.object({ bundlePath }),
6660
+ input: import_zod34.z.object({ bundlePath }),
5904
6661
  fromArgv: (_argv, path) => ({ bundlePath: path }),
5905
6662
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
5906
6663
  // Warnings never fail the exit code; every other severity does.
@@ -5910,16 +6667,16 @@ var validateCommand = define({
5910
6667
  });
5911
6668
 
5912
6669
  // src/commands/verify.ts
5913
- var import_zod34 = require("zod");
6670
+ var import_zod35 = require("zod");
5914
6671
  var verifyCommand = define({
5915
6672
  name: "verify",
5916
6673
  tool: "kb_verify",
5917
6674
  usage: "verify <concept-id> --note <text>",
5918
6675
  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.",
5919
- input: import_zod34.z.object({
6676
+ input: import_zod35.z.object({
5920
6677
  bundlePath,
5921
6678
  conceptId,
5922
- note: import_zod34.z.string().refine((s) => s.trim().length > 0, {
6679
+ note: import_zod35.z.string().refine((s) => s.trim().length > 0, {
5923
6680
  message: "note must say what the check found"
5924
6681
  })
5925
6682
  }),
@@ -5939,15 +6696,15 @@ var verifyCommand = define({
5939
6696
  });
5940
6697
 
5941
6698
  // src/commands/write.ts
5942
- var import_zod35 = require("zod");
6699
+ var import_zod36 = require("zod");
5943
6700
  var writeCommand = define({
5944
6701
  name: "write",
5945
6702
  tool: "kb_write",
5946
6703
  usage: "write <type> < record.json",
5947
6704
  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.",
5948
- input: import_zod35.z.object({
6705
+ input: import_zod36.z.object({
5949
6706
  bundlePath,
5950
- type: import_zod35.z.enum(KB_RECORD_TYPES),
6707
+ type: import_zod36.z.enum(KB_RECORD_TYPES),
5951
6708
  input: composeInputSchema
5952
6709
  }),
5953
6710
  fromArgv: async (argv, path, stdin) => ({
@@ -5971,13 +6728,13 @@ var writeCommand = define({
5971
6728
  });
5972
6729
 
5973
6730
  // src/commands/write-decision.ts
5974
- var import_zod36 = require("zod");
6731
+ var import_zod37 = require("zod");
5975
6732
  var writeDecisionCommand = define({
5976
6733
  name: "write-decision",
5977
6734
  tool: "kb_write_decision",
5978
6735
  usage: "write-decision < decision.json",
5979
6736
  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.",
5980
- input: import_zod36.z.object({ bundlePath, input: decisionInputSchema }),
6737
+ input: import_zod37.z.object({ bundlePath, input: decisionInputSchema }),
5981
6738
  fromArgv: async (_argv, path, stdin) => ({
5982
6739
  bundlePath: path,
5983
6740
  input: JSON.parse(await stdin())
@@ -6007,6 +6764,7 @@ var KB_COMMANDS = [
6007
6764
  answerCommand,
6008
6765
  verifyCommand,
6009
6766
  anchorResolveCommand,
6767
+ reassessCommand,
6010
6768
  loadCommand,
6011
6769
  catalogCommand,
6012
6770
  packCommand,
@@ -6037,7 +6795,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
6037
6795
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
6038
6796
 
6039
6797
  // src/version.ts
6040
- var VERSION = true ? "0.1.17" : "0.0.0-dev";
6798
+ var VERSION = true ? "0.1.18" : "0.0.0-dev";
6041
6799
 
6042
6800
  // src/mcp.ts
6043
6801
  function createKbMcpServer() {
@@ -6131,21 +6889,21 @@ async function runKbCli(argv) {
6131
6889
  `);
6132
6890
  }
6133
6891
  function takeLiteral(argv) {
6134
- const at = argv.indexOf("--");
6135
- if (at === -1) return { flags: argv, literal: [] };
6136
- return { flags: argv.slice(0, at), literal: argv.slice(at + 1) };
6892
+ const at2 = argv.indexOf("--");
6893
+ if (at2 === -1) return { flags: argv, literal: [] };
6894
+ return { flags: argv.slice(0, at2), literal: argv.slice(at2 + 1) };
6137
6895
  }
6138
6896
  function takeBundle(argv) {
6139
- const at = argv.indexOf("--bundle");
6140
- if (at === -1) {
6897
+ const at2 = argv.indexOf("--bundle");
6898
+ if (at2 === -1) {
6141
6899
  return { bundle: (0, import_node_path12.join)(process.cwd(), KB_DIR), explicit: false, rest: argv };
6142
6900
  }
6143
- const bundle = argv[at + 1];
6901
+ const bundle = argv[at2 + 1];
6144
6902
  if (!bundle) die("--bundle requires a path");
6145
6903
  return {
6146
6904
  bundle,
6147
6905
  explicit: true,
6148
- rest: [...argv.slice(0, at), ...argv.slice(at + 2)]
6906
+ rest: [...argv.slice(0, at2), ...argv.slice(at2 + 2)]
6149
6907
  };
6150
6908
  }
6151
6909
  function readStdin() {
@@ -6243,6 +7001,7 @@ function usage() {
6243
7001
  backlinks,
6244
7002
  buildContext,
6245
7003
  catalog,
7004
+ classifyDrift,
6246
7005
  composeDecisionRecord,
6247
7006
  composeInputSchema,
6248
7007
  composeLinkSchema,
@@ -6290,6 +7049,7 @@ function usage() {
6290
7049
  readMergedPins,
6291
7050
  readPinsLayer,
6292
7051
  readRemoteAnchors,
7052
+ reassessPacket,
6293
7053
  regexResolver,
6294
7054
  renderCatalogLine,
6295
7055
  renderIndex,
@@ -6311,6 +7071,7 @@ function usage() {
6311
7071
  toHookJson,
6312
7072
  trace,
6313
7073
  treeSitterLanguages,
7074
+ unifiedDiff,
6314
7075
  unpinBase,
6315
7076
  validateBundle
6316
7077
  });