@saasontools/strauss-kb 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-main.cjs CHANGED
@@ -31,488 +31,10 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
31
31
  var import_node_path15 = require("path");
32
32
 
33
33
  // src/decision-record.ts
34
- var import_zod3 = require("zod");
35
-
36
- // src/compose.ts
37
- var import_zod2 = require("zod");
38
-
39
- // src/kb-record.schema.ts
40
- var import_zod = require("zod");
41
- var kbSourceSchema = import_zod.z.object({
42
- id: import_zod.z.string().min(1),
43
- resource: import_zod.z.string().min(1),
44
- title: import_zod.z.string().min(1).optional(),
45
- author: import_zod.z.string().min(1).optional(),
46
- last_modified: import_zod.z.string().min(1).optional()
47
- }).passthrough();
48
- var kbActorStampSchema = import_zod.z.object({
49
- by: import_zod.z.string().min(1),
50
- at: import_zod.z.string().min(1)
51
- }).passthrough();
52
- var kbVerifiedEventSchema = kbActorStampSchema.extend({
53
- note: import_zod.z.string().refine((s) => s.trim().length > 0, {
54
- message: "note must say what the check found"
55
- })
56
- });
57
- var kbAnchorSpanSchema = import_zod.z.object({
58
- start: import_zod.z.number().int().positive(),
59
- end: import_zod.z.number().int().positive()
60
- }).strict();
61
- var kbAnchorSchema = import_zod.z.object({
62
- file: import_zod.z.string().min(1),
63
- symbol: import_zod.z.string().min(1).optional(),
64
- /**
65
- * The lines the concept names, when no symbol covers them — deleted code,
66
- * YAML, SQL, Markdown. Alternative to `symbol`, never a refinement of it.
67
- */
68
- span: kbAnchorSpanSchema.optional(),
69
- /**
70
- * Which side of the change the anchor describes. `old` is code as it was
71
- * committed at `ref`, which is the only way to anchor something deleted;
72
- * absent means the working tree.
73
- */
74
- side: import_zod.z.enum(["old", "new"]).optional(),
75
- /**
76
- * Which repository the file lives in — a remote URL
77
- * (`https://github.com/org/name`) or a short name. Absent means the base's
78
- * own repository, which is what nearly every anchor means.
79
- *
80
- * Unvalidated beyond not-blank: one repository has many spellings, matched
81
- * after normalisation. Only a full URL can be fetched from, so `validate`
82
- * warns on a short one; see ARCHITECTURE.
83
- */
84
- repo: import_zod.z.string().trim().min(1).optional(),
85
- /**
86
- * The git rev the evidence was taken at. Prefer a commit SHA: a branch
87
- * name is a moving pointer, so an anchor pinned to one says the evidence
88
- * came from wherever that branch happens to be now, which is not a
89
- * baseline. A foreign anchor is checked at this rev, and compared against
90
- * the remote's default branch on top of it.
91
- */
92
- ref: import_zod.z.string().trim().min(1).optional(),
93
- hash: import_zod.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
94
- message: "hash must be sha256:<64 hex chars>"
95
- }).optional(),
96
- /**
97
- * What `hash` was taken over: the span's raw text, or the normalised token
98
- * stream a parser sees (`ast`). Absent means `raw`, which is what every
99
- * anchor stamped before this field carries, so old hashes keep comparing
100
- * the way they were written. An `ast` hash is blind to whitespace and
101
- * comments, so reformatting the anchored code is not drift.
102
- */
103
- hash_kind: import_zod.z.enum(["raw", "ast"]).optional(),
104
- /** ISO 8601 timestamp of the last successful resolution. */
105
- resolved_at: import_zod.z.string().min(1).optional(),
106
- /** Line count of the text the hash was taken over. */
107
- lines: import_zod.z.number().int().positive().optional(),
108
- /**
109
- * Which resolver produced the hashed span. Absent means an anchor stamped
110
- * before resolvers were named, which is read as `regex` — the only one
111
- * there was. A hash from a different resolver is drift, not a match.
112
- */
113
- resolver: import_zod.z.enum(["tree-sitter", "regex", "span"]).optional()
114
- }).strict();
115
- var kbAnchorWriteSchema = kbAnchorSchema.superRefine((anchor, ctx) => {
116
- if (anchor.span && anchor.symbol) {
117
- ctx.addIssue({
118
- code: import_zod.z.ZodIssueCode.custom,
119
- path: ["span"],
120
- message: "an anchor names a symbol or a span, not both"
121
- });
122
- }
123
- if (anchor.span && anchor.span.end < anchor.span.start) {
124
- ctx.addIssue({
125
- code: import_zod.z.ZodIssueCode.custom,
126
- path: ["span", "end"],
127
- message: "span end must not precede start"
128
- });
129
- }
130
- if (anchor.span && anchor.hash_kind === "ast") {
131
- ctx.addIssue({
132
- code: import_zod.z.ZodIssueCode.custom,
133
- path: ["hash_kind"],
134
- message: "a span is hashed raw, never ast"
135
- });
136
- }
137
- if (anchor.side === "old" && !anchor.ref) {
138
- ctx.addIssue({
139
- code: import_zod.z.ZodIssueCode.custom,
140
- path: ["ref"],
141
- message: 'side: "old" needs a ref \u2014 committed code has no other address'
142
- });
143
- }
144
- });
145
- var kbLinkSchema = import_zod.z.object({
146
- target: import_zod.z.string().min(1),
147
- rel: import_zod.z.string().min(1)
148
- }).passthrough();
149
- var KB_RECORD_TYPES = [
150
- "fact",
151
- "requirement",
152
- "constraint",
153
- "decision",
154
- "assumption",
155
- "open-question",
156
- "risk",
157
- "contract",
158
- "flow",
159
- "affected-system",
160
- "test-obligation",
161
- "source-note"
162
- ];
163
- var KB_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
164
- var KB_CONCEPT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*$/;
165
- var kbConceptIdSchema = import_zod.z.string().regex(KB_CONCEPT_ID_PATTERN, {
166
- message: "concept id must be <type>.<slug>, both kebab-case"
167
- });
168
- var KB_RECORD_STATUSES = [
169
- "draft",
170
- "proposed",
171
- "accepted",
172
- "open",
173
- "resolved",
174
- "rejected",
175
- "superseded"
176
- ];
177
- var KB_MATERIALITIES = [
178
- "blocking",
179
- "important",
180
- "non-blocking"
181
- ];
182
- var KB_CONFIDENCES = ["low", "medium", "high"];
183
- var kbRecordFrontmatterSchema = import_zod.z.object({
184
- // OKF: the only always-required key. A concept carrying just `type` is
185
- // fully conformant, so everything below stays optional.
186
- type: import_zod.z.string().min(1),
187
- // OKF recommended.
188
- title: import_zod.z.string().min(1).optional(),
189
- description: import_zod.z.string().min(1).optional(),
190
- resource: import_zod.z.string().min(1).optional(),
191
- tags: import_zod.z.array(import_zod.z.string()).optional(),
192
- // OKF optional: provenance and freshness.
193
- sources: import_zod.z.array(kbSourceSchema).optional(),
194
- generated: kbActorStampSchema.optional(),
195
- verified: import_zod.z.array(kbActorStampSchema).optional(),
196
- stale_after: import_zod.z.string().min(1).optional(),
197
- // strauss extensions — see the module comment.
198
- strauss_anchors: import_zod.z.array(kbAnchorSchema).optional(),
199
- strauss_verify: import_zod.z.array(import_zod.z.string().min(1)).optional(),
200
- // Typed causal edges, source → target, living on the source. `A depends_on
201
- // B` means A needs B, so `kb_impact` walks these inbound: what breaks if B
202
- // changes is whatever declared a dependence on it.
203
- strauss_links: import_zod.z.array(kbLinkSchema).optional(),
204
- // Total after parsing, tolerant before it. Our producers must supply a
205
- // status — an absent one would leave every reader inventing its own default
206
- // — but OKF calls a concept carrying only `type` fully conformant, so
207
- // rejecting a foreign record for the lack of one would put us outside the
208
- // spec. The default resolves it in the single place that can: here.
209
- strauss_status: import_zod.z.enum(KB_RECORD_STATUSES).default("draft"),
210
- strauss_supersedes: import_zod.z.array(import_zod.z.string().min(1)).optional(),
211
- strauss_superseded_by: import_zod.z.string().min(1).optional(),
212
- strauss_answered: kbActorStampSchema.optional(),
213
- strauss_materiality: import_zod.z.enum(KB_MATERIALITIES).optional(),
214
- strauss_confidence: import_zod.z.enum(KB_CONFIDENCES).optional(),
215
- strauss_owner: import_zod.z.string().min(1).optional(),
216
- // "No source exists" as a field rather than a sentinel entry inside
217
- // `sources`. A sentinel in a reference list is a value doing work a field
218
- // should do; as a field, `sources` may be legitimately empty.
219
- strauss_assumption: import_zod.z.boolean().optional()
220
- }).passthrough();
221
-
222
- // src/record-types.ts
223
- var RECORD_TYPES = {
224
- fact: {
225
- purpose: "Observed or sourced fact",
226
- sections: ["Claim", "Evidence", "Implication"],
227
- initialStatus: "accepted"
228
- },
229
- requirement: {
230
- purpose: "Required behavior or outcome",
231
- sections: ["Claim", "Evidence", "Implication"],
232
- initialStatus: "proposed"
233
- },
234
- constraint: {
235
- purpose: "Limitation, compatibility boundary, policy, or restriction",
236
- sections: ["Claim", "Evidence", "Implication"],
237
- initialStatus: "accepted"
238
- },
239
- decision: {
240
- purpose: "Chosen or proposed direction",
241
- sections: ["Decision", "Rationale", "Rejected", "Impact"],
242
- initialStatus: "accepted"
243
- },
244
- assumption: {
245
- purpose: "Unsourced or not-yet-confirmed working assumption",
246
- sections: ["Claim", "Why we think so", "What would falsify it"],
247
- initialStatus: "draft"
248
- },
249
- "open-question": {
250
- purpose: "Question needing resolution",
251
- sections: ["Question", "Why it matters", "Default assumption"],
252
- initialStatus: "open"
253
- },
254
- risk: {
255
- purpose: "Something that can go wrong",
256
- sections: ["Risk", "Why it matters", "Mitigation", "Verification"],
257
- initialStatus: "open"
258
- },
259
- contract: {
260
- purpose: "API, data, event, schema, or permission contract",
261
- sections: ["Contract", "Producer", "Consumer", "Compatibility"],
262
- initialStatus: "proposed"
263
- },
264
- flow: {
265
- purpose: "Sequence, lifecycle, or state behavior",
266
- sections: ["Flow", "Trigger", "Steps", "Failure modes"],
267
- initialStatus: "accepted"
268
- },
269
- "affected-system": {
270
- purpose: "Component, service, package, integration, or external system",
271
- sections: ["System", "How it is affected", "Blast radius"],
272
- initialStatus: "accepted"
273
- },
274
- "test-obligation": {
275
- purpose: "Behavior or contract that must be verified",
276
- sections: ["Obligation", "Why it matters", "How to verify"],
277
- initialStatus: "open"
278
- },
279
- "source-note": {
280
- purpose: "Extracted note from source material",
281
- sections: ["Note", "Where it came from"],
282
- initialStatus: "accepted"
283
- }
284
- };
285
- function isKbRecordType(value) {
286
- return Object.prototype.hasOwnProperty.call(RECORD_TYPES, value);
287
- }
288
- var KB_LINK_RELS = [
289
- "depends_on",
290
- "constrains",
291
- "informs",
292
- "blocks",
293
- "invalidates",
294
- "verified_by",
295
- "satisfies",
296
- "related_to"
297
- ];
298
- var LINK_RELS = {
299
- depends_on: {
300
- purpose: "The source needs the target to hold; the source breaks if the target changes",
301
- phrase: "Depends on",
302
- dependant: "source"
303
- },
304
- constrains: {
305
- purpose: "The source bounds what the target may do; the target breaks if the constraint changes",
306
- phrase: "Constrains",
307
- dependant: "target"
308
- },
309
- informs: {
310
- purpose: "The source shaped the target without binding it; the target is what needs revisiting",
311
- phrase: "Informs",
312
- dependant: "target"
313
- },
314
- blocks: {
315
- purpose: "The target cannot proceed until the source is settled; the target is what waits",
316
- phrase: "Blocks",
317
- dependant: "target"
318
- },
319
- invalidates: {
320
- purpose: "The source makes the target no longer hold; the target is what stops holding",
321
- phrase: "Invalidates",
322
- dependant: "target"
323
- },
324
- verified_by: {
325
- purpose: "The target is the check that confirms the source; the source's confirmation moves with it",
326
- phrase: "Verified by",
327
- dependant: "source"
328
- },
329
- satisfies: {
330
- purpose: "The source discharges the target's requirement; the source must change if the requirement does",
331
- phrase: "Satisfies",
332
- dependant: "source"
333
- },
334
- related_to: {
335
- purpose: "A pointer worth following, with no claim of dependence",
336
- phrase: "Relates to",
337
- dependant: null
338
- }
339
- };
340
- var KB_CAUSAL_LINK_RELS = KB_LINK_RELS.filter(
341
- (rel) => LINK_RELS[rel].dependant !== null
342
- );
343
- function isKbLinkRel(value) {
344
- return Object.prototype.hasOwnProperty.call(LINK_RELS, value);
345
- }
346
-
347
- // src/compose.ts
348
- var composeLinkSchema = import_zod2.z.object({
349
- target: kbConceptIdSchema,
350
- rel: import_zod2.z.enum(KB_LINK_RELS)
351
- }).strict();
352
- var composeInputSchema = import_zod2.z.object({
353
- slug: import_zod2.z.string().min(1),
354
- /** One line, in the reader's terms. Becomes OKF `title`. */
355
- title: import_zod2.z.string().min(1),
356
- /** The consequence — what breaks if this is wrong. Becomes `description`. */
357
- why: import_zod2.z.string().min(1),
358
- /** Keyed by section heading from the type's spec. Unknown keys rejected. */
359
- sections: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string().min(1)).optional(),
360
- anchors: import_zod2.z.array(kbAnchorWriteSchema).optional(),
361
- sources: import_zod2.z.array(kbSourceSchema).optional(),
362
- /** No source exists, as a claim rather than a sentinel in `sources`. */
363
- assumption: import_zod2.z.boolean().optional(),
364
- /**
365
- * OKF `stale_after`: the absolute date this record stops being trusted.
366
- * Anything the outside world can change — pricing, quotas, versions,
367
- * reception counts — should carry one.
368
- */
369
- stale_after: import_zod2.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
370
- message: "stale_after must be YYYY-MM-DD"
371
- }).refine((date) => !Number.isNaN(Date.parse(date)), {
372
- message: "stale_after must be a real date"
373
- }).optional(),
374
- verify: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
375
- tags: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
376
- /** Concept ids this record relates to; rendered as body links. */
377
- relatedConceptIds: import_zod2.z.array(kbConceptIdSchema).optional(),
378
- /**
379
- * Typed causal edges, source → target: `{ target: "fact.b", rel:
380
- * "depends_on" }` on record A says A needs B. Stored in frontmatter and
381
- * also rendered as one prose sentence each, so the meaning survives a
382
- * reader that knows only OKF. The vocabulary goes into the description from
383
- * the same table the walk uses, so `kb_schema` emits it.
384
- */
385
- links: import_zod2.z.array(composeLinkSchema).max(64).optional().describe(
386
- `Typed causal edges, source \u2192 target \u2014 a link on this record says this record <rel> the target. ${KB_LINK_RELS.map(
387
- (rel) => `${rel}: ${LINK_RELS[rel].purpose}`
388
- ).join("; ")}.`
389
- ),
390
- /** Concept ids this record replaces. The store settles the backlinks. */
391
- supersedes: import_zod2.z.array(kbConceptIdSchema).max(32).optional(),
392
- materiality: import_zod2.z.enum(KB_MATERIALITIES).optional(),
393
- confidence: import_zod2.z.enum(KB_CONFIDENCES).optional(),
394
- owner: import_zod2.z.string().min(1).optional()
395
- }).strict();
396
- function composeRecord(type, input, writtenBy, writtenAt) {
397
- const parsed = composeInputSchema.parse(input);
398
- const spec = RECORD_TYPES[type];
399
- const sections = parsed.sections ?? {};
400
- const unknown = Object.keys(sections).filter(
401
- (heading) => !spec.sections.includes(heading)
402
- );
403
- if (unknown.length) {
404
- throw new Error(
405
- `kb: ${type} has no section ${unknown.join(", ")} \u2014 expected one of ${spec.sections.join(", ")}`
406
- );
407
- }
408
- const frontmatter = {
409
- title: parsed.title,
410
- description: parsed.why,
411
- generated: { by: writtenBy, at: writtenAt },
412
- // Empty rather than absent: a later verification pass appends here, and an
413
- // empty list says "not yet verified" where a missing key would only say
414
- // "this producer didn't think about it".
415
- verified: [],
416
- strauss_status: spec.initialStatus
417
- };
418
- if (parsed.stale_after) frontmatter.stale_after = parsed.stale_after;
419
- if (parsed.anchors?.length) frontmatter.strauss_anchors = parsed.anchors;
420
- if (parsed.verify?.length) frontmatter.strauss_verify = parsed.verify;
421
- if (parsed.tags?.length) frontmatter.tags = parsed.tags;
422
- if (parsed.sources?.length) frontmatter.sources = parsed.sources;
423
- if (parsed.assumption) frontmatter.strauss_assumption = true;
424
- if (parsed.materiality) frontmatter.strauss_materiality = parsed.materiality;
425
- if (parsed.confidence) frontmatter.strauss_confidence = parsed.confidence;
426
- if (parsed.owner) frontmatter.strauss_owner = parsed.owner;
427
- if (parsed.supersedes?.length)
428
- frontmatter.strauss_supersedes = parsed.supersedes;
429
- const selfLink = parsed.links?.find(
430
- (link2) => link2.target === `${type}.${parsed.slug}`
431
- );
432
- if (selfLink) {
433
- throw new Error(
434
- `kb: ${type}.${parsed.slug} cannot ${selfLink.rel} itself \u2014 a link must name another record`
435
- );
436
- }
437
- if (parsed.links?.length) frontmatter.strauss_links = parsed.links;
438
- const blocks = [];
439
- for (const heading of spec.sections) {
440
- const text = sections[heading];
441
- if (text) blocks.push(`## ${heading}
442
-
443
- ${text}`);
444
- }
445
- if (!blocks.length) blocks.push(parsed.why);
446
- for (const related of parsed.relatedConceptIds ?? []) {
447
- blocks.push(`Relates to [${related}](${related}.md).`);
448
- }
449
- for (const link2 of parsed.links ?? []) {
450
- blocks.push(
451
- `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
452
- );
453
- }
454
- if (parsed.sources?.length) {
455
- blocks.push(
456
- parsed.sources.map((source) => `[^${source.id}]: ${source.title ?? source.resource}`).join("\n")
457
- );
458
- }
459
- return {
460
- type,
461
- slug: parsed.slug,
462
- frontmatter,
463
- body: `${blocks.join("\n\n")}
464
- `
465
- };
466
- }
467
-
468
- // src/decision-record.ts
469
- var DECISION_TYPE = "decision";
470
- var NO_DECISION_SLUG = "none";
471
- var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
472
- alternative: import_zod3.z.string().min(1).optional(),
473
- impact: import_zod3.z.string().min(1).optional()
474
- }).strict();
475
- function composeDecisionRecord(input, writtenBy, writtenAt) {
476
- const { alternative, impact: impact2, ...rest } = input;
477
- return composeRecord(
478
- DECISION_TYPE,
479
- {
480
- ...rest,
481
- sections: {
482
- Decision: input.title,
483
- Rationale: input.why,
484
- ...alternative ? { Rejected: alternative } : {},
485
- ...impact2 ? { Impact: impact2 } : {}
486
- }
487
- },
488
- writtenBy,
489
- writtenAt
490
- );
491
- }
492
- function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
493
- return composeRecord(
494
- DECISION_TYPE,
495
- {
496
- slug: NO_DECISION_SLUG,
497
- title: "No decision to record",
498
- why: reason,
499
- sections: { Decision: reason }
500
- },
501
- writtenBy,
502
- writtenAt
503
- );
504
- }
505
- function isNoDecisionRecord(record) {
506
- return record.conceptId === `${DECISION_TYPE}.${NO_DECISION_SLUG}`;
507
- }
508
- function selectDecisions(records) {
509
- return records.filter(
510
- (record) => record.conceptId.startsWith(`${DECISION_TYPE}.`) && !isNoDecisionRecord(record)
511
- );
512
- }
34
+ var import_zod4 = require("zod");
513
35
 
514
- // src/commands/anchor-resolve.ts
515
- var import_zod7 = require("zod");
36
+ // src/compose.ts
37
+ var import_zod3 = require("zod");
516
38
 
517
39
  // src/concurrency.ts
518
40
  var DEFAULT_IO_CONCURRENCY = 16;
@@ -1326,26 +848,26 @@ var import_node_path4 = require("path");
1326
848
  var import_node_url = require("url");
1327
849
 
1328
850
  // src/grammars/model.ts
1329
- var import_zod4 = require("zod");
1330
- var sha2562 = import_zod4.z.string().regex(/^[0-9a-f]{64}$/);
1331
- var grammarWasmSchema = import_zod4.z.object({
1332
- url: import_zod4.z.string().min(1),
851
+ var import_zod = require("zod");
852
+ var sha2562 = import_zod.z.string().regex(/^[0-9a-f]{64}$/);
853
+ var grammarWasmSchema = import_zod.z.object({
854
+ url: import_zod.z.string().min(1),
1333
855
  sha256: sha2562,
1334
- bytes: import_zod4.z.number().int().positive()
856
+ bytes: import_zod.z.number().int().positive()
1335
857
  });
1336
- var grammarTagsSchema = import_zod4.z.object({ url: import_zod4.z.string().min(1), sha256: sha2562 });
1337
- var grammarPackSchema = import_zod4.z.object({
1338
- package: import_zod4.z.string().min(1),
858
+ var grammarTagsSchema = import_zod.z.object({ url: import_zod.z.string().min(1), sha256: sha2562 });
859
+ var grammarPackSchema = import_zod.z.object({
860
+ package: import_zod.z.string().min(1),
1339
861
  wasm: grammarWasmSchema,
1340
- tags: import_zod4.z.array(grammarTagsSchema),
1341
- license: import_zod4.z.string().min(1),
1342
- extensions: import_zod4.z.array(import_zod4.z.string().min(1))
862
+ tags: import_zod.z.array(grammarTagsSchema),
863
+ license: import_zod.z.string().min(1),
864
+ extensions: import_zod.z.array(import_zod.z.string().min(1))
1343
865
  });
1344
- var grammarManifestSchema = import_zod4.z.object({
866
+ var grammarManifestSchema = import_zod.z.object({
1345
867
  /** The runtime the packs were proved against. */
1346
- webTreeSitter: import_zod4.z.string().min(1),
1347
- linguist: import_zod4.z.object({ tag: import_zod4.z.string().min(1), commit: import_zod4.z.string().min(1) }),
1348
- packs: import_zod4.z.record(import_zod4.z.string().min(1), grammarPackSchema)
868
+ webTreeSitter: import_zod.z.string().min(1),
869
+ linguist: import_zod.z.object({ tag: import_zod.z.string().min(1), commit: import_zod.z.string().min(1) }),
870
+ packs: import_zod.z.record(import_zod.z.string().min(1), grammarPackSchema)
1349
871
  });
1350
872
 
1351
873
  // src/grammars/manifest.ts
@@ -2026,262 +1548,849 @@ function sliceSpan(source, range) {
2026
1548
  resolver: "span"
2027
1549
  };
2028
1550
  }
2029
- function fromResolve(resolver, source, symbol, file) {
2030
- const span2 = resolver.resolve(source, symbol, file);
2031
- return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
2032
- }
2033
- function isResolverName(name) {
2034
- return name === "tree-sitter" || name === "regex" || name === "span";
2035
- }
2036
- async function prepareResolvers(resolvers, files) {
2037
- for (const resolver of resolvers) await resolver.prepare?.(files);
1551
+ function fromResolve(resolver, source, symbol, file) {
1552
+ const span2 = resolver.resolve(source, symbol, file);
1553
+ return span2 ? { kind: "resolved", span: span2 } : { kind: "unresolved", reason: "symbol-not-found" };
1554
+ }
1555
+ function isResolverName(name) {
1556
+ return name === "tree-sitter" || name === "regex" || name === "span";
1557
+ }
1558
+ async function prepareResolvers(resolvers, files) {
1559
+ for (const resolver of resolvers) await resolver.prepare?.(files);
1560
+ }
1561
+ function defaultAnchorResolvers(grammars = {}) {
1562
+ return [new TreeSitterResolver(grammars), regexResolver];
1563
+ }
1564
+ function resolverChanged(source, anchor, produced) {
1565
+ const previous = anchor.resolver ?? "regex";
1566
+ if (!produced || !anchor.symbol || previous === produced) return false;
1567
+ if (previous !== "regex") return false;
1568
+ const before = regexResolver.resolve(
1569
+ source.replace(/\r\n/g, "\n"),
1570
+ anchor.symbol
1571
+ );
1572
+ return before !== null && hashAnchorText(before.text) === anchor.hash;
1573
+ }
1574
+ function anchorHashOf(anchor, outcome) {
1575
+ if (outcome.resolver === "span") {
1576
+ return { hash: hashAnchorText(outcome.span.text), kind: "raw" };
1577
+ }
1578
+ const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
1579
+ const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
1580
+ return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
1581
+ }
1582
+
1583
+ // src/anchor-resolver/drift.ts
1584
+ async function detectAnchorDrift(records, options = {}) {
1585
+ const repoRoot = options.repoRoot ?? process.cwd();
1586
+ const resolvers = options.resolvers ?? (options.resolver ? [options.resolver] : defaultAnchorResolvers({
1587
+ offline: options.remote?.offline === true
1588
+ }));
1589
+ const origin = new LazyOrigin(repoRoot);
1590
+ const planned = /* @__PURE__ */ new Map();
1591
+ let declaresRepo = false;
1592
+ for (const record of records) {
1593
+ const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
1594
+ (anchor) => anchor.hash
1595
+ );
1596
+ if (!anchors.length) continue;
1597
+ if (anchors.some((anchor) => anchor.repo)) declaresRepo = true;
1598
+ planned.set(
1599
+ record.conceptId,
1600
+ anchors.map((anchor) => ({ anchor, foreign: false }))
1601
+ );
1602
+ }
1603
+ if (declaresRepo) {
1604
+ await origin.prime();
1605
+ for (const entries of planned.values()) {
1606
+ for (const entry of entries)
1607
+ entry.foreign = origin.isForeign(entry.anchor);
1608
+ }
1609
+ }
1610
+ const files = [];
1611
+ const committedWants = [];
1612
+ const wants = [];
1613
+ for (const entries of planned.values()) {
1614
+ for (const { anchor, foreign } of entries) {
1615
+ if (foreign) wants.push(...remoteWants(anchor));
1616
+ else if (anchor.side === "old") committedWants.push(anchor);
1617
+ else files.push(anchor.file);
1618
+ }
1619
+ }
1620
+ const [reads, committed, remote] = await Promise.all([
1621
+ readAnchorFiles(
1622
+ files,
1623
+ options.reader ?? anchorFileReader(repoRoot),
1624
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY
1625
+ ),
1626
+ readCommitted(repoRoot, committedWants, options),
1627
+ (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
1628
+ ]);
1629
+ await prepareResolvers(resolvers, [
1630
+ ...files,
1631
+ ...committedWants.map((anchor) => anchor.file),
1632
+ ...wants.map((want) => want.file)
1633
+ ]);
1634
+ const drift = /* @__PURE__ */ new Map();
1635
+ for (const record of records) {
1636
+ const entries = [];
1637
+ for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
1638
+ if (foreign) {
1639
+ entries.push(remoteEntry(anchor, remote, resolvers));
1640
+ continue;
1641
+ }
1642
+ const read = anchor.side === "old" ? committed.get(atRefKey(anchor)) : reads.get(anchor.file);
1643
+ entries.push(localEntry(anchor, read, resolvers));
1644
+ }
1645
+ if (entries.length) drift.set(record.conceptId, entries);
1646
+ }
1647
+ return drift;
1648
+ }
1649
+ function atRefKey(anchor) {
1650
+ return `${anchor.ref ?? ""}\0${anchor.file}`;
1651
+ }
1652
+ async function readCommitted(repoRoot, anchors, options = {}) {
1653
+ if (!anchors.length) return /* @__PURE__ */ new Map();
1654
+ const read = options.readAtRef ?? readFileAtRef;
1655
+ const byKey = /* @__PURE__ */ new Map();
1656
+ for (const anchor of anchors) byKey.set(atRefKey(anchor), anchor);
1657
+ const keys = [...byKey.keys()];
1658
+ const results = await mapLimit(
1659
+ keys,
1660
+ options.concurrency ?? DEFAULT_IO_CONCURRENCY,
1661
+ (key2) => read(repoRoot, byKey.get(key2))
1662
+ );
1663
+ return new Map(keys.map((key2, at2) => [key2, results[at2]]));
1664
+ }
1665
+ function remoteWants(anchor) {
1666
+ const repo = anchor.repo;
1667
+ const wants = [{ repo, file: anchor.file }];
1668
+ if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
1669
+ return wants;
1670
+ }
1671
+ function base(anchor) {
1672
+ return {
1673
+ file: anchor.file,
1674
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
1675
+ ...anchor.side === "old" ? { side: "old" } : {},
1676
+ storedHash: anchor.hash
1677
+ };
1678
+ }
1679
+ function unresolved(anchor, reason, repo) {
1680
+ return {
1681
+ ...base(anchor),
1682
+ state: "unresolved",
1683
+ diffSize: null,
1684
+ ...reason ? { reason } : {},
1685
+ ...repo ? { repo } : {},
1686
+ ...classOf(reason)
1687
+ };
1688
+ }
1689
+ var GONE_REASONS = /* @__PURE__ */ new Set([
1690
+ "file-missing",
1691
+ "symbol-not-found",
1692
+ "span-out-of-range",
1693
+ "ref-unreadable"
1694
+ ]);
1695
+ function provisionalDriftClass(entry) {
1696
+ if (entry.state === "unresolved") {
1697
+ return GONE_REASONS.has(entry.reason) ? "gone" : void 0;
1698
+ }
1699
+ return entry.state === "drifted" ? "changed" : void 0;
1700
+ }
1701
+ function classOf(reason) {
1702
+ const settled = provisionalDriftClass({ state: "unresolved", reason });
1703
+ return settled ? { class: settled } : {};
1704
+ }
1705
+ function hashIn(source, anchor, resolvers) {
1706
+ const outcome = resolveAnchorSpan(source, anchor, resolvers);
1707
+ if (!outcome.ok) return { ok: false, reason: outcome.reason };
1708
+ const { hash, kind } = anchorHashOf(anchor, outcome);
1709
+ return {
1710
+ ok: true,
1711
+ current: {
1712
+ hash,
1713
+ kind,
1714
+ lines: outcome.span.endLine - outcome.span.startLine + 1,
1715
+ ...outcome.resolver ? { resolver: outcome.resolver } : {}
1716
+ }
1717
+ };
1718
+ }
1719
+ function resolverExtras(source, anchor, current) {
1720
+ return {
1721
+ ...current.resolver ? { resolver: current.resolver } : {},
1722
+ ...current.hash !== anchor.hash && resolverChanged(source, anchor, current.resolver) ? { reason: "resolver-changed" } : {}
1723
+ };
2038
1724
  }
2039
- function defaultAnchorResolvers(grammars = {}) {
2040
- return [new TreeSitterResolver(grammars), regexResolver];
1725
+ function compared(anchor, current, extra = {}) {
1726
+ const matched = current.hash === anchor.hash;
1727
+ return {
1728
+ ...base(anchor),
1729
+ state: matched ? "match" : "drifted",
1730
+ currentHash: current.hash,
1731
+ hashKind: current.kind,
1732
+ diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
1733
+ ...matched ? {} : { class: "changed" },
1734
+ ...extra
1735
+ };
2041
1736
  }
2042
- function resolverChanged(source, anchor, produced) {
2043
- const previous = anchor.resolver ?? "regex";
2044
- if (!produced || !anchor.symbol || previous === produced) return false;
2045
- if (previous !== "regex") return false;
2046
- const before = regexResolver.resolve(
2047
- source.replace(/\r\n/g, "\n"),
2048
- anchor.symbol
1737
+ function localEntry(anchor, read, resolvers) {
1738
+ if (!read.ok) return unresolved(anchor, read.reason);
1739
+ const found = hashIn(read.source, anchor, resolvers);
1740
+ if (!found.ok) return unresolved(anchor, found.reason);
1741
+ return compared(
1742
+ anchor,
1743
+ found.current,
1744
+ resolverExtras(read.source, anchor, found.current)
2049
1745
  );
2050
- return before !== null && hashAnchorText(before.text) === anchor.hash;
2051
1746
  }
2052
- function anchorHashOf(anchor, outcome) {
2053
- if (outcome.resolver === "span") {
2054
- return { hash: hashAnchorText(outcome.span.text), kind: "raw" };
1747
+ function remoteEntry(anchor, remote, resolvers) {
1748
+ const repo = anchor.repo;
1749
+ const key2 = normalizeRepoUrl(repo);
1750
+ const atDefault = remote.get(wantKey(key2, void 0, anchor.file));
1751
+ const primary = anchor.ref ? remote.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
1752
+ if (!primary) return unresolved(anchor, "remote-unreachable", repo);
1753
+ if (!primary.ok) return unresolved(anchor, primary.reason, repo);
1754
+ const found = hashIn(primary.source, anchor, resolvers);
1755
+ if (!found.ok) return unresolved(anchor, found.reason, repo);
1756
+ const current = found.current;
1757
+ const extras = resolverExtras(primary.source, anchor, current);
1758
+ if (!anchor.ref) return compared(anchor, current, { repo, ...extras });
1759
+ if (current.hash !== anchor.hash) {
1760
+ return compared(anchor, current, {
1761
+ repo,
1762
+ ...extras,
1763
+ remoteState: "drifted-from-ref"
1764
+ });
2055
1765
  }
2056
- const stored = anchor.hash ? anchor.hash_kind ?? "raw" : void 0;
2057
- const wanted = stored ?? (outcome.normalized ? "ast" : "raw");
2058
- return wanted === "ast" && outcome.normalized ? { hash: hashAnchorText(outcome.normalized), kind: "ast" } : { hash: hashAnchorText(outcome.span.text), kind: "raw" };
1766
+ if (anchor.side === "old") {
1767
+ return compared(anchor, current, {
1768
+ repo,
1769
+ ...extras,
1770
+ remoteState: "matches-ref"
1771
+ });
1772
+ }
1773
+ const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
1774
+ return head?.ok && head.current.hash !== anchor.hash ? {
1775
+ ...compared(anchor, head.current, {
1776
+ repo,
1777
+ ...head.current.resolver ? { resolver: head.current.resolver } : {}
1778
+ }),
1779
+ state: "drifted",
1780
+ remoteState: "drifted-on-default"
1781
+ } : compared(anchor, current, {
1782
+ repo,
1783
+ ...extras,
1784
+ remoteState: "matches-ref"
1785
+ });
2059
1786
  }
2060
1787
 
2061
- // src/anchor-resolver/drift.ts
2062
- async function detectAnchorDrift(records, options = {}) {
2063
- const repoRoot = options.repoRoot ?? process.cwd();
2064
- const resolvers = options.resolvers ?? (options.resolver ? [options.resolver] : defaultAnchorResolvers({
2065
- offline: options.remote?.offline === true
2066
- }));
2067
- const origin = new LazyOrigin(repoRoot);
2068
- const planned = /* @__PURE__ */ new Map();
2069
- let declaresRepo = false;
2070
- for (const record of records) {
2071
- const anchors = (record.frontmatter.strauss_anchors ?? []).filter(
2072
- (anchor) => anchor.hash
2073
- );
2074
- if (!anchors.length) continue;
2075
- if (anchors.some((anchor) => anchor.repo)) declaresRepo = true;
2076
- planned.set(
2077
- record.conceptId,
2078
- anchors.map((anchor) => ({ anchor, foreign: false }))
2079
- );
1788
+ // src/kb-record.schema.ts
1789
+ var import_zod2 = require("zod");
1790
+ var kbSourceSchema = import_zod2.z.object({
1791
+ id: import_zod2.z.string().min(1),
1792
+ resource: import_zod2.z.string().min(1),
1793
+ title: import_zod2.z.string().min(1).optional(),
1794
+ author: import_zod2.z.string().min(1).optional(),
1795
+ last_modified: import_zod2.z.string().min(1).optional()
1796
+ }).passthrough();
1797
+ var kbActorStampSchema = import_zod2.z.object({
1798
+ by: import_zod2.z.string().min(1),
1799
+ at: import_zod2.z.string().min(1)
1800
+ }).passthrough();
1801
+ var kbVerifiedEventSchema = kbActorStampSchema.extend({
1802
+ note: import_zod2.z.string().refine((s) => s.trim().length > 0, {
1803
+ message: "note must say what the check found"
1804
+ })
1805
+ });
1806
+ var kbAnchorSpanSchema = import_zod2.z.object({
1807
+ start: import_zod2.z.number().int().positive(),
1808
+ end: import_zod2.z.number().int().positive()
1809
+ }).strict();
1810
+ var kbAnchorSchema = import_zod2.z.object({
1811
+ file: import_zod2.z.string().min(1),
1812
+ symbol: import_zod2.z.string().min(1).optional(),
1813
+ /**
1814
+ * The lines the concept names, when no symbol covers them — deleted code,
1815
+ * YAML, SQL, Markdown. Alternative to `symbol`, never a refinement of it.
1816
+ */
1817
+ span: kbAnchorSpanSchema.optional(),
1818
+ /**
1819
+ * Which side of the change the anchor describes. `old` is code as it was
1820
+ * committed at `ref`, which is the only way to anchor something deleted;
1821
+ * absent means the working tree.
1822
+ */
1823
+ side: import_zod2.z.enum(["old", "new"]).optional(),
1824
+ /**
1825
+ * Which repository the file lives in — a remote URL
1826
+ * (`https://github.com/org/name`) or a short name. Absent means the base's
1827
+ * own repository, which is what nearly every anchor means.
1828
+ *
1829
+ * Unvalidated beyond not-blank: one repository has many spellings, matched
1830
+ * after normalisation. Only a full URL can be fetched from, so `validate`
1831
+ * warns on a short one; see ARCHITECTURE.
1832
+ */
1833
+ repo: import_zod2.z.string().trim().min(1).optional(),
1834
+ /**
1835
+ * The git rev the evidence was taken at. Prefer a commit SHA: a branch
1836
+ * name is a moving pointer, so an anchor pinned to one says the evidence
1837
+ * came from wherever that branch happens to be now, which is not a
1838
+ * baseline. A foreign anchor is checked at this rev, and compared against
1839
+ * the remote's default branch on top of it.
1840
+ */
1841
+ ref: import_zod2.z.string().trim().min(1).optional(),
1842
+ hash: import_zod2.z.string().regex(/^sha256:[0-9a-f]{64}$/, {
1843
+ message: "hash must be sha256:<64 hex chars>"
1844
+ }).optional(),
1845
+ /**
1846
+ * What `hash` was taken over: the span's raw text, or the normalised token
1847
+ * stream a parser sees (`ast`). Absent means `raw`, which is what every
1848
+ * anchor stamped before this field carries, so old hashes keep comparing
1849
+ * the way they were written. An `ast` hash is blind to whitespace and
1850
+ * comments, so reformatting the anchored code is not drift.
1851
+ */
1852
+ hash_kind: import_zod2.z.enum(["raw", "ast"]).optional(),
1853
+ /** ISO 8601 timestamp of the last successful resolution. */
1854
+ resolved_at: import_zod2.z.string().min(1).optional(),
1855
+ /** Line count of the text the hash was taken over. */
1856
+ lines: import_zod2.z.number().int().positive().optional(),
1857
+ /**
1858
+ * Which resolver produced the hashed span. Absent means an anchor stamped
1859
+ * before resolvers were named, which is read as `regex` — the only one
1860
+ * there was. A hash from a different resolver is drift, not a match.
1861
+ */
1862
+ resolver: import_zod2.z.enum(["tree-sitter", "regex", "span"]).optional()
1863
+ }).strict();
1864
+ var kbAnchorWriteSchema = kbAnchorSchema.superRefine((anchor, ctx) => {
1865
+ if (anchor.span && anchor.symbol) {
1866
+ ctx.addIssue({
1867
+ code: import_zod2.z.ZodIssueCode.custom,
1868
+ path: ["span"],
1869
+ message: "an anchor names a symbol or a span, not both"
1870
+ });
1871
+ }
1872
+ if (anchor.span && anchor.span.end < anchor.span.start) {
1873
+ ctx.addIssue({
1874
+ code: import_zod2.z.ZodIssueCode.custom,
1875
+ path: ["span", "end"],
1876
+ message: "span end must not precede start"
1877
+ });
1878
+ }
1879
+ if (anchor.span && anchor.hash_kind === "ast") {
1880
+ ctx.addIssue({
1881
+ code: import_zod2.z.ZodIssueCode.custom,
1882
+ path: ["hash_kind"],
1883
+ message: "a span is hashed raw, never ast"
1884
+ });
1885
+ }
1886
+ if (anchor.side === "old" && !anchor.ref) {
1887
+ ctx.addIssue({
1888
+ code: import_zod2.z.ZodIssueCode.custom,
1889
+ path: ["ref"],
1890
+ message: 'side: "old" needs a ref \u2014 committed code has no other address'
1891
+ });
1892
+ }
1893
+ });
1894
+ var kbAnchorLocatorSchema = kbAnchorSchema.pick({
1895
+ file: true,
1896
+ symbol: true,
1897
+ span: true,
1898
+ side: true,
1899
+ repo: true,
1900
+ ref: true
1901
+ });
1902
+ var kbLinkSchema = import_zod2.z.object({
1903
+ target: import_zod2.z.string().min(1),
1904
+ rel: import_zod2.z.string().min(1)
1905
+ }).passthrough();
1906
+ var KB_RECORD_TYPES = [
1907
+ "fact",
1908
+ "requirement",
1909
+ "constraint",
1910
+ "decision",
1911
+ "assumption",
1912
+ "open-question",
1913
+ "risk",
1914
+ "contract",
1915
+ "flow",
1916
+ "affected-system",
1917
+ "test-obligation",
1918
+ "source-note"
1919
+ ];
1920
+ var KB_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1921
+ var KB_CONCEPT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*$/;
1922
+ var kbConceptIdSchema = import_zod2.z.string().regex(KB_CONCEPT_ID_PATTERN, {
1923
+ message: "concept id must be <type>.<slug>, both kebab-case"
1924
+ });
1925
+ var KB_RECORD_STATUSES = [
1926
+ "draft",
1927
+ "proposed",
1928
+ "accepted",
1929
+ "open",
1930
+ "resolved",
1931
+ "rejected",
1932
+ "superseded"
1933
+ ];
1934
+ var KB_MATERIALITIES = [
1935
+ "blocking",
1936
+ "important",
1937
+ "non-blocking"
1938
+ ];
1939
+ var KB_CONFIDENCES = ["low", "medium", "high"];
1940
+ var kbRecordFrontmatterSchema = import_zod2.z.object({
1941
+ // OKF: the only always-required key. A concept carrying just `type` is
1942
+ // fully conformant, so everything below stays optional.
1943
+ type: import_zod2.z.string().min(1),
1944
+ // OKF recommended.
1945
+ title: import_zod2.z.string().min(1).optional(),
1946
+ description: import_zod2.z.string().min(1).optional(),
1947
+ resource: import_zod2.z.string().min(1).optional(),
1948
+ tags: import_zod2.z.array(import_zod2.z.string()).optional(),
1949
+ // OKF optional: provenance and freshness.
1950
+ sources: import_zod2.z.array(kbSourceSchema).optional(),
1951
+ generated: kbActorStampSchema.optional(),
1952
+ verified: import_zod2.z.array(kbActorStampSchema).optional(),
1953
+ stale_after: import_zod2.z.string().min(1).optional(),
1954
+ // strauss extensions — see the module comment.
1955
+ strauss_anchors: import_zod2.z.array(kbAnchorSchema).optional(),
1956
+ strauss_verify: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
1957
+ // Typed causal edges, source → target, living on the source. `A depends_on
1958
+ // B` means A needs B, so `kb_impact` walks these inbound: what breaks if B
1959
+ // changes is whatever declared a dependence on it.
1960
+ strauss_links: import_zod2.z.array(kbLinkSchema).optional(),
1961
+ // Total after parsing, tolerant before it. Our producers must supply a
1962
+ // status — an absent one would leave every reader inventing its own default
1963
+ // — but OKF calls a concept carrying only `type` fully conformant, so
1964
+ // rejecting a foreign record for the lack of one would put us outside the
1965
+ // spec. The default resolves it in the single place that can: here.
1966
+ strauss_status: import_zod2.z.enum(KB_RECORD_STATUSES).default("draft"),
1967
+ strauss_supersedes: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
1968
+ strauss_superseded_by: import_zod2.z.string().min(1).optional(),
1969
+ strauss_answered: kbActorStampSchema.optional(),
1970
+ strauss_materiality: import_zod2.z.enum(KB_MATERIALITIES).optional(),
1971
+ strauss_confidence: import_zod2.z.enum(KB_CONFIDENCES).optional(),
1972
+ strauss_owner: import_zod2.z.string().min(1).optional(),
1973
+ // "No source exists" as a field rather than a sentinel entry inside
1974
+ // `sources`. A sentinel in a reference list is a value doing work a field
1975
+ // should do; as a field, `sources` may be legitimately empty.
1976
+ strauss_assumption: import_zod2.z.boolean().optional()
1977
+ }).passthrough();
1978
+
1979
+ // src/errors.ts
1980
+ var BaseError = class extends Error {
1981
+ code;
1982
+ errorType;
1983
+ fault;
1984
+ retriable;
1985
+ reportToUser;
1986
+ details;
1987
+ constructor(props) {
1988
+ super(props.message);
1989
+ this.name = props.name ?? this.constructor.name;
1990
+ this.code = props.code ?? 500;
1991
+ this.errorType = props.errorType;
1992
+ this.fault = props.fault;
1993
+ this.retriable = props.retriable ?? true;
1994
+ this.reportToUser = props.reportToUser ?? false;
1995
+ this.details = props.details;
2080
1996
  }
2081
- if (declaresRepo) {
2082
- await origin.prime();
2083
- for (const entries of planned.values()) {
2084
- for (const entry of entries)
2085
- entry.foreign = origin.isForeign(entry.anchor);
2086
- }
1997
+ };
1998
+
1999
+ // src/anchors/errors.ts
2000
+ function locatorText(locator) {
2001
+ const span2 = locator.span ? `:${locator.span.start}-${locator.span.end}` : "";
2002
+ const symbol = locator.symbol ? `:${cap(locator.symbol)}` : "";
2003
+ const repo = locator.repo ? `${cap(locator.repo)}@` : "";
2004
+ const ref = locator.ref ? `@${cap(locator.ref)}` : "";
2005
+ return `${repo}${cap(locator.file)}${symbol}${span2}${ref}`;
2006
+ }
2007
+ var FIELD_CAP = 120;
2008
+ function cap(value) {
2009
+ return value.length > FIELD_CAP ? `${value.slice(0, FIELD_CAP - 1)}\u2026` : value;
2010
+ }
2011
+ var KbAnchorSetDuplicateError = class extends BaseError {
2012
+ constructor(locator) {
2013
+ super({
2014
+ message: `kb: ${locator} appears twice in this set \u2014 a record holds each pointer once`,
2015
+ errorType: "KbAnchorSetDuplicate" /* KbAnchorSetDuplicate */,
2016
+ code: 400,
2017
+ fault: "User" /* User */,
2018
+ retriable: false,
2019
+ reportToUser: true,
2020
+ details: { locator, action: "refused" }
2021
+ });
2022
+ this.locator = locator;
2087
2023
  }
2088
- const files = [];
2089
- const committedWants = [];
2090
- const wants = [];
2091
- for (const entries of planned.values()) {
2092
- for (const { anchor, foreign } of entries) {
2093
- if (foreign) wants.push(...remoteWants(anchor));
2094
- else if (anchor.side === "old") committedWants.push(anchor);
2095
- else files.push(anchor.file);
2024
+ locator;
2025
+ };
2026
+
2027
+ // src/anchors/apply.ts
2028
+ var LOCATOR_FIELDS = [
2029
+ "file",
2030
+ "symbol",
2031
+ "span",
2032
+ "side",
2033
+ "repo",
2034
+ "ref"
2035
+ ];
2036
+ function applyAnchorSet(current, incoming) {
2037
+ const anchors = incoming.map((anchor) => ({ ...anchor }));
2038
+ const seen = /* @__PURE__ */ new Set();
2039
+ for (const anchor of anchors) {
2040
+ const key2 = locatorKey(anchor);
2041
+ if (seen.has(key2)) {
2042
+ throw new KbAnchorSetDuplicateError(locatorText(locatorOf(anchor)));
2096
2043
  }
2044
+ seen.add(key2);
2097
2045
  }
2098
- const [reads, committed, remote] = await Promise.all([
2099
- readAnchorFiles(
2100
- files,
2101
- options.reader ?? anchorFileReader(repoRoot),
2102
- options.concurrency ?? DEFAULT_IO_CONCURRENCY
2103
- ),
2104
- readCommitted(repoRoot, committedWants, options),
2105
- (options.readRemote ?? readRemoteAnchors)(wants, options.remote ?? {})
2106
- ]);
2107
- await prepareResolvers(resolvers, [
2108
- ...files,
2109
- ...committedWants.map((anchor) => anchor.file),
2110
- ...wants.map((want) => want.file)
2111
- ]);
2112
- const drift = /* @__PURE__ */ new Map();
2113
- for (const record of records) {
2114
- const entries = [];
2115
- for (const { anchor, foreign } of planned.get(record.conceptId) ?? []) {
2116
- if (foreign) {
2117
- entries.push(remoteEntry(anchor, remote, resolvers));
2118
- continue;
2119
- }
2120
- const read = anchor.side === "old" ? committed.get(atRefKey(anchor)) : reads.get(anchor.file);
2121
- entries.push(localEntry(anchor, read, resolvers));
2046
+ return { anchors, changes: diff(current, anchors) };
2047
+ }
2048
+ function diff(current, next) {
2049
+ const before = new Map(
2050
+ current.filter((anchor) => anchor.hash).map((a) => [a.hash, a])
2051
+ );
2052
+ const beforeLocators = new Map(current.map((a) => [locatorKey(a), a]));
2053
+ const afterLocators = new Set(next.map((anchor) => locatorKey(anchor)));
2054
+ const moved = /* @__PURE__ */ new Set();
2055
+ const changes = [];
2056
+ for (const anchor of next) {
2057
+ const source = anchor.hash ? before.get(anchor.hash) : void 0;
2058
+ if (source) {
2059
+ if (locatorKey(source) === locatorKey(anchor)) continue;
2060
+ moved.add(locatorKey(source));
2061
+ changes.push({
2062
+ op: "move",
2063
+ from: locatorOf(source),
2064
+ to: locatorOf(anchor)
2065
+ });
2066
+ continue;
2122
2067
  }
2123
- if (entries.length) drift.set(record.conceptId, entries);
2068
+ if (beforeLocators.has(locatorKey(anchor))) continue;
2069
+ changes.push({ op: "add", to: locatorOf(anchor) });
2124
2070
  }
2125
- return drift;
2126
- }
2127
- function atRefKey(anchor) {
2128
- return `${anchor.ref ?? ""}\0${anchor.file}`;
2071
+ for (const anchor of current) {
2072
+ const key2 = locatorKey(anchor);
2073
+ if (afterLocators.has(key2) || moved.has(key2)) continue;
2074
+ changes.push({ op: "drop", from: locatorOf(anchor) });
2075
+ }
2076
+ return changes;
2129
2077
  }
2130
- async function readCommitted(repoRoot, anchors, options = {}) {
2131
- if (!anchors.length) return /* @__PURE__ */ new Map();
2132
- const read = options.readAtRef ?? readFileAtRef;
2133
- const byKey = /* @__PURE__ */ new Map();
2134
- for (const anchor of anchors) byKey.set(atRefKey(anchor), anchor);
2135
- const keys = [...byKey.keys()];
2136
- const results = await mapLimit(
2137
- keys,
2138
- options.concurrency ?? DEFAULT_IO_CONCURRENCY,
2139
- (key2) => read(repoRoot, byKey.get(key2))
2078
+ function locatorOf(anchor) {
2079
+ return kbAnchorLocatorSchema.parse(
2080
+ Object.fromEntries(
2081
+ LOCATOR_FIELDS.flatMap(
2082
+ (field) => anchor[field] === void 0 ? [] : [[field, anchor[field]]]
2083
+ )
2084
+ )
2140
2085
  );
2141
- return new Map(keys.map((key2, at2) => [key2, results[at2]]));
2142
2086
  }
2143
- function remoteWants(anchor) {
2144
- const repo = anchor.repo;
2145
- const wants = [{ repo, file: anchor.file }];
2146
- if (anchor.ref) wants.unshift({ repo, ref: anchor.ref, file: anchor.file });
2147
- return wants;
2087
+ function locatorKey(anchor) {
2088
+ return JSON.stringify([
2089
+ anchor.file,
2090
+ anchor.symbol ?? "",
2091
+ anchor.span ? `${anchor.span.start}-${anchor.span.end}` : "",
2092
+ anchor.side ?? "new",
2093
+ anchor.repo === void 0 ? "" : normalizeRepoUrl(anchor.repo),
2094
+ anchor.ref ?? ""
2095
+ ]);
2148
2096
  }
2149
- function base(anchor) {
2150
- return {
2151
- file: anchor.file,
2152
- ...anchor.symbol ? { symbol: anchor.symbol } : {},
2153
- ...anchor.side === "old" ? { side: "old" } : {},
2154
- storedHash: anchor.hash
2155
- };
2097
+
2098
+ // src/record-types.ts
2099
+ var RECORD_TYPES = {
2100
+ fact: {
2101
+ purpose: "Observed or sourced fact",
2102
+ sections: ["Claim", "Evidence", "Implication"],
2103
+ initialStatus: "accepted"
2104
+ },
2105
+ requirement: {
2106
+ purpose: "Required behavior or outcome",
2107
+ sections: ["Claim", "Evidence", "Implication"],
2108
+ initialStatus: "proposed"
2109
+ },
2110
+ constraint: {
2111
+ purpose: "Limitation, compatibility boundary, policy, or restriction",
2112
+ sections: ["Claim", "Evidence", "Implication"],
2113
+ initialStatus: "accepted"
2114
+ },
2115
+ decision: {
2116
+ purpose: "Chosen or proposed direction",
2117
+ sections: ["Decision", "Rationale", "Rejected", "Impact"],
2118
+ initialStatus: "accepted"
2119
+ },
2120
+ assumption: {
2121
+ purpose: "Unsourced or not-yet-confirmed working assumption",
2122
+ sections: ["Claim", "Why we think so", "What would falsify it"],
2123
+ initialStatus: "draft"
2124
+ },
2125
+ "open-question": {
2126
+ purpose: "Question needing resolution",
2127
+ sections: ["Question", "Why it matters", "Default assumption"],
2128
+ initialStatus: "open"
2129
+ },
2130
+ risk: {
2131
+ purpose: "Something that can go wrong",
2132
+ sections: ["Risk", "Why it matters", "Mitigation", "Verification"],
2133
+ initialStatus: "open"
2134
+ },
2135
+ contract: {
2136
+ purpose: "API, data, event, schema, or permission contract",
2137
+ sections: ["Contract", "Producer", "Consumer", "Compatibility"],
2138
+ initialStatus: "proposed"
2139
+ },
2140
+ flow: {
2141
+ purpose: "Sequence, lifecycle, or state behavior",
2142
+ sections: ["Flow", "Trigger", "Steps", "Failure modes"],
2143
+ initialStatus: "accepted"
2144
+ },
2145
+ "affected-system": {
2146
+ purpose: "Component, service, package, integration, or external system",
2147
+ sections: ["System", "How it is affected", "Blast radius"],
2148
+ initialStatus: "accepted"
2149
+ },
2150
+ "test-obligation": {
2151
+ purpose: "Behavior or contract that must be verified",
2152
+ sections: ["Obligation", "Why it matters", "How to verify"],
2153
+ initialStatus: "open"
2154
+ },
2155
+ "source-note": {
2156
+ purpose: "Extracted note from source material",
2157
+ sections: ["Note", "Where it came from"],
2158
+ initialStatus: "accepted"
2159
+ }
2160
+ };
2161
+ function isKbRecordType(value) {
2162
+ return Object.prototype.hasOwnProperty.call(RECORD_TYPES, value);
2156
2163
  }
2157
- function unresolved(anchor, reason, repo) {
2158
- return {
2159
- ...base(anchor),
2160
- state: "unresolved",
2161
- diffSize: null,
2162
- ...reason ? { reason } : {},
2163
- ...repo ? { repo } : {},
2164
- ...classOf(reason)
2165
- };
2164
+ var KB_LINK_RELS = [
2165
+ "depends_on",
2166
+ "constrains",
2167
+ "informs",
2168
+ "blocks",
2169
+ "invalidates",
2170
+ "verified_by",
2171
+ "satisfies",
2172
+ "related_to"
2173
+ ];
2174
+ var LINK_RELS = {
2175
+ depends_on: {
2176
+ purpose: "The source needs the target to hold; the source breaks if the target changes",
2177
+ phrase: "Depends on",
2178
+ dependant: "source"
2179
+ },
2180
+ constrains: {
2181
+ purpose: "The source bounds what the target may do; the target breaks if the constraint changes",
2182
+ phrase: "Constrains",
2183
+ dependant: "target"
2184
+ },
2185
+ informs: {
2186
+ purpose: "The source shaped the target without binding it; the target is what needs revisiting",
2187
+ phrase: "Informs",
2188
+ dependant: "target"
2189
+ },
2190
+ blocks: {
2191
+ purpose: "The target cannot proceed until the source is settled; the target is what waits",
2192
+ phrase: "Blocks",
2193
+ dependant: "target"
2194
+ },
2195
+ invalidates: {
2196
+ purpose: "The source makes the target no longer hold; the target is what stops holding",
2197
+ phrase: "Invalidates",
2198
+ dependant: "target"
2199
+ },
2200
+ verified_by: {
2201
+ purpose: "The target is the check that confirms the source; the source's confirmation moves with it",
2202
+ phrase: "Verified by",
2203
+ dependant: "source"
2204
+ },
2205
+ satisfies: {
2206
+ purpose: "The source discharges the target's requirement; the source must change if the requirement does",
2207
+ phrase: "Satisfies",
2208
+ dependant: "source"
2209
+ },
2210
+ related_to: {
2211
+ purpose: "A pointer worth following, with no claim of dependence",
2212
+ phrase: "Relates to",
2213
+ dependant: null
2214
+ }
2215
+ };
2216
+ var KB_CAUSAL_LINK_RELS = KB_LINK_RELS.filter(
2217
+ (rel) => LINK_RELS[rel].dependant !== null
2218
+ );
2219
+ function isKbLinkRel(value) {
2220
+ return Object.prototype.hasOwnProperty.call(LINK_RELS, value);
2166
2221
  }
2167
- var GONE_REASONS = /* @__PURE__ */ new Set([
2168
- "file-missing",
2169
- "symbol-not-found",
2170
- "span-out-of-range",
2171
- "ref-unreadable"
2172
- ]);
2173
- function provisionalDriftClass(entry) {
2174
- if (entry.state === "unresolved") {
2175
- return GONE_REASONS.has(entry.reason) ? "gone" : void 0;
2222
+
2223
+ // src/compose.ts
2224
+ var composeLinkSchema = import_zod3.z.object({
2225
+ target: kbConceptIdSchema,
2226
+ rel: import_zod3.z.enum(KB_LINK_RELS)
2227
+ }).strict();
2228
+ var composeInputSchema = import_zod3.z.object({
2229
+ slug: import_zod3.z.string().min(1),
2230
+ /** One line, in the reader's terms. Becomes OKF `title`. */
2231
+ title: import_zod3.z.string().min(1),
2232
+ /** The consequence — what breaks if this is wrong. Becomes `description`. */
2233
+ why: import_zod3.z.string().min(1),
2234
+ /** Keyed by section heading from the type's spec. Unknown keys rejected. */
2235
+ sections: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.string().min(1)).optional(),
2236
+ anchors: import_zod3.z.array(kbAnchorWriteSchema).optional(),
2237
+ sources: import_zod3.z.array(kbSourceSchema).optional(),
2238
+ /** No source exists, as a claim rather than a sentinel in `sources`. */
2239
+ assumption: import_zod3.z.boolean().optional(),
2240
+ /**
2241
+ * OKF `stale_after`: the absolute date this record stops being trusted.
2242
+ * Anything the outside world can change — pricing, quotas, versions,
2243
+ * reception counts — should carry one.
2244
+ */
2245
+ stale_after: import_zod3.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
2246
+ message: "stale_after must be YYYY-MM-DD"
2247
+ }).refine((date) => !Number.isNaN(Date.parse(date)), {
2248
+ message: "stale_after must be a real date"
2249
+ }).optional(),
2250
+ verify: import_zod3.z.array(import_zod3.z.string().min(1)).optional(),
2251
+ tags: import_zod3.z.array(import_zod3.z.string().min(1)).optional(),
2252
+ /** Concept ids this record relates to; rendered as body links. */
2253
+ relatedConceptIds: import_zod3.z.array(kbConceptIdSchema).optional(),
2254
+ /**
2255
+ * Typed causal edges, source → target: `{ target: "fact.b", rel:
2256
+ * "depends_on" }` on record A says A needs B. Stored in frontmatter and
2257
+ * also rendered as one prose sentence each, so the meaning survives a
2258
+ * reader that knows only OKF. The vocabulary goes into the description from
2259
+ * the same table the walk uses, so `kb_schema` emits it.
2260
+ */
2261
+ links: import_zod3.z.array(composeLinkSchema).max(64).optional().describe(
2262
+ `Typed causal edges, source \u2192 target \u2014 a link on this record says this record <rel> the target. ${KB_LINK_RELS.map(
2263
+ (rel) => `${rel}: ${LINK_RELS[rel].purpose}`
2264
+ ).join("; ")}.`
2265
+ ),
2266
+ /** Concept ids this record replaces. The store settles the backlinks. */
2267
+ supersedes: import_zod3.z.array(kbConceptIdSchema).max(32).optional(),
2268
+ materiality: import_zod3.z.enum(KB_MATERIALITIES).optional(),
2269
+ confidence: import_zod3.z.enum(KB_CONFIDENCES).optional(),
2270
+ owner: import_zod3.z.string().min(1).optional()
2271
+ }).strict();
2272
+ function composeRecord(type, input, writtenBy, writtenAt) {
2273
+ const parsed = composeInputSchema.parse(input);
2274
+ const spec = RECORD_TYPES[type];
2275
+ const sections = parsed.sections ?? {};
2276
+ const unknown = Object.keys(sections).filter(
2277
+ (heading) => !spec.sections.includes(heading)
2278
+ );
2279
+ if (unknown.length) {
2280
+ throw new Error(
2281
+ `kb: ${type} has no section ${unknown.join(", ")} \u2014 expected one of ${spec.sections.join(", ")}`
2282
+ );
2176
2283
  }
2177
- return entry.state === "drifted" ? "changed" : void 0;
2178
- }
2179
- function classOf(reason) {
2180
- const settled = provisionalDriftClass({ state: "unresolved", reason });
2181
- return settled ? { class: settled } : {};
2182
- }
2183
- function hashIn(source, anchor, resolvers) {
2184
- const outcome = resolveAnchorSpan(source, anchor, resolvers);
2185
- if (!outcome.ok) return { ok: false, reason: outcome.reason };
2186
- const { hash, kind } = anchorHashOf(anchor, outcome);
2187
- return {
2188
- ok: true,
2189
- current: {
2190
- hash,
2191
- kind,
2192
- lines: outcome.span.endLine - outcome.span.startLine + 1,
2193
- ...outcome.resolver ? { resolver: outcome.resolver } : {}
2194
- }
2284
+ const frontmatter = {
2285
+ title: parsed.title,
2286
+ description: parsed.why,
2287
+ generated: { by: writtenBy, at: writtenAt },
2288
+ // Empty rather than absent: a later verification pass appends here, and an
2289
+ // empty list says "not yet verified" where a missing key would only say
2290
+ // "this producer didn't think about it".
2291
+ verified: [],
2292
+ strauss_status: spec.initialStatus
2195
2293
  };
2196
- }
2197
- function resolverExtras(source, anchor, current) {
2294
+ if (parsed.stale_after) frontmatter.stale_after = parsed.stale_after;
2295
+ if (parsed.anchors?.length) {
2296
+ frontmatter.strauss_anchors = applyAnchorSet([], parsed.anchors).anchors;
2297
+ }
2298
+ if (parsed.verify?.length) frontmatter.strauss_verify = parsed.verify;
2299
+ if (parsed.tags?.length) frontmatter.tags = parsed.tags;
2300
+ if (parsed.sources?.length) frontmatter.sources = parsed.sources;
2301
+ if (parsed.assumption) frontmatter.strauss_assumption = true;
2302
+ if (parsed.materiality) frontmatter.strauss_materiality = parsed.materiality;
2303
+ if (parsed.confidence) frontmatter.strauss_confidence = parsed.confidence;
2304
+ if (parsed.owner) frontmatter.strauss_owner = parsed.owner;
2305
+ if (parsed.supersedes?.length)
2306
+ frontmatter.strauss_supersedes = parsed.supersedes;
2307
+ const selfLink = parsed.links?.find(
2308
+ (link2) => link2.target === `${type}.${parsed.slug}`
2309
+ );
2310
+ if (selfLink) {
2311
+ throw new Error(
2312
+ `kb: ${type}.${parsed.slug} cannot ${selfLink.rel} itself \u2014 a link must name another record`
2313
+ );
2314
+ }
2315
+ if (parsed.links?.length) frontmatter.strauss_links = parsed.links;
2316
+ const blocks = [];
2317
+ for (const heading of spec.sections) {
2318
+ const text = sections[heading];
2319
+ if (text) blocks.push(`## ${heading}
2320
+
2321
+ ${text}`);
2322
+ }
2323
+ if (!blocks.length) blocks.push(parsed.why);
2324
+ for (const related of parsed.relatedConceptIds ?? []) {
2325
+ blocks.push(`Relates to [${related}](${related}.md).`);
2326
+ }
2327
+ for (const link2 of parsed.links ?? []) {
2328
+ blocks.push(
2329
+ `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
2330
+ );
2331
+ }
2332
+ if (parsed.sources?.length) {
2333
+ blocks.push(
2334
+ parsed.sources.map((source) => `[^${source.id}]: ${source.title ?? source.resource}`).join("\n")
2335
+ );
2336
+ }
2198
2337
  return {
2199
- ...current.resolver ? { resolver: current.resolver } : {},
2200
- ...current.hash !== anchor.hash && resolverChanged(source, anchor, current.resolver) ? { reason: "resolver-changed" } : {}
2338
+ type,
2339
+ slug: parsed.slug,
2340
+ frontmatter,
2341
+ body: `${blocks.join("\n\n")}
2342
+ `
2201
2343
  };
2202
2344
  }
2203
- function compared(anchor, current, extra = {}) {
2204
- const matched = current.hash === anchor.hash;
2205
- return {
2206
- ...base(anchor),
2207
- state: matched ? "match" : "drifted",
2208
- currentHash: current.hash,
2209
- hashKind: current.kind,
2210
- diffSize: anchor.lines === void 0 ? null : Math.abs(current.lines - anchor.lines),
2211
- ...matched ? {} : { class: "changed" },
2212
- ...extra
2213
- };
2345
+
2346
+ // src/decision-record.ts
2347
+ var DECISION_TYPE = "decision";
2348
+ var NO_DECISION_SLUG = "none";
2349
+ var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
2350
+ alternative: import_zod4.z.string().min(1).optional(),
2351
+ impact: import_zod4.z.string().min(1).optional()
2352
+ }).strict();
2353
+ function composeDecisionRecord(input, writtenBy, writtenAt) {
2354
+ const { alternative, impact: impact2, ...rest } = input;
2355
+ return composeRecord(
2356
+ DECISION_TYPE,
2357
+ {
2358
+ ...rest,
2359
+ sections: {
2360
+ Decision: input.title,
2361
+ Rationale: input.why,
2362
+ ...alternative ? { Rejected: alternative } : {},
2363
+ ...impact2 ? { Impact: impact2 } : {}
2364
+ }
2365
+ },
2366
+ writtenBy,
2367
+ writtenAt
2368
+ );
2214
2369
  }
2215
- function localEntry(anchor, read, resolvers) {
2216
- if (!read.ok) return unresolved(anchor, read.reason);
2217
- const found = hashIn(read.source, anchor, resolvers);
2218
- if (!found.ok) return unresolved(anchor, found.reason);
2219
- return compared(
2220
- anchor,
2221
- found.current,
2222
- resolverExtras(read.source, anchor, found.current)
2370
+ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
2371
+ return composeRecord(
2372
+ DECISION_TYPE,
2373
+ {
2374
+ slug: NO_DECISION_SLUG,
2375
+ title: "No decision to record",
2376
+ why: reason,
2377
+ sections: { Decision: reason }
2378
+ },
2379
+ writtenBy,
2380
+ writtenAt
2223
2381
  );
2224
2382
  }
2225
- function remoteEntry(anchor, remote, resolvers) {
2226
- const repo = anchor.repo;
2227
- const key2 = normalizeRepoUrl(repo);
2228
- const atDefault = remote.get(wantKey(key2, void 0, anchor.file));
2229
- const primary = anchor.ref ? remote.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
2230
- if (!primary) return unresolved(anchor, "remote-unreachable", repo);
2231
- if (!primary.ok) return unresolved(anchor, primary.reason, repo);
2232
- const found = hashIn(primary.source, anchor, resolvers);
2233
- if (!found.ok) return unresolved(anchor, found.reason, repo);
2234
- const current = found.current;
2235
- const extras = resolverExtras(primary.source, anchor, current);
2236
- if (!anchor.ref) return compared(anchor, current, { repo, ...extras });
2237
- if (current.hash !== anchor.hash) {
2238
- return compared(anchor, current, {
2239
- repo,
2240
- ...extras,
2241
- remoteState: "drifted-from-ref"
2242
- });
2243
- }
2244
- if (anchor.side === "old") {
2245
- return compared(anchor, current, {
2246
- repo,
2247
- ...extras,
2248
- remoteState: "matches-ref"
2249
- });
2250
- }
2251
- const head = atDefault?.ok ? hashIn(atDefault.source, anchor, resolvers) : null;
2252
- return head?.ok && head.current.hash !== anchor.hash ? {
2253
- ...compared(anchor, head.current, {
2254
- repo,
2255
- ...head.current.resolver ? { resolver: head.current.resolver } : {}
2256
- }),
2257
- state: "drifted",
2258
- remoteState: "drifted-on-default"
2259
- } : compared(anchor, current, {
2260
- repo,
2261
- ...extras,
2262
- remoteState: "matches-ref"
2263
- });
2383
+ function isNoDecisionRecord(record) {
2384
+ return record.conceptId === `${DECISION_TYPE}.${NO_DECISION_SLUG}`;
2385
+ }
2386
+ function selectDecisions(records) {
2387
+ return records.filter(
2388
+ (record) => record.conceptId.startsWith(`${DECISION_TYPE}.`) && !isNoDecisionRecord(record)
2389
+ );
2264
2390
  }
2265
2391
 
2266
- // src/errors.ts
2267
- var BaseError = class extends Error {
2268
- code;
2269
- errorType;
2270
- fault;
2271
- retriable;
2272
- reportToUser;
2273
- details;
2274
- constructor(props) {
2275
- super(props.message);
2276
- this.name = props.name ?? this.constructor.name;
2277
- this.code = props.code ?? 500;
2278
- this.errorType = props.errorType;
2279
- this.fault = props.fault;
2280
- this.retriable = props.retriable ?? true;
2281
- this.reportToUser = props.reportToUser ?? false;
2282
- this.details = props.details;
2283
- }
2284
- };
2392
+ // src/commands/anchor-resolve/command.ts
2393
+ var import_zod8 = require("zod");
2285
2394
 
2286
2395
  // src/kb-errors.ts
2287
2396
  var KbRecordAlreadyExistsError = class extends BaseError {
@@ -2557,8 +2666,75 @@ var KbStampDigestBaselineError = class extends BaseError {
2557
2666
  });
2558
2667
  this.since = since;
2559
2668
  }
2560
- since;
2561
- };
2669
+ since;
2670
+ };
2671
+
2672
+ // src/commands/model.ts
2673
+ var import_zod5 = require("zod");
2674
+ var bundlePath = import_zod5.z.string().min(1).describe("Absolute path to the knowledge base directory.");
2675
+ var conceptId = import_zod5.z.string().min(1).describe("e.g. decision.cursor-v2");
2676
+ var TAGS = import_zod5.z.array(import_zod5.z.string().min(1)).optional().describe(
2677
+ "Keep only records carrying every one of these frontmatter tags. Matched exactly."
2678
+ );
2679
+ var REPO_ROOT = import_zod5.z.string().min(1).optional().describe(
2680
+ "Where the anchored source lives, for the drift check. Defaults to the working directory."
2681
+ );
2682
+ function define(command) {
2683
+ return command;
2684
+ }
2685
+ function argvFlag(argv, name) {
2686
+ const joined = argv.find((arg) => arg.startsWith(`${name}=`));
2687
+ if (joined !== void 0) {
2688
+ const value2 = joined.slice(name.length + 1);
2689
+ if (!value2) throw new KbMissingFlagValueError(name);
2690
+ return value2;
2691
+ }
2692
+ const at2 = argv.indexOf(name);
2693
+ if (at2 === -1) return void 0;
2694
+ const value = argv[at2 + 1];
2695
+ if (value === void 0 || value.startsWith("--")) {
2696
+ throw new KbMissingFlagValueError(name);
2697
+ }
2698
+ return value;
2699
+ }
2700
+ function argvFlags(argv, name) {
2701
+ const values = [];
2702
+ for (const [at2, arg] of argv.entries()) {
2703
+ if (arg.startsWith(`${name}=`)) {
2704
+ const value = arg.slice(name.length + 1);
2705
+ if (!value) throw new KbMissingFlagValueError(name);
2706
+ values.push(value);
2707
+ } else if (arg === name) {
2708
+ const value = argv[at2 + 1];
2709
+ if (value === void 0 || value.startsWith("--")) {
2710
+ throw new KbMissingFlagValueError(name);
2711
+ }
2712
+ values.push(value);
2713
+ }
2714
+ }
2715
+ return values;
2716
+ }
2717
+ function argvWithout(argv, ...names) {
2718
+ const kept = [];
2719
+ for (let at2 = 0; at2 < argv.length; at2 += 1) {
2720
+ const arg = argv[at2];
2721
+ if (names.some((name) => arg.startsWith(`${name}=`))) continue;
2722
+ if (names.includes(arg)) {
2723
+ at2 += 1;
2724
+ continue;
2725
+ }
2726
+ kept.push(arg);
2727
+ }
2728
+ return kept;
2729
+ }
2730
+ function argvPositional(argv, ...names) {
2731
+ return argvWithout(argv.slice(1), ...names).find(
2732
+ (arg) => !arg.startsWith("--")
2733
+ );
2734
+ }
2735
+
2736
+ // src/commands/anchor-resolve/apply.ts
2737
+ var import_zod7 = require("zod");
2562
2738
 
2563
2739
  // src/kb-pins/budgets.ts
2564
2740
  function asBudgets(value) {
@@ -2621,14 +2797,14 @@ var import_node_path7 = require("path");
2621
2797
 
2622
2798
  // src/kb-pins/model.ts
2623
2799
  var import_node_path6 = require("path");
2624
- var import_zod5 = require("zod");
2800
+ var import_zod6 = require("zod");
2625
2801
  var PINS_FILE = (0, import_node_path6.join)(".strauss", "kb-pins.json");
2626
2802
  var PINS_LOCAL_FILE = (0, import_node_path6.join)(".strauss", "kb-pins.local.json");
2627
2803
  var PIN_LAYERS = ["project", "local", "user"];
2628
- var pinSchema = import_zod5.z.object({
2804
+ var pinSchema = import_zod6.z.object({
2629
2805
  /** Relative to the manifest's root, so the file is committable. */
2630
- path: import_zod5.z.string().min(1),
2631
- pinnedAt: import_zod5.z.string().min(1).optional(),
2806
+ path: import_zod6.z.string().min(1),
2807
+ pinnedAt: import_zod6.z.string().min(1).optional(),
2632
2808
  /**
2633
2809
  * How `context` renders this base. `full` preloads the whole base into
2634
2810
  * the block regardless of the full-under threshold — for a base whose
@@ -2638,7 +2814,7 @@ var pinSchema = import_zod5.z.object({
2638
2814
  * Absent: the profile's full-under threshold decides. Invalid values
2639
2815
  * degrade to absent rather than failing the manifest.
2640
2816
  */
2641
- mode: import_zod5.z.enum(["full", "index"]).optional().catch(void 0),
2817
+ mode: import_zod6.z.enum(["full", "index"]).optional().catch(void 0),
2642
2818
  /**
2643
2819
  * Context profiles this pin surfaces in (e.g. only at session-start,
2644
2820
  * not per turn). Absent: every profile. A run without a profile sees
@@ -2646,17 +2822,17 @@ var pinSchema = import_zod5.z.object({
2646
2822
  * that skill at point of use than pinned at all — pins are what every
2647
2823
  * session should see.
2648
2824
  */
2649
- profiles: import_zod5.z.array(import_zod5.z.string()).optional().catch(void 0),
2825
+ profiles: import_zod6.z.array(import_zod6.z.string()).optional().catch(void 0),
2650
2826
  /**
2651
2827
  * The base is concluded — a finished piece of research, a frozen ADR
2652
2828
  * set. Write commands against it refuse while this workspace holds the
2653
2829
  * pin, and `context` labels it read-only. Workspace policy, not base
2654
2830
  * state: the base itself stays copyable and writable elsewhere.
2655
2831
  */
2656
- frozen: import_zod5.z.boolean().optional().catch(void 0)
2832
+ frozen: import_zod6.z.boolean().optional().catch(void 0)
2657
2833
  }).passthrough();
2658
- var pinsManifestSchema = import_zod5.z.object({
2659
- pins: import_zod5.z.array(pinSchema).default([]),
2834
+ var pinsManifestSchema = import_zod6.z.object({
2835
+ pins: import_zod6.z.array(pinSchema).default([]),
2660
2836
  /**
2661
2837
  * Per-repo budgets for the `context` command, keyed by profile —
2662
2838
  * `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
@@ -2665,7 +2841,7 @@ var pinsManifestSchema = import_zod5.z.object({
2665
2841
  * the index at every session start. `contextProfileBudgets` does the
2666
2842
  * tolerant read.
2667
2843
  */
2668
- context: import_zod5.z.unknown().optional()
2844
+ context: import_zod6.z.unknown().optional()
2669
2845
  }).passthrough();
2670
2846
 
2671
2847
  // src/kb-pins/layers.ts
@@ -2858,193 +3034,185 @@ async function unpinBase(workspaceDir, bundlePath2) {
2858
3034
  };
2859
3035
  }
2860
3036
 
2861
- // src/commands/model.ts
2862
- var import_zod6 = require("zod");
2863
- var bundlePath = import_zod6.z.string().min(1).describe("Absolute path to the knowledge base directory.");
2864
- var conceptId = import_zod6.z.string().min(1).describe("e.g. decision.cursor-v2");
2865
- var TAGS = import_zod6.z.array(import_zod6.z.string().min(1)).optional().describe(
2866
- "Keep only records carrying every one of these frontmatter tags. Matched exactly."
2867
- );
2868
- var REPO_ROOT = import_zod6.z.string().min(1).optional().describe(
2869
- "Where the anchored source lives, for the drift check. Defaults to the working directory."
2870
- );
2871
- function define(command) {
2872
- return command;
2873
- }
2874
- function argvFlag(argv, name) {
2875
- const joined = argv.find((arg) => arg.startsWith(`${name}=`));
2876
- if (joined !== void 0) {
2877
- const value2 = joined.slice(name.length + 1);
2878
- if (!value2) throw new KbMissingFlagValueError(name);
2879
- return value2;
2880
- }
2881
- const at2 = argv.indexOf(name);
2882
- if (at2 === -1) return void 0;
2883
- const value = argv[at2 + 1];
2884
- if (value === void 0 || value.startsWith("--")) {
2885
- throw new KbMissingFlagValueError(name);
3037
+ // src/commands/anchor-resolve/apply.ts
3038
+ async function baseFrozen(cwd, bundlePath2) {
3039
+ try {
3040
+ await assertBaseNotFrozen(cwd, bundlePath2);
3041
+ return false;
3042
+ } catch (caught) {
3043
+ if (!(caught instanceof KbBaseFrozenError)) throw caught;
3044
+ return true;
2886
3045
  }
2887
- return value;
2888
3046
  }
2889
- function argvFlags(argv, name) {
2890
- const values = [];
2891
- for (const [at2, arg] of argv.entries()) {
2892
- if (arg.startsWith(`${name}=`)) {
2893
- const value = arg.slice(name.length + 1);
2894
- if (!value) throw new KbMissingFlagValueError(name);
2895
- values.push(value);
2896
- } else if (arg === name) {
2897
- const value = argv[at2 + 1];
2898
- if (value === void 0 || value.startsWith("--")) {
2899
- throw new KbMissingFlagValueError(name);
3047
+ async function applyPlan(plans, target) {
3048
+ if (!plans.some((plan) => plan.write)) {
3049
+ return { results: plans.map((plan) => plan.finding) };
3050
+ }
3051
+ let failure = target.frozen ? "frozen" : void 0;
3052
+ let error;
3053
+ if (!failure) {
3054
+ try {
3055
+ await target.store.updateAnchors(
3056
+ target.bundlePath,
3057
+ target.conceptId,
3058
+ plans.map((plan) => plan.anchor),
3059
+ target.actor
3060
+ );
3061
+ } catch (caught) {
3062
+ if (caught instanceof BaseError || caught instanceof import_zod7.z.ZodError) {
3063
+ throw caught;
2900
3064
  }
2901
- values.push(value);
3065
+ failure = "write-failed";
3066
+ error = clamp(caught instanceof Error ? caught.message : String(caught));
2902
3067
  }
2903
3068
  }
2904
- return values;
3069
+ return {
3070
+ results: plans.map((plan) => settle(plan, failure)),
3071
+ ...error ? { error } : {}
3072
+ };
2905
3073
  }
2906
- function argvWithout(argv, ...names) {
2907
- const kept = [];
2908
- for (let at2 = 0; at2 < argv.length; at2 += 1) {
2909
- const arg = argv[at2];
2910
- if (names.some((name) => arg.startsWith(`${name}=`))) continue;
2911
- if (names.includes(arg)) {
2912
- at2 += 1;
2913
- continue;
2914
- }
2915
- kept.push(arg);
3074
+ function settle(plan, failure) {
3075
+ if (!plan.write) return plan.finding;
3076
+ if (failure) {
3077
+ return { ...plan.finding, outcome: "failed", outcomeReason: failure };
2916
3078
  }
2917
- return kept;
3079
+ if (plan.write === "refresh") return plan.finding;
3080
+ return plan.write === "stamp" ? { ...plan.finding, state: "stamped", outcome: "applied" } : { ...plan.finding, outcome: "applied", rebaselined: true };
2918
3081
  }
2919
- function argvPositional(argv, ...names) {
2920
- return argvWithout(argv.slice(1), ...names).find(
2921
- (arg) => !arg.startsWith("--")
3082
+ function clamp(message) {
3083
+ const line = message.split("\n")[0] ?? "";
3084
+ return line.length > 200 ? `${line.slice(0, 199)}\u2026` : line;
3085
+ }
3086
+
3087
+ // src/commands/anchor-resolve/sources.ts
3088
+ async function readSources(anchors, root, offline) {
3089
+ const origin = new LazyOrigin(root);
3090
+ if (anchors.some((anchor) => anchor.repo)) await origin.prime();
3091
+ const foreign = new Map(
3092
+ anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
3093
+ );
3094
+ const local = anchors.filter(
3095
+ (anchor) => !foreign.get(anchor) && anchor.side !== "old"
3096
+ );
3097
+ const committed = anchors.filter(
3098
+ (anchor) => !foreign.get(anchor) && anchor.side === "old"
3099
+ );
3100
+ const remote = anchors.filter((anchor) => foreign.get(anchor));
3101
+ const reads = await readAnchorFiles(
3102
+ local.map((anchor) => anchor.file),
3103
+ anchorFileReader(root)
2922
3104
  );
3105
+ const atRef = await readCommitted(root, committed);
3106
+ const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
3107
+ offline
3108
+ });
3109
+ const sources = /* @__PURE__ */ new Map();
3110
+ for (const anchor of local) {
3111
+ const read = reads.get(anchor.file);
3112
+ sources.set(
3113
+ anchor,
3114
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
3115
+ );
3116
+ }
3117
+ for (const anchor of committed) {
3118
+ const read = atRef.get(atRefKey(anchor));
3119
+ sources.set(
3120
+ anchor,
3121
+ read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
3122
+ );
3123
+ }
3124
+ for (const anchor of remote) {
3125
+ const repo = anchor.repo;
3126
+ const key2 = normalizeRepoUrl(repo);
3127
+ const atDefault = blobs.get(wantKey(key2, void 0, anchor.file));
3128
+ const primary = anchor.ref ? blobs.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
3129
+ if (!primary?.ok) {
3130
+ sources.set(anchor, {
3131
+ ok: false,
3132
+ reason: primary?.ok === false ? primary.reason : "remote-unreachable",
3133
+ repo
3134
+ });
3135
+ continue;
3136
+ }
3137
+ sources.set(anchor, {
3138
+ ok: true,
3139
+ source: primary.source,
3140
+ repo,
3141
+ ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
3142
+ });
3143
+ }
3144
+ return sources;
2923
3145
  }
2924
3146
 
2925
- // src/commands/anchor-resolve.ts
2926
- var anchorResolveCommand = define({
2927
- name: "anchor-resolve",
2928
- tool: "kb_anchor_resolve",
2929
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp] [--check]",
2930
- description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. Never writes verified[]; a judgment is kb_verify. Exits non-zero on drift.",
2931
- input: import_zod7.z.object({
2932
- bundlePath,
2933
- conceptId,
2934
- repoRoot: import_zod7.z.string().min(1).optional(),
2935
- offline: import_zod7.z.boolean().optional().describe(
2936
- "Resolve foreign anchors from the local repo cache only, never fetching."
2937
- ),
2938
- rebaseline: import_zod7.z.boolean().optional().describe(
2939
- "Accept the current code as the new baseline for anchors that drifted."
2940
- ),
2941
- restamp: import_zod7.z.boolean().optional().describe(
2942
- "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
2943
- ),
2944
- check: import_zod7.z.boolean().optional().describe(
2945
- "Resolve and report only: no hash, no `resolved_at`, no log entry."
2946
- )
2947
- }),
2948
- fromArgv: (argv, path) => ({
2949
- bundlePath: path,
2950
- conceptId: argv[1],
2951
- repoRoot: argvFlag(argv, "--repo-root"),
2952
- offline: argv.includes("--offline"),
2953
- rebaseline: argv.includes("--rebaseline"),
2954
- restamp: argv.includes("--restamp"),
2955
- check: argv.includes("--check")
2956
- }),
2957
- run: async ({ store, actor, now }, {
2958
- bundlePath: path,
2959
- conceptId: id,
2960
- repoRoot,
2961
- offline,
2962
- rebaseline,
2963
- restamp,
2964
- check
2965
- }) => {
2966
- if (check && (rebaseline || restamp)) {
2967
- throw new KbFlagConflictError([
2968
- "check",
2969
- rebaseline ? "rebaseline" : "restamp"
2970
- ]);
3147
+ // src/commands/anchor-resolve/plan.ts
3148
+ async function planAnchors(anchors, options) {
3149
+ const { root, offline, rebaseline, restamp, check, frozen, now } = options;
3150
+ const sources = await readSources(anchors, root, offline);
3151
+ const resolvers = defaultAnchorResolvers({ offline });
3152
+ await prepareResolvers(
3153
+ resolvers,
3154
+ anchors.map((anchor) => anchor.file)
3155
+ );
3156
+ const plans = [];
3157
+ for (const anchor of anchors) {
3158
+ const base2 = {
3159
+ file: anchor.file,
3160
+ ...anchor.symbol ? { symbol: anchor.symbol } : {},
3161
+ ...anchor.side === "old" ? { side: "old" } : {},
3162
+ // Carried onto unresolved findings too: an anchor that once hashed
3163
+ // and now resolves to nothing is a broken anchor, and the exit code
3164
+ // has to be able to tell it from one nobody ever stamped.
3165
+ ...anchor.hash ? { storedHash: anchor.hash } : {}
3166
+ };
3167
+ const source = sources.get(anchor);
3168
+ if (source.repo) base2.repo = source.repo;
3169
+ if (!source.ok) {
3170
+ plans.push({
3171
+ finding: { ...base2, state: "unresolved", reason: source.reason },
3172
+ anchor
3173
+ });
3174
+ continue;
2971
3175
  }
2972
- const root = repoRoot ?? process.cwd();
2973
- const record = await store.read(path, id);
2974
- if (!record) throw new KbRecordNotFoundError(id);
2975
- const anchors = record.frontmatter.strauss_anchors ?? [];
2976
- if (!anchors.length) {
2977
- return {
2978
- conceptId: id,
2979
- results: [],
2980
- note: "record has no anchors"
2981
- };
3176
+ const outcome = resolveAnchorSpan(source.source, anchor, resolvers);
3177
+ if (!outcome.ok) {
3178
+ plans.push({
3179
+ finding: { ...base2, state: "unresolved", reason: outcome.reason },
3180
+ anchor
3181
+ });
3182
+ continue;
2982
3183
  }
2983
- const results = [];
2984
- const updated = [];
2985
- let dirty = false;
2986
- const sources = await readSources(anchors, root, offline === true);
2987
- const resolvers = defaultAnchorResolvers({ offline: offline === true });
2988
- await prepareResolvers(
2989
- resolvers,
2990
- anchors.map((anchor) => anchor.file)
2991
- );
2992
- for (const anchor of anchors) {
2993
- const base2 = {
2994
- file: anchor.file,
2995
- ...anchor.symbol ? { symbol: anchor.symbol } : {},
2996
- ...anchor.side === "old" ? { side: "old" } : {},
2997
- // Carried onto unresolved findings too: an anchor that once hashed
2998
- // and now resolves to nothing is a broken anchor, and the exit code
2999
- // has to be able to tell it from one nobody ever stamped.
3000
- ...anchor.hash ? { storedHash: anchor.hash } : {}
3001
- };
3002
- const source = sources.get(anchor);
3003
- if (source.repo) base2.repo = source.repo;
3004
- if (!source.ok) {
3005
- results.push({ ...base2, state: "unresolved", reason: source.reason });
3006
- updated.push(anchor);
3007
- continue;
3008
- }
3009
- const outcome = resolveAnchorSpan(source.source, anchor, resolvers);
3010
- if (!outcome.ok) {
3011
- results.push({
3012
- ...base2,
3013
- state: "unresolved",
3014
- reason: outcome.reason
3015
- });
3016
- updated.push(anchor);
3017
- continue;
3018
- }
3019
- const resolved = outcome.span;
3020
- const producedBy = outcome.resolver;
3021
- const { hash: currentHash, kind } = anchorHashOf(anchor, outcome);
3022
- const currentLines = resolved.endLine - resolved.startLine + 1;
3023
- const stampedKind = outcome.normalized ? "ast" : "raw";
3024
- const stampedHash = outcome.normalized ? anchorHashOf({ ...anchor, hash: void 0 }, outcome).hash : currentHash;
3025
- const stamped = {
3026
- ...anchor,
3027
- hash: stampedHash,
3028
- hash_kind: stampedKind,
3029
- lines: currentLines,
3030
- resolved_at: now(),
3031
- ...producedBy ? { resolver: producedBy } : {}
3032
- };
3033
- const pinned = anchor.ref !== void 0 && source.repo !== void 0;
3034
- if (!anchor.hash) {
3035
- results.push({
3184
+ const resolved = outcome.span;
3185
+ const producedBy = outcome.resolver;
3186
+ const { hash: currentHash, kind } = anchorHashOf(anchor, outcome);
3187
+ const currentLines = resolved.endLine - resolved.startLine + 1;
3188
+ const stampedKind = outcome.normalized ? "ast" : "raw";
3189
+ const stampedHash = outcome.normalized ? anchorHashOf({ ...anchor, hash: void 0 }, outcome).hash : currentHash;
3190
+ const stamped = {
3191
+ ...anchor,
3192
+ hash: stampedHash,
3193
+ hash_kind: stampedKind,
3194
+ lines: currentLines,
3195
+ resolved_at: now(),
3196
+ ...producedBy ? { resolver: producedBy } : {}
3197
+ };
3198
+ const pinned = anchor.ref !== void 0 && source.repo !== void 0;
3199
+ if (!anchor.hash) {
3200
+ plans.push({
3201
+ finding: {
3036
3202
  ...base2,
3037
- state: check ? "unstamped" : "stamped",
3203
+ state: "unstamped",
3038
3204
  currentHash: stampedHash,
3039
3205
  hashKind: stampedKind,
3040
3206
  ...producedBy ? { resolver: producedBy } : {}
3041
- });
3042
- updated.push(stamped);
3043
- dirty = true;
3044
- continue;
3045
- }
3046
- if (anchor.hash !== currentHash) {
3047
- results.push({
3207
+ },
3208
+ anchor: check ? anchor : stamped,
3209
+ ...check ? {} : { write: "stamp" }
3210
+ });
3211
+ continue;
3212
+ }
3213
+ if (anchor.hash !== currentHash) {
3214
+ plans.push({
3215
+ finding: {
3048
3216
  ...base2,
3049
3217
  state: "drifted",
3050
3218
  currentHash,
@@ -3054,152 +3222,289 @@ var anchorResolveCommand = define({
3054
3222
  // A regex-stamped anchor re-read by tree-sitter drifts because the
3055
3223
  // resolver changed, not because the code did.
3056
3224
  ...resolverChanged(source.source, anchor, producedBy) ? { reason: "resolver-changed" } : {},
3057
- ...pinned ? { remoteState: "drifted-from-ref" } : {},
3058
- ...rebaseline ? { rebaselined: true } : {}
3059
- });
3060
- updated.push(rebaseline ? stamped : anchor);
3061
- if (rebaseline) dirty = true;
3062
- continue;
3063
- }
3064
- const onDefault = pinned ? headHash(source, anchor, resolvers) : void 0;
3065
- if (onDefault && onDefault.hash !== anchor.hash) {
3066
- results.push({
3225
+ ...pinned ? { remoteState: "drifted-from-ref" } : {}
3226
+ },
3227
+ anchor: rebaseline && !check ? stamped : anchor,
3228
+ ...rebaseline && !check ? { write: "rebaseline" } : {}
3229
+ });
3230
+ continue;
3231
+ }
3232
+ const onDefault = pinned ? headHash(source, anchor, resolvers) : void 0;
3233
+ if (onDefault && onDefault.hash !== anchor.hash) {
3234
+ plans.push({
3235
+ finding: {
3067
3236
  ...base2,
3068
3237
  state: "drifted",
3069
3238
  currentHash: onDefault.hash,
3070
3239
  diffSize: lineDelta(anchor, onDefault.lines),
3071
- remoteState: "drifted-on-default"
3072
- });
3073
- updated.push(anchor);
3074
- continue;
3075
- }
3076
- results.push({
3240
+ remoteState: "drifted-on-default",
3241
+ ...rebaseline ? {
3242
+ outcome: "skipped",
3243
+ outcomeReason: "pinned-ref"
3244
+ } : {}
3245
+ },
3246
+ anchor
3247
+ });
3248
+ continue;
3249
+ }
3250
+ const backfill = anchor.resolved_at === void 0 && !frozen;
3251
+ const refresh = !check && (restamp || backfill);
3252
+ plans.push({
3253
+ finding: {
3077
3254
  ...base2,
3078
3255
  state: "match",
3079
3256
  currentHash,
3080
3257
  hashKind: kind,
3081
3258
  ...producedBy ? { resolver: producedBy } : {},
3082
3259
  ...pinned ? { remoteState: "matches-ref" } : {}
3083
- });
3084
- const refresh = restamp || anchor.resolved_at === void 0;
3085
- updated.push(refresh ? { ...anchor, resolved_at: now() } : anchor);
3086
- if (refresh) dirty = true;
3260
+ },
3261
+ anchor: refresh ? { ...anchor, resolved_at: now() } : anchor,
3262
+ ...refresh ? { write: "refresh" } : {}
3263
+ });
3264
+ }
3265
+ return plans;
3266
+ }
3267
+ function lineDelta(anchor, current) {
3268
+ return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
3269
+ }
3270
+ function headHash(source, anchor, resolvers) {
3271
+ if (source.head === void 0) return void 0;
3272
+ const outcome = resolveAnchorSpan(source.head, anchor, resolvers);
3273
+ if (!outcome.ok) return void 0;
3274
+ return {
3275
+ hash: hashAnchorText(outcome.span.text),
3276
+ lines: outcome.span.endLine - outcome.span.startLine + 1
3277
+ };
3278
+ }
3279
+
3280
+ // src/commands/anchor-resolve/command.ts
3281
+ var anchorResolveCommand = define({
3282
+ name: "anchor-resolve",
3283
+ tool: "kb_anchor_resolve",
3284
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp] [--check]",
3285
+ description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. Never writes verified[]; a judgment is kb_verify. Each result says what it compared and whether the write applied.",
3286
+ input: import_zod8.z.object({
3287
+ bundlePath,
3288
+ conceptId,
3289
+ repoRoot: import_zod8.z.string().min(1).optional(),
3290
+ offline: import_zod8.z.boolean().optional().describe(
3291
+ "Resolve foreign anchors from the local repo cache only, never fetching."
3292
+ ),
3293
+ rebaseline: import_zod8.z.boolean().optional().describe(
3294
+ "Accept the current code as the new baseline for anchors that drifted."
3295
+ ),
3296
+ restamp: import_zod8.z.boolean().optional().describe(
3297
+ "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
3298
+ ),
3299
+ check: import_zod8.z.boolean().optional().describe(
3300
+ "Resolve and report only: no hash, no `resolved_at`, no log entry."
3301
+ )
3302
+ }),
3303
+ fromArgv: (argv, path) => ({
3304
+ bundlePath: path,
3305
+ conceptId: argv[1],
3306
+ repoRoot: argvFlag(argv, "--repo-root"),
3307
+ offline: argv.includes("--offline"),
3308
+ rebaseline: argv.includes("--rebaseline"),
3309
+ restamp: argv.includes("--restamp"),
3310
+ check: argv.includes("--check")
3311
+ }),
3312
+ run: async ({ store, actor, now }, {
3313
+ bundlePath: path,
3314
+ conceptId: id,
3315
+ repoRoot,
3316
+ offline,
3317
+ rebaseline,
3318
+ restamp,
3319
+ check
3320
+ }) => {
3321
+ if (check && (rebaseline || restamp)) {
3322
+ throw new KbFlagConflictError([
3323
+ "check",
3324
+ rebaseline ? "rebaseline" : "restamp"
3325
+ ]);
3087
3326
  }
3088
- let frozen = false;
3089
- if (dirty && !check) {
3090
- try {
3091
- await assertBaseNotFrozen(process.cwd(), path);
3092
- } catch (error) {
3093
- if (!(error instanceof KbBaseFrozenError)) throw error;
3094
- frozen = true;
3095
- }
3096
- if (!frozen) await store.updateAnchors(path, id, updated, actor);
3327
+ const root = repoRoot ?? process.cwd();
3328
+ const record = await store.read(path, id);
3329
+ if (!record) throw new KbRecordNotFoundError(id);
3330
+ const anchors = record.frontmatter.strauss_anchors ?? [];
3331
+ if (!anchors.length) {
3332
+ return {
3333
+ conceptId: id,
3334
+ results: [],
3335
+ note: "record has no anchors"
3336
+ };
3097
3337
  }
3098
- const frozenNote = frozen ? { frozen: true, note: "base is frozen: nothing was stamped" } : {};
3338
+ const frozen = check ? false : await baseFrozen(process.cwd(), path);
3339
+ const plans = await planAnchors(anchors, {
3340
+ root,
3341
+ offline: offline === true,
3342
+ rebaseline: rebaseline === true,
3343
+ restamp: restamp === true,
3344
+ check: check === true,
3345
+ frozen,
3346
+ now
3347
+ });
3348
+ const applied = await applyPlan(plans, {
3349
+ store,
3350
+ actor,
3351
+ bundlePath: path,
3352
+ conceptId: id,
3353
+ frozen
3354
+ });
3355
+ const results = applied.results;
3356
+ const refused = frozen && plans.some((plan) => plan.write);
3099
3357
  const hints = grammarHints();
3100
3358
  const hintNote = hints.length ? { hints } : {};
3101
3359
  const unreachable = results.filter(
3102
3360
  (entry) => isUncheckedReason(entry.reason)
3103
3361
  ).length;
3104
3362
  const matches3 = results.filter((entry) => entry.state === "match").length;
3105
- const note = `${matches3}/${results.length - unreachable} anchors match, ${unreachable} unreachable`;
3363
+ const note = [
3364
+ unreachable ? `${matches3}/${results.length - unreachable} anchors match, ${unreachable} unreachable` : "",
3365
+ refused ? "base is frozen: nothing was stamped" : "",
3366
+ applied.error ? `nothing was written: ${applied.error}` : ""
3367
+ ].filter(Boolean).join("; ");
3106
3368
  return {
3107
3369
  conceptId: id,
3108
3370
  results,
3109
- ...unreachable ? { note } : {},
3110
- ...frozenNote,
3371
+ ...note ? { note } : {},
3372
+ ...refused ? { frozen: true } : {},
3111
3373
  ...hintNote
3112
3374
  };
3113
3375
  },
3376
+ // Drift is a finding until a write settles it: a rebaseline the base took is
3377
+ // the answer to the drift it reports, while one refused, skipped, or never
3378
+ // asked for leaves the gate exactly what it was meant to catch.
3379
+ //
3114
3380
  // A stored hash that no longer resolves is a broken anchor, not an absence:
3115
- // the file was deleted or the symbol renamed, and exiting zero on it would
3116
- // let the one edit that destroys an anchor pass the gate that exists to
3117
- // catch it. An anchor nobody ever stamped is still just unstamped, and one
3118
- // whose remote nothing could reach was never checked — failing CI on either
3119
- // would gate on work this command did not do.
3381
+ // the file was deleted or the symbol renamed. An anchor nobody ever stamped
3382
+ // is still just unstamped, and one whose remote nothing could reach was
3383
+ // never checked failing CI on either would gate on work this command did
3384
+ // not do.
3120
3385
  failsWhen: (result) => result.results.some(
3121
- (entry) => entry.state === "drifted" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
3386
+ (entry) => entry.outcome === "failed" || entry.outcome === "skipped" || entry.state === "drifted" && entry.outcome !== "applied" || entry.state === "unresolved" && entry.storedHash !== void 0 && !isUncheckedReason(entry.reason)
3122
3387
  )
3123
3388
  });
3124
- function lineDelta(anchor, current) {
3125
- return anchor.lines === void 0 ? null : Math.abs(current - anchor.lines);
3126
- }
3127
- function headHash(source, anchor, resolvers) {
3128
- if (source.head === void 0) return void 0;
3129
- const outcome = resolveAnchorSpan(source.head, anchor, resolvers);
3130
- if (!outcome.ok) return void 0;
3131
- return {
3132
- hash: hashAnchorText(outcome.span.text),
3133
- lines: outcome.span.endLine - outcome.span.startLine + 1
3134
- };
3135
- }
3136
- async function readSources(anchors, root, offline) {
3137
- const origin = new LazyOrigin(root);
3138
- if (anchors.some((anchor) => anchor.repo)) await origin.prime();
3139
- const foreign = new Map(
3140
- anchors.map((anchor) => [anchor, origin.isForeign(anchor)])
3141
- );
3142
- const local = anchors.filter(
3143
- (anchor) => !foreign.get(anchor) && anchor.side !== "old"
3144
- );
3145
- const committed = anchors.filter(
3146
- (anchor) => !foreign.get(anchor) && anchor.side === "old"
3147
- );
3148
- const remote = anchors.filter((anchor) => foreign.get(anchor));
3149
- const reads = await readAnchorFiles(
3150
- local.map((anchor) => anchor.file),
3151
- anchorFileReader(root)
3152
- );
3153
- const atRef = await readCommitted(root, committed);
3154
- const blobs = await readRemoteAnchors(remote.flatMap(remoteWants), {
3155
- offline
3156
- });
3157
- const sources = /* @__PURE__ */ new Map();
3158
- for (const anchor of local) {
3159
- const read = reads.get(anchor.file);
3160
- sources.set(
3161
- anchor,
3162
- read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
3163
- );
3164
- }
3165
- for (const anchor of committed) {
3166
- const read = atRef.get(atRefKey(anchor));
3167
- sources.set(
3168
- anchor,
3169
- read.ok ? { ok: true, source: read.source } : { ok: false, reason: read.reason }
3389
+
3390
+ // src/commands/anchor-set/model.ts
3391
+ var import_zod9 = require("zod");
3392
+ var anchorSetInputSchema = import_zod9.z.object({
3393
+ reason: import_zod9.z.string().refine((text) => text.trim().length > 0, {
3394
+ message: "reason must say what was reviewed"
3395
+ }).describe(
3396
+ "What the reviewer read that makes these the right pointers. Recorded in the log."
3397
+ ),
3398
+ anchors: import_zod9.z.array(kbAnchorWriteSchema).min(1).describe(
3399
+ "The complete new anchor set. Carry an anchor's hash forward to keep drift visible until the new code is read."
3400
+ )
3401
+ }).strict();
3402
+ var anchorSetCommandInput = import_zod9.z.object({
3403
+ bundlePath,
3404
+ conceptId,
3405
+ input: anchorSetInputSchema,
3406
+ resolve: import_zod9.z.boolean().optional().describe(
3407
+ "Also resolve and stamp every anchor against the current code, as anchor-resolve --rebaseline does."
3408
+ ),
3409
+ repoRoot: import_zod9.z.string().min(1).optional().describe(
3410
+ "Where the anchored source lives, for resolve. Defaults to the working directory."
3411
+ ),
3412
+ offline: import_zod9.z.boolean().optional().describe("With resolve, read foreign anchors from the repo cache only.")
3413
+ });
3414
+
3415
+ // src/commands/anchor-set/command.ts
3416
+ var NOTE = "pointers only: nothing was resolved or verified. Run anchor-resolve to check the new pointers, --rebaseline to accept the code, or pass resolve to do both here.";
3417
+ var STAMPED_NOTE = "pointers set and stamped against the current code. Not verification: run verify separately if someone reviewed it.";
3418
+ var INCOMPLETE_NOTE = "pointers set, but not every anchor was stamped: see each resolved entry's state and outcome.";
3419
+ var anchorSetCommand = define({
3420
+ name: "anchor-set",
3421
+ tool: "kb_anchor_set",
3422
+ usage: "anchor-set <concept-id> [--resolve] [--repo-root <path>] [--offline] < anchors.json",
3423
+ description: "Set a record's code anchors after a reviewed refactor, with a reason. The array is the whole set. With resolve, every anchor is stamped against the current code in the same call; without it, run kb_anchor_resolve next. Recorded in the log, never verification.",
3424
+ input: anchorSetCommandInput,
3425
+ fromArgv: async (argv, path, stdin) => ({
3426
+ bundlePath: path,
3427
+ conceptId: argv[1],
3428
+ input: JSON.parse(await stdin()),
3429
+ resolve: argv.includes("--resolve"),
3430
+ repoRoot: argvFlag(argv, "--repo-root"),
3431
+ offline: argv.includes("--offline")
3432
+ }),
3433
+ run: async (ctx, { bundlePath: path, conceptId: id, input, resolve: resolve7, repoRoot, offline }) => {
3434
+ const { store, actor } = ctx;
3435
+ await assertBaseNotFrozen(process.cwd(), path);
3436
+ let applied;
3437
+ const record = await store.updateAnchors(
3438
+ path,
3439
+ id,
3440
+ (current) => {
3441
+ applied = applyAnchorSet(current, input.anchors);
3442
+ return {
3443
+ anchors: applied.anchors,
3444
+ log: {
3445
+ operation: "anchor-set",
3446
+ reason: input.reason,
3447
+ anchors: applied.changes
3448
+ }
3449
+ };
3450
+ },
3451
+ actor
3170
3452
  );
3171
- }
3172
- for (const anchor of remote) {
3173
- const repo = anchor.repo;
3174
- const key2 = normalizeRepoUrl(repo);
3175
- const atDefault = blobs.get(wantKey(key2, void 0, anchor.file));
3176
- const primary = anchor.ref ? blobs.get(wantKey(key2, anchor.ref, anchor.file)) : atDefault;
3177
- if (!primary?.ok) {
3178
- sources.set(anchor, {
3179
- ok: false,
3180
- reason: primary?.ok === false ? primary.reason : "remote-unreachable",
3181
- repo
3182
- });
3183
- continue;
3453
+ const changes = applied?.changes ?? [];
3454
+ if (!resolve7) {
3455
+ return {
3456
+ conceptId: id,
3457
+ reason: input.reason,
3458
+ changes,
3459
+ anchors: record.frontmatter.strauss_anchors ?? [],
3460
+ baseline: "unchanged",
3461
+ note: NOTE
3462
+ };
3184
3463
  }
3185
- sources.set(anchor, {
3186
- ok: true,
3187
- source: primary.source,
3188
- repo,
3189
- ...anchor.ref && atDefault?.ok ? { head: atDefault.source } : {}
3190
- });
3464
+ const resolved = await anchorResolveCommand.run(
3465
+ ctx,
3466
+ anchorResolveCommand.input.parse({
3467
+ bundlePath: path,
3468
+ conceptId: id,
3469
+ rebaseline: true,
3470
+ ...repoRoot ? { repoRoot } : {},
3471
+ ...offline ? { offline } : {}
3472
+ })
3473
+ );
3474
+ const after = await store.read(path, id);
3475
+ const stamped = resolved.results.every(
3476
+ (entry) => entry.state === "match" || entry.outcome === "applied"
3477
+ );
3478
+ return {
3479
+ conceptId: id,
3480
+ reason: input.reason,
3481
+ changes,
3482
+ anchors: after?.frontmatter.strauss_anchors ?? [],
3483
+ baseline: stamped ? "stamped" : "incomplete",
3484
+ resolved: resolved.results,
3485
+ note: stamped ? STAMPED_NOTE : INCOMPLETE_NOTE
3486
+ };
3487
+ },
3488
+ // With `resolve`, a pointer that names nothing is a failed set, not a
3489
+ // finding to read later, and a stamp that did not land fails as it does in
3490
+ // anchor-resolve. A remote nothing could reach was never checked, so it does
3491
+ // not fail — the same line anchor-resolve draws.
3492
+ failsWhen: (result, input) => {
3493
+ const resolved = result.resolved ?? [];
3494
+ return resolved.some(
3495
+ (entry) => entry.state === "unresolved" && !isUncheckedReason(entry.reason)
3496
+ ) || anchorResolveCommand.failsWhen?.({ results: resolved }, input) === true;
3191
3497
  }
3192
- return sources;
3193
- }
3498
+ });
3194
3499
 
3195
3500
  // src/commands/answer.ts
3196
- var import_zod8 = require("zod");
3501
+ var import_zod10 = require("zod");
3197
3502
  var answerCommand = define({
3198
3503
  name: "answer",
3199
3504
  tool: "kb_answer",
3200
3505
  usage: "answer <concept-id> <answer...>",
3201
3506
  description: "Resolve an open question: set status, stamp who and when, append an Answer section. If the answer overturns a decision or assumption, supersede that record explicitly.",
3202
- input: import_zod8.z.object({ bundlePath, conceptId, answer: import_zod8.z.string().min(1) }),
3507
+ input: import_zod10.z.object({ bundlePath, conceptId, answer: import_zod10.z.string().min(1) }),
3203
3508
  fromArgv: (argv, path) => ({
3204
3509
  bundlePath: path,
3205
3510
  conceptId: argv[1],
@@ -3213,19 +3518,19 @@ var answerCommand = define({
3213
3518
  });
3214
3519
 
3215
3520
  // src/commands/backlinks.ts
3216
- var import_zod9 = require("zod");
3521
+ var import_zod11 = require("zod");
3217
3522
  var backlinksCommand = define({
3218
3523
  name: "backlinks",
3219
3524
  tool: "kb_backlinks",
3220
3525
  usage: "backlinks <concept-id>",
3221
3526
  description: "Who points at this record: every inbound typed causal link (`strauss_links`), one hop, every rel including `related_to`, each with its rel and the standing of the record that made it. Use it when you need the exact edges \u2014 reviewing or renaming a record.",
3222
- input: import_zod9.z.object({ bundlePath, conceptId }),
3527
+ input: import_zod11.z.object({ bundlePath, conceptId }),
3223
3528
  fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
3224
3529
  run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
3225
3530
  });
3226
3531
 
3227
3532
  // src/commands/catalog.ts
3228
- var import_zod10 = require("zod");
3533
+ var import_zod12 = require("zod");
3229
3534
 
3230
3535
  // src/adjudicate.ts
3231
3536
  var STANDING = {
@@ -3404,9 +3709,9 @@ var catalogCommand = define({
3404
3709
  tool: "kb_catalog",
3405
3710
  usage: "catalog [type] [--tag T]...",
3406
3711
  description: "Lists every record as one line \u2014 concept id, type, title, standing, and a stale flag \u2014 at roughly thirty tokens each. Pick this over kb_load once kb_load refuses: kb_catalog never refuses. Superseded records show only their replacement; fetch bodies with kb_load, kb_pack, kb_query, or kb_trace.",
3407
- input: import_zod10.z.object({
3712
+ input: import_zod12.z.object({
3408
3713
  bundlePath,
3409
- type: import_zod10.z.enum(KB_RECORD_TYPES).optional(),
3714
+ type: import_zod12.z.enum(KB_RECORD_TYPES).optional(),
3410
3715
  tags: TAGS
3411
3716
  }),
3412
3717
  fromArgv: (argv, path) => {
@@ -3478,7 +3783,7 @@ function count(value, noun) {
3478
3783
  var import_node_buffer = require("buffer");
3479
3784
  var import_promises7 = require("fs/promises");
3480
3785
  var import_node_path10 = require("path");
3481
- var import_zod13 = require("zod");
3786
+ var import_zod15 = require("zod");
3482
3787
 
3483
3788
  // src/match-diff.ts
3484
3789
  function matchToDiff(files, records, options = {}) {
@@ -4082,7 +4387,7 @@ function claimOf(record) {
4082
4387
  }
4083
4388
 
4084
4389
  // src/commands/match/command.ts
4085
- var import_zod12 = require("zod");
4390
+ var import_zod14 = require("zod");
4086
4391
 
4087
4392
  // src/commands/match/errors.ts
4088
4393
  var KbMatchInputError = class extends BaseError {
@@ -4102,21 +4407,21 @@ var KbMatchInputError = class extends BaseError {
4102
4407
  };
4103
4408
 
4104
4409
  // src/commands/match/model.ts
4105
- var import_zod11 = require("zod");
4106
- var diffHunkSchema = import_zod11.z.object({
4107
- startLine: import_zod11.z.number().int().positive(),
4108
- endLine: import_zod11.z.number().int().positive(),
4109
- side: import_zod11.z.enum(["old", "new"]).optional()
4410
+ var import_zod13 = require("zod");
4411
+ var diffHunkSchema = import_zod13.z.object({
4412
+ startLine: import_zod13.z.number().int().positive(),
4413
+ endLine: import_zod13.z.number().int().positive(),
4414
+ side: import_zod13.z.enum(["old", "new"]).optional()
4110
4415
  }).passthrough();
4111
- var diffFileSchema = import_zod11.z.object({
4112
- filePath: import_zod11.z.string().min(1).describe("Repo-relative, spelled the way anchors are."),
4113
- hunks: import_zod11.z.array(diffHunkSchema)
4416
+ var diffFileSchema = import_zod13.z.object({
4417
+ filePath: import_zod13.z.string().min(1).describe("Repo-relative, spelled the way anchors are."),
4418
+ hunks: import_zod13.z.array(diffHunkSchema)
4114
4419
  });
4115
- var symbolRangeSchema = import_zod11.z.object({
4116
- file: import_zod11.z.string().min(1),
4117
- symbol: import_zod11.z.string().min(1),
4118
- startLine: import_zod11.z.number().int().positive(),
4119
- endLine: import_zod11.z.number().int().positive()
4420
+ var symbolRangeSchema = import_zod13.z.object({
4421
+ file: import_zod13.z.string().min(1),
4422
+ symbol: import_zod13.z.string().min(1),
4423
+ startLine: import_zod13.z.number().int().positive(),
4424
+ endLine: import_zod13.z.number().int().positive()
4120
4425
  });
4121
4426
 
4122
4427
  // src/commands/match/parse-unified-diff.ts
@@ -4365,17 +4670,17 @@ var matchCommand = define({
4365
4670
  tool: "kb_match",
4366
4671
  usage: "match --git <base>..<head> | --stdin [--repo-root <path>] [--offline] [--include-non-current]",
4367
4672
  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.",
4368
- input: import_zod12.z.object({
4673
+ input: import_zod14.z.object({
4369
4674
  bundlePath,
4370
- files: import_zod12.z.array(diffFileSchema).describe("The changed files, each with its post-change line ranges."),
4371
- symbolRanges: import_zod12.z.array(symbolRangeSchema).optional().describe(
4675
+ files: import_zod14.z.array(diffFileSchema).describe("The changed files, each with its post-change line ranges."),
4676
+ symbolRanges: import_zod14.z.array(symbolRangeSchema).optional().describe(
4372
4677
  "Symbol spans the caller already has. Resolved from repoRoot when omitted."
4373
4678
  ),
4374
4679
  repoRoot: REPO_ROOT,
4375
- offline: import_zod12.z.boolean().optional().describe(
4680
+ offline: import_zod14.z.boolean().optional().describe(
4376
4681
  "Resolve symbol ranges from what is already on disk, never fetching a grammar."
4377
4682
  ),
4378
- includeNonCurrent: import_zod12.z.boolean().optional().describe(
4683
+ includeNonCurrent: import_zod14.z.boolean().optional().describe(
4379
4684
  "Return superseded, rejected and unsettled records too, each carrying its standing."
4380
4685
  )
4381
4686
  }),
@@ -4389,11 +4694,11 @@ var matchCommand = define({
4389
4694
  ...argv.includes("--include-non-current") ? { includeNonCurrent: true } : {}
4390
4695
  };
4391
4696
  if (range !== void 0) {
4392
- const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
4393
- if (!diff.ok) {
4394
- throw new KbMatchInputError(`--git ${range} ${REFUSED[diff.reason]}`);
4697
+ const diff2 = await readRangeDiff(repoRoot ?? process.cwd(), range);
4698
+ if (!diff2.ok) {
4699
+ throw new KbMatchInputError(`--git ${range} ${REFUSED[diff2.reason]}`);
4395
4700
  }
4396
- return { ...base2, files: parseUnifiedDiff(diff.text) };
4701
+ return { ...base2, files: parseUnifiedDiff(diff2.text) };
4397
4702
  }
4398
4703
  if (!argv.includes("--stdin")) {
4399
4704
  throw new KbMatchInputError(
@@ -4479,22 +4784,22 @@ function project(match, ranges, all) {
4479
4784
 
4480
4785
  // src/commands/classify.ts
4481
4786
  var classifyFileSchema = diffFileSchema.extend({
4482
- hunks: import_zod13.z.array(
4483
- diffHunkSchema.extend({ lines: import_zod13.z.array(import_zod13.z.string()).optional() })
4787
+ hunks: import_zod15.z.array(
4788
+ diffHunkSchema.extend({ lines: import_zod15.z.array(import_zod15.z.string()).optional() })
4484
4789
  ),
4485
- renamedFrom: import_zod13.z.string().min(1).optional().describe("Where `git diff -M` says the path came from."),
4486
- similarity: import_zod13.z.number().min(0).max(100).optional()
4790
+ renamedFrom: import_zod15.z.string().min(1).optional().describe("Where `git diff -M` says the path came from."),
4791
+ similarity: import_zod15.z.number().min(0).max(100).optional()
4487
4792
  });
4488
4793
  var classifyCommand = define({
4489
4794
  name: "classify",
4490
4795
  tool: "kb_classify",
4491
4796
  usage: "classify --git <base>..<head> | --stdin [--repo-root <path>] [--offline]",
4492
4797
  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.",
4493
- input: import_zod13.z.object({
4798
+ input: import_zod15.z.object({
4494
4799
  bundlePath,
4495
- files: import_zod13.z.array(classifyFileSchema).describe("The changed files, each with its line ranges."),
4800
+ files: import_zod15.z.array(classifyFileSchema).describe("The changed files, each with its line ranges."),
4496
4801
  repoRoot: REPO_ROOT,
4497
- offline: import_zod13.z.boolean().optional().describe(
4802
+ offline: import_zod15.z.boolean().optional().describe(
4498
4803
  "Resolve symbol ranges from what is already on disk, never fetching a grammar."
4499
4804
  )
4500
4805
  }),
@@ -4507,15 +4812,15 @@ var classifyCommand = define({
4507
4812
  ...argv.includes("--offline") ? { offline: true } : {}
4508
4813
  };
4509
4814
  if (range !== void 0) {
4510
- const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
4511
- if (!diff.ok) {
4815
+ const diff2 = await readRangeDiff(repoRoot ?? process.cwd(), range);
4816
+ if (!diff2.ok) {
4512
4817
  throw new KbClassifyInputError(
4513
- `--git ${range} ${REFUSED2[diff.reason]}`
4818
+ `--git ${range} ${REFUSED2[diff2.reason]}`
4514
4819
  );
4515
4820
  }
4516
4821
  return {
4517
4822
  ...base2,
4518
- files: parseUnifiedDiff(diff.text, {
4823
+ files: parseUnifiedDiff(diff2.text, {
4519
4824
  keepEmpty: true,
4520
4825
  withLines: true
4521
4826
  })
@@ -4605,7 +4910,7 @@ function renderClassify(result) {
4605
4910
  }
4606
4911
 
4607
4912
  // src/commands/context.ts
4608
- var import_zod14 = require("zod");
4913
+ var import_zod16 = require("zod");
4609
4914
 
4610
4915
  // src/kb-context.ts
4611
4916
  var import_promises8 = require("fs/promises");
@@ -4876,23 +5181,23 @@ var contextCommand = define({
4876
5181
  tool: "kb_context",
4877
5182
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
4878
5183
  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.",
4879
- input: import_zod14.z.object({
4880
- budgetTokens: import_zod14.z.number().int().positive().optional().describe(
5184
+ input: import_zod16.z.object({
5185
+ budgetTokens: import_zod16.z.number().int().positive().optional().describe(
4881
5186
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
4882
5187
  ),
4883
- fullUnderTokens: import_zod14.z.number().int().positive().optional().describe(
5188
+ fullUnderTokens: import_zod16.z.number().int().positive().optional().describe(
4884
5189
  "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."
4885
5190
  ),
4886
- profile: import_zod14.z.string().optional().describe(
5191
+ profile: import_zod16.z.string().optional().describe(
4887
5192
  "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."
4888
5193
  ),
4889
- excludeTags: import_zod14.z.array(import_zod14.z.string().min(1)).optional().describe(
5194
+ excludeTags: import_zod16.z.array(import_zod16.z.string().min(1)).optional().describe(
4890
5195
  "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
4891
5196
  ),
4892
- format: import_zod14.z.enum(["markdown", "json"]).optional().describe(
5197
+ format: import_zod16.z.enum(["markdown", "json"]).optional().describe(
4893
5198
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
4894
5199
  ),
4895
- event: import_zod14.z.string().optional().describe(
5200
+ event: import_zod16.z.string().optional().describe(
4896
5201
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
4897
5202
  )
4898
5203
  }),
@@ -4931,7 +5236,7 @@ var contextCommand = define({
4931
5236
  });
4932
5237
 
4933
5238
  // src/commands/doctor.ts
4934
- var import_zod16 = require("zod");
5239
+ var import_zod18 = require("zod");
4935
5240
 
4936
5241
  // src/kb-edges.ts
4937
5242
  var KB_EDGE_KINDS = [
@@ -5447,17 +5752,17 @@ function ageInDays(record, now) {
5447
5752
  }
5448
5753
 
5449
5754
  // src/commands/reassess.ts
5450
- var import_zod15 = require("zod");
5755
+ var import_zod17 = require("zod");
5451
5756
  var reassessCommand = define({
5452
5757
  name: "reassess",
5453
5758
  tool: "kb_reassess",
5454
5759
  usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
5455
5760
  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.",
5456
- input: import_zod15.z.object({
5761
+ input: import_zod17.z.object({
5457
5762
  bundlePath,
5458
5763
  conceptId,
5459
5764
  repoRoot: REPO_ROOT,
5460
- withDiff: import_zod15.z.boolean().optional().describe(
5765
+ withDiff: import_zod17.z.boolean().optional().describe(
5461
5766
  "Recover each anchor's committed span and render the diff. Reads git history."
5462
5767
  )
5463
5768
  }),
@@ -5602,13 +5907,13 @@ function at(file, symbol) {
5602
5907
  }
5603
5908
 
5604
5909
  // src/commands/doctor.ts
5605
- var days = (what, fallback) => import_zod16.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5910
+ var days = (what, fallback) => import_zod18.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5606
5911
  var doctorCommand = define({
5607
5912
  name: "doctor",
5608
5913
  tool: "kb_doctor",
5609
5914
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
5610
5915
  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.",
5611
- input: import_zod16.z.object({
5916
+ input: import_zod18.z.object({
5612
5917
  bundlePath,
5613
5918
  repoRoot: REPO_ROOT,
5614
5919
  expiringDays: days(
@@ -5623,16 +5928,16 @@ var doctorCommand = define({
5623
5928
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
5624
5929
  DEFAULT_AGING_DAYS
5625
5930
  ),
5626
- offline: import_zod16.z.boolean().optional().describe(
5931
+ offline: import_zod18.z.boolean().optional().describe(
5627
5932
  "Read foreign anchors from the local repo cache only, never fetching."
5628
5933
  ),
5629
- strict: import_zod16.z.boolean().optional().describe(
5934
+ strict: import_zod18.z.boolean().optional().describe(
5630
5935
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
5631
5936
  ),
5632
- drifted: import_zod16.z.boolean().optional().describe(
5937
+ drifted: import_zod18.z.boolean().optional().describe(
5633
5938
  "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
5634
5939
  ),
5635
- withDiff: import_zod16.z.boolean().optional().describe(
5940
+ withDiff: import_zod18.z.boolean().optional().describe(
5636
5941
  "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
5637
5942
  )
5638
5943
  }),
@@ -5809,7 +6114,7 @@ function renderPackets(result) {
5809
6114
  // src/commands/export.ts
5810
6115
  var import_promises9 = require("fs/promises");
5811
6116
  var import_node_path11 = require("path");
5812
- var import_zod17 = require("zod");
6117
+ var import_zod19 = require("zod");
5813
6118
  var NUMBERED = /^(\d{4})-(.+)\.md$/;
5814
6119
  var MARKER = "<!-- strauss-kb export: ";
5815
6120
  var exportCommand = define({
@@ -5817,10 +6122,10 @@ var exportCommand = define({
5817
6122
  tool: "kb_export",
5818
6123
  usage: "export --format madr --to <dir>",
5819
6124
  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.",
5820
- input: import_zod17.z.object({
6125
+ input: import_zod19.z.object({
5821
6126
  bundlePath,
5822
- format: import_zod17.z.enum(["madr"]).describe("Output layout. `madr` is the only one so far."),
5823
- to: import_zod17.z.string().min(1).describe("Directory the ADR files are written into.")
6127
+ format: import_zod19.z.enum(["madr"]).describe("Output layout. `madr` is the only one so far."),
6128
+ to: import_zod19.z.string().min(1).describe("Directory the ADR files are written into.")
5824
6129
  }),
5825
6130
  fromArgv: (argv, path) => ({
5826
6131
  bundlePath: path,
@@ -5942,19 +6247,19 @@ function bodySections(body) {
5942
6247
  }
5943
6248
 
5944
6249
  // src/commands/impact.ts
5945
- var import_zod18 = require("zod");
6250
+ var import_zod20 = require("zod");
5946
6251
  var impactCommand = define({
5947
6252
  name: "impact",
5948
6253
  tool: "kb_impact",
5949
6254
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
5950
6255
  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.",
5951
- input: import_zod18.z.object({
6256
+ input: import_zod20.z.object({
5952
6257
  bundlePath,
5953
6258
  conceptId,
5954
- depth: import_zod18.z.number().int().positive().optional().describe(
6259
+ depth: import_zod20.z.number().int().positive().optional().describe(
5955
6260
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
5956
6261
  ),
5957
- rels: import_zod18.z.array(import_zod18.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
6262
+ rels: import_zod20.z.array(import_zod20.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
5958
6263
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
5959
6264
  )
5960
6265
  }),
@@ -5975,15 +6280,15 @@ var impactCommand = define({
5975
6280
  });
5976
6281
 
5977
6282
  // src/commands/list.ts
5978
- var import_zod19 = require("zod");
6283
+ var import_zod21 = require("zod");
5979
6284
  var listCommand = define({
5980
6285
  name: "list",
5981
6286
  tool: "kb_list",
5982
6287
  usage: "list [type] [--tag T]...",
5983
6288
  description: "Every record, optionally one type or tag. For enumerating; use kb_query for a question.",
5984
- input: import_zod19.z.object({
6289
+ input: import_zod21.z.object({
5985
6290
  bundlePath,
5986
- type: import_zod19.z.enum(KB_RECORD_TYPES).optional(),
6291
+ type: import_zod21.z.enum(KB_RECORD_TYPES).optional(),
5987
6292
  tags: TAGS
5988
6293
  }),
5989
6294
  fromArgv: (argv, path) => {
@@ -6007,17 +6312,17 @@ var listCommand = define({
6007
6312
  });
6008
6313
 
6009
6314
  // src/commands/load.ts
6010
- var import_zod20 = require("zod");
6315
+ var import_zod22 = require("zod");
6011
6316
  var loadCommand = define({
6012
6317
  name: "load",
6013
6318
  tool: "kb_load",
6014
6319
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
6015
6320
  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.",
6016
- input: import_zod20.z.object({
6321
+ input: import_zod22.z.object({
6017
6322
  bundlePath,
6018
- type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
6019
- budgetTokens: import_zod20.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6020
- all: import_zod20.z.boolean().optional().describe(
6323
+ type: import_zod22.z.enum(KB_RECORD_TYPES).optional(),
6324
+ budgetTokens: import_zod22.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6325
+ all: import_zod22.z.boolean().optional().describe(
6021
6326
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
6022
6327
  ),
6023
6328
  repoRoot: REPO_ROOT
@@ -6059,25 +6364,25 @@ var loadCommand = define({
6059
6364
  });
6060
6365
 
6061
6366
  // src/commands/log.ts
6062
- var import_zod21 = require("zod");
6367
+ var import_zod23 = require("zod");
6063
6368
  var logCommand = define({
6064
6369
  name: "log",
6065
6370
  tool: "kb_log",
6066
6371
  usage: "log",
6067
6372
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
6068
- input: import_zod21.z.object({ bundlePath }),
6373
+ input: import_zod23.z.object({ bundlePath }),
6069
6374
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6070
6375
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
6071
6376
  });
6072
6377
 
6073
6378
  // src/commands/no-decision.ts
6074
- var import_zod22 = require("zod");
6379
+ var import_zod24 = require("zod");
6075
6380
  var noDecisionCommand = define({
6076
6381
  name: "no-decision",
6077
6382
  tool: "kb_no_decision",
6078
6383
  usage: "no-decision <reason...>",
6079
6384
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
6080
- input: import_zod22.z.object({ bundlePath, reason: import_zod22.z.string().min(1) }),
6385
+ input: import_zod24.z.object({ bundlePath, reason: import_zod24.z.string().min(1) }),
6081
6386
  fromArgv: (argv, path) => ({
6082
6387
  bundlePath: path,
6083
6388
  reason: argv.slice(1).join(" ").trim()
@@ -6094,20 +6399,20 @@ var noDecisionCommand = define({
6094
6399
  });
6095
6400
 
6096
6401
  // src/commands/pack.ts
6097
- var import_zod23 = require("zod");
6402
+ var import_zod25 = require("zod");
6098
6403
  var packCommand = define({
6099
6404
  name: "pack",
6100
6405
  tool: "kb_pack",
6101
6406
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
6102
6407
  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.",
6103
- input: import_zod23.z.object({
6408
+ input: import_zod25.z.object({
6104
6409
  bundlePath,
6105
6410
  conceptId,
6106
- hops: import_zod23.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6107
- maxNodes: import_zod23.z.number().int().positive().optional().describe(
6411
+ hops: import_zod25.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6412
+ maxNodes: import_zod25.z.number().int().positive().optional().describe(
6108
6413
  "How many records the pack may hold, root included. Defaults to 20."
6109
6414
  ),
6110
- budgetTokens: import_zod23.z.number().int().positive().optional().describe(
6415
+ budgetTokens: import_zod25.z.number().int().positive().optional().describe(
6111
6416
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
6112
6417
  )
6113
6418
  }),
@@ -6194,22 +6499,22 @@ function warningLabel(warning) {
6194
6499
  }
6195
6500
 
6196
6501
  // src/commands/pin.ts
6197
- var import_zod24 = require("zod");
6502
+ var import_zod26 = require("zod");
6198
6503
  var pinCommand = define({
6199
6504
  name: "pin",
6200
6505
  tool: "kb_pin",
6201
6506
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
6202
6507
  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.",
6203
- input: import_zod24.z.object({
6508
+ input: import_zod26.z.object({
6204
6509
  bundlePath,
6205
- mode: import_zod24.z.enum(["full", "index"]).optional().describe(
6510
+ mode: import_zod26.z.enum(["full", "index"]).optional().describe(
6206
6511
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
6207
6512
  ),
6208
- profiles: import_zod24.z.array(import_zod24.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6209
- layer: import_zod24.z.enum(["project", "local", "user"]).optional().describe(
6513
+ profiles: import_zod26.z.array(import_zod26.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6514
+ layer: import_zod26.z.enum(["project", "local", "user"]).optional().describe(
6210
6515
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
6211
6516
  ),
6212
- frozen: import_zod24.z.boolean().optional().describe(
6517
+ frozen: import_zod26.z.boolean().optional().describe(
6213
6518
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
6214
6519
  )
6215
6520
  }),
@@ -6238,13 +6543,13 @@ var pinCommand = define({
6238
6543
  });
6239
6544
 
6240
6545
  // src/commands/pins.ts
6241
- var import_zod25 = require("zod");
6546
+ var import_zod27 = require("zod");
6242
6547
  var pinsCommand = define({
6243
6548
  name: "pins",
6244
6549
  tool: "kb_pins",
6245
6550
  usage: "pins",
6246
6551
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
6247
- input: import_zod25.z.object({}),
6552
+ input: import_zod27.z.object({}),
6248
6553
  fromArgv: () => ({}),
6249
6554
  run: ({ store }) => listPins(store, process.cwd())
6250
6555
  });
@@ -6515,16 +6820,16 @@ function recordType(conceptId2) {
6515
6820
  }
6516
6821
 
6517
6822
  // src/commands/promote/model.ts
6518
- var import_zod26 = require("zod");
6519
- var promoteInputSchema = import_zod26.z.object({
6823
+ var import_zod28 = require("zod");
6824
+ var promoteInputSchema = import_zod28.z.object({
6520
6825
  bundlePath,
6521
- conceptIds: import_zod26.z.array(conceptId).max(64).optional().describe("Records to copy into the target base. Omit with `list`."),
6522
- to: import_zod26.z.string().min(1).optional().describe("Absolute path to the base being promoted into."),
6523
- source: import_zod26.z.string().min(1).optional().describe(
6826
+ conceptIds: import_zod28.z.array(conceptId).max(64).optional().describe("Records to copy into the target base. Omit with `list`."),
6827
+ to: import_zod28.z.string().min(1).optional().describe("Absolute path to the base being promoted into."),
6828
+ source: import_zod28.z.string().min(1).optional().describe(
6524
6829
  "Where the promotion came from, usually the pull request URL. Recorded on each copy as a source."
6525
6830
  ),
6526
- force: import_zod26.z.boolean().optional().describe("Overwrite a record the target base already holds."),
6527
- list: import_zod26.z.boolean().optional().describe("List the source base's candidates instead of promoting.")
6831
+ force: import_zod28.z.boolean().optional().describe("Overwrite a record the target base already holds."),
6832
+ list: import_zod28.z.boolean().optional().describe("List the source base's candidates instead of promoting.")
6528
6833
  }).refine((input) => input.list === true || input.to !== void 0, {
6529
6834
  message: "promote needs a target base \u2014 pass --to <bundle>, or --list",
6530
6835
  path: ["to"]
@@ -6661,17 +6966,17 @@ function renderPromote(result) {
6661
6966
  }
6662
6967
 
6663
6968
  // src/commands/query.ts
6664
- var import_zod27 = require("zod");
6969
+ var import_zod29 = require("zod");
6665
6970
  var queryCommand = define({
6666
6971
  name: "query",
6667
6972
  tool: "kb_query",
6668
6973
  usage: "query <text...> [--tag T]... [--repo-root PATH]",
6669
6974
  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.",
6670
- input: import_zod27.z.object({
6975
+ input: import_zod29.z.object({
6671
6976
  bundlePath,
6672
- text: import_zod27.z.string().optional(),
6673
- type: import_zod27.z.enum(KB_RECORD_TYPES).optional(),
6674
- includeNonCurrent: import_zod27.z.boolean().optional(),
6977
+ text: import_zod29.z.string().optional(),
6978
+ type: import_zod29.z.enum(KB_RECORD_TYPES).optional(),
6979
+ includeNonCurrent: import_zod29.z.boolean().optional(),
6675
6980
  tags: TAGS,
6676
6981
  repoRoot: REPO_ROOT
6677
6982
  }),
@@ -6705,27 +7010,32 @@ var queryCommand = define({
6705
7010
  });
6706
7011
 
6707
7012
  // src/commands/read-index.ts
6708
- var import_zod28 = require("zod");
7013
+ var import_zod30 = require("zod");
6709
7014
  var readIndexCommand = define({
6710
7015
  name: "index",
6711
7016
  tool: "kb_index",
6712
7017
  usage: "index",
6713
7018
  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.",
6714
- input: import_zod28.z.object({ bundlePath }),
7019
+ input: import_zod30.z.object({ bundlePath }),
6715
7020
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6716
7021
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
6717
7022
  });
6718
7023
 
6719
7024
  // src/commands/schema.ts
6720
- var import_zod31 = require("zod");
7025
+ var import_zod33 = require("zod");
6721
7026
 
6722
7027
  // src/json-schema.ts
6723
- var import_zod30 = require("zod");
7028
+ var import_zod32 = require("zod");
6724
7029
 
6725
7030
  // src/kb-log.ts
6726
- var import_zod29 = require("zod");
7031
+ var import_zod31 = require("zod");
6727
7032
  var LOG_FILE = "log.jsonl";
6728
- var kbLogEntrySchema = import_zod29.z.object({
7033
+ var kbLogAnchorChangeSchema = import_zod31.z.object({
7034
+ op: import_zod31.z.enum(["move", "add", "drop"]),
7035
+ from: kbAnchorLocatorSchema.optional(),
7036
+ to: kbAnchorLocatorSchema.optional()
7037
+ }).strict();
7038
+ var kbLogEntryFields = import_zod31.z.object({
6729
7039
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
6730
7040
  // below), and a value that isn't actually chronological — a Unix
6731
7041
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -6734,18 +7044,28 @@ var kbLogEntrySchema = import_zod29.z.object({
6734
7044
  // and rejects everything else, including a non-`Z` offset — so a
6735
7045
  // malformed `at` is reported the same way a malformed line already is,
6736
7046
  // rather than silently sorting into the wrong place.
6737
- at: import_zod29.z.iso.datetime(),
6738
- by: import_zod29.z.string().min(1),
6739
- operation: import_zod29.z.string().min(1),
6740
- conceptId: import_zod29.z.string().min(1),
7047
+ at: import_zod31.z.iso.datetime(),
7048
+ by: import_zod31.z.string().min(1),
7049
+ operation: import_zod31.z.string().min(1),
7050
+ conceptId: import_zod31.z.string().min(1),
6741
7051
  /**
6742
7052
  * The operation's other end, where it has one: a second concept id for
6743
7053
  * supersession, the other base's path for promotion.
6744
7054
  */
6745
- target: import_zod29.z.string().min(1).optional()
6746
- }).strict();
7055
+ target: import_zod31.z.string().min(1).optional(),
7056
+ /**
7057
+ * Why the operation was performed, where the operation demands one.
7058
+ * `anchor-set` does: a pointer moved by a reader is only auditable if
7059
+ * the reading is recorded beside it.
7060
+ */
7061
+ reason: import_zod31.z.string().min(1).optional(),
7062
+ /** What `anchor-set` changed, derived from the record before and after. */
7063
+ anchors: import_zod31.z.array(kbLogAnchorChangeSchema).optional()
7064
+ });
7065
+ var kbLogEntrySchema = kbLogEntryFields.passthrough();
7066
+ var kbLogEntryWriteSchema = kbLogEntryFields.strict();
6747
7067
  function renderLogEntry(entry) {
6748
- return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
7068
+ return `${JSON.stringify(kbLogEntryWriteSchema.parse(entry))}
6749
7069
  `;
6750
7070
  }
6751
7071
  var CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})/;
@@ -6786,11 +7106,11 @@ function parseLog(raw) {
6786
7106
  // src/json-schema.ts
6787
7107
  function kbJsonSchemas() {
6788
7108
  return {
6789
- recordFrontmatter: import_zod30.z.toJSONSchema(kbRecordFrontmatterSchema, {
7109
+ recordFrontmatter: import_zod32.z.toJSONSchema(kbRecordFrontmatterSchema, {
6790
7110
  io: "input"
6791
7111
  }),
6792
- composeInput: import_zod30.z.toJSONSchema(composeInputSchema, { io: "input" }),
6793
- logEntry: import_zod30.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
7112
+ composeInput: import_zod32.z.toJSONSchema(composeInputSchema, { io: "input" }),
7113
+ logEntry: import_zod32.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
6794
7114
  };
6795
7115
  }
6796
7116
 
@@ -6800,25 +7120,25 @@ var schemaCommand = define({
6800
7120
  tool: "kb_schema",
6801
7121
  usage: "schema",
6802
7122
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
6803
- input: import_zod31.z.object({}),
7123
+ input: import_zod33.z.object({}),
6804
7124
  fromArgv: () => ({}),
6805
7125
  run: () => Promise.resolve(kbJsonSchemas())
6806
7126
  });
6807
7127
 
6808
7128
  // src/commands/stamp.ts
6809
7129
  var import_promises10 = require("fs/promises");
6810
- var import_zod32 = require("zod");
7130
+ var import_zod34 = require("zod");
6811
7131
  var DIGEST = /^[0-9a-f]{64}$/;
6812
7132
  var stampCommand = define({
6813
7133
  name: "stamp",
6814
7134
  tool: "kb_stamp",
6815
7135
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
6816
7136
  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.",
6817
- input: import_zod32.z.object({
6818
- bundlePath: import_zod32.z.string().min(1).optional().describe(
7137
+ input: import_zod34.z.object({
7138
+ bundlePath: import_zod34.z.string().min(1).optional().describe(
6819
7139
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
6820
7140
  ),
6821
- since: import_zod32.z.string().min(1).optional().describe(
7141
+ since: import_zod34.z.string().min(1).optional().describe(
6822
7142
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
6823
7143
  )
6824
7144
  }),
@@ -6904,16 +7224,16 @@ async function readBaseline(since) {
6904
7224
  }
6905
7225
 
6906
7226
  // src/commands/status.ts
6907
- var import_zod33 = require("zod");
7227
+ var import_zod35 = require("zod");
6908
7228
  var statusCommand = define({
6909
7229
  name: "status",
6910
7230
  tool: "kb_status",
6911
7231
  usage: "status <concept-id> <status>",
6912
7232
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
6913
- input: import_zod33.z.object({
7233
+ input: import_zod35.z.object({
6914
7234
  bundlePath,
6915
7235
  conceptId,
6916
- status: import_zod33.z.enum(KB_RECORD_STATUSES)
7236
+ status: import_zod35.z.enum(KB_RECORD_STATUSES)
6917
7237
  }),
6918
7238
  fromArgv: (argv, path) => ({
6919
7239
  bundlePath: path,
@@ -6928,13 +7248,13 @@ var statusCommand = define({
6928
7248
  });
6929
7249
 
6930
7250
  // src/commands/supersede.ts
6931
- var import_zod34 = require("zod");
7251
+ var import_zod36 = require("zod");
6932
7252
  var supersedeCommand = define({
6933
7253
  name: "supersede",
6934
7254
  tool: "kb_supersede",
6935
7255
  usage: "supersede <concept-id> <replacement-id>",
6936
7256
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
6937
- input: import_zod34.z.object({ bundlePath, conceptId, replacementId: conceptId }),
7257
+ input: import_zod36.z.object({ bundlePath, conceptId, replacementId: conceptId }),
6938
7258
  fromArgv: (argv, path) => ({
6939
7259
  bundlePath: path,
6940
7260
  conceptId: argv[1],
@@ -6948,7 +7268,7 @@ var supersedeCommand = define({
6948
7268
  });
6949
7269
 
6950
7270
  // src/commands/sweep.ts
6951
- var import_zod35 = require("zod");
7271
+ var import_zod37 = require("zod");
6952
7272
  var TERMINAL = [
6953
7273
  "resolved",
6954
7274
  "rejected",
@@ -6959,15 +7279,15 @@ var sweepCommand = define({
6959
7279
  tool: "kb_sweep",
6960
7280
  usage: "sweep --tag <tag> --terminal [--dry-run]",
6961
7281
  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.",
6962
- input: import_zod35.z.object({
7282
+ input: import_zod37.z.object({
6963
7283
  bundlePath,
6964
- 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."),
6965
- terminal: import_zod35.z.literal(true, {
7284
+ tag: import_zod37.z.string({ error: "sweep needs --tag: it never sweeps a whole base" }).min(1).describe("Only records carrying this tag are considered."),
7285
+ terminal: import_zod37.z.literal(true, {
6966
7286
  error: "sweep needs --terminal: it deletes only settled records"
6967
7287
  }).describe(
6968
7288
  "Required. Names the only scope sweep deletes: resolved, rejected and superseded records."
6969
7289
  ),
6970
- dryRun: import_zod35.z.boolean().optional().describe("Report what would go, and delete nothing.")
7290
+ dryRun: import_zod37.z.boolean().optional().describe("Report what would go, and delete nothing.")
6971
7291
  }),
6972
7292
  fromArgv: (argv, path) => ({
6973
7293
  bundlePath: path,
@@ -7084,16 +7404,16 @@ function renderSweep(result) {
7084
7404
  }
7085
7405
 
7086
7406
  // src/commands/sync-instructions.ts
7087
- var import_zod36 = require("zod");
7407
+ var import_zod38 = require("zod");
7088
7408
  var syncInstructionsCommand = define({
7089
7409
  name: "sync-instructions",
7090
7410
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
7091
7411
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
7092
- input: import_zod36.z.object({
7093
- file: import_zod36.z.string().min(1).describe("The instruction file to edit in place."),
7094
- budgetTokens: import_zod36.z.number().int().positive().optional(),
7095
- fullUnderTokens: import_zod36.z.number().int().positive().optional(),
7096
- profile: import_zod36.z.string().optional()
7412
+ input: import_zod38.z.object({
7413
+ file: import_zod38.z.string().min(1).describe("The instruction file to edit in place."),
7414
+ budgetTokens: import_zod38.z.number().int().positive().optional(),
7415
+ fullUnderTokens: import_zod38.z.number().int().positive().optional(),
7416
+ profile: import_zod38.z.string().optional()
7097
7417
  }),
7098
7418
  fromArgv: (argv) => {
7099
7419
  const budget = argvFlag(argv, "--budget");
@@ -7119,7 +7439,7 @@ var syncInstructionsCommand = define({
7119
7439
  });
7120
7440
 
7121
7441
  // src/commands/trace.ts
7122
- var import_zod37 = require("zod");
7442
+ var import_zod39 = require("zod");
7123
7443
 
7124
7444
  // src/trace.ts
7125
7445
  var TRACE_EDGES = [
@@ -7175,11 +7495,11 @@ var traceCommand = define({
7175
7495
  tool: "kb_trace",
7176
7496
  usage: "trace <concept-id> [edges...]",
7177
7497
  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".',
7178
- input: import_zod37.z.object({
7498
+ input: import_zod39.z.object({
7179
7499
  bundlePath,
7180
7500
  conceptId,
7181
- edges: import_zod37.z.array(import_zod37.z.enum(TRACE_EDGES)).optional(),
7182
- depth: import_zod37.z.number().int().positive().optional()
7501
+ edges: import_zod39.z.array(import_zod39.z.enum(TRACE_EDGES)).optional(),
7502
+ depth: import_zod39.z.number().int().positive().optional()
7183
7503
  }),
7184
7504
  fromArgv: (argv, path) => ({
7185
7505
  bundlePath: path,
@@ -7201,37 +7521,37 @@ var traceCommand = define({
7201
7521
  });
7202
7522
 
7203
7523
  // src/commands/types.ts
7204
- var import_zod38 = require("zod");
7524
+ var import_zod40 = require("zod");
7205
7525
  var typesCommand = define({
7206
7526
  name: "types",
7207
7527
  tool: "kb_types",
7208
7528
  usage: "types",
7209
7529
  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.",
7210
- input: import_zod38.z.object({}),
7530
+ input: import_zod40.z.object({}),
7211
7531
  fromArgv: () => ({}),
7212
7532
  run: () => Promise.resolve(RECORD_TYPES)
7213
7533
  });
7214
7534
 
7215
7535
  // src/commands/unpin.ts
7216
- var import_zod39 = require("zod");
7536
+ var import_zod41 = require("zod");
7217
7537
  var unpinCommand = define({
7218
7538
  name: "unpin",
7219
7539
  tool: "kb_unpin",
7220
7540
  usage: "unpin [bundle-path]",
7221
7541
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
7222
- input: import_zod39.z.object({ bundlePath }),
7542
+ input: import_zod41.z.object({ bundlePath }),
7223
7543
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
7224
7544
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
7225
7545
  });
7226
7546
 
7227
7547
  // src/commands/validate.ts
7228
- var import_zod40 = require("zod");
7548
+ var import_zod42 = require("zod");
7229
7549
  var validateCommand = define({
7230
7550
  name: "validate",
7231
7551
  tool: "kb_validate",
7232
7552
  usage: "validate",
7233
7553
  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.",
7234
- input: import_zod40.z.object({ bundlePath }),
7554
+ input: import_zod42.z.object({ bundlePath }),
7235
7555
  fromArgv: (_argv, path) => ({ bundlePath: path }),
7236
7556
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
7237
7557
  // Warnings never fail the exit code; every other severity does.
@@ -7241,16 +7561,16 @@ var validateCommand = define({
7241
7561
  });
7242
7562
 
7243
7563
  // src/commands/verify.ts
7244
- var import_zod41 = require("zod");
7564
+ var import_zod43 = require("zod");
7245
7565
  var verifyCommand = define({
7246
7566
  name: "verify",
7247
7567
  tool: "kb_verify",
7248
7568
  usage: "verify <concept-id> --note <text>",
7249
7569
  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.",
7250
- input: import_zod41.z.object({
7570
+ input: import_zod43.z.object({
7251
7571
  bundlePath,
7252
7572
  conceptId,
7253
- note: import_zod41.z.string().refine((s) => s.trim().length > 0, {
7573
+ note: import_zod43.z.string().refine((s) => s.trim().length > 0, {
7254
7574
  message: "note must say what the check found"
7255
7575
  })
7256
7576
  }),
@@ -7270,15 +7590,15 @@ var verifyCommand = define({
7270
7590
  });
7271
7591
 
7272
7592
  // src/commands/write.ts
7273
- var import_zod42 = require("zod");
7593
+ var import_zod44 = require("zod");
7274
7594
  var writeCommand = define({
7275
7595
  name: "write",
7276
7596
  tool: "kb_write",
7277
7597
  usage: "write <type> < record.json",
7278
7598
  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.",
7279
- input: import_zod42.z.object({
7599
+ input: import_zod44.z.object({
7280
7600
  bundlePath,
7281
- type: import_zod42.z.enum(KB_RECORD_TYPES),
7601
+ type: import_zod44.z.enum(KB_RECORD_TYPES),
7282
7602
  input: composeInputSchema
7283
7603
  }),
7284
7604
  fromArgv: async (argv, path, stdin) => ({
@@ -7302,13 +7622,13 @@ var writeCommand = define({
7302
7622
  });
7303
7623
 
7304
7624
  // src/commands/write-decision.ts
7305
- var import_zod43 = require("zod");
7625
+ var import_zod45 = require("zod");
7306
7626
  var writeDecisionCommand = define({
7307
7627
  name: "write-decision",
7308
7628
  tool: "kb_write_decision",
7309
7629
  usage: "write-decision < decision.json",
7310
7630
  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.",
7311
- input: import_zod43.z.object({ bundlePath, input: decisionInputSchema }),
7631
+ input: import_zod45.z.object({ bundlePath, input: decisionInputSchema }),
7312
7632
  fromArgv: async (_argv, path, stdin) => ({
7313
7633
  bundlePath: path,
7314
7634
  input: JSON.parse(await stdin())
@@ -7338,6 +7658,7 @@ var KB_COMMANDS = [
7338
7658
  answerCommand,
7339
7659
  verifyCommand,
7340
7660
  anchorResolveCommand,
7661
+ anchorSetCommand,
7341
7662
  reassessCommand,
7342
7663
  promoteCommand,
7343
7664
  loadCommand,
@@ -7773,23 +8094,31 @@ var KbStore = class {
7773
8094
  );
7774
8095
  }
7775
8096
  /**
7776
- * Replaces a record's anchors wholesale, preserving everything else.
7777
- *
7778
- * Wholesale rather than merged: the caller just resolved the anchors it is
7779
- * writing, so it holds the complete current set, and a merge would keep
7780
- * stale entries the resolution pass deliberately dropped.
7781
- *
7782
- * Through the write schema: this is a write, and a defect a hand-edit put in
7783
- * the frontmatter must not be published back out under an actor stamp.
8097
+ * Replaces a record's anchors, preserving everything else. An array is the
8098
+ * whole set; a function is a patch and runs inside the mutation, against
8099
+ * the anchors the record holds then see
8100
+ * `decision.anchor-update-patch-inside-mutation`.
7784
8101
  */
7785
8102
  async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
7786
8103
  assertActor(actor);
7787
- const checked = anchors.map((anchor) => kbAnchorWriteSchema.parse(anchor));
8104
+ let entry = {
8105
+ operation: "anchor-resolve",
8106
+ by: actor
8107
+ };
7788
8108
  return this.mutate(
7789
8109
  bundlePath2,
7790
8110
  conceptId2,
7791
- (frontmatter) => ({ ...frontmatter, strauss_anchors: checked }),
7792
- { operation: "anchor-resolve", by: actor }
8111
+ (frontmatter) => {
8112
+ const write = typeof anchors === "function" ? anchors(frontmatter.strauss_anchors ?? []) : { anchors };
8113
+ if (write.log) entry = { ...write.log, by: actor };
8114
+ return {
8115
+ ...frontmatter,
8116
+ strauss_anchors: write.anchors.map(
8117
+ (anchor) => kbAnchorWriteSchema.parse(anchor)
8118
+ )
8119
+ };
8120
+ },
8121
+ () => entry
7793
8122
  );
7794
8123
  }
7795
8124
  /**
@@ -8243,7 +8572,10 @@ ${answer}
8243
8572
  throw new KbWriteConflictError(conceptId2);
8244
8573
  }
8245
8574
  await this.publish(target, contents, true, conceptId2);
8246
- await this.record(this.root(bundlePath2), { ...entry, conceptId: conceptId2 });
8575
+ await this.record(this.root(bundlePath2), {
8576
+ ...typeof entry === "function" ? entry() : entry,
8577
+ conceptId: conceptId2
8578
+ });
8247
8579
  return { conceptId: conceptId2, frontmatter, body };
8248
8580
  }
8249
8581
  /**
@@ -8459,7 +8791,7 @@ function assertActor(actor, { named = false } = {}) {
8459
8791
  }
8460
8792
 
8461
8793
  // src/version.ts
8462
- var VERSION = true ? "0.1.21" : "0.0.0-dev";
8794
+ var VERSION = true ? "0.1.23" : "0.0.0-dev";
8463
8795
 
8464
8796
  // src/cli.ts
8465
8797
  async function runKbCli(argv) {