@saasontools/strauss-kb 0.1.20 → 0.1.22

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,617 +31,139 @@ 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");
34
+ var import_zod4 = require("zod");
35
35
 
36
36
  // src/compose.ts
37
- var import_zod2 = require("zod");
37
+ var import_zod3 = require("zod");
38
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
- });
39
+ // src/concurrency.ts
40
+ var DEFAULT_IO_CONCURRENCY = 16;
41
+ async function mapLimit(items, limit, fn) {
42
+ if (!Number.isInteger(limit) || limit < 1) {
43
+ throw new RangeError(
44
+ `mapLimit: "limit" must be a positive integer, got ${limit}`
45
+ );
122
46
  }
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
- });
47
+ const out = new Array(items.length);
48
+ let next = 0;
49
+ let failed = false;
50
+ const runners = Array.from(
51
+ { length: Math.min(limit, items.length) },
52
+ async () => {
53
+ while (!failed && next < items.length) {
54
+ const at2 = next++;
55
+ try {
56
+ out[at2] = await fn(items[at2], at2);
57
+ } catch (error) {
58
+ failed = true;
59
+ throw error;
60
+ }
61
+ }
62
+ }
63
+ );
64
+ await Promise.all(runners);
65
+ return out;
66
+ }
67
+
68
+ // src/drift/git.ts
69
+ var import_node_child_process2 = require("child_process");
70
+ var import_node_util2 = require("util");
71
+
72
+ // src/remote-repo/git.ts
73
+ var import_node_child_process = require("child_process");
74
+ var import_node_util = require("util");
75
+
76
+ // src/anchor-resolver/model.ts
77
+ var MAX_ANCHOR_FILE_BYTES = 1048576;
78
+
79
+ // src/remote-repo/git.ts
80
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
81
+ function childEnv() {
82
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
83
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
84
+ delete env[name];
129
85
  }
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"
86
+ return env;
87
+ }
88
+ async function git(args, options = {}) {
89
+ try {
90
+ const { stdout, stderr } = await execFileAsync("git", args, {
91
+ ...options.cwd ? { cwd: options.cwd } : {},
92
+ timeout: options.timeoutMs ?? 3e4,
93
+ maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
94
+ encoding: "utf8",
95
+ windowsHide: true,
96
+ env: childEnv()
135
97
  });
98
+ return { ok: true, stdout, stderr, overflowed: false };
99
+ } catch (error) {
100
+ const failure = error;
101
+ return {
102
+ ok: false,
103
+ stdout: failure.stdout ?? "",
104
+ stderr: failure.stderr ?? "",
105
+ overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
106
+ };
136
107
  }
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
- });
108
+ }
109
+ function transportReason(stderr) {
110
+ const text = stderr.toLowerCase();
111
+ if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
112
+ return "repo-unauthorized";
143
113
  }
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();
114
+ if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
115
+ return "ref-not-found";
116
+ }
117
+ return "remote-unreachable";
118
+ }
221
119
 
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);
120
+ // src/remote-repo/validate.ts
121
+ var MAX_REF_LENGTH = 200;
122
+ var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
123
+ function refShapeIsSafe(ref) {
124
+ if (!ref || ref.length > MAX_REF_LENGTH) return false;
125
+ if (ref.includes("..")) return false;
126
+ return REF_SHAPE.test(ref);
287
127
  }
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);
128
+ function localRevShapeIsSafe(rev) {
129
+ if (!rev || rev.length > MAX_REF_LENGTH) return false;
130
+ if (rev.includes("..")) return false;
131
+ return /^[A-Za-z0-9][A-Za-z0-9._/^~-]*$/.test(rev);
345
132
  }
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
- };
133
+ async function refIsWellFormed(ref) {
134
+ if (!refShapeIsSafe(ref)) return false;
135
+ const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
136
+ return checked.ok;
466
137
  }
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
- );
138
+ function filePathIsSafe(file) {
139
+ const path = file.replace(/^\.\//, "");
140
+ if (!path || path.startsWith("-") || path.includes("\0")) return false;
141
+ return !path.split("/").includes("..");
491
142
  }
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
- );
143
+ var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
144
+ function allowedProtocols() {
145
+ const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
146
+ if (raw === void 0) return [...DEFAULT_PROTOCOLS];
147
+ const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
148
+ return listed.length ? listed : [...DEFAULT_PROTOCOLS];
504
149
  }
505
- function isNoDecisionRecord(record) {
506
- return record.conceptId === `${DECISION_TYPE}.${NO_DECISION_SLUG}`;
150
+ function isShortRepoName(repo) {
151
+ return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
507
152
  }
508
- function selectDecisions(records) {
509
- return records.filter(
510
- (record) => record.conceptId.startsWith(`${DECISION_TYPE}.`) && !isNoDecisionRecord(record)
511
- );
512
- }
513
-
514
- // src/commands/anchor-resolve.ts
515
- var import_zod7 = require("zod");
516
-
517
- // src/concurrency.ts
518
- var DEFAULT_IO_CONCURRENCY = 16;
519
- async function mapLimit(items, limit, fn) {
520
- if (!Number.isInteger(limit) || limit < 1) {
521
- throw new RangeError(
522
- `mapLimit: "limit" must be a positive integer, got ${limit}`
523
- );
524
- }
525
- const out = new Array(items.length);
526
- let next = 0;
527
- let failed = false;
528
- const runners = Array.from(
529
- { length: Math.min(limit, items.length) },
530
- async () => {
531
- while (!failed && next < items.length) {
532
- const at2 = next++;
533
- try {
534
- out[at2] = await fn(items[at2], at2);
535
- } catch (error) {
536
- failed = true;
537
- throw error;
538
- }
539
- }
540
- }
541
- );
542
- await Promise.all(runners);
543
- return out;
544
- }
545
-
546
- // src/drift/git.ts
547
- var import_node_child_process2 = require("child_process");
548
- var import_node_util2 = require("util");
549
-
550
- // src/remote-repo/git.ts
551
- var import_node_child_process = require("child_process");
552
- var import_node_util = require("util");
553
-
554
- // src/anchor-resolver/model.ts
555
- var MAX_ANCHOR_FILE_BYTES = 1048576;
556
-
557
- // src/remote-repo/git.ts
558
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
559
- function childEnv() {
560
- const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
561
- for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
562
- delete env[name];
563
- }
564
- return env;
565
- }
566
- async function git(args, options = {}) {
567
- try {
568
- const { stdout, stderr } = await execFileAsync("git", args, {
569
- ...options.cwd ? { cwd: options.cwd } : {},
570
- timeout: options.timeoutMs ?? 3e4,
571
- maxBuffer: options.maxBytes ?? MAX_ANCHOR_FILE_BYTES,
572
- encoding: "utf8",
573
- windowsHide: true,
574
- env: childEnv()
575
- });
576
- return { ok: true, stdout, stderr, overflowed: false };
577
- } catch (error) {
578
- const failure = error;
579
- return {
580
- ok: false,
581
- stdout: failure.stdout ?? "",
582
- stderr: failure.stderr ?? "",
583
- overflowed: failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
584
- };
585
- }
586
- }
587
- function transportReason(stderr) {
588
- const text = stderr.toLowerCase();
589
- if (text.includes("authentication failed") || text.includes("permission denied") || text.includes("could not read username") || text.includes("403 forbidden") || text.includes("access denied")) {
590
- return "repo-unauthorized";
591
- }
592
- if (text.includes("couldn't find remote ref") || text.includes("unadvertised object") || text.includes("not our ref")) {
593
- return "ref-not-found";
594
- }
595
- return "remote-unreachable";
596
- }
597
-
598
- // src/remote-repo/validate.ts
599
- var MAX_REF_LENGTH = 200;
600
- var REF_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
601
- function refShapeIsSafe(ref) {
602
- if (!ref || ref.length > MAX_REF_LENGTH) return false;
603
- if (ref.includes("..")) return false;
604
- return REF_SHAPE.test(ref);
605
- }
606
- function localRevShapeIsSafe(rev) {
607
- if (!rev || rev.length > MAX_REF_LENGTH) return false;
608
- if (rev.includes("..")) return false;
609
- return /^[A-Za-z0-9][A-Za-z0-9._/^~-]*$/.test(rev);
610
- }
611
- async function refIsWellFormed(ref) {
612
- if (!refShapeIsSafe(ref)) return false;
613
- const checked = await git(["check-ref-format", "--allow-onelevel", ref]);
614
- return checked.ok;
615
- }
616
- function filePathIsSafe(file) {
617
- const path = file.replace(/^\.\//, "");
618
- if (!path || path.startsWith("-") || path.includes("\0")) return false;
619
- return !path.split("/").includes("..");
620
- }
621
- var DEFAULT_PROTOCOLS = ["https", "ssh", "git"];
622
- function allowedProtocols() {
623
- const raw = process.env["STRAUSS_KB_REPO_PROTOCOLS"];
624
- if (raw === void 0) return [...DEFAULT_PROTOCOLS];
625
- const listed = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
626
- return listed.length ? listed : [...DEFAULT_PROTOCOLS];
627
- }
628
- function isShortRepoName(repo) {
629
- return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(repo.trim());
630
- }
631
- var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
632
- var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
633
- var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
634
- function repoUrlIsSafe(repo) {
635
- const url = repo.trim();
636
- if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
637
- const allowed = allowedProtocols();
638
- if (SCP_LIKE.test(url)) return allowed.includes("ssh");
639
- const scheme = URL_SCHEME.exec(url);
640
- if (!scheme?.[1]) return false;
641
- if (!allowed.includes(scheme[1].toLowerCase())) return false;
642
- const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
643
- const at2 = authority.lastIndexOf("@");
644
- return at2 < 0 || !authority.slice(0, at2).includes(":");
153
+ var SCP_LIKE = /^[\w.-]+@[\w.-]+:(?!\/)\S+$/;
154
+ var URL_SCHEME = /^([A-Za-z0-9+.-]+):\/\//;
155
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
156
+ function repoUrlIsSafe(repo) {
157
+ const url = repo.trim();
158
+ if (!url || url.startsWith("-") || CONTROL_CHARS.test(url)) return false;
159
+ const allowed = allowedProtocols();
160
+ if (SCP_LIKE.test(url)) return allowed.includes("ssh");
161
+ const scheme = URL_SCHEME.exec(url);
162
+ if (!scheme?.[1]) return false;
163
+ if (!allowed.includes(scheme[1].toLowerCase())) return false;
164
+ const authority = url.slice(scheme[0].length).split("/")[0] ?? "";
165
+ const at2 = authority.lastIndexOf("@");
166
+ return at2 < 0 || !authority.slice(0, at2).includes(":");
645
167
  }
646
168
  function protocolArgs() {
647
169
  const allowed = allowedProtocols();
@@ -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
@@ -1967,321 +1489,908 @@ function distanceToParent(lines, index2, parent) {
1967
1489
  for (let at2 = index2; at2 >= floor; at2--) {
1968
1490
  if (parent.test(lines[at2] ?? "")) return index2 - at2;
1969
1491
  }
1970
- return Number.POSITIVE_INFINITY;
1492
+ return Number.POSITIVE_INFINITY;
1493
+ }
1494
+ function hashAnchorText(text) {
1495
+ return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
1496
+ }
1497
+ function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1498
+ const normalized = source.replace(/\r\n/g, "\n");
1499
+ if (anchor.span) return sliceSpan(normalized, anchor.span);
1500
+ if (!anchor.symbol) {
1501
+ const lines = normalized.split("\n");
1502
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1503
+ return {
1504
+ ok: true,
1505
+ span: {
1506
+ text: normalized,
1507
+ startLine: 1,
1508
+ endLine: Math.max(1, lines.length)
1509
+ }
1510
+ };
1511
+ }
1512
+ let afterParsedMiss = false;
1513
+ for (const resolver of resolvers) {
1514
+ const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file, {
1515
+ afterParsedMiss
1516
+ }) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1517
+ if (attempt.kind === "abstain") continue;
1518
+ if (attempt.kind === "unresolved") {
1519
+ if (attempt.reason === "symbol-not-found") {
1520
+ if (resolver.attempt) afterParsedMiss = true;
1521
+ continue;
1522
+ }
1523
+ return { ok: false, reason: attempt.reason };
1524
+ }
1525
+ const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
1526
+ return {
1527
+ ok: true,
1528
+ span: attempt.span,
1529
+ ...isResolverName(resolver.name) ? { resolver: resolver.name } : {},
1530
+ ...tokens2 ? { normalized: tokens2 } : {}
1531
+ };
1532
+ }
1533
+ return { ok: false, reason: "symbol-not-found" };
1534
+ }
1535
+ function sliceSpan(source, range) {
1536
+ const lines = source.split("\n");
1537
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1538
+ if (range.end > lines.length) {
1539
+ return { ok: false, reason: "span-out-of-range" };
1540
+ }
1541
+ return {
1542
+ ok: true,
1543
+ span: {
1544
+ text: lines.slice(range.start - 1, range.end).join("\n"),
1545
+ startLine: range.start,
1546
+ endLine: range.end
1547
+ },
1548
+ resolver: "span"
1549
+ };
1550
+ }
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
+ };
1724
+ }
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
+ };
1736
+ }
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)
1745
+ );
1746
+ }
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
+ });
1765
+ }
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
+ });
1786
+ }
1787
+
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;
1996
+ }
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}`;
1971
2006
  }
1972
- function hashAnchorText(text) {
1973
- return `sha256:${(0, import_node_crypto3.createHash)("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex")}`;
2007
+ var FIELD_CAP = 120;
2008
+ function cap(value) {
2009
+ return value.length > FIELD_CAP ? `${value.slice(0, FIELD_CAP - 1)}\u2026` : value;
1974
2010
  }
1975
- function resolveAnchorSpan(source, anchor, resolvers = [regexResolver]) {
1976
- const normalized = source.replace(/\r\n/g, "\n");
1977
- if (anchor.span) return sliceSpan(normalized, anchor.span);
1978
- if (!anchor.symbol) {
1979
- const lines = normalized.split("\n");
1980
- if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1981
- return {
1982
- ok: true,
1983
- span: {
1984
- text: normalized,
1985
- startLine: 1,
1986
- endLine: Math.max(1, lines.length)
1987
- }
1988
- };
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;
1989
2023
  }
1990
- let afterParsedMiss = false;
1991
- for (const resolver of resolvers) {
1992
- const attempt = resolver.attempt ? resolver.attempt(normalized, anchor.symbol, anchor.file, {
1993
- afterParsedMiss
1994
- }) : fromResolve(resolver, normalized, anchor.symbol, anchor.file);
1995
- if (attempt.kind === "abstain") continue;
1996
- if (attempt.kind === "unresolved") {
1997
- if (attempt.reason === "symbol-not-found") {
1998
- if (resolver.attempt) afterParsedMiss = true;
1999
- continue;
2000
- }
2001
- return { ok: false, reason: attempt.reason };
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)));
2002
2043
  }
2003
- const tokens2 = resolver.normalize?.(attempt.span.text, anchor.file);
2004
- return {
2005
- ok: true,
2006
- span: attempt.span,
2007
- ...isResolverName(resolver.name) ? { resolver: resolver.name } : {},
2008
- ...tokens2 ? { normalized: tokens2 } : {}
2009
- };
2044
+ seen.add(key2);
2010
2045
  }
2011
- return { ok: false, reason: "symbol-not-found" };
2046
+ return { anchors, changes: diff(current, anchors) };
2012
2047
  }
2013
- function sliceSpan(source, range) {
2014
- const lines = source.split("\n");
2015
- if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
2016
- if (range.end > lines.length) {
2017
- return { ok: false, reason: "span-out-of-range" };
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;
2067
+ }
2068
+ if (beforeLocators.has(locatorKey(anchor))) continue;
2069
+ changes.push({ op: "add", to: locatorOf(anchor) });
2018
2070
  }
2019
- return {
2020
- ok: true,
2021
- span: {
2022
- text: lines.slice(range.start - 1, range.end).join("\n"),
2023
- startLine: range.start,
2024
- endLine: range.end
2025
- },
2026
- resolver: "span"
2027
- };
2028
- }
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";
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;
2035
2077
  }
2036
- async function prepareResolvers(resolvers, files) {
2037
- for (const resolver of resolvers) await resolver.prepare?.(files);
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
+ )
2085
+ );
2038
2086
  }
2039
- function defaultAnchorResolvers(grammars = {}) {
2040
- return [new TreeSitterResolver(grammars), regexResolver];
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
+ ]);
2041
2096
  }
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
2049
- );
2050
- return before !== null && hashAnchorText(before.text) === anchor.hash;
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);
2051
2163
  }
2052
- function anchorHashOf(anchor, outcome) {
2053
- if (outcome.resolver === "span") {
2054
- return { hash: hashAnchorText(outcome.span.text), kind: "raw" };
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
2055
2214
  }
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" };
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);
2059
2221
  }
2060
2222
 
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
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(", ")}`
2073
2282
  );
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 }))
2283
+ }
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
2293
+ };
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`
2079
2313
  );
2080
2314
  }
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
- }
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}`);
2087
2322
  }
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);
2096
- }
2323
+ if (!blocks.length) blocks.push(parsed.why);
2324
+ for (const related of parsed.relatedConceptIds ?? []) {
2325
+ blocks.push(`Relates to [${related}](${related}.md).`);
2097
2326
  }
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));
2122
- }
2123
- if (entries.length) drift.set(record.conceptId, entries);
2327
+ for (const link2 of parsed.links ?? []) {
2328
+ blocks.push(
2329
+ `${LINK_RELS[link2.rel].phrase} [${link2.target}](${link2.target}.md).`
2330
+ );
2124
2331
  }
2125
- return drift;
2126
- }
2127
- function atRefKey(anchor) {
2128
- return `${anchor.ref ?? ""}\0${anchor.file}`;
2129
- }
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))
2140
- );
2141
- return new Map(keys.map((key2, at2) => [key2, results[at2]]));
2142
- }
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;
2148
- }
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
- };
2156
- }
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
- };
2166
- }
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;
2332
+ if (parsed.sources?.length) {
2333
+ blocks.push(
2334
+ parsed.sources.map((source) => `[^${source.id}]: ${source.title ?? source.resource}`).join("\n")
2335
+ );
2176
2336
  }
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
- }
2195
- };
2196
- }
2197
- function resolverExtras(source, anchor, current) {
2198
- return {
2199
- ...current.resolver ? { resolver: current.resolver } : {},
2200
- ...current.hash !== anchor.hash && resolverChanged(source, anchor, current.resolver) ? { reason: "resolver-changed" } : {}
2201
- };
2202
- }
2203
- function compared(anchor, current, extra = {}) {
2204
- const matched = current.hash === anchor.hash;
2205
2337
  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
2338
+ type,
2339
+ slug: parsed.slug,
2340
+ frontmatter,
2341
+ body: `${blocks.join("\n\n")}
2342
+ `
2213
2343
  };
2214
2344
  }
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)
2223
- );
2224
- }
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
- });
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
+ );
2369
+ }
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
2381
+ );
2382
+ }
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.ts
2393
+ var import_zod7 = require("zod");
2285
2394
 
2286
2395
  // src/kb-errors.ts
2287
2396
  var KbRecordAlreadyExistsError = class extends BaseError {
@@ -2348,6 +2457,38 @@ var KbSelfVerificationError = class extends BaseError {
2348
2457
  actor;
2349
2458
  generatedBy;
2350
2459
  };
2460
+ var KbInvalidActorError = class extends BaseError {
2461
+ constructor(actor, reason) {
2462
+ super({
2463
+ message: `kb: actor ${JSON.stringify(actor)} ${reason} \u2014 set STRAUSS_KB_ACTOR, e.g. human:alice or agent:reviewer`,
2464
+ errorType: "KbInvalidActor" /* KbInvalidActor */,
2465
+ code: 400,
2466
+ fault: "User" /* User */,
2467
+ retriable: false,
2468
+ reportToUser: true,
2469
+ details: { actor, reason, action: "refused" }
2470
+ });
2471
+ this.actor = actor;
2472
+ this.reason = reason;
2473
+ }
2474
+ actor;
2475
+ reason;
2476
+ };
2477
+ var KbFlagConflictError = class extends BaseError {
2478
+ constructor(flags) {
2479
+ super({
2480
+ message: `kb: ${flags.join(" and ")} cannot be combined`,
2481
+ errorType: "KbFlagConflict" /* KbFlagConflict */,
2482
+ code: 400,
2483
+ fault: "User" /* User */,
2484
+ retriable: false,
2485
+ reportToUser: true,
2486
+ details: { flags }
2487
+ });
2488
+ this.flags = flags;
2489
+ }
2490
+ flags;
2491
+ };
2351
2492
  var KbPackBudgetExceededError = class extends BaseError {
2352
2493
  constructor(recordCount, approxTokens2, budgetTokens, excluded) {
2353
2494
  super({
@@ -2891,19 +3032,11 @@ function argvPositional(argv, ...names) {
2891
3032
  }
2892
3033
 
2893
3034
  // src/commands/anchor-resolve.ts
2894
- function resolverSummary(results) {
2895
- const names = [
2896
- ...new Set(
2897
- results.flatMap((entry) => entry.resolver ? [entry.resolver] : [])
2898
- )
2899
- ].sort();
2900
- return names.length ? `${names.join(" + ")} resolver` : "whole-file";
2901
- }
2902
3035
  var anchorResolveCommand = define({
2903
3036
  name: "anchor-resolve",
2904
3037
  tool: "kb_anchor_resolve",
2905
- usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp]",
2906
- description: "Resolve a record's anchors: stamp a hash onto anchors that lack one, report drift where the code moved. An anchor naming another repository is read from that remote through a bare cache; --offline uses the cache only. kb_verify's mechanical counterpart \u2014 reach for it when the question is whether the code still is what it was. Exits non-zero on drift.",
3038
+ usage: "anchor-resolve <concept-id> [--repo-root <path>] [--offline] [--rebaseline] [--restamp] [--check]",
3039
+ 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.",
2907
3040
  input: import_zod7.z.object({
2908
3041
  bundlePath,
2909
3042
  conceptId,
@@ -2916,6 +3049,9 @@ var anchorResolveCommand = define({
2916
3049
  ),
2917
3050
  restamp: import_zod7.z.boolean().optional().describe(
2918
3051
  "Refresh `resolved_at` on anchors that already match. Off by default, so a green run writes nothing."
3052
+ ),
3053
+ check: import_zod7.z.boolean().optional().describe(
3054
+ "Resolve and report only: no hash, no `resolved_at`, no log entry."
2919
3055
  )
2920
3056
  }),
2921
3057
  fromArgv: (argv, path) => ({
@@ -2924,9 +3060,24 @@ var anchorResolveCommand = define({
2924
3060
  repoRoot: argvFlag(argv, "--repo-root"),
2925
3061
  offline: argv.includes("--offline"),
2926
3062
  rebaseline: argv.includes("--rebaseline"),
2927
- restamp: argv.includes("--restamp")
3063
+ restamp: argv.includes("--restamp"),
3064
+ check: argv.includes("--check")
2928
3065
  }),
2929
- run: async ({ store, actor, now }, { bundlePath: path, conceptId: id, repoRoot, offline, rebaseline, restamp }) => {
3066
+ run: async ({ store, actor, now }, {
3067
+ bundlePath: path,
3068
+ conceptId: id,
3069
+ repoRoot,
3070
+ offline,
3071
+ rebaseline,
3072
+ restamp,
3073
+ check
3074
+ }) => {
3075
+ if (check && (rebaseline || restamp)) {
3076
+ throw new KbFlagConflictError([
3077
+ "check",
3078
+ rebaseline ? "rebaseline" : "restamp"
3079
+ ]);
3080
+ }
2930
3081
  const root = repoRoot ?? process.cwd();
2931
3082
  const record = await store.read(path, id);
2932
3083
  if (!record) throw new KbRecordNotFoundError(id);
@@ -2935,7 +3086,6 @@ var anchorResolveCommand = define({
2935
3086
  return {
2936
3087
  conceptId: id,
2937
3088
  results: [],
2938
- verified: false,
2939
3089
  note: "record has no anchors"
2940
3090
  };
2941
3091
  }
@@ -2993,7 +3143,7 @@ var anchorResolveCommand = define({
2993
3143
  if (!anchor.hash) {
2994
3144
  results.push({
2995
3145
  ...base2,
2996
- state: "stamped",
3146
+ state: check ? "unstamped" : "stamped",
2997
3147
  currentHash: stampedHash,
2998
3148
  hashKind: stampedKind,
2999
3149
  ...producedBy ? { resolver: producedBy } : {}
@@ -3045,7 +3195,7 @@ var anchorResolveCommand = define({
3045
3195
  if (refresh) dirty = true;
3046
3196
  }
3047
3197
  let frozen = false;
3048
- if (dirty) {
3198
+ if (dirty && !check) {
3049
3199
  try {
3050
3200
  await assertBaseNotFrozen(process.cwd(), path);
3051
3201
  } catch (error) {
@@ -3060,42 +3210,11 @@ var anchorResolveCommand = define({
3060
3210
  const unreachable = results.filter(
3061
3211
  (entry) => isUncheckedReason(entry.reason)
3062
3212
  ).length;
3063
- const checked = results.length - unreachable;
3064
3213
  const matches3 = results.filter((entry) => entry.state === "match").length;
3065
- const note = `${matches3}/${checked} anchors match${unreachable ? `, ${unreachable} unreachable` : ""}`;
3066
- const clean = checked > 0 && matches3 === checked && unreachable === 0;
3067
- if (clean) {
3068
- try {
3069
- await store.verify(
3070
- path,
3071
- id,
3072
- `anchor-resolve: ${note} (${resolverSummary(results)})`,
3073
- actor,
3074
- now()
3075
- );
3076
- } catch (error) {
3077
- if (!(error instanceof KbSelfVerificationError)) throw error;
3078
- return {
3079
- conceptId: id,
3080
- results,
3081
- verified: false,
3082
- verifyRefused: "self-verification",
3083
- ...frozenNote,
3084
- ...hintNote
3085
- };
3086
- }
3087
- return {
3088
- conceptId: id,
3089
- results,
3090
- verified: true,
3091
- ...frozenNote,
3092
- ...hintNote
3093
- };
3094
- }
3214
+ const note = `${matches3}/${results.length - unreachable} anchors match, ${unreachable} unreachable`;
3095
3215
  return {
3096
3216
  conceptId: id,
3097
3217
  results,
3098
- verified: false,
3099
3218
  ...unreachable ? { note } : {},
3100
3219
  ...frozenNote,
3101
3220
  ...hintNote
@@ -3182,14 +3301,117 @@ async function readSources(anchors, root, offline) {
3182
3301
  return sources;
3183
3302
  }
3184
3303
 
3185
- // src/commands/answer.ts
3304
+ // src/commands/anchor-set/model.ts
3186
3305
  var import_zod8 = require("zod");
3306
+ var anchorSetInputSchema = import_zod8.z.object({
3307
+ reason: import_zod8.z.string().refine((text) => text.trim().length > 0, {
3308
+ message: "reason must say what was reviewed"
3309
+ }).describe(
3310
+ "What the reviewer read that makes these the right pointers. Recorded in the log."
3311
+ ),
3312
+ anchors: import_zod8.z.array(kbAnchorWriteSchema).min(1).describe(
3313
+ "The complete new anchor set. Carry an anchor's hash forward to keep drift visible until the new code is read."
3314
+ )
3315
+ }).strict();
3316
+ var anchorSetCommandInput = import_zod8.z.object({
3317
+ bundlePath,
3318
+ conceptId,
3319
+ input: anchorSetInputSchema,
3320
+ resolve: import_zod8.z.boolean().optional().describe(
3321
+ "Also resolve and stamp every anchor against the current code, as anchor-resolve --rebaseline does."
3322
+ ),
3323
+ repoRoot: import_zod8.z.string().min(1).optional().describe(
3324
+ "Where the anchored source lives, for resolve. Defaults to the working directory."
3325
+ ),
3326
+ offline: import_zod8.z.boolean().optional().describe("With resolve, read foreign anchors from the repo cache only.")
3327
+ });
3328
+
3329
+ // src/commands/anchor-set/command.ts
3330
+ 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.";
3331
+ var STAMPED_NOTE = "pointers set and stamped against the current code. Not verification: run verify separately if someone reviewed it.";
3332
+ var anchorSetCommand = define({
3333
+ name: "anchor-set",
3334
+ tool: "kb_anchor_set",
3335
+ usage: "anchor-set <concept-id> [--resolve] [--repo-root <path>] [--offline] < anchors.json",
3336
+ 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.",
3337
+ input: anchorSetCommandInput,
3338
+ fromArgv: async (argv, path, stdin) => ({
3339
+ bundlePath: path,
3340
+ conceptId: argv[1],
3341
+ input: JSON.parse(await stdin()),
3342
+ resolve: argv.includes("--resolve"),
3343
+ repoRoot: argvFlag(argv, "--repo-root"),
3344
+ offline: argv.includes("--offline")
3345
+ }),
3346
+ run: async (ctx, { bundlePath: path, conceptId: id, input, resolve: resolve7, repoRoot, offline }) => {
3347
+ const { store, actor } = ctx;
3348
+ await assertBaseNotFrozen(process.cwd(), path);
3349
+ let applied;
3350
+ const record = await store.updateAnchors(
3351
+ path,
3352
+ id,
3353
+ (current) => {
3354
+ applied = applyAnchorSet(current, input.anchors);
3355
+ return {
3356
+ anchors: applied.anchors,
3357
+ log: {
3358
+ operation: "anchor-set",
3359
+ reason: input.reason,
3360
+ anchors: applied.changes
3361
+ }
3362
+ };
3363
+ },
3364
+ actor
3365
+ );
3366
+ const changes = applied?.changes ?? [];
3367
+ if (!resolve7) {
3368
+ return {
3369
+ conceptId: id,
3370
+ reason: input.reason,
3371
+ changes,
3372
+ anchors: record.frontmatter.strauss_anchors ?? [],
3373
+ baseline: "unchanged",
3374
+ note: NOTE
3375
+ };
3376
+ }
3377
+ const resolved = await anchorResolveCommand.run(
3378
+ ctx,
3379
+ anchorResolveCommand.input.parse({
3380
+ bundlePath: path,
3381
+ conceptId: id,
3382
+ rebaseline: true,
3383
+ ...repoRoot ? { repoRoot } : {},
3384
+ ...offline ? { offline } : {}
3385
+ })
3386
+ );
3387
+ const after = await store.read(path, id);
3388
+ return {
3389
+ conceptId: id,
3390
+ reason: input.reason,
3391
+ changes,
3392
+ anchors: after?.frontmatter.strauss_anchors ?? [],
3393
+ baseline: "stamped",
3394
+ resolved: resolved.results,
3395
+ note: STAMPED_NOTE
3396
+ };
3397
+ },
3398
+ // With `resolve`, a pointer that names nothing is a failed set, not a
3399
+ // finding to read later. A remote nothing could reach was never checked, so
3400
+ // it does not fail — the same line anchor-resolve draws.
3401
+ failsWhen: (result) => (result.resolved ?? []).some((entry) => {
3402
+ const { state, reason } = entry;
3403
+ return state === "unresolved" && !isUncheckedReason(reason);
3404
+ })
3405
+ });
3406
+
3407
+ // src/commands/answer.ts
3408
+ var import_zod9 = require("zod");
3187
3409
  var answerCommand = define({
3188
3410
  name: "answer",
3189
3411
  tool: "kb_answer",
3190
3412
  usage: "answer <concept-id> <answer...>",
3191
3413
  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.",
3192
- input: import_zod8.z.object({ bundlePath, conceptId, answer: import_zod8.z.string().min(1) }),
3414
+ input: import_zod9.z.object({ bundlePath, conceptId, answer: import_zod9.z.string().min(1) }),
3193
3415
  fromArgv: (argv, path) => ({
3194
3416
  bundlePath: path,
3195
3417
  conceptId: argv[1],
@@ -3203,19 +3425,19 @@ var answerCommand = define({
3203
3425
  });
3204
3426
 
3205
3427
  // src/commands/backlinks.ts
3206
- var import_zod9 = require("zod");
3428
+ var import_zod10 = require("zod");
3207
3429
  var backlinksCommand = define({
3208
3430
  name: "backlinks",
3209
3431
  tool: "kb_backlinks",
3210
3432
  usage: "backlinks <concept-id>",
3211
3433
  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.",
3212
- input: import_zod9.z.object({ bundlePath, conceptId }),
3434
+ input: import_zod10.z.object({ bundlePath, conceptId }),
3213
3435
  fromArgv: (argv, path) => ({ bundlePath: path, conceptId: argv[1] }),
3214
3436
  run: async ({ store }, { bundlePath: path, conceptId: id }) => store.backlinks(path, id)
3215
3437
  });
3216
3438
 
3217
3439
  // src/commands/catalog.ts
3218
- var import_zod10 = require("zod");
3440
+ var import_zod11 = require("zod");
3219
3441
 
3220
3442
  // src/adjudicate.ts
3221
3443
  var STANDING = {
@@ -3394,9 +3616,9 @@ var catalogCommand = define({
3394
3616
  tool: "kb_catalog",
3395
3617
  usage: "catalog [type] [--tag T]...",
3396
3618
  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.",
3397
- input: import_zod10.z.object({
3619
+ input: import_zod11.z.object({
3398
3620
  bundlePath,
3399
- type: import_zod10.z.enum(KB_RECORD_TYPES).optional(),
3621
+ type: import_zod11.z.enum(KB_RECORD_TYPES).optional(),
3400
3622
  tags: TAGS
3401
3623
  }),
3402
3624
  fromArgv: (argv, path) => {
@@ -3468,7 +3690,7 @@ function count(value, noun) {
3468
3690
  var import_node_buffer = require("buffer");
3469
3691
  var import_promises7 = require("fs/promises");
3470
3692
  var import_node_path10 = require("path");
3471
- var import_zod13 = require("zod");
3693
+ var import_zod14 = require("zod");
3472
3694
 
3473
3695
  // src/match-diff.ts
3474
3696
  function matchToDiff(files, records, options = {}) {
@@ -4072,7 +4294,7 @@ function claimOf(record) {
4072
4294
  }
4073
4295
 
4074
4296
  // src/commands/match/command.ts
4075
- var import_zod12 = require("zod");
4297
+ var import_zod13 = require("zod");
4076
4298
 
4077
4299
  // src/commands/match/errors.ts
4078
4300
  var KbMatchInputError = class extends BaseError {
@@ -4092,21 +4314,21 @@ var KbMatchInputError = class extends BaseError {
4092
4314
  };
4093
4315
 
4094
4316
  // src/commands/match/model.ts
4095
- var import_zod11 = require("zod");
4096
- var diffHunkSchema = import_zod11.z.object({
4097
- startLine: import_zod11.z.number().int().positive(),
4098
- endLine: import_zod11.z.number().int().positive(),
4099
- side: import_zod11.z.enum(["old", "new"]).optional()
4317
+ var import_zod12 = require("zod");
4318
+ var diffHunkSchema = import_zod12.z.object({
4319
+ startLine: import_zod12.z.number().int().positive(),
4320
+ endLine: import_zod12.z.number().int().positive(),
4321
+ side: import_zod12.z.enum(["old", "new"]).optional()
4100
4322
  }).passthrough();
4101
- var diffFileSchema = import_zod11.z.object({
4102
- filePath: import_zod11.z.string().min(1).describe("Repo-relative, spelled the way anchors are."),
4103
- hunks: import_zod11.z.array(diffHunkSchema)
4323
+ var diffFileSchema = import_zod12.z.object({
4324
+ filePath: import_zod12.z.string().min(1).describe("Repo-relative, spelled the way anchors are."),
4325
+ hunks: import_zod12.z.array(diffHunkSchema)
4104
4326
  });
4105
- var symbolRangeSchema = import_zod11.z.object({
4106
- file: import_zod11.z.string().min(1),
4107
- symbol: import_zod11.z.string().min(1),
4108
- startLine: import_zod11.z.number().int().positive(),
4109
- endLine: import_zod11.z.number().int().positive()
4327
+ var symbolRangeSchema = import_zod12.z.object({
4328
+ file: import_zod12.z.string().min(1),
4329
+ symbol: import_zod12.z.string().min(1),
4330
+ startLine: import_zod12.z.number().int().positive(),
4331
+ endLine: import_zod12.z.number().int().positive()
4110
4332
  });
4111
4333
 
4112
4334
  // src/commands/match/parse-unified-diff.ts
@@ -4355,17 +4577,17 @@ var matchCommand = define({
4355
4577
  tool: "kb_match",
4356
4578
  usage: "match --git <base>..<head> | --stdin [--repo-root <path>] [--offline] [--include-non-current]",
4357
4579
  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.",
4358
- input: import_zod12.z.object({
4580
+ input: import_zod13.z.object({
4359
4581
  bundlePath,
4360
- files: import_zod12.z.array(diffFileSchema).describe("The changed files, each with its post-change line ranges."),
4361
- symbolRanges: import_zod12.z.array(symbolRangeSchema).optional().describe(
4582
+ files: import_zod13.z.array(diffFileSchema).describe("The changed files, each with its post-change line ranges."),
4583
+ symbolRanges: import_zod13.z.array(symbolRangeSchema).optional().describe(
4362
4584
  "Symbol spans the caller already has. Resolved from repoRoot when omitted."
4363
4585
  ),
4364
4586
  repoRoot: REPO_ROOT,
4365
- offline: import_zod12.z.boolean().optional().describe(
4587
+ offline: import_zod13.z.boolean().optional().describe(
4366
4588
  "Resolve symbol ranges from what is already on disk, never fetching a grammar."
4367
4589
  ),
4368
- includeNonCurrent: import_zod12.z.boolean().optional().describe(
4590
+ includeNonCurrent: import_zod13.z.boolean().optional().describe(
4369
4591
  "Return superseded, rejected and unsettled records too, each carrying its standing."
4370
4592
  )
4371
4593
  }),
@@ -4379,11 +4601,11 @@ var matchCommand = define({
4379
4601
  ...argv.includes("--include-non-current") ? { includeNonCurrent: true } : {}
4380
4602
  };
4381
4603
  if (range !== void 0) {
4382
- const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
4383
- if (!diff.ok) {
4384
- throw new KbMatchInputError(`--git ${range} ${REFUSED[diff.reason]}`);
4604
+ const diff2 = await readRangeDiff(repoRoot ?? process.cwd(), range);
4605
+ if (!diff2.ok) {
4606
+ throw new KbMatchInputError(`--git ${range} ${REFUSED[diff2.reason]}`);
4385
4607
  }
4386
- return { ...base2, files: parseUnifiedDiff(diff.text) };
4608
+ return { ...base2, files: parseUnifiedDiff(diff2.text) };
4387
4609
  }
4388
4610
  if (!argv.includes("--stdin")) {
4389
4611
  throw new KbMatchInputError(
@@ -4469,22 +4691,22 @@ function project(match, ranges, all) {
4469
4691
 
4470
4692
  // src/commands/classify.ts
4471
4693
  var classifyFileSchema = diffFileSchema.extend({
4472
- hunks: import_zod13.z.array(
4473
- diffHunkSchema.extend({ lines: import_zod13.z.array(import_zod13.z.string()).optional() })
4694
+ hunks: import_zod14.z.array(
4695
+ diffHunkSchema.extend({ lines: import_zod14.z.array(import_zod14.z.string()).optional() })
4474
4696
  ),
4475
- renamedFrom: import_zod13.z.string().min(1).optional().describe("Where `git diff -M` says the path came from."),
4476
- similarity: import_zod13.z.number().min(0).max(100).optional()
4697
+ renamedFrom: import_zod14.z.string().min(1).optional().describe("Where `git diff -M` says the path came from."),
4698
+ similarity: import_zod14.z.number().min(0).max(100).optional()
4477
4699
  });
4478
4700
  var classifyCommand = define({
4479
4701
  name: "classify",
4480
4702
  tool: "kb_classify",
4481
4703
  usage: "classify --git <base>..<head> | --stdin [--repo-root <path>] [--offline]",
4482
4704
  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.",
4483
- input: import_zod13.z.object({
4705
+ input: import_zod14.z.object({
4484
4706
  bundlePath,
4485
- files: import_zod13.z.array(classifyFileSchema).describe("The changed files, each with its line ranges."),
4707
+ files: import_zod14.z.array(classifyFileSchema).describe("The changed files, each with its line ranges."),
4486
4708
  repoRoot: REPO_ROOT,
4487
- offline: import_zod13.z.boolean().optional().describe(
4709
+ offline: import_zod14.z.boolean().optional().describe(
4488
4710
  "Resolve symbol ranges from what is already on disk, never fetching a grammar."
4489
4711
  )
4490
4712
  }),
@@ -4497,15 +4719,15 @@ var classifyCommand = define({
4497
4719
  ...argv.includes("--offline") ? { offline: true } : {}
4498
4720
  };
4499
4721
  if (range !== void 0) {
4500
- const diff = await readRangeDiff(repoRoot ?? process.cwd(), range);
4501
- if (!diff.ok) {
4722
+ const diff2 = await readRangeDiff(repoRoot ?? process.cwd(), range);
4723
+ if (!diff2.ok) {
4502
4724
  throw new KbClassifyInputError(
4503
- `--git ${range} ${REFUSED2[diff.reason]}`
4725
+ `--git ${range} ${REFUSED2[diff2.reason]}`
4504
4726
  );
4505
4727
  }
4506
4728
  return {
4507
4729
  ...base2,
4508
- files: parseUnifiedDiff(diff.text, {
4730
+ files: parseUnifiedDiff(diff2.text, {
4509
4731
  keepEmpty: true,
4510
4732
  withLines: true
4511
4733
  })
@@ -4595,7 +4817,7 @@ function renderClassify(result) {
4595
4817
  }
4596
4818
 
4597
4819
  // src/commands/context.ts
4598
- var import_zod14 = require("zod");
4820
+ var import_zod15 = require("zod");
4599
4821
 
4600
4822
  // src/kb-context.ts
4601
4823
  var import_promises8 = require("fs/promises");
@@ -4866,23 +5088,23 @@ var contextCommand = define({
4866
5088
  tool: "kb_context",
4867
5089
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--exclude-tag T]... [--format json] [--event NAME]",
4868
5090
  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.",
4869
- input: import_zod14.z.object({
4870
- budgetTokens: import_zod14.z.number().int().positive().optional().describe(
5091
+ input: import_zod15.z.object({
5092
+ budgetTokens: import_zod15.z.number().int().positive().optional().describe(
4871
5093
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
4872
5094
  ),
4873
- fullUnderTokens: import_zod14.z.number().int().positive().optional().describe(
5095
+ fullUnderTokens: import_zod15.z.number().int().positive().optional().describe(
4874
5096
  "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."
4875
5097
  ),
4876
- profile: import_zod14.z.string().optional().describe(
5098
+ profile: import_zod15.z.string().optional().describe(
4877
5099
  "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."
4878
5100
  ),
4879
- excludeTags: import_zod14.z.array(import_zod14.z.string().min(1)).optional().describe(
5101
+ excludeTags: import_zod15.z.array(import_zod15.z.string().min(1)).optional().describe(
4880
5102
  "Frontmatter tags whose records stay out of the block. The base stays pinned and stays readable by tool; resolved like the budgets."
4881
5103
  ),
4882
- format: import_zod14.z.enum(["markdown", "json"]).optional().describe(
5104
+ format: import_zod15.z.enum(["markdown", "json"]).optional().describe(
4883
5105
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
4884
5106
  ),
4885
- event: import_zod14.z.string().optional().describe(
5107
+ event: import_zod15.z.string().optional().describe(
4886
5108
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
4887
5109
  )
4888
5110
  }),
@@ -4921,7 +5143,7 @@ var contextCommand = define({
4921
5143
  });
4922
5144
 
4923
5145
  // src/commands/doctor.ts
4924
- var import_zod16 = require("zod");
5146
+ var import_zod17 = require("zod");
4925
5147
 
4926
5148
  // src/kb-edges.ts
4927
5149
  var KB_EDGE_KINDS = [
@@ -5437,17 +5659,17 @@ function ageInDays(record, now) {
5437
5659
  }
5438
5660
 
5439
5661
  // src/commands/reassess.ts
5440
- var import_zod15 = require("zod");
5662
+ var import_zod16 = require("zod");
5441
5663
  var reassessCommand = define({
5442
5664
  name: "reassess",
5443
5665
  tool: "kb_reassess",
5444
5666
  usage: "reassess <concept-id> [--repo-root <path>] [--with-diff]",
5445
5667
  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.",
5446
- input: import_zod15.z.object({
5668
+ input: import_zod16.z.object({
5447
5669
  bundlePath,
5448
5670
  conceptId,
5449
5671
  repoRoot: REPO_ROOT,
5450
- withDiff: import_zod15.z.boolean().optional().describe(
5672
+ withDiff: import_zod16.z.boolean().optional().describe(
5451
5673
  "Recover each anchor's committed span and render the diff. Reads git history."
5452
5674
  )
5453
5675
  }),
@@ -5592,13 +5814,13 @@ function at(file, symbol) {
5592
5814
  }
5593
5815
 
5594
5816
  // src/commands/doctor.ts
5595
- var days = (what, fallback) => import_zod16.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5817
+ var days = (what, fallback) => import_zod17.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
5596
5818
  var doctorCommand = define({
5597
5819
  name: "doctor",
5598
5820
  tool: "kb_doctor",
5599
5821
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--repo-root PATH] [--offline] [--strict] [--drifted [--with-diff]]",
5600
5822
  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.",
5601
- input: import_zod16.z.object({
5823
+ input: import_zod17.z.object({
5602
5824
  bundlePath,
5603
5825
  repoRoot: REPO_ROOT,
5604
5826
  expiringDays: days(
@@ -5613,16 +5835,16 @@ var doctorCommand = define({
5613
5835
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
5614
5836
  DEFAULT_AGING_DAYS
5615
5837
  ),
5616
- offline: import_zod16.z.boolean().optional().describe(
5838
+ offline: import_zod17.z.boolean().optional().describe(
5617
5839
  "Read foreign anchors from the local repo cache only, never fetching."
5618
5840
  ),
5619
- strict: import_zod16.z.boolean().optional().describe(
5841
+ strict: import_zod17.z.boolean().optional().describe(
5620
5842
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
5621
5843
  ),
5622
- drifted: import_zod16.z.boolean().optional().describe(
5844
+ drifted: import_zod17.z.boolean().optional().describe(
5623
5845
  "Report only drift, as a reassessment packet per record: claim, per-anchor class, and what depends on it."
5624
5846
  ),
5625
- withDiff: import_zod16.z.boolean().optional().describe(
5847
+ withDiff: import_zod17.z.boolean().optional().describe(
5626
5848
  "With `drifted`: recover each anchor's committed span and render the old-vs-new diff. Reads git history."
5627
5849
  )
5628
5850
  }),
@@ -5799,7 +6021,7 @@ function renderPackets(result) {
5799
6021
  // src/commands/export.ts
5800
6022
  var import_promises9 = require("fs/promises");
5801
6023
  var import_node_path11 = require("path");
5802
- var import_zod17 = require("zod");
6024
+ var import_zod18 = require("zod");
5803
6025
  var NUMBERED = /^(\d{4})-(.+)\.md$/;
5804
6026
  var MARKER = "<!-- strauss-kb export: ";
5805
6027
  var exportCommand = define({
@@ -5807,10 +6029,10 @@ var exportCommand = define({
5807
6029
  tool: "kb_export",
5808
6030
  usage: "export --format madr --to <dir>",
5809
6031
  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.",
5810
- input: import_zod17.z.object({
6032
+ input: import_zod18.z.object({
5811
6033
  bundlePath,
5812
- format: import_zod17.z.enum(["madr"]).describe("Output layout. `madr` is the only one so far."),
5813
- to: import_zod17.z.string().min(1).describe("Directory the ADR files are written into.")
6034
+ format: import_zod18.z.enum(["madr"]).describe("Output layout. `madr` is the only one so far."),
6035
+ to: import_zod18.z.string().min(1).describe("Directory the ADR files are written into.")
5814
6036
  }),
5815
6037
  fromArgv: (argv, path) => ({
5816
6038
  bundlePath: path,
@@ -5932,19 +6154,19 @@ function bodySections(body) {
5932
6154
  }
5933
6155
 
5934
6156
  // src/commands/impact.ts
5935
- var import_zod18 = require("zod");
6157
+ var import_zod19 = require("zod");
5936
6158
  var impactCommand = define({
5937
6159
  name: "impact",
5938
6160
  tool: "kb_impact",
5939
6161
  usage: "impact <concept-id> [--depth N] [--rels a,b]",
5940
6162
  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.",
5941
- input: import_zod18.z.object({
6163
+ input: import_zod19.z.object({
5942
6164
  bundlePath,
5943
6165
  conceptId,
5944
- depth: import_zod18.z.number().int().positive().optional().describe(
6166
+ depth: import_zod19.z.number().int().positive().optional().describe(
5945
6167
  "Hops out from the record. Unbounded when omitted; a walk this cuts reports truncated: true."
5946
6168
  ),
5947
- rels: import_zod18.z.array(import_zod18.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
6169
+ rels: import_zod19.z.array(import_zod19.z.enum(KB_CAUSAL_LINK_RELS)).optional().describe(
5948
6170
  "Narrow which rels the walk follows. Defaults to every rel that carries a dependence \u2014 all but related_to."
5949
6171
  )
5950
6172
  }),
@@ -5965,15 +6187,15 @@ var impactCommand = define({
5965
6187
  });
5966
6188
 
5967
6189
  // src/commands/list.ts
5968
- var import_zod19 = require("zod");
6190
+ var import_zod20 = require("zod");
5969
6191
  var listCommand = define({
5970
6192
  name: "list",
5971
6193
  tool: "kb_list",
5972
6194
  usage: "list [type] [--tag T]...",
5973
6195
  description: "Every record, optionally one type or tag. For enumerating; use kb_query for a question.",
5974
- input: import_zod19.z.object({
6196
+ input: import_zod20.z.object({
5975
6197
  bundlePath,
5976
- type: import_zod19.z.enum(KB_RECORD_TYPES).optional(),
6198
+ type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
5977
6199
  tags: TAGS
5978
6200
  }),
5979
6201
  fromArgv: (argv, path) => {
@@ -5997,17 +6219,17 @@ var listCommand = define({
5997
6219
  });
5998
6220
 
5999
6221
  // src/commands/load.ts
6000
- var import_zod20 = require("zod");
6222
+ var import_zod21 = require("zod");
6001
6223
  var loadCommand = define({
6002
6224
  name: "load",
6003
6225
  tool: "kb_load",
6004
6226
  usage: "load [type] [--budget N | --all] [--repo-root PATH]",
6005
6227
  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.",
6006
- input: import_zod20.z.object({
6228
+ input: import_zod21.z.object({
6007
6229
  bundlePath,
6008
- type: import_zod20.z.enum(KB_RECORD_TYPES).optional(),
6009
- budgetTokens: import_zod20.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6010
- all: import_zod20.z.boolean().optional().describe(
6230
+ type: import_zod21.z.enum(KB_RECORD_TYPES).optional(),
6231
+ budgetTokens: import_zod21.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
6232
+ all: import_zod21.z.boolean().optional().describe(
6011
6233
  "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
6012
6234
  ),
6013
6235
  repoRoot: REPO_ROOT
@@ -6049,25 +6271,25 @@ var loadCommand = define({
6049
6271
  });
6050
6272
 
6051
6273
  // src/commands/log.ts
6052
- var import_zod21 = require("zod");
6274
+ var import_zod22 = require("zod");
6053
6275
  var logCommand = define({
6054
6276
  name: "log",
6055
6277
  tool: "kb_log",
6056
6278
  usage: "log",
6057
6279
  description: "Who touched what, and when. Append-only; malformed lines are reported, never repaired.",
6058
- input: import_zod21.z.object({ bundlePath }),
6280
+ input: import_zod22.z.object({ bundlePath }),
6059
6281
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6060
6282
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
6061
6283
  });
6062
6284
 
6063
6285
  // src/commands/no-decision.ts
6064
- var import_zod22 = require("zod");
6286
+ var import_zod23 = require("zod");
6065
6287
  var noDecisionCommand = define({
6066
6288
  name: "no-decision",
6067
6289
  tool: "kb_no_decision",
6068
6290
  usage: "no-decision <reason...>",
6069
6291
  description: "Record in one sentence that a piece of work had nothing to decide. Idempotent.",
6070
- input: import_zod22.z.object({ bundlePath, reason: import_zod22.z.string().min(1) }),
6292
+ input: import_zod23.z.object({ bundlePath, reason: import_zod23.z.string().min(1) }),
6071
6293
  fromArgv: (argv, path) => ({
6072
6294
  bundlePath: path,
6073
6295
  reason: argv.slice(1).join(" ").trim()
@@ -6084,20 +6306,20 @@ var noDecisionCommand = define({
6084
6306
  });
6085
6307
 
6086
6308
  // src/commands/pack.ts
6087
- var import_zod23 = require("zod");
6309
+ var import_zod24 = require("zod");
6088
6310
  var packCommand = define({
6089
6311
  name: "pack",
6090
6312
  tool: "kb_pack",
6091
6313
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
6092
6314
  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.",
6093
- input: import_zod23.z.object({
6315
+ input: import_zod24.z.object({
6094
6316
  bundlePath,
6095
6317
  conceptId,
6096
- hops: import_zod23.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6097
- maxNodes: import_zod23.z.number().int().positive().optional().describe(
6318
+ hops: import_zod24.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
6319
+ maxNodes: import_zod24.z.number().int().positive().optional().describe(
6098
6320
  "How many records the pack may hold, root included. Defaults to 20."
6099
6321
  ),
6100
- budgetTokens: import_zod23.z.number().int().positive().optional().describe(
6322
+ budgetTokens: import_zod24.z.number().int().positive().optional().describe(
6101
6323
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
6102
6324
  )
6103
6325
  }),
@@ -6184,22 +6406,22 @@ function warningLabel(warning) {
6184
6406
  }
6185
6407
 
6186
6408
  // src/commands/pin.ts
6187
- var import_zod24 = require("zod");
6409
+ var import_zod25 = require("zod");
6188
6410
  var pinCommand = define({
6189
6411
  name: "pin",
6190
6412
  tool: "kb_pin",
6191
6413
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
6192
6414
  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.",
6193
- input: import_zod24.z.object({
6415
+ input: import_zod25.z.object({
6194
6416
  bundlePath,
6195
- mode: import_zod24.z.enum(["full", "index"]).optional().describe(
6417
+ mode: import_zod25.z.enum(["full", "index"]).optional().describe(
6196
6418
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
6197
6419
  ),
6198
- profiles: import_zod24.z.array(import_zod24.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6199
- layer: import_zod24.z.enum(["project", "local", "user"]).optional().describe(
6420
+ profiles: import_zod25.z.array(import_zod25.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
6421
+ layer: import_zod25.z.enum(["project", "local", "user"]).optional().describe(
6200
6422
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
6201
6423
  ),
6202
- frozen: import_zod24.z.boolean().optional().describe(
6424
+ frozen: import_zod25.z.boolean().optional().describe(
6203
6425
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
6204
6426
  )
6205
6427
  }),
@@ -6228,13 +6450,13 @@ var pinCommand = define({
6228
6450
  });
6229
6451
 
6230
6452
  // src/commands/pins.ts
6231
- var import_zod25 = require("zod");
6453
+ var import_zod26 = require("zod");
6232
6454
  var pinsCommand = define({
6233
6455
  name: "pins",
6234
6456
  tool: "kb_pins",
6235
6457
  usage: "pins",
6236
6458
  description: "Every pinned base across the manifest layers, with its layer and whether it resolves to records. Takes no bundlePath.",
6237
- input: import_zod25.z.object({}),
6459
+ input: import_zod26.z.object({}),
6238
6460
  fromArgv: () => ({}),
6239
6461
  run: ({ store }) => listPins(store, process.cwd())
6240
6462
  });
@@ -6505,16 +6727,16 @@ function recordType(conceptId2) {
6505
6727
  }
6506
6728
 
6507
6729
  // src/commands/promote/model.ts
6508
- var import_zod26 = require("zod");
6509
- var promoteInputSchema = import_zod26.z.object({
6730
+ var import_zod27 = require("zod");
6731
+ var promoteInputSchema = import_zod27.z.object({
6510
6732
  bundlePath,
6511
- conceptIds: import_zod26.z.array(conceptId).max(64).optional().describe("Records to copy into the target base. Omit with `list`."),
6512
- to: import_zod26.z.string().min(1).optional().describe("Absolute path to the base being promoted into."),
6513
- source: import_zod26.z.string().min(1).optional().describe(
6733
+ conceptIds: import_zod27.z.array(conceptId).max(64).optional().describe("Records to copy into the target base. Omit with `list`."),
6734
+ to: import_zod27.z.string().min(1).optional().describe("Absolute path to the base being promoted into."),
6735
+ source: import_zod27.z.string().min(1).optional().describe(
6514
6736
  "Where the promotion came from, usually the pull request URL. Recorded on each copy as a source."
6515
6737
  ),
6516
- force: import_zod26.z.boolean().optional().describe("Overwrite a record the target base already holds."),
6517
- list: import_zod26.z.boolean().optional().describe("List the source base's candidates instead of promoting.")
6738
+ force: import_zod27.z.boolean().optional().describe("Overwrite a record the target base already holds."),
6739
+ list: import_zod27.z.boolean().optional().describe("List the source base's candidates instead of promoting.")
6518
6740
  }).refine((input) => input.list === true || input.to !== void 0, {
6519
6741
  message: "promote needs a target base \u2014 pass --to <bundle>, or --list",
6520
6742
  path: ["to"]
@@ -6651,17 +6873,17 @@ function renderPromote(result) {
6651
6873
  }
6652
6874
 
6653
6875
  // src/commands/query.ts
6654
- var import_zod27 = require("zod");
6876
+ var import_zod28 = require("zod");
6655
6877
  var queryCommand = define({
6656
6878
  name: "query",
6657
6879
  tool: "kb_query",
6658
6880
  usage: "query <text...> [--tag T]... [--repo-root PATH]",
6659
6881
  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.",
6660
- input: import_zod27.z.object({
6882
+ input: import_zod28.z.object({
6661
6883
  bundlePath,
6662
- text: import_zod27.z.string().optional(),
6663
- type: import_zod27.z.enum(KB_RECORD_TYPES).optional(),
6664
- includeNonCurrent: import_zod27.z.boolean().optional(),
6884
+ text: import_zod28.z.string().optional(),
6885
+ type: import_zod28.z.enum(KB_RECORD_TYPES).optional(),
6886
+ includeNonCurrent: import_zod28.z.boolean().optional(),
6665
6887
  tags: TAGS,
6666
6888
  repoRoot: REPO_ROOT
6667
6889
  }),
@@ -6695,27 +6917,32 @@ var queryCommand = define({
6695
6917
  });
6696
6918
 
6697
6919
  // src/commands/read-index.ts
6698
- var import_zod28 = require("zod");
6920
+ var import_zod29 = require("zod");
6699
6921
  var readIndexCommand = define({
6700
6922
  name: "index",
6701
6923
  tool: "kb_index",
6702
6924
  usage: "index",
6703
6925
  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.",
6704
- input: import_zod28.z.object({ bundlePath }),
6926
+ input: import_zod29.z.object({ bundlePath }),
6705
6927
  fromArgv: (_argv, path) => ({ bundlePath: path }),
6706
6928
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
6707
6929
  });
6708
6930
 
6709
6931
  // src/commands/schema.ts
6710
- var import_zod31 = require("zod");
6932
+ var import_zod32 = require("zod");
6711
6933
 
6712
6934
  // src/json-schema.ts
6713
- var import_zod30 = require("zod");
6935
+ var import_zod31 = require("zod");
6714
6936
 
6715
6937
  // src/kb-log.ts
6716
- var import_zod29 = require("zod");
6938
+ var import_zod30 = require("zod");
6717
6939
  var LOG_FILE = "log.jsonl";
6718
- var kbLogEntrySchema = import_zod29.z.object({
6940
+ var kbLogAnchorChangeSchema = import_zod30.z.object({
6941
+ op: import_zod30.z.enum(["move", "add", "drop"]),
6942
+ from: kbAnchorLocatorSchema.optional(),
6943
+ to: kbAnchorLocatorSchema.optional()
6944
+ }).strict();
6945
+ var kbLogEntryFields = import_zod30.z.object({
6719
6946
  // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
6720
6947
  // below), and a value that isn't actually chronological — a Unix
6721
6948
  // timestamp, a human-typed date, garbage — would sort wrong without
@@ -6724,18 +6951,28 @@ var kbLogEntrySchema = import_zod29.z.object({
6724
6951
  // and rejects everything else, including a non-`Z` offset — so a
6725
6952
  // malformed `at` is reported the same way a malformed line already is,
6726
6953
  // rather than silently sorting into the wrong place.
6727
- at: import_zod29.z.iso.datetime(),
6728
- by: import_zod29.z.string().min(1),
6729
- operation: import_zod29.z.string().min(1),
6730
- conceptId: import_zod29.z.string().min(1),
6954
+ at: import_zod30.z.iso.datetime(),
6955
+ by: import_zod30.z.string().min(1),
6956
+ operation: import_zod30.z.string().min(1),
6957
+ conceptId: import_zod30.z.string().min(1),
6731
6958
  /**
6732
6959
  * The operation's other end, where it has one: a second concept id for
6733
6960
  * supersession, the other base's path for promotion.
6734
6961
  */
6735
- target: import_zod29.z.string().min(1).optional()
6736
- }).strict();
6962
+ target: import_zod30.z.string().min(1).optional(),
6963
+ /**
6964
+ * Why the operation was performed, where the operation demands one.
6965
+ * `anchor-set` does: a pointer moved by a reader is only auditable if
6966
+ * the reading is recorded beside it.
6967
+ */
6968
+ reason: import_zod30.z.string().min(1).optional(),
6969
+ /** What `anchor-set` changed, derived from the record before and after. */
6970
+ anchors: import_zod30.z.array(kbLogAnchorChangeSchema).optional()
6971
+ });
6972
+ var kbLogEntrySchema = kbLogEntryFields.passthrough();
6973
+ var kbLogEntryWriteSchema = kbLogEntryFields.strict();
6737
6974
  function renderLogEntry(entry) {
6738
- return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
6975
+ return `${JSON.stringify(kbLogEntryWriteSchema.parse(entry))}
6739
6976
  `;
6740
6977
  }
6741
6978
  var CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})/;
@@ -6776,11 +7013,11 @@ function parseLog(raw) {
6776
7013
  // src/json-schema.ts
6777
7014
  function kbJsonSchemas() {
6778
7015
  return {
6779
- recordFrontmatter: import_zod30.z.toJSONSchema(kbRecordFrontmatterSchema, {
7016
+ recordFrontmatter: import_zod31.z.toJSONSchema(kbRecordFrontmatterSchema, {
6780
7017
  io: "input"
6781
7018
  }),
6782
- composeInput: import_zod30.z.toJSONSchema(composeInputSchema, { io: "input" }),
6783
- logEntry: import_zod30.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
7019
+ composeInput: import_zod31.z.toJSONSchema(composeInputSchema, { io: "input" }),
7020
+ logEntry: import_zod31.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
6784
7021
  };
6785
7022
  }
6786
7023
 
@@ -6790,25 +7027,25 @@ var schemaCommand = define({
6790
7027
  tool: "kb_schema",
6791
7028
  usage: "schema",
6792
7029
  description: "JSON Schema for frontmatter, write input, and log entries, generated from the enforcing code.",
6793
- input: import_zod31.z.object({}),
7030
+ input: import_zod32.z.object({}),
6794
7031
  fromArgv: () => ({}),
6795
7032
  run: () => Promise.resolve(kbJsonSchemas())
6796
7033
  });
6797
7034
 
6798
7035
  // src/commands/stamp.ts
6799
7036
  var import_promises10 = require("fs/promises");
6800
- var import_zod32 = require("zod");
7037
+ var import_zod33 = require("zod");
6801
7038
  var DIGEST = /^[0-9a-f]{64}$/;
6802
7039
  var stampCommand = define({
6803
7040
  name: "stamp",
6804
7041
  tool: "kb_stamp",
6805
7042
  usage: "stamp [--bundle PATH] [--since DIGEST|FILE]",
6806
7043
  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.",
6807
- input: import_zod32.z.object({
6808
- bundlePath: import_zod32.z.string().min(1).optional().describe(
7044
+ input: import_zod33.z.object({
7045
+ bundlePath: import_zod33.z.string().min(1).optional().describe(
6809
7046
  "Absolute path to one knowledge base. Omit to stamp every pinned base."
6810
7047
  ),
6811
- since: import_zod32.z.string().min(1).optional().describe(
7048
+ since: import_zod33.z.string().min(1).optional().describe(
6812
7049
  "Prior digest, or path to a prior `stamp --json`; only moved bases return, with changed ids when the baseline is a file."
6813
7050
  )
6814
7051
  }),
@@ -6894,16 +7131,16 @@ async function readBaseline(since) {
6894
7131
  }
6895
7132
 
6896
7133
  // src/commands/status.ts
6897
- var import_zod33 = require("zod");
7134
+ var import_zod34 = require("zod");
6898
7135
  var statusCommand = define({
6899
7136
  name: "status",
6900
7137
  tool: "kb_status",
6901
7138
  usage: "status <concept-id> <status>",
6902
7139
  description: "Move a record's status. Compare-and-swap: a concurrent change fails instead of being overwritten.",
6903
- input: import_zod33.z.object({
7140
+ input: import_zod34.z.object({
6904
7141
  bundlePath,
6905
7142
  conceptId,
6906
- status: import_zod33.z.enum(KB_RECORD_STATUSES)
7143
+ status: import_zod34.z.enum(KB_RECORD_STATUSES)
6907
7144
  }),
6908
7145
  fromArgv: (argv, path) => ({
6909
7146
  bundlePath: path,
@@ -6918,13 +7155,13 @@ var statusCommand = define({
6918
7155
  });
6919
7156
 
6920
7157
  // src/commands/supersede.ts
6921
- var import_zod34 = require("zod");
7158
+ var import_zod35 = require("zod");
6922
7159
  var supersedeCommand = define({
6923
7160
  name: "supersede",
6924
7161
  tool: "kb_supersede",
6925
7162
  usage: "supersede <concept-id> <replacement-id>",
6926
7163
  description: "Mark a record superseded by another, linked in both directions. Use instead of editing a record whose meaning changed.",
6927
- input: import_zod34.z.object({ bundlePath, conceptId, replacementId: conceptId }),
7164
+ input: import_zod35.z.object({ bundlePath, conceptId, replacementId: conceptId }),
6928
7165
  fromArgv: (argv, path) => ({
6929
7166
  bundlePath: path,
6930
7167
  conceptId: argv[1],
@@ -6938,7 +7175,7 @@ var supersedeCommand = define({
6938
7175
  });
6939
7176
 
6940
7177
  // src/commands/sweep.ts
6941
- var import_zod35 = require("zod");
7178
+ var import_zod36 = require("zod");
6942
7179
  var TERMINAL = [
6943
7180
  "resolved",
6944
7181
  "rejected",
@@ -6949,15 +7186,15 @@ var sweepCommand = define({
6949
7186
  tool: "kb_sweep",
6950
7187
  usage: "sweep --tag <tag> --terminal [--dry-run]",
6951
7188
  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.",
6952
- input: import_zod35.z.object({
7189
+ input: import_zod36.z.object({
6953
7190
  bundlePath,
6954
- 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."),
6955
- terminal: import_zod35.z.literal(true, {
7191
+ tag: import_zod36.z.string({ error: "sweep needs --tag: it never sweeps a whole base" }).min(1).describe("Only records carrying this tag are considered."),
7192
+ terminal: import_zod36.z.literal(true, {
6956
7193
  error: "sweep needs --terminal: it deletes only settled records"
6957
7194
  }).describe(
6958
7195
  "Required. Names the only scope sweep deletes: resolved, rejected and superseded records."
6959
7196
  ),
6960
- dryRun: import_zod35.z.boolean().optional().describe("Report what would go, and delete nothing.")
7197
+ dryRun: import_zod36.z.boolean().optional().describe("Report what would go, and delete nothing.")
6961
7198
  }),
6962
7199
  fromArgv: (argv, path) => ({
6963
7200
  bundlePath: path,
@@ -7074,16 +7311,16 @@ function renderSweep(result) {
7074
7311
  }
7075
7312
 
7076
7313
  // src/commands/sync-instructions.ts
7077
- var import_zod36 = require("zod");
7314
+ var import_zod37 = require("zod");
7078
7315
  var syncInstructionsCommand = define({
7079
7316
  name: "sync-instructions",
7080
7317
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
7081
7318
  description: "CLI-only: plant the kb_context block between sentinel comments in AGENTS.md or CLAUDE.md, idempotently.",
7082
- input: import_zod36.z.object({
7083
- file: import_zod36.z.string().min(1).describe("The instruction file to edit in place."),
7084
- budgetTokens: import_zod36.z.number().int().positive().optional(),
7085
- fullUnderTokens: import_zod36.z.number().int().positive().optional(),
7086
- profile: import_zod36.z.string().optional()
7319
+ input: import_zod37.z.object({
7320
+ file: import_zod37.z.string().min(1).describe("The instruction file to edit in place."),
7321
+ budgetTokens: import_zod37.z.number().int().positive().optional(),
7322
+ fullUnderTokens: import_zod37.z.number().int().positive().optional(),
7323
+ profile: import_zod37.z.string().optional()
7087
7324
  }),
7088
7325
  fromArgv: (argv) => {
7089
7326
  const budget = argvFlag(argv, "--budget");
@@ -7109,7 +7346,7 @@ var syncInstructionsCommand = define({
7109
7346
  });
7110
7347
 
7111
7348
  // src/commands/trace.ts
7112
- var import_zod37 = require("zod");
7349
+ var import_zod38 = require("zod");
7113
7350
 
7114
7351
  // src/trace.ts
7115
7352
  var TRACE_EDGES = [
@@ -7165,11 +7402,11 @@ var traceCommand = define({
7165
7402
  tool: "kb_trace",
7166
7403
  usage: "trace <concept-id> [edges...]",
7167
7404
  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".',
7168
- input: import_zod37.z.object({
7405
+ input: import_zod38.z.object({
7169
7406
  bundlePath,
7170
7407
  conceptId,
7171
- edges: import_zod37.z.array(import_zod37.z.enum(TRACE_EDGES)).optional(),
7172
- depth: import_zod37.z.number().int().positive().optional()
7408
+ edges: import_zod38.z.array(import_zod38.z.enum(TRACE_EDGES)).optional(),
7409
+ depth: import_zod38.z.number().int().positive().optional()
7173
7410
  }),
7174
7411
  fromArgv: (argv, path) => ({
7175
7412
  bundlePath: path,
@@ -7191,37 +7428,37 @@ var traceCommand = define({
7191
7428
  });
7192
7429
 
7193
7430
  // src/commands/types.ts
7194
- var import_zod38 = require("zod");
7431
+ var import_zod39 = require("zod");
7195
7432
  var typesCommand = define({
7196
7433
  name: "types",
7197
7434
  tool: "kb_types",
7198
7435
  usage: "types",
7199
7436
  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.",
7200
- input: import_zod38.z.object({}),
7437
+ input: import_zod39.z.object({}),
7201
7438
  fromArgv: () => ({}),
7202
7439
  run: () => Promise.resolve(RECORD_TYPES)
7203
7440
  });
7204
7441
 
7205
7442
  // src/commands/unpin.ts
7206
- var import_zod39 = require("zod");
7443
+ var import_zod40 = require("zod");
7207
7444
  var unpinCommand = define({
7208
7445
  name: "unpin",
7209
7446
  tool: "kb_unpin",
7210
7447
  usage: "unpin [bundle-path]",
7211
7448
  description: "Remove a base from every manifest layer that holds it. Reports the layers touched.",
7212
- input: import_zod39.z.object({ bundlePath }),
7449
+ input: import_zod40.z.object({ bundlePath }),
7213
7450
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
7214
7451
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
7215
7452
  });
7216
7453
 
7217
7454
  // src/commands/validate.ts
7218
- var import_zod40 = require("zod");
7455
+ var import_zod41 = require("zod");
7219
7456
  var validateCommand = define({
7220
7457
  name: "validate",
7221
7458
  tool: "kb_validate",
7222
7459
  usage: "validate",
7223
7460
  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.",
7224
- input: import_zod40.z.object({ bundlePath }),
7461
+ input: import_zod41.z.object({ bundlePath }),
7225
7462
  fromArgv: (_argv, path) => ({ bundlePath: path }),
7226
7463
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
7227
7464
  // Warnings never fail the exit code; every other severity does.
@@ -7231,16 +7468,16 @@ var validateCommand = define({
7231
7468
  });
7232
7469
 
7233
7470
  // src/commands/verify.ts
7234
- var import_zod41 = require("zod");
7471
+ var import_zod42 = require("zod");
7235
7472
  var verifyCommand = define({
7236
7473
  name: "verify",
7237
7474
  tool: "kb_verify",
7238
7475
  usage: "verify <concept-id> --note <text>",
7239
7476
  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.",
7240
- input: import_zod41.z.object({
7477
+ input: import_zod42.z.object({
7241
7478
  bundlePath,
7242
7479
  conceptId,
7243
- note: import_zod41.z.string().refine((s) => s.trim().length > 0, {
7480
+ note: import_zod42.z.string().refine((s) => s.trim().length > 0, {
7244
7481
  message: "note must say what the check found"
7245
7482
  })
7246
7483
  }),
@@ -7260,15 +7497,15 @@ var verifyCommand = define({
7260
7497
  });
7261
7498
 
7262
7499
  // src/commands/write.ts
7263
- var import_zod42 = require("zod");
7500
+ var import_zod43 = require("zod");
7264
7501
  var writeCommand = define({
7265
7502
  name: "write",
7266
7503
  tool: "kb_write",
7267
7504
  usage: "write <type> < record.json",
7268
7505
  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.",
7269
- input: import_zod42.z.object({
7506
+ input: import_zod43.z.object({
7270
7507
  bundlePath,
7271
- type: import_zod42.z.enum(KB_RECORD_TYPES),
7508
+ type: import_zod43.z.enum(KB_RECORD_TYPES),
7272
7509
  input: composeInputSchema
7273
7510
  }),
7274
7511
  fromArgv: async (argv, path, stdin) => ({
@@ -7292,13 +7529,13 @@ var writeCommand = define({
7292
7529
  });
7293
7530
 
7294
7531
  // src/commands/write-decision.ts
7295
- var import_zod43 = require("zod");
7532
+ var import_zod44 = require("zod");
7296
7533
  var writeDecisionCommand = define({
7297
7534
  name: "write-decision",
7298
7535
  tool: "kb_write_decision",
7299
7536
  usage: "write-decision < decision.json",
7300
7537
  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.",
7301
- input: import_zod43.z.object({ bundlePath, input: decisionInputSchema }),
7538
+ input: import_zod44.z.object({ bundlePath, input: decisionInputSchema }),
7302
7539
  fromArgv: async (_argv, path, stdin) => ({
7303
7540
  bundlePath: path,
7304
7541
  input: JSON.parse(await stdin())
@@ -7328,6 +7565,7 @@ var KB_COMMANDS = [
7328
7565
  answerCommand,
7329
7566
  verifyCommand,
7330
7567
  anchorResolveCommand,
7568
+ anchorSetCommand,
7331
7569
  reassessCommand,
7332
7570
  promoteCommand,
7333
7571
  loadCommand,
@@ -7652,6 +7890,7 @@ var KbStore = class {
7652
7890
  * its contents.
7653
7891
  */
7654
7892
  async write(bundlePath2, input, actor = "unknown") {
7893
+ assertActor(actor);
7655
7894
  if (!KB_SLUG_PATTERN.test(input.slug)) {
7656
7895
  throw new KbInvalidConceptIdError("slug must be kebab-case", {
7657
7896
  slug: input.slug
@@ -7753,6 +7992,7 @@ var KbStore = class {
7753
7992
  * timeouts.
7754
7993
  */
7755
7994
  async setStatus(bundlePath2, conceptId2, status, actor = "unknown") {
7995
+ assertActor(actor);
7756
7996
  return this.mutate(
7757
7997
  bundlePath2,
7758
7998
  conceptId2,
@@ -7761,22 +8001,31 @@ var KbStore = class {
7761
8001
  );
7762
8002
  }
7763
8003
  /**
7764
- * Replaces a record's anchors wholesale, preserving everything else.
7765
- *
7766
- * Wholesale rather than merged: the caller just resolved the anchors it is
7767
- * writing, so it holds the complete current set, and a merge would keep
7768
- * stale entries the resolution pass deliberately dropped.
7769
- *
7770
- * Through the write schema: this is a write, and a defect a hand-edit put in
7771
- * the frontmatter must not be published back out under an actor stamp.
8004
+ * Replaces a record's anchors, preserving everything else. An array is the
8005
+ * whole set; a function is a patch and runs inside the mutation, against
8006
+ * the anchors the record holds then see
8007
+ * `decision.anchor-update-patch-inside-mutation`.
7772
8008
  */
7773
8009
  async updateAnchors(bundlePath2, conceptId2, anchors, actor = "unknown") {
7774
- const checked = anchors.map((anchor) => kbAnchorWriteSchema.parse(anchor));
8010
+ assertActor(actor);
8011
+ let entry = {
8012
+ operation: "anchor-resolve",
8013
+ by: actor
8014
+ };
7775
8015
  return this.mutate(
7776
8016
  bundlePath2,
7777
8017
  conceptId2,
7778
- (frontmatter) => ({ ...frontmatter, strauss_anchors: checked }),
7779
- { operation: "anchor-resolve", by: actor }
8018
+ (frontmatter) => {
8019
+ const write = typeof anchors === "function" ? anchors(frontmatter.strauss_anchors ?? []) : { anchors };
8020
+ if (write.log) entry = { ...write.log, by: actor };
8021
+ return {
8022
+ ...frontmatter,
8023
+ strauss_anchors: write.anchors.map(
8024
+ (anchor) => kbAnchorWriteSchema.parse(anchor)
8025
+ )
8026
+ };
8027
+ },
8028
+ () => entry
7780
8029
  );
7781
8030
  }
7782
8031
  /**
@@ -7791,6 +8040,7 @@ var KbStore = class {
7791
8040
  * logs what it publishes.
7792
8041
  */
7793
8042
  async verify(bundlePath2, conceptId2, note, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
8043
+ assertActor(actor, { named: true });
7794
8044
  const event = kbVerifiedEventSchema.parse({ by: actor, at: at2, note });
7795
8045
  const existing = await this.read(bundlePath2, conceptId2);
7796
8046
  if (!existing) throw new KbRecordNotFoundError(conceptId2);
@@ -7821,6 +8071,7 @@ var KbStore = class {
7821
8071
  * in normal use, and validation drops to catching hand-edits.
7822
8072
  */
7823
8073
  async supersede(bundlePath2, conceptId2, replacementId, actor = "unknown") {
8074
+ assertActor(actor);
7824
8075
  const replacement = await this.read(bundlePath2, replacementId);
7825
8076
  if (!replacement) throw new KbRecordNotFoundError(replacementId);
7826
8077
  const superseded = await this.markSuperseded(
@@ -7844,6 +8095,7 @@ var KbStore = class {
7844
8095
  }
7845
8096
  /** Resolves an open question, stamping who answered and when. */
7846
8097
  async answer(bundlePath2, conceptId2, answer, actor = "unknown", at2 = (/* @__PURE__ */ new Date()).toISOString()) {
8098
+ assertActor(actor);
7847
8099
  return this.mutate(
7848
8100
  bundlePath2,
7849
8101
  conceptId2,
@@ -7870,6 +8122,7 @@ ${answer}
7870
8122
  * terminal status since the caller listed it is reported, not removed.
7871
8123
  */
7872
8124
  async deleteRecord(bundlePath2, conceptId2, expected, actor = "unknown") {
8125
+ assertActor(actor);
7873
8126
  const target = this.recordPath(bundlePath2, conceptId2);
7874
8127
  const witness = await this.read(bundlePath2, conceptId2);
7875
8128
  if (!witness) throw new KbRecordNotFoundError(conceptId2);
@@ -8173,6 +8426,7 @@ ${answer}
8173
8426
  * base too, where nothing was written.
8174
8427
  */
8175
8428
  async note(bundlePath2, entry) {
8429
+ assertActor(entry.by);
8176
8430
  await this.record(this.root(bundlePath2), entry);
8177
8431
  }
8178
8432
  /**
@@ -8225,7 +8479,10 @@ ${answer}
8225
8479
  throw new KbWriteConflictError(conceptId2);
8226
8480
  }
8227
8481
  await this.publish(target, contents, true, conceptId2);
8228
- await this.record(this.root(bundlePath2), { ...entry, conceptId: conceptId2 });
8482
+ await this.record(this.root(bundlePath2), {
8483
+ ...typeof entry === "function" ? entry() : entry,
8484
+ conceptId: conceptId2
8485
+ });
8229
8486
  return { conceptId: conceptId2, frontmatter, body };
8230
8487
  }
8231
8488
  /**
@@ -8430,9 +8687,18 @@ function normalizeActor(id) {
8430
8687
  if (colon === -1) return id.toLowerCase();
8431
8688
  return id.slice(0, colon + 1).toLowerCase() + id.slice(colon + 1);
8432
8689
  }
8690
+ var KB_ACTOR_PATTERN = /^[A-Za-z][\w-]*(?::[\p{L}\p{M}\p{N}_.@+/-]+)?$/u;
8691
+ function assertActor(actor, { named = false } = {}) {
8692
+ if (!KB_ACTOR_PATTERN.test(actor)) {
8693
+ throw new KbInvalidActorError(actor, "is not kind or kind:name");
8694
+ }
8695
+ if (named && normalizeActor(actor) === "unknown") {
8696
+ throw new KbInvalidActorError(actor, "cannot verify: name who checked");
8697
+ }
8698
+ }
8433
8699
 
8434
8700
  // src/version.ts
8435
- var VERSION = true ? "0.1.20" : "0.0.0-dev";
8701
+ var VERSION = true ? "0.1.22" : "0.0.0-dev";
8436
8702
 
8437
8703
  // src/cli.ts
8438
8704
  async function runKbCli(argv) {