@saasontools/strauss-kb 0.1.2 → 0.1.3

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.
@@ -0,0 +1,2323 @@
1
+ // src/kb-record.schema.ts
2
+ import { z } from "zod";
3
+ var kbSourceSchema = z.object({
4
+ id: z.string().min(1),
5
+ resource: z.string().min(1),
6
+ title: z.string().min(1).optional(),
7
+ author: z.string().min(1).optional(),
8
+ last_modified: z.string().min(1).optional()
9
+ }).passthrough();
10
+ var kbActorStampSchema = z.object({
11
+ by: z.string().min(1),
12
+ at: z.string().min(1)
13
+ }).passthrough();
14
+ var kbAnchorSchema = z.object({
15
+ file: z.string().min(1),
16
+ symbol: z.string().min(1).optional()
17
+ }).strict();
18
+ var KB_RECORD_TYPES = [
19
+ "fact",
20
+ "requirement",
21
+ "constraint",
22
+ "decision",
23
+ "assumption",
24
+ "open-question",
25
+ "risk",
26
+ "contract",
27
+ "flow",
28
+ "affected-system",
29
+ "test-obligation",
30
+ "source-note"
31
+ ];
32
+ var KB_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
33
+ var KB_CONCEPT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*$/;
34
+ var kbConceptIdSchema = z.string().regex(KB_CONCEPT_ID_PATTERN, {
35
+ message: "concept id must be <type>.<slug>, both kebab-case"
36
+ });
37
+ var KB_RECORD_STATUSES = [
38
+ "draft",
39
+ "proposed",
40
+ "accepted",
41
+ "open",
42
+ "resolved",
43
+ "rejected",
44
+ "superseded"
45
+ ];
46
+ var KB_MATERIALITIES = [
47
+ "blocking",
48
+ "important",
49
+ "non-blocking"
50
+ ];
51
+ var KB_CONFIDENCES = ["low", "medium", "high"];
52
+ var kbRecordFrontmatterSchema = z.object({
53
+ // OKF: the only always-required key. A concept carrying just `type` is
54
+ // fully conformant, so everything below stays optional.
55
+ type: z.string().min(1),
56
+ // OKF recommended.
57
+ title: z.string().min(1).optional(),
58
+ description: z.string().min(1).optional(),
59
+ resource: z.string().min(1).optional(),
60
+ tags: z.array(z.string()).optional(),
61
+ // OKF optional: provenance and freshness.
62
+ sources: z.array(kbSourceSchema).optional(),
63
+ generated: kbActorStampSchema.optional(),
64
+ verified: z.array(kbActorStampSchema).optional(),
65
+ stale_after: z.string().min(1).optional(),
66
+ // strauss extensions — see the module comment.
67
+ strauss_anchors: z.array(kbAnchorSchema).optional(),
68
+ strauss_verify: z.array(z.string().min(1)).optional(),
69
+ // Total after parsing, tolerant before it. Our producers must supply a
70
+ // status — an absent one would leave every reader inventing its own default
71
+ // — but OKF calls a concept carrying only `type` fully conformant, so
72
+ // rejecting a foreign record for the lack of one would put us outside the
73
+ // spec. The default resolves it in the single place that can: here.
74
+ strauss_status: z.enum(KB_RECORD_STATUSES).default("draft"),
75
+ strauss_supersedes: z.array(z.string().min(1)).optional(),
76
+ strauss_superseded_by: z.string().min(1).optional(),
77
+ strauss_answered: kbActorStampSchema.optional(),
78
+ strauss_materiality: z.enum(KB_MATERIALITIES).optional(),
79
+ strauss_confidence: z.enum(KB_CONFIDENCES).optional(),
80
+ strauss_owner: z.string().min(1).optional(),
81
+ // "No source exists" as a field rather than a sentinel entry inside
82
+ // `sources`. A sentinel in a reference list is a value doing work a field
83
+ // should do; as a field, `sources` may be legitimately empty.
84
+ strauss_assumption: z.boolean().optional()
85
+ }).passthrough();
86
+
87
+ // src/record-types.ts
88
+ var RECORD_TYPES = {
89
+ fact: {
90
+ purpose: "Observed or sourced fact",
91
+ sections: ["Claim", "Evidence", "Implication"],
92
+ initialStatus: "accepted"
93
+ },
94
+ requirement: {
95
+ purpose: "Required behavior or outcome",
96
+ sections: ["Claim", "Evidence", "Implication"],
97
+ initialStatus: "proposed"
98
+ },
99
+ constraint: {
100
+ purpose: "Limitation, compatibility boundary, policy, or restriction",
101
+ sections: ["Claim", "Evidence", "Implication"],
102
+ initialStatus: "accepted"
103
+ },
104
+ decision: {
105
+ purpose: "Chosen or proposed direction",
106
+ sections: ["Decision", "Rationale", "Rejected", "Impact"],
107
+ initialStatus: "accepted"
108
+ },
109
+ assumption: {
110
+ purpose: "Unsourced or not-yet-confirmed working assumption",
111
+ sections: ["Claim", "Why we think so", "What would falsify it"],
112
+ initialStatus: "draft"
113
+ },
114
+ "open-question": {
115
+ purpose: "Question needing resolution",
116
+ sections: ["Question", "Why it matters", "Default assumption"],
117
+ initialStatus: "open"
118
+ },
119
+ risk: {
120
+ purpose: "Something that can go wrong",
121
+ sections: ["Risk", "Why it matters", "Mitigation", "Verification"],
122
+ initialStatus: "open"
123
+ },
124
+ contract: {
125
+ purpose: "API, data, event, schema, or permission contract",
126
+ sections: ["Contract", "Producer", "Consumer", "Compatibility"],
127
+ initialStatus: "proposed"
128
+ },
129
+ flow: {
130
+ purpose: "Sequence, lifecycle, or state behavior",
131
+ sections: ["Flow", "Trigger", "Steps", "Failure modes"],
132
+ initialStatus: "accepted"
133
+ },
134
+ "affected-system": {
135
+ purpose: "Component, service, package, integration, or external system",
136
+ sections: ["System", "How it is affected", "Blast radius"],
137
+ initialStatus: "accepted"
138
+ },
139
+ "test-obligation": {
140
+ purpose: "Behavior or contract that must be verified",
141
+ sections: ["Obligation", "Why it matters", "How to verify"],
142
+ initialStatus: "open"
143
+ },
144
+ "source-note": {
145
+ purpose: "Extracted note from source material",
146
+ sections: ["Note", "Where it came from"],
147
+ initialStatus: "accepted"
148
+ }
149
+ };
150
+ function isKbRecordType(value) {
151
+ return Object.prototype.hasOwnProperty.call(RECORD_TYPES, value);
152
+ }
153
+
154
+ // src/compose.ts
155
+ import { z as z2 } from "zod";
156
+ var composeInputSchema = z2.object({
157
+ slug: z2.string().min(1),
158
+ /** One line, in the reader's terms. Becomes OKF `title`. */
159
+ title: z2.string().min(1),
160
+ /** The consequence — what breaks if this is wrong. Becomes `description`. */
161
+ why: z2.string().min(1),
162
+ /** Keyed by section heading from the type's spec. Unknown keys rejected. */
163
+ sections: z2.record(z2.string(), z2.string().min(1)).optional(),
164
+ anchors: z2.array(kbAnchorSchema).optional(),
165
+ sources: z2.array(kbSourceSchema).optional(),
166
+ /** No source exists, as a claim rather than a sentinel in `sources`. */
167
+ assumption: z2.boolean().optional(),
168
+ /**
169
+ * OKF `stale_after`: the absolute date this record stops being trusted.
170
+ * Anything the outside world can change — pricing, quotas, versions,
171
+ * reception counts — should carry one.
172
+ */
173
+ stale_after: z2.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
174
+ message: "stale_after must be YYYY-MM-DD"
175
+ }).refine((date) => !Number.isNaN(Date.parse(date)), {
176
+ message: "stale_after must be a real date"
177
+ }).optional(),
178
+ verify: z2.array(z2.string().min(1)).optional(),
179
+ tags: z2.array(z2.string().min(1)).optional(),
180
+ /** Concept ids this record relates to; rendered as body links. */
181
+ relatedConceptIds: z2.array(kbConceptIdSchema).optional(),
182
+ /** Concept ids this record replaces. The store settles the backlinks. */
183
+ supersedes: z2.array(kbConceptIdSchema).optional(),
184
+ materiality: z2.enum(KB_MATERIALITIES).optional(),
185
+ confidence: z2.enum(KB_CONFIDENCES).optional(),
186
+ owner: z2.string().min(1).optional()
187
+ }).strict();
188
+ function composeRecord(type, input, writtenBy, writtenAt) {
189
+ const parsed = composeInputSchema.parse(input);
190
+ const spec = RECORD_TYPES[type];
191
+ const sections = parsed.sections ?? {};
192
+ const unknown = Object.keys(sections).filter(
193
+ (heading) => !spec.sections.includes(heading)
194
+ );
195
+ if (unknown.length) {
196
+ throw new Error(
197
+ `kb: ${type} has no section ${unknown.join(", ")} \u2014 expected one of ${spec.sections.join(", ")}`
198
+ );
199
+ }
200
+ const frontmatter = {
201
+ title: parsed.title,
202
+ description: parsed.why,
203
+ generated: { by: writtenBy, at: writtenAt },
204
+ // Empty rather than absent: a later verification pass appends here, and an
205
+ // empty list says "not yet verified" where a missing key would only say
206
+ // "this producer didn't think about it".
207
+ verified: [],
208
+ strauss_status: spec.initialStatus
209
+ };
210
+ if (parsed.stale_after) frontmatter.stale_after = parsed.stale_after;
211
+ if (parsed.anchors?.length) frontmatter.strauss_anchors = parsed.anchors;
212
+ if (parsed.verify?.length) frontmatter.strauss_verify = parsed.verify;
213
+ if (parsed.tags?.length) frontmatter.tags = parsed.tags;
214
+ if (parsed.sources?.length) frontmatter.sources = parsed.sources;
215
+ if (parsed.assumption) frontmatter.strauss_assumption = true;
216
+ if (parsed.materiality) frontmatter.strauss_materiality = parsed.materiality;
217
+ if (parsed.confidence) frontmatter.strauss_confidence = parsed.confidence;
218
+ if (parsed.owner) frontmatter.strauss_owner = parsed.owner;
219
+ if (parsed.supersedes?.length)
220
+ frontmatter.strauss_supersedes = parsed.supersedes;
221
+ const blocks = [];
222
+ for (const heading of spec.sections) {
223
+ const text = sections[heading];
224
+ if (text) blocks.push(`## ${heading}
225
+
226
+ ${text}`);
227
+ }
228
+ if (!blocks.length) blocks.push(parsed.why);
229
+ for (const related of parsed.relatedConceptIds ?? []) {
230
+ blocks.push(`Relates to [${related}](${related}.md).`);
231
+ }
232
+ if (parsed.sources?.length) {
233
+ blocks.push(
234
+ parsed.sources.map((source) => `[^${source.id}]: ${source.title ?? source.resource}`).join("\n")
235
+ );
236
+ }
237
+ return {
238
+ type,
239
+ slug: parsed.slug,
240
+ frontmatter,
241
+ body: `${blocks.join("\n\n")}
242
+ `
243
+ };
244
+ }
245
+
246
+ // src/decision-record.ts
247
+ import { z as z3 } from "zod";
248
+ var DECISION_TYPE = "decision";
249
+ var NO_DECISION_SLUG = "none";
250
+ var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
251
+ alternative: z3.string().min(1).optional(),
252
+ impact: z3.string().min(1).optional()
253
+ }).strict();
254
+ function composeDecisionRecord(input, writtenBy, writtenAt) {
255
+ const { alternative, impact, ...rest } = input;
256
+ return composeRecord(
257
+ DECISION_TYPE,
258
+ {
259
+ ...rest,
260
+ sections: {
261
+ Decision: input.title,
262
+ Rationale: input.why,
263
+ ...alternative ? { Rejected: alternative } : {},
264
+ ...impact ? { Impact: impact } : {}
265
+ }
266
+ },
267
+ writtenBy,
268
+ writtenAt
269
+ );
270
+ }
271
+ function composeNoDecisionRecord(reason, writtenBy, writtenAt) {
272
+ return composeRecord(
273
+ DECISION_TYPE,
274
+ {
275
+ slug: NO_DECISION_SLUG,
276
+ title: "No decision to record",
277
+ why: reason,
278
+ sections: { Decision: reason }
279
+ },
280
+ writtenBy,
281
+ writtenAt
282
+ );
283
+ }
284
+ function isNoDecisionRecord(record) {
285
+ return record.conceptId === `${DECISION_TYPE}.${NO_DECISION_SLUG}`;
286
+ }
287
+ function selectDecisions(records) {
288
+ return records.filter(
289
+ (record) => record.conceptId.startsWith(`${DECISION_TYPE}.`) && !isNoDecisionRecord(record)
290
+ );
291
+ }
292
+
293
+ // src/kb-pins/budgets.ts
294
+ function asBudgets(value) {
295
+ if (value === null || typeof value !== "object") return {};
296
+ const table = value;
297
+ const pick = (key, min) => {
298
+ const raw = table[key];
299
+ return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
300
+ };
301
+ const budgetTokens = pick("budgetTokens", 1);
302
+ const fullUnderTokens = pick("fullUnderTokens", 0);
303
+ return {
304
+ ...budgetTokens ? { budgetTokens } : {},
305
+ ...fullUnderTokens !== void 0 ? { fullUnderTokens } : {}
306
+ };
307
+ }
308
+ function contextProfileBudgets(manifest, profile) {
309
+ const table = manifest.context;
310
+ if (table === null || typeof table !== "object") return {};
311
+ const entries = table;
312
+ return {
313
+ ...asBudgets(entries["default"]),
314
+ ...profile ? asBudgets(entries[profile]) : {}
315
+ };
316
+ }
317
+ function mergedContextBudgets(merged, profile) {
318
+ const layered = ["user", "local", "project"].map((layer) => {
319
+ const manifest = merged.manifests[layer];
320
+ return manifest ? contextProfileBudgets(manifest, profile) : {};
321
+ });
322
+ return { ...layered[0], ...layered[1], ...layered[2] };
323
+ }
324
+
325
+ // src/kb-pins/errors.ts
326
+ var KbPinsMalformedError = class extends Error {
327
+ constructor(file, cause) {
328
+ super(`pin manifest ${file} is not readable (${cause}) \u2014 fix or remove it`);
329
+ this.name = "KbPinsMalformedError";
330
+ }
331
+ };
332
+ var KbBaseFrozenError = class extends Error {
333
+ constructor(bundlePath2, layer) {
334
+ super(
335
+ `${bundlePath2} is frozen (read-only) by this workspace's ${layer} pin manifest \u2014 re-pin with --unfreeze, or unpin, to change it`
336
+ );
337
+ this.name = "KbBaseFrozenError";
338
+ }
339
+ };
340
+
341
+ // src/kb-pins/model.ts
342
+ import { join } from "path";
343
+ import { z as z4 } from "zod";
344
+ var PINS_FILE = join(".strauss", "kb-pins.json");
345
+ var PINS_LOCAL_FILE = join(".strauss", "kb-pins.local.json");
346
+ var PIN_LAYERS = ["project", "local", "user"];
347
+ var pinSchema = z4.object({
348
+ /** Relative to the manifest's root, so the file is committable. */
349
+ path: z4.string().min(1),
350
+ pinnedAt: z4.string().min(1).optional(),
351
+ /**
352
+ * How `context` renders this base. `full` preloads the whole base into
353
+ * the block regardless of the full-under threshold — for a base whose
354
+ * contents should simply be present, the way an ADR base should be —
355
+ * still answering to the block budget, with an index fallback that says
356
+ * so when it cannot fit. `index` never upgrades, whatever the threshold.
357
+ * Absent: the profile's full-under threshold decides. Invalid values
358
+ * degrade to absent rather than failing the manifest.
359
+ */
360
+ mode: z4.enum(["full", "index"]).optional().catch(void 0),
361
+ /**
362
+ * Context profiles this pin surfaces in (e.g. only at session-start,
363
+ * not per turn). Absent: every profile. A run without a profile sees
364
+ * every pin. A base that only matters to one skill is better loaded by
365
+ * that skill at point of use than pinned at all — pins are what every
366
+ * session should see.
367
+ */
368
+ profiles: z4.array(z4.string()).optional().catch(void 0),
369
+ /**
370
+ * The base is concluded — a finished piece of research, a frozen ADR
371
+ * set. Write commands against it refuse while this workspace holds the
372
+ * pin, and `context` labels it read-only. Workspace policy, not base
373
+ * state: the base itself stays copyable and writable elsewhere.
374
+ */
375
+ frozen: z4.boolean().optional().catch(void 0)
376
+ }).passthrough();
377
+ var pinsManifestSchema = z4.object({
378
+ pins: z4.array(pinSchema).default([]),
379
+ /**
380
+ * Per-repo budgets for the `context` command, keyed by profile —
381
+ * `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
382
+ * them. Deliberately untyped here: a typo'd budget must degrade to the
383
+ * built-in default, not make the whole manifest unreadable and silence
384
+ * the index at every session start. `contextProfileBudgets` does the
385
+ * tolerant read.
386
+ */
387
+ context: z4.unknown().optional()
388
+ }).passthrough();
389
+
390
+ // src/kb-pins/layers.ts
391
+ import { mkdir, readFile, writeFile } from "fs/promises";
392
+ import { homedir } from "os";
393
+ import { dirname, isAbsolute, join as join2, relative, resolve, sep } from "path";
394
+ function userRoot() {
395
+ return process.env.STRAUSS_KB_USER_ROOT || homedir();
396
+ }
397
+ function layerRoot(workspaceDir, layer) {
398
+ return layer === "user" ? userRoot() : resolve(workspaceDir);
399
+ }
400
+ function layerFile(workspaceDir, layer) {
401
+ return join2(
402
+ layerRoot(workspaceDir, layer),
403
+ layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
404
+ );
405
+ }
406
+ async function readPinsLayer(workspaceDir, layer) {
407
+ const file = layerFile(workspaceDir, layer);
408
+ let raw;
409
+ try {
410
+ raw = await readFile(file, "utf8");
411
+ } catch {
412
+ return { pins: [] };
413
+ }
414
+ let parsed;
415
+ try {
416
+ parsed = JSON.parse(raw);
417
+ } catch (error) {
418
+ throw new KbPinsMalformedError(
419
+ file,
420
+ error instanceof Error ? error.message : "invalid JSON"
421
+ );
422
+ }
423
+ const manifest = pinsManifestSchema.safeParse(parsed);
424
+ if (!manifest.success) {
425
+ throw new KbPinsMalformedError(
426
+ file,
427
+ manifest.error.issues[0]?.message ?? "invalid shape"
428
+ );
429
+ }
430
+ return manifest.data;
431
+ }
432
+ async function writePinsLayer(workspaceDir, layer, manifest) {
433
+ const file = layerFile(workspaceDir, layer);
434
+ await mkdir(dirname(file), { recursive: true });
435
+ await writeFile(file, `${JSON.stringify(manifest, null, 2)}
436
+ `, "utf8");
437
+ }
438
+ function resolvePinPath(rootDir, path) {
439
+ return isAbsolute(path) ? resolve(path) : resolve(rootDir, path.split("/").join(sep));
440
+ }
441
+ function storablePath(rootDir, bundlePath2) {
442
+ const rel = relative(resolve(rootDir), resolve(bundlePath2));
443
+ return (rel === "" ? "." : rel).split(sep).join("/");
444
+ }
445
+ async function readMergedPins(workspaceDir) {
446
+ const manifests = {};
447
+ const pins = [];
448
+ const seen = /* @__PURE__ */ new Set();
449
+ for (const layer of PIN_LAYERS) {
450
+ let manifest;
451
+ try {
452
+ manifest = await readPinsLayer(workspaceDir, layer);
453
+ } catch {
454
+ continue;
455
+ }
456
+ manifests[layer] = manifest;
457
+ const root = layerRoot(workspaceDir, layer);
458
+ for (const entry of manifest.pins) {
459
+ const absolutePath = resolvePinPath(root, entry.path);
460
+ if (seen.has(absolutePath)) continue;
461
+ seen.add(absolutePath);
462
+ pins.push({ ...entry, layer, absolutePath });
463
+ }
464
+ }
465
+ return { pins, manifests };
466
+ }
467
+
468
+ // src/kb-pins/frozen.ts
469
+ import { resolve as resolve2 } from "path";
470
+ async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
471
+ const merged = await readMergedPins(workspaceDir);
472
+ const absolute = resolve2(bundlePath2);
473
+ const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
474
+ if (pin?.frozen === true) {
475
+ throw new KbBaseFrozenError(pin.path, pin.layer);
476
+ }
477
+ }
478
+
479
+ // src/kb-pins/list.ts
480
+ async function listPins(store, workspaceDir) {
481
+ const merged = await readMergedPins(workspaceDir);
482
+ return Promise.all(
483
+ merged.pins.map(async (entry) => {
484
+ const records = await store.list(entry.absolutePath);
485
+ return {
486
+ path: entry.path,
487
+ layer: entry.layer,
488
+ pinnedAt: entry.pinnedAt ?? null,
489
+ absolutePath: entry.absolutePath,
490
+ valid: records.length > 0,
491
+ recordCount: records.length,
492
+ mode: entry.mode ?? null,
493
+ profiles: entry.profiles ?? null,
494
+ frozen: entry.frozen === true
495
+ };
496
+ })
497
+ );
498
+ }
499
+
500
+ // src/kb-pins/pin.ts
501
+ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
502
+ const layer = options.layer ?? "project";
503
+ const root = layerRoot(workspaceDir, layer);
504
+ const manifest = await readPinsLayer(workspaceDir, layer);
505
+ const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
506
+ const existing = manifest.pins.find(
507
+ (entry2) => resolvePinPath(root, entry2.path) === absolute
508
+ );
509
+ const records = await store.list(absolute);
510
+ const warning = records.length === 0 ? `no records found at ${absolute} \u2014 pinned anyway; bases are routinely pinned before they are populated` : void 0;
511
+ const fields = {
512
+ ...options.mode ? { mode: options.mode } : {},
513
+ ...options.profiles?.length ? { profiles: options.profiles } : {},
514
+ ...options.frozen !== void 0 ? { frozen: options.frozen } : {}
515
+ };
516
+ if (existing) {
517
+ const updated = { ...existing, ...fields };
518
+ if (Object.keys(fields).length) {
519
+ await writePinsLayer(workspaceDir, layer, {
520
+ ...manifest,
521
+ pins: manifest.pins.map(
522
+ (entry2) => entry2 === existing ? updated : entry2
523
+ )
524
+ });
525
+ }
526
+ return {
527
+ path: existing.path,
528
+ layer,
529
+ pinnedAt: existing.pinnedAt ?? at,
530
+ alreadyPinned: true,
531
+ ...updated.mode ? { mode: updated.mode } : {},
532
+ ...updated.profiles ? { profiles: updated.profiles } : {},
533
+ ...updated.frozen !== void 0 ? { frozen: updated.frozen } : {},
534
+ ...warning ? { warning } : {}
535
+ };
536
+ }
537
+ const entry = {
538
+ path: storablePath(root, bundlePath2),
539
+ pinnedAt: at,
540
+ ...fields
541
+ };
542
+ await writePinsLayer(workspaceDir, layer, {
543
+ ...manifest,
544
+ pins: [...manifest.pins, entry]
545
+ });
546
+ return {
547
+ path: entry.path,
548
+ layer,
549
+ pinnedAt: at,
550
+ alreadyPinned: false,
551
+ ...fields,
552
+ ...warning ? { warning } : {}
553
+ };
554
+ }
555
+
556
+ // src/kb-pins/unpin.ts
557
+ import { resolve as resolve3 } from "path";
558
+ async function unpinBase(workspaceDir, bundlePath2) {
559
+ const layers = [];
560
+ for (const layer of PIN_LAYERS) {
561
+ const root = layerRoot(workspaceDir, layer);
562
+ let manifest;
563
+ try {
564
+ manifest = await readPinsLayer(workspaceDir, layer);
565
+ } catch {
566
+ continue;
567
+ }
568
+ const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
569
+ const kept = manifest.pins.filter(
570
+ (entry) => resolvePinPath(root, entry.path) !== absolute
571
+ );
572
+ if (kept.length !== manifest.pins.length) {
573
+ await writePinsLayer(workspaceDir, layer, { ...manifest, pins: kept });
574
+ layers.push(layer);
575
+ }
576
+ }
577
+ return {
578
+ path: storablePath(resolve3(workspaceDir), bundlePath2),
579
+ removed: layers.length > 0,
580
+ layers
581
+ };
582
+ }
583
+
584
+ // src/adjudicate.ts
585
+ var STANDING = {
586
+ accepted: "current",
587
+ resolved: "current",
588
+ draft: "unsettled",
589
+ proposed: "unsettled",
590
+ open: "open",
591
+ rejected: "rejected",
592
+ superseded: "superseded"
593
+ };
594
+ function adjudicate(hits, bundle, now = /* @__PURE__ */ new Date()) {
595
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
596
+ return hits.map((record) => {
597
+ const status = record.frontmatter.strauss_status;
598
+ const warnings = [];
599
+ let heads = [];
600
+ if (status === "superseded") {
601
+ const resolved = resolveHeads(record, byId);
602
+ heads = resolved.heads;
603
+ warnings.push(...resolved.warnings);
604
+ if (heads.length) {
605
+ warnings.push({
606
+ kind: "superseded",
607
+ by: heads.map((head) => head.conceptId)
608
+ });
609
+ }
610
+ } else if (status === "rejected") {
611
+ warnings.push({ kind: "rejected" });
612
+ } else if (status === "draft" || status === "proposed") {
613
+ warnings.push({ kind: "unsettled", status });
614
+ } else if (status === "open") {
615
+ warnings.push({ kind: "unresolved-question" });
616
+ }
617
+ const staleAfter = record.frontmatter.stale_after;
618
+ if (staleAfter && Date.parse(staleAfter) < now.getTime()) {
619
+ warnings.push({ kind: "stale", staleAfter });
620
+ }
621
+ if (!record.frontmatter.verified?.length) {
622
+ warnings.push({ kind: "unverified" });
623
+ }
624
+ return { record, standing: STANDING[status], heads, warnings };
625
+ });
626
+ }
627
+ function resolveHeads(from, byId) {
628
+ const warnings = [];
629
+ const heads = /* @__PURE__ */ new Map();
630
+ const seen = /* @__PURE__ */ new Set([from.conceptId]);
631
+ const queue = [from];
632
+ while (queue.length) {
633
+ const current = queue.shift();
634
+ const next = successors(current, byId);
635
+ for (const missing of next.missing) {
636
+ warnings.push({ kind: "broken-chain", missing });
637
+ }
638
+ if (!next.records.length) {
639
+ if (current.conceptId !== from.conceptId)
640
+ heads.set(current.conceptId, current);
641
+ continue;
642
+ }
643
+ for (const record of next.records) {
644
+ if (seen.has(record.conceptId)) {
645
+ warnings.push({ kind: "chain-cycle", through: [...seen] });
646
+ continue;
647
+ }
648
+ seen.add(record.conceptId);
649
+ queue.push(record);
650
+ }
651
+ }
652
+ if (heads.size > 1) {
653
+ warnings.push({ kind: "forked-chain", heads: [...heads.keys()] });
654
+ }
655
+ return { heads: [...heads.values()], warnings };
656
+ }
657
+ function successors(record, byId) {
658
+ const ids = /* @__PURE__ */ new Set();
659
+ const forward = record.frontmatter.strauss_superseded_by;
660
+ if (forward) ids.add(forward);
661
+ for (const [id, candidate] of byId) {
662
+ if (candidate.frontmatter.strauss_supersedes?.includes(record.conceptId)) {
663
+ ids.add(id);
664
+ }
665
+ }
666
+ const records = [];
667
+ const missing = [];
668
+ for (const id of ids) {
669
+ const found = byId.get(id);
670
+ if (found) records.push(found);
671
+ else missing.push(id);
672
+ }
673
+ return { records, missing };
674
+ }
675
+
676
+ // src/kb-index.ts
677
+ var INDEX_FILE = "INDEX.md";
678
+ var HEADING = "# KB Index";
679
+ function renderIndex(records) {
680
+ const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map(renderIndexLine);
681
+ return `${HEADING}
682
+
683
+ ${lines.join("\n")}
684
+ `;
685
+ }
686
+ function renderIndexLine(record) {
687
+ const { frontmatter: fm } = record;
688
+ const parts = [fm.type, fm.strauss_status];
689
+ if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
690
+ if (fm.description) parts.push(fm.description);
691
+ return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
692
+ }
693
+ function indexIsStale(stored, expected) {
694
+ return stored !== expected;
695
+ }
696
+
697
+ // src/kb-context.ts
698
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
699
+ var HEADING2 = "## Knowledge bases (pinned)";
700
+ var DEFAULT_CONTEXT_BUDGET = 4e3;
701
+ var CONTEXT_PROFILES = {
702
+ "session-start": { fullUnderTokens: 1500 },
703
+ compact: { budgetTokens: 2500 },
704
+ turn: { budgetTokens: 2500 }
705
+ };
706
+ function approxTokens(text) {
707
+ return Math.ceil(text.length / 4);
708
+ }
709
+ function preamble() {
710
+ return [
711
+ HEADING2,
712
+ "",
713
+ "What follows is an index of this workspace's pinned knowledge bases \u2014",
714
+ "concept ids, titles and standing only. The record bodies are NOT in this",
715
+ "context.",
716
+ "",
717
+ "Consult records only through the strauss-kb MCP tools: `kb_load` (the",
718
+ "preferred first call), `kb_query`, and `kb_trace`, passing the",
719
+ "`bundlePath` listed with each base. Do not read record files directly:",
720
+ "a raw file read bypasses supersession resolution, and a superseded or",
721
+ "rejected record file reads exactly like a current one \u2014 only the store",
722
+ "resolves chains and standing.",
723
+ "",
724
+ "KB content loaded earlier in a long session may have been compacted",
725
+ "away. Before answering a question one of these bases governs, load it",
726
+ "again at the point of use \u2014 reloading a small base costs a few thousand",
727
+ "tokens."
728
+ ].join("\n");
729
+ }
730
+ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens) {
731
+ const bundle = await store.list(absolutePath);
732
+ if (bundle.length === 0) {
733
+ return {
734
+ path,
735
+ absolutePath,
736
+ mode: "empty",
737
+ body: "No readable records yet \u2014 pinned ahead of being populated."
738
+ };
739
+ }
740
+ const fullCap = pinMode === "full" ? budgetTokens : pinMode === "index" ? 0 : fullUnderTokens;
741
+ let degradedFrom;
742
+ if (fullCap > 0) {
743
+ const full = await store.load(absolutePath, {
744
+ budgetTokens: fullCap
745
+ });
746
+ if (!full.loaded && pinMode === "full") {
747
+ degradedFrom = { approxTokens: full.approxTokens };
748
+ }
749
+ if (full.loaded) {
750
+ const records = full.records.map(
751
+ (hit) => [
752
+ `#### ${hit.record.conceptId} \u2014 ${hit.record.frontmatter.title ?? "(untitled)"} (${hit.standing})`,
753
+ "",
754
+ hit.record.body.trim()
755
+ ].join("\n")
756
+ );
757
+ const superseded2 = full.superseded.map(
758
+ (entry) => `- \`${entry.conceptId}\` \u2192 superseded by ${entry.supersededBy.map((id) => `\`${id}\``).join(", ") || "(missing replacement)"}`
759
+ );
760
+ return {
761
+ path,
762
+ absolutePath,
763
+ mode: "full",
764
+ body: [
765
+ ...records,
766
+ ...superseded2.length ? [
767
+ "#### Superseded (bodies withheld \u2014 kb_trace reaches them)",
768
+ ...superseded2
769
+ ] : []
770
+ ].join("\n\n")
771
+ };
772
+ }
773
+ }
774
+ const adjudicated = adjudicate(bundle, bundle);
775
+ const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
776
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
777
+ (hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
778
+ );
779
+ return {
780
+ path,
781
+ absolutePath,
782
+ mode: "index",
783
+ body: [...lines, ...superseded].join("\n"),
784
+ ...degradedFrom ? { degradedFrom } : {}
785
+ };
786
+ }
787
+ async function buildContext(store, workspaceDir, options = {}) {
788
+ const builtin = options.profile ? CONTEXT_PROFILES[options.profile] ?? {} : {};
789
+ let budgetTokens = options.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
790
+ let fullUnderTokens = options.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
791
+ const merged = await readMergedPins(workspaceDir);
792
+ const fromManifest = mergedContextBudgets(merged, options.profile);
793
+ budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
794
+ fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
795
+ const pins = merged.pins.filter(
796
+ (pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
797
+ );
798
+ if (pins.length === 0) {
799
+ return {
800
+ block: "",
801
+ refused: false,
802
+ approxTokens: 0,
803
+ budgetTokens,
804
+ bases: []
805
+ };
806
+ }
807
+ const sections = await Promise.all(
808
+ pins.map(async (pin) => ({
809
+ section: await renderBase(
810
+ store,
811
+ pin.path,
812
+ pin.absolutePath,
813
+ fullUnderTokens,
814
+ pin.mode,
815
+ budgetTokens
816
+ ),
817
+ frozen: pin.frozen === true
818
+ }))
819
+ );
820
+ const modeLabel = {
821
+ index: "index only \u2014 record bodies are not here",
822
+ full: "full records \u2014 this base arrives whole",
823
+ empty: "empty"
824
+ };
825
+ for (const { section } of sections) {
826
+ if (section.degradedFrom) {
827
+ options.warn?.({
828
+ operation: "kb.context.full-pin-degraded",
829
+ path: section.path,
830
+ approxTokens: section.degradedFrom.approxTokens,
831
+ budgetTokens
832
+ });
833
+ }
834
+ }
835
+ const rendered = sections.map(({ section, frozen }) => {
836
+ const label = section.degradedFrom ? `index only \u2014 pinned \`mode: full\`, but its ~${section.degradedFrom.approxTokens} tokens exceed this block's ${budgetTokens}-token budget; kb_load it directly (load's budget is separate), or raise this profile's budget` : modeLabel[section.mode];
837
+ return [
838
+ `### ${section.path} (${label}${frozen ? " \xB7 frozen, read-only" : ""})`,
839
+ "",
840
+ `bundlePath: \`${section.absolutePath}\``,
841
+ "",
842
+ section.body
843
+ ].join("\n");
844
+ });
845
+ const block = [preamble(), "", rendered.join("\n\n"), ""].join("\n");
846
+ const bases = sections.map(({ section }) => ({
847
+ path: section.path,
848
+ absolutePath: section.absolutePath,
849
+ approxTokens: approxTokens(section.body)
850
+ }));
851
+ const total = approxTokens(block);
852
+ if (total > budgetTokens) {
853
+ options.warn?.({
854
+ operation: "kb.context.refused",
855
+ approxTokens: total,
856
+ budgetTokens,
857
+ bases: bases.map((base) => base.path)
858
+ });
859
+ const refusal = [
860
+ HEADING2,
861
+ "",
862
+ `The pinned index runs to ~${total} tokens, past the ${budgetTokens}-token`,
863
+ "budget, and was not emitted \u2014 a truncated index is indistinguishable",
864
+ "from a complete one. The pinned bases:",
865
+ "",
866
+ ...bases.map(
867
+ (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
868
+ ),
869
+ "",
870
+ "For the question at hand, read what you need now \u2014 `kb_load` a base",
871
+ "(its own budget is separate), or `kb_index` for one base's shape.",
872
+ "",
873
+ "To bring this block back under budget, in order of preference:",
874
+ "- supersede or resolve stale records \u2014 the base shrinks, the knowledge keeps",
875
+ "- force a large base to index lines: `strauss-kb pin <path> --mode index`",
876
+ "- scope a pin to the profiles that need it: `strauss-kb pin <path> --profiles session-start`",
877
+ "- raise this profile's budget under `context` in .strauss/kb-pins.json",
878
+ "- unpin what no session actually needs",
879
+ ""
880
+ ].join("\n");
881
+ return {
882
+ block: refusal,
883
+ refused: true,
884
+ approxTokens: total,
885
+ budgetTokens,
886
+ bases
887
+ };
888
+ }
889
+ return { block, refused: false, approxTokens: total, budgetTokens, bases };
890
+ }
891
+ function toHookJson(block, event) {
892
+ return JSON.stringify({
893
+ hookSpecificOutput: {
894
+ hookEventName: event,
895
+ additionalContext: block
896
+ }
897
+ });
898
+ }
899
+ var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
900
+ var CONTEXT_END = "<!-- strauss-kb:end -->";
901
+ async function syncInstructions(file, block) {
902
+ const existing = await readFile2(file, "utf8").catch(() => null);
903
+ const region = block ? `${CONTEXT_BEGIN}
904
+ ${block.trim()}
905
+ ${CONTEXT_END}` : null;
906
+ if (existing === null) {
907
+ if (!region) return { file, action: "unchanged" };
908
+ await writeFile2(file, `${region}
909
+ `, "utf8");
910
+ return { file, action: "created" };
911
+ }
912
+ const begin = existing.indexOf(CONTEXT_BEGIN);
913
+ const end = existing.indexOf(CONTEXT_END);
914
+ if (begin !== -1 && end !== -1 && end >= begin) {
915
+ const before = existing.slice(0, begin);
916
+ const after = existing.slice(end + CONTEXT_END.length);
917
+ const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
918
+ if (next === existing) return { file, action: "unchanged" };
919
+ await writeFile2(file, next, "utf8");
920
+ return { file, action: region ? "replaced" : "removed" };
921
+ }
922
+ if (!region) return { file, action: "unchanged" };
923
+ await writeFile2(
924
+ file,
925
+ `${existing.replace(/\n*$/, "\n\n")}${region}
926
+ `,
927
+ "utf8"
928
+ );
929
+ return { file, action: "appended" };
930
+ }
931
+
932
+ // src/kb-log.ts
933
+ import { z as z5 } from "zod";
934
+ var LOG_FILE = "log.jsonl";
935
+ var kbLogEntrySchema = z5.object({
936
+ at: z5.string().min(1),
937
+ by: z5.string().min(1),
938
+ operation: z5.string().min(1),
939
+ conceptId: z5.string().min(1),
940
+ /** Second concept id, where the operation relates two — supersession. */
941
+ target: z5.string().min(1).optional()
942
+ }).strict();
943
+ function renderLogEntry(entry) {
944
+ return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
945
+ `;
946
+ }
947
+ function parseLog(raw) {
948
+ const entries = [];
949
+ const malformed = [];
950
+ raw.split("\n").forEach((text, index) => {
951
+ if (!text.trim()) return;
952
+ let value;
953
+ try {
954
+ value = JSON.parse(text);
955
+ } catch {
956
+ malformed.push({ line: index + 1, text });
957
+ return;
958
+ }
959
+ const parsed = kbLogEntrySchema.safeParse(value);
960
+ if (!parsed.success) {
961
+ malformed.push({ line: index + 1, text });
962
+ return;
963
+ }
964
+ entries.push(parsed.data);
965
+ });
966
+ return { entries, malformed };
967
+ }
968
+
969
+ // src/json-schema.ts
970
+ import { z as z6 } from "zod";
971
+ function kbJsonSchemas() {
972
+ return {
973
+ recordFrontmatter: z6.toJSONSchema(kbRecordFrontmatterSchema, {
974
+ io: "input"
975
+ }),
976
+ composeInput: z6.toJSONSchema(composeInputSchema, { io: "input" }),
977
+ logEntry: z6.toJSONSchema(kbLogEntrySchema, { io: "input" })
978
+ };
979
+ }
980
+
981
+ // src/trace.ts
982
+ var TRACE_EDGES = ["supersession", "anchor", "source"];
983
+ function trace(seedId, bundle, options = {}) {
984
+ const edges = options.edges?.length ? options.edges : TRACE_EDGES;
985
+ const maxDepth = options.depth ?? 3;
986
+ const byId = new Map(bundle.map((record) => [record.conceptId, record]));
987
+ const seed = byId.get(seedId);
988
+ if (!seed) return [];
989
+ const reached = /* @__PURE__ */ new Map([
990
+ [seedId, { record: seed, depth: 0, via: [] }]
991
+ ]);
992
+ let frontier = [seed];
993
+ for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
994
+ const next = [];
995
+ for (const from of frontier) {
996
+ for (const edge of edges) {
997
+ for (const record of neighbours(from, bundle, edge)) {
998
+ const existing = reached.get(record.conceptId);
999
+ if (existing) {
1000
+ if (existing.depth > 0 && !existing.via.includes(edge)) {
1001
+ existing.via.push(edge);
1002
+ }
1003
+ continue;
1004
+ }
1005
+ reached.set(record.conceptId, { record, depth, via: [edge] });
1006
+ next.push(record);
1007
+ }
1008
+ }
1009
+ }
1010
+ frontier = next;
1011
+ }
1012
+ return [...reached.values()].sort(byGeneratedAt);
1013
+ }
1014
+ function neighbours(from, bundle, edge) {
1015
+ switch (edge) {
1016
+ case "supersession":
1017
+ return bundle.filter(
1018
+ (candidate) => candidate.conceptId !== from.conceptId && (candidate.conceptId === from.frontmatter.strauss_superseded_by || from.frontmatter.strauss_supersedes?.includes(
1019
+ candidate.conceptId
1020
+ ) || candidate.frontmatter.strauss_superseded_by === from.conceptId || candidate.frontmatter.strauss_supersedes?.includes(from.conceptId))
1021
+ );
1022
+ // The edge that answers "why is this code shaped this way": every record
1023
+ // attached to the same file or symbol, whatever its standing.
1024
+ case "anchor": {
1025
+ const mine = from.frontmatter.strauss_anchors ?? [];
1026
+ if (!mine.length) return [];
1027
+ return bundle.filter(
1028
+ (candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.strauss_anchors ?? []).some(
1029
+ (theirs) => mine.some((ours) => anchorsTouch(ours, theirs))
1030
+ )
1031
+ );
1032
+ }
1033
+ case "source": {
1034
+ const mine = new Set((from.frontmatter.sources ?? []).map((s) => s.id));
1035
+ if (!mine.size) return [];
1036
+ return bundle.filter(
1037
+ (candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.sources ?? []).some(
1038
+ (source) => mine.has(source.id)
1039
+ )
1040
+ );
1041
+ }
1042
+ }
1043
+ }
1044
+ function anchorsTouch(left, right) {
1045
+ if (left.file !== right.file) return false;
1046
+ if (!left.symbol || !right.symbol) return true;
1047
+ return left.symbol === right.symbol;
1048
+ }
1049
+ function byGeneratedAt(left, right) {
1050
+ const at = (step) => step.record.frontmatter.generated?.at ?? "";
1051
+ return at(left).localeCompare(at(right)) || left.depth - right.depth;
1052
+ }
1053
+
1054
+ // src/validate.ts
1055
+ function validateBundle(records) {
1056
+ const byId = new Map(records.map((record) => [record.conceptId, record]));
1057
+ const problems = [];
1058
+ const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
1059
+ for (const record of records) {
1060
+ const { conceptId: conceptId2, frontmatter: fm } = record;
1061
+ if (!isKbRecordType(fm.type)) {
1062
+ report("type", conceptId2, `unrecognised type "${fm.type}"`);
1063
+ }
1064
+ if (fm.strauss_status === "superseded") {
1065
+ const by = fm.strauss_superseded_by;
1066
+ if (!by) {
1067
+ report("superseded_by", conceptId2, "superseded with no replacement");
1068
+ } else if (!byId.has(by)) {
1069
+ report("superseded_by", conceptId2, `replacement ${by} is missing`);
1070
+ } else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
1071
+ report("backlink", by, `does not list ${conceptId2} in supersedes`);
1072
+ }
1073
+ }
1074
+ for (const old of fm.strauss_supersedes ?? []) {
1075
+ const previous = byId.get(old);
1076
+ if (!previous) {
1077
+ report("supersedes", conceptId2, `target ${old} is missing`);
1078
+ } else if (previous.frontmatter.strauss_status !== "superseded") {
1079
+ report("supersedes", conceptId2, `${old} is not marked superseded`);
1080
+ }
1081
+ }
1082
+ if (fm.strauss_assumption && fm.sources?.length) {
1083
+ report("assumption", conceptId2, "marked an assumption but cites sources");
1084
+ }
1085
+ }
1086
+ return problems;
1087
+ }
1088
+
1089
+ // src/commands/answer.ts
1090
+ import { z as z8 } from "zod";
1091
+
1092
+ // src/commands/model.ts
1093
+ import { z as z7 } from "zod";
1094
+ var bundlePath = z7.string().min(1).describe("Absolute path to the knowledge base directory.");
1095
+ var conceptId = z7.string().min(1).describe("e.g. decision.cursor-v2");
1096
+ function define(command) {
1097
+ return command;
1098
+ }
1099
+ function argvFlag(argv, name) {
1100
+ const at = argv.indexOf(name);
1101
+ return at !== -1 ? argv[at + 1] : void 0;
1102
+ }
1103
+
1104
+ // src/commands/answer.ts
1105
+ var answerCommand = define({
1106
+ name: "answer",
1107
+ tool: "kb_answer",
1108
+ usage: "answer <concept-id> <answer...>",
1109
+ description: "Resolve an open question: sets the status, stamps who answered and when, and appends an Answer section. If the answer overturns an assumption or a decision, that is a supersession \u2014 do it explicitly.",
1110
+ input: z8.object({ bundlePath, conceptId, answer: z8.string().min(1) }),
1111
+ fromArgv: (argv, path) => ({
1112
+ bundlePath: path,
1113
+ conceptId: argv[1],
1114
+ answer: argv.slice(2).join(" ").trim()
1115
+ }),
1116
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, answer }) => {
1117
+ await assertBaseNotFrozen(process.cwd(), path);
1118
+ const record = await store.answer(path, id, answer, actor);
1119
+ return { conceptId: record.conceptId };
1120
+ }
1121
+ });
1122
+
1123
+ // src/commands/context.ts
1124
+ import { z as z9 } from "zod";
1125
+ var contextCommand = define({
1126
+ name: "context",
1127
+ tool: "kb_context",
1128
+ usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
1129
+ description: "The pinned-base index block, for injection at every context birth \u2014 startup, clear, resume, and after compaction. An index, not the content: concept ids, titles and standing, with the bodies left behind kb_load at the point of use. Emits nothing when nothing is pinned. Refuses with the list of bases and their sizes rather than truncating past its budget. Budgets resolve most-specific-first: explicit flags, then the workspace manifests' `context` tables (per profile, over their `default`), then the built-in profile (session-start, compact, turn), then package defaults \u2014 so a repo tunes its own numbers in .strauss/kb-pins.json without touching hook commands. Like kb_schema and kb_types this takes no bundlePath \u2014 it reads the workspace pin manifests, because which bases a session should see is workspace state, not a property of one base.",
1130
+ input: z9.object({
1131
+ budgetTokens: z9.number().int().positive().optional().describe(
1132
+ "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
1133
+ ),
1134
+ fullUnderTokens: z9.number().int().positive().optional().describe(
1135
+ "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."
1136
+ ),
1137
+ profile: z9.string().optional().describe(
1138
+ "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."
1139
+ ),
1140
+ format: z9.enum(["markdown", "json"]).optional().describe(
1141
+ "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
1142
+ ),
1143
+ event: z9.string().optional().describe(
1144
+ "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
1145
+ )
1146
+ }),
1147
+ fromArgv: (argv) => {
1148
+ const budget = argvFlag(argv, "--budget");
1149
+ const fullUnder = argvFlag(argv, "--full-under");
1150
+ const profile = argvFlag(argv, "--profile");
1151
+ const format = argvFlag(argv, "--format");
1152
+ const event = argvFlag(argv, "--event");
1153
+ return {
1154
+ ...budget ? { budgetTokens: Number(budget) } : {},
1155
+ ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
1156
+ ...profile ? { profile } : {},
1157
+ ...format ? { format } : {},
1158
+ ...event ? { event } : {}
1159
+ };
1160
+ },
1161
+ run: async ({ store }, { budgetTokens, fullUnderTokens, profile, format, event }) => {
1162
+ const result = await buildContext(store, process.cwd(), {
1163
+ ...budgetTokens ? { budgetTokens } : {},
1164
+ ...fullUnderTokens ? { fullUnderTokens } : {},
1165
+ ...profile ? { profile } : {},
1166
+ // Degradations — a full pin that could not fit, a refused block — go
1167
+ // to stderr as well as into the block itself: stderr is diagnostics on
1168
+ // both surfaces (hooks discard it, MCP logs it), so an operator can
1169
+ // see budget pressure without reading injected context.
1170
+ warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
1171
+ `)
1172
+ });
1173
+ if (!result.block) return "";
1174
+ return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
1175
+ }
1176
+ });
1177
+
1178
+ // src/commands/list.ts
1179
+ import { z as z10 } from "zod";
1180
+ var listCommand = define({
1181
+ name: "list",
1182
+ tool: "kb_list",
1183
+ usage: "list [type]",
1184
+ description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
1185
+ input: z10.object({ bundlePath, type: z10.enum(KB_RECORD_TYPES).optional() }),
1186
+ fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
1187
+ run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
1188
+ conceptId: record.conceptId,
1189
+ title: record.frontmatter.title ?? null,
1190
+ description: record.frontmatter.description ?? null,
1191
+ status: record.frontmatter.strauss_status,
1192
+ anchors: record.frontmatter.strauss_anchors ?? []
1193
+ }))
1194
+ });
1195
+
1196
+ // src/commands/load.ts
1197
+ import { z as z11 } from "zod";
1198
+ var loadCommand = define({
1199
+ name: "load",
1200
+ tool: "kb_load",
1201
+ usage: "load [type] [--budget N]",
1202
+ description: "Load the whole knowledge base at once, each record with its standing. Prefer this over searching: these bases run to a few thousand tokens, and a reader holding all of it has perfect recall and knows why it is asking, which no ranker does. Superseded records arrive under `superseded` as name, replacement and date only \u2014 their bodies no longer hold, and reading one later in a long session is the mistake this prevents; pass the id to kb_trace when you need the history. Rejected and unresolved records arrive whole: what was turned down, and what is still open, is the part a diff cannot show you. Refuses with a count rather than truncating when the base is too large \u2014 a truncated base is indistinguishable from a complete one, and would have you conclude something was never decided from a slice you did not know was a slice. Call at the point of use, not once per session: a base loaded early is summarised away by compaction, so if the visible context holds no records from this base and the question at hand is one it might govern, load before answering \u2014 never conclude nothing was decided from a context with no KB content in it. This tool (with kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.",
1203
+ input: z11.object({
1204
+ bundlePath,
1205
+ type: z11.enum(KB_RECORD_TYPES).optional(),
1206
+ budgetTokens: z11.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000.")
1207
+ }),
1208
+ fromArgv: (argv, path) => {
1209
+ const budget = argvFlag(argv, "--budget");
1210
+ return {
1211
+ bundlePath: path,
1212
+ ...argv[1] && argv[1] !== "--budget" ? { type: argv[1] } : {},
1213
+ ...budget ? { budgetTokens: Number(budget) } : {}
1214
+ };
1215
+ },
1216
+ run: async ({ store }, { bundlePath: path, type, budgetTokens }) => {
1217
+ const result = await store.load(path, {
1218
+ ...type ? { type } : {},
1219
+ ...budgetTokens ? { budgetTokens } : {}
1220
+ });
1221
+ if (!result.loaded) return result;
1222
+ return {
1223
+ ...result,
1224
+ records: result.records.map((hit) => ({
1225
+ conceptId: hit.record.conceptId,
1226
+ title: hit.record.frontmatter.title ?? null,
1227
+ standing: hit.standing,
1228
+ supersededBy: hit.heads.map((head) => head.conceptId),
1229
+ warnings: hit.warnings,
1230
+ anchors: hit.record.frontmatter.strauss_anchors ?? [],
1231
+ body: hit.record.body
1232
+ }))
1233
+ };
1234
+ }
1235
+ });
1236
+
1237
+ // src/commands/log.ts
1238
+ import { z as z12 } from "zod";
1239
+ var logCommand = define({
1240
+ name: "log",
1241
+ tool: "kb_log",
1242
+ usage: "log",
1243
+ description: "What touched what, and when. The only artifact here that cannot be reconstructed from the records, so malformed lines are reported rather than repaired.",
1244
+ input: z12.object({ bundlePath }),
1245
+ fromArgv: (_argv, path) => ({ bundlePath: path }),
1246
+ run: ({ store }, { bundlePath: path }) => store.readLog(path)
1247
+ });
1248
+
1249
+ // src/commands/no-decision.ts
1250
+ import { z as z13 } from "zod";
1251
+ var noDecisionCommand = define({
1252
+ name: "no-decision",
1253
+ tool: "kb_no_decision",
1254
+ usage: "no-decision <reason...>",
1255
+ description: 'Claim in one sentence that there was nothing to decide. Gating on "did you write a decision?" rewards writing a junk one; gating on "did you answer?" does not, so silence has to be expressible. Idempotent \u2014 restating it is not a collision.',
1256
+ input: z13.object({ bundlePath, reason: z13.string().min(1) }),
1257
+ fromArgv: (argv, path) => ({
1258
+ bundlePath: path,
1259
+ reason: argv.slice(1).join(" ").trim()
1260
+ }),
1261
+ run: async ({ store, actor, now }, { bundlePath: path, reason }) => {
1262
+ await assertBaseNotFrozen(process.cwd(), path);
1263
+ const record = await store.write(
1264
+ path,
1265
+ { ...composeNoDecisionRecord(reason, actor, now()), overwrite: true },
1266
+ actor
1267
+ );
1268
+ return { conceptId: record.conceptId };
1269
+ }
1270
+ });
1271
+
1272
+ // src/commands/pin.ts
1273
+ import { z as z14 } from "zod";
1274
+ var pinCommand = define({
1275
+ name: "pin",
1276
+ tool: "kb_pin",
1277
+ usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
1278
+ description: "Pin a base into a workspace pin manifest, so `context` surfaces it at every context birth. Three layers, nearest wins: the committed project manifest (.strauss/kb-pins.json, the default), `--local` (.strauss/kb-pins.local.json, personal and gitignored), and `--user` (~/.strauss/kb-pins.json, every workspace). Idempotent \u2014 re-pinning changes nothing unless --mode, --profiles, or --frozen/--unfreeze are given, which update just those fields. `--mode full` preloads the whole base into the block regardless of the full-under threshold; `--mode index` never upgrades. `--profiles` scopes the pin to named context profiles. `--frozen` marks the base concluded: write commands against it refuse and `context` labels it read-only. A path with no records yet succeeds with a warning; bases are routinely pinned before they are populated. Pins are workspace state: the pinned base itself is never touched.",
1279
+ input: z14.object({
1280
+ bundlePath,
1281
+ mode: z14.enum(["full", "index"]).optional().describe(
1282
+ "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
1283
+ ),
1284
+ profiles: z14.array(z14.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
1285
+ layer: z14.enum(["project", "local", "user"]).optional().describe(
1286
+ "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
1287
+ ),
1288
+ frozen: z14.boolean().optional().describe(
1289
+ "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
1290
+ )
1291
+ }),
1292
+ fromArgv: (argv, path) => {
1293
+ const positional = argv[1] && !argv[1].startsWith("--") ? argv[1] : path;
1294
+ const mode = argvFlag(argv, "--mode");
1295
+ const profiles = argvFlag(argv, "--profiles");
1296
+ const layer = argv.includes("--user") ? "user" : argv.includes("--local") ? "local" : void 0;
1297
+ const frozen = argv.includes("--frozen") ? true : argv.includes("--unfreeze") ? false : void 0;
1298
+ return {
1299
+ bundlePath: positional,
1300
+ ...mode ? { mode } : {},
1301
+ ...profiles ? {
1302
+ profiles: profiles.split(",").map((p) => p.trim()).filter(Boolean)
1303
+ } : {},
1304
+ ...layer ? { layer } : {},
1305
+ ...frozen !== void 0 ? { frozen } : {}
1306
+ };
1307
+ },
1308
+ run: ({ store, now }, { bundlePath: path, mode, profiles, layer, frozen }) => pinBase(store, process.cwd(), path, now(), {
1309
+ ...mode ? { mode } : {},
1310
+ ...profiles ? { profiles } : {},
1311
+ ...layer ? { layer } : {},
1312
+ ...frozen !== void 0 ? { frozen } : {}
1313
+ })
1314
+ });
1315
+
1316
+ // src/commands/pins.ts
1317
+ import { z as z15 } from "zod";
1318
+ var pinsCommand = define({
1319
+ name: "pins",
1320
+ tool: "kb_pins",
1321
+ usage: "pins",
1322
+ description: "Every pinned base across the manifest layers, each with its layer and whether it currently resolves to readable records. Reads the workspace manifests rather than any one base, like kb_context.",
1323
+ input: z15.object({}),
1324
+ fromArgv: () => ({}),
1325
+ run: ({ store }) => listPins(store, process.cwd())
1326
+ });
1327
+
1328
+ // src/commands/query.ts
1329
+ import { z as z16 } from "zod";
1330
+ var queryCommand = define({
1331
+ name: "query",
1332
+ tool: "kb_query",
1333
+ usage: "query <text...>",
1334
+ description: "Search and return each match with its standing. Results are flagged, never filtered: a superseded record comes back alongside whatever replaced it, and a rejected one is marked as something explicitly not adopted. Prefer kb_load when the base fits its budget: on this package's measurements, a reader holding the whole base answered eight of nine questions whose wording appears in no record, where embedding search answered four. Never read record files directly \u2014 this tool (with kb_load and kb_trace) is the only supported way to read a base; a file read bypasses supersession resolution and returns replaced records as if current.",
1335
+ input: z16.object({
1336
+ bundlePath,
1337
+ text: z16.string().optional(),
1338
+ type: z16.enum(KB_RECORD_TYPES).optional(),
1339
+ includeNonCurrent: z16.boolean().optional()
1340
+ }),
1341
+ fromArgv: (argv, path) => ({
1342
+ bundlePath: path,
1343
+ text: argv.slice(1).join(" ").trim(),
1344
+ includeNonCurrent: true
1345
+ }),
1346
+ run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
1347
+ ...type ? { type } : {},
1348
+ includeNonCurrent: includeNonCurrent === true
1349
+ })).map((hit) => ({
1350
+ conceptId: hit.record.conceptId,
1351
+ title: hit.record.frontmatter.title ?? null,
1352
+ description: hit.record.frontmatter.description ?? null,
1353
+ standing: hit.standing,
1354
+ supersededBy: hit.heads.map((head) => head.conceptId),
1355
+ warnings: hit.warnings,
1356
+ body: hit.record.body
1357
+ }))
1358
+ });
1359
+
1360
+ // src/commands/read-index.ts
1361
+ import { z as z17 } from "zod";
1362
+ var readIndexCommand = define({
1363
+ name: "index",
1364
+ tool: "kb_index",
1365
+ usage: "index",
1366
+ description: "The index, rebuilt if it disagrees with the records. One call gives the whole shape of the base: title, type, status, and description per record. The cheap re-orientation call after compaction or deep in a long session \u2014 a few hundred tokens; call it (or kb_context, when bases are pinned) first, then kb_load or fetch by concept id.",
1367
+ input: z17.object({ bundlePath }),
1368
+ fromArgv: (_argv, path) => ({ bundlePath: path }),
1369
+ run: ({ store }, { bundlePath: path }) => store.readIndex(path)
1370
+ });
1371
+
1372
+ // src/commands/schema.ts
1373
+ import { z as z18 } from "zod";
1374
+ var schemaCommand = define({
1375
+ name: "schema",
1376
+ tool: "kb_schema",
1377
+ usage: "schema",
1378
+ description: "JSON Schema for the frontmatter, the write input, and log entries \u2014 generated from the code that enforces them, so it cannot drift from what a write will accept.",
1379
+ input: z18.object({}),
1380
+ fromArgv: () => ({}),
1381
+ run: () => Promise.resolve(kbJsonSchemas())
1382
+ });
1383
+
1384
+ // src/commands/status.ts
1385
+ import { z as z19 } from "zod";
1386
+ var statusCommand = define({
1387
+ name: "status",
1388
+ tool: "kb_status",
1389
+ usage: "status <concept-id> <status>",
1390
+ description: "Move a record's status, leaving everything else alone. Uses a compare-and-swap, so a concurrent change fails loudly rather than being overwritten.",
1391
+ input: z19.object({
1392
+ bundlePath,
1393
+ conceptId,
1394
+ status: z19.enum(KB_RECORD_STATUSES)
1395
+ }),
1396
+ fromArgv: (argv, path) => ({
1397
+ bundlePath: path,
1398
+ conceptId: argv[1],
1399
+ status: argv[2]
1400
+ }),
1401
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, status }) => {
1402
+ await assertBaseNotFrozen(process.cwd(), path);
1403
+ const record = await store.setStatus(path, id, status, actor);
1404
+ return { conceptId: record.conceptId, status };
1405
+ }
1406
+ });
1407
+
1408
+ // src/commands/supersede.ts
1409
+ import { z as z20 } from "zod";
1410
+ var supersedeCommand = define({
1411
+ name: "supersede",
1412
+ tool: "kb_supersede",
1413
+ usage: "supersede <concept-id> <replacement-id>",
1414
+ description: "Mark a record superseded by another, linking both directions. Use this rather than editing a record whose meaning changed \u2014 a record that quietly becomes something else invalidates every reference to it, and the earlier understanding is what a later trace needs.",
1415
+ input: z20.object({ bundlePath, conceptId, replacementId: conceptId }),
1416
+ fromArgv: (argv, path) => ({
1417
+ bundlePath: path,
1418
+ conceptId: argv[1],
1419
+ replacementId: argv[2]
1420
+ }),
1421
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, replacementId }) => {
1422
+ await assertBaseNotFrozen(process.cwd(), path);
1423
+ await store.supersede(path, id, replacementId, actor);
1424
+ return { superseded: id, replacedBy: replacementId };
1425
+ }
1426
+ });
1427
+
1428
+ // src/commands/sync-instructions.ts
1429
+ import { z as z21 } from "zod";
1430
+ var syncInstructionsCommand = define({
1431
+ name: "sync-instructions",
1432
+ usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
1433
+ description: "Idempotently plant the `context` block between sentinel comments in an instruction file (AGENTS.md, CLAUDE.md), creating the block when absent and leaving everything outside the sentinels alone. CLI-only: this is file plumbing for runtimes whose instruction files are re-read where their conversations are not, not an agent capability \u2014 the capability is kb_context.",
1434
+ input: z21.object({
1435
+ file: z21.string().min(1).describe("The instruction file to edit in place."),
1436
+ budgetTokens: z21.number().int().positive().optional(),
1437
+ fullUnderTokens: z21.number().int().positive().optional(),
1438
+ profile: z21.string().optional()
1439
+ }),
1440
+ fromArgv: (argv) => {
1441
+ const budget = argvFlag(argv, "--budget");
1442
+ const fullUnder = argvFlag(argv, "--full-under");
1443
+ const profile = argvFlag(argv, "--profile");
1444
+ return {
1445
+ file: argv[1],
1446
+ ...budget ? { budgetTokens: Number(budget) } : {},
1447
+ ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
1448
+ ...profile ? { profile } : {}
1449
+ };
1450
+ },
1451
+ run: async ({ store }, { file, budgetTokens, fullUnderTokens, profile }) => {
1452
+ const result = await buildContext(store, process.cwd(), {
1453
+ ...budgetTokens ? { budgetTokens } : {},
1454
+ ...fullUnderTokens ? { fullUnderTokens } : {},
1455
+ ...profile ? { profile } : {},
1456
+ warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
1457
+ `)
1458
+ });
1459
+ return syncInstructions(file, result.block);
1460
+ }
1461
+ });
1462
+
1463
+ // src/commands/trace.ts
1464
+ import { z as z22 } from "zod";
1465
+ var traceCommand = define({
1466
+ name: "trace",
1467
+ tool: "kb_trace",
1468
+ usage: "trace <concept-id> [edges...]",
1469
+ description: 'How a position was arrived at, as a timeline ordered by when each record was written. Deliberately includes rejected, draft, and superseded records \u2014 in a history those are the content, not noise. Follows supersession, shared code anchors, and shared sources. Use when the question is "why is this the way it is" rather than "what do we hold now". This tool (with kb_load and kb_query) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.',
1470
+ input: z22.object({
1471
+ bundlePath,
1472
+ conceptId,
1473
+ edges: z22.array(z22.enum(TRACE_EDGES)).optional(),
1474
+ depth: z22.number().int().positive().optional()
1475
+ }),
1476
+ fromArgv: (argv, path) => ({
1477
+ bundlePath: path,
1478
+ conceptId: argv[1],
1479
+ edges: argv.slice(2).filter((edge) => TRACE_EDGES.includes(edge))
1480
+ }),
1481
+ run: async ({ store }, { bundlePath: path, conceptId: id, edges, depth }) => (await store.trace(path, id, {
1482
+ ...edges?.length ? { edges } : {},
1483
+ ...depth ? { depth } : {}
1484
+ })).map((step) => ({
1485
+ conceptId: step.record.conceptId,
1486
+ at: step.record.frontmatter.generated?.at ?? null,
1487
+ status: step.record.frontmatter.strauss_status,
1488
+ title: step.record.frontmatter.title ?? null,
1489
+ depth: step.depth,
1490
+ via: step.via,
1491
+ body: step.record.body
1492
+ }))
1493
+ });
1494
+
1495
+ // src/commands/types.ts
1496
+ import { z as z23 } from "zod";
1497
+ var typesCommand = define({
1498
+ name: "types",
1499
+ tool: "kb_types",
1500
+ usage: "types",
1501
+ 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.",
1502
+ input: z23.object({}),
1503
+ fromArgv: () => ({}),
1504
+ run: () => Promise.resolve(RECORD_TYPES)
1505
+ });
1506
+
1507
+ // src/commands/unpin.ts
1508
+ import { z as z24 } from "zod";
1509
+ var unpinCommand = define({
1510
+ name: "unpin",
1511
+ tool: "kb_unpin",
1512
+ usage: "unpin [bundle-path]",
1513
+ description: "Remove a base from every pin manifest layer that holds it \u2014 project, local, and user \u2014 because unpinned means gone, not still injected from another file. Reports which layers were touched.",
1514
+ input: z24.object({ bundlePath }),
1515
+ fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
1516
+ run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
1517
+ });
1518
+
1519
+ // src/commands/validate.ts
1520
+ import { z as z25 } from "zod";
1521
+ var validateCommand = define({
1522
+ name: "validate",
1523
+ tool: "kb_validate",
1524
+ usage: "validate",
1525
+ description: "Check pointers no single record can see: supersession links that disagree between the two records, and assumptions that cite sources. Per-record shape is enforced on every read, so a problem here means someone edited a file by hand.",
1526
+ input: z25.object({ bundlePath }),
1527
+ fromArgv: (_argv, path) => ({ bundlePath: path }),
1528
+ run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
1529
+ failsWhen: (result) => Array.isArray(result) && result.length > 0
1530
+ });
1531
+
1532
+ // src/commands/write.ts
1533
+ import { z as z26 } from "zod";
1534
+ var writeCommand = define({
1535
+ name: "write",
1536
+ tool: "kb_write",
1537
+ usage: "write <type> < record.json",
1538
+ description: [
1539
+ "Write one record. Search first \u2014 the same knowledge filed twice under different slugs is how a base rots, and a duplicate concept id is rejected rather than overwritten. Call kb_types for the sections each type accepts.",
1540
+ "",
1541
+ "Judgment the tool cannot enforce for you:",
1542
+ "- An unsourced claim is an `assumption` record with assumption: true, never a `fact` with a vague source. The distinction is what lets a later reader separate what was established from what was guessed.",
1543
+ "- When two records conflict, say so in a `risk`, an `open-question`, or a superseding `decision`. Quietly picking a winner destroys the disagreement, which is usually the useful part.",
1544
+ "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
1545
+ "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
1546
+ ].join("\n"),
1547
+ input: z26.object({
1548
+ bundlePath,
1549
+ type: z26.enum(KB_RECORD_TYPES),
1550
+ input: composeInputSchema
1551
+ }),
1552
+ fromArgv: async (argv, path, stdin) => ({
1553
+ bundlePath: path,
1554
+ type: argv[1],
1555
+ input: JSON.parse(await stdin())
1556
+ }),
1557
+ run: async ({ store, actor, now }, { bundlePath: path, type, input }) => {
1558
+ await assertBaseNotFrozen(process.cwd(), path);
1559
+ const record = await store.write(
1560
+ path,
1561
+ composeRecord(type, input, actor, now()),
1562
+ actor
1563
+ );
1564
+ return { conceptId: record.conceptId };
1565
+ }
1566
+ });
1567
+
1568
+ // src/commands/write-decision.ts
1569
+ import { z as z27 } from "zod";
1570
+ var writeDecisionCommand = define({
1571
+ name: "write-decision",
1572
+ tool: "kb_write_decision",
1573
+ usage: "write-decision < decision.json",
1574
+ description: [
1575
+ "Write a decision. Takes `alternative` and `impact` as fields rather than free sections, because what was rejected is the part a later reader cannot reconstruct from the code \u2014 a heading is too easy to leave empty.",
1576
+ "",
1577
+ "What belongs in one:",
1578
+ '- Record a decision when a later reader would otherwise "simplify" the constraint away. If the diff already answers the question, there is nothing here to write.',
1579
+ "- `alternative` is what you turned down and why, not a list of everything considered.",
1580
+ "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
1581
+ ].join("\n"),
1582
+ input: z27.object({ bundlePath, input: decisionInputSchema }),
1583
+ fromArgv: async (_argv, path, stdin) => ({
1584
+ bundlePath: path,
1585
+ input: JSON.parse(await stdin())
1586
+ }),
1587
+ run: async ({ store, actor, now }, { bundlePath: path, input }) => {
1588
+ await assertBaseNotFrozen(process.cwd(), path);
1589
+ const record = await store.write(
1590
+ path,
1591
+ composeDecisionRecord(input, actor, now()),
1592
+ actor
1593
+ );
1594
+ return { conceptId: record.conceptId };
1595
+ }
1596
+ });
1597
+
1598
+ // src/commands/index.ts
1599
+ var KB_COMMANDS = [
1600
+ writeCommand,
1601
+ writeDecisionCommand,
1602
+ noDecisionCommand,
1603
+ statusCommand,
1604
+ supersedeCommand,
1605
+ answerCommand,
1606
+ loadCommand,
1607
+ queryCommand,
1608
+ traceCommand,
1609
+ listCommand,
1610
+ readIndexCommand,
1611
+ logCommand,
1612
+ validateCommand,
1613
+ schemaCommand,
1614
+ pinCommand,
1615
+ unpinCommand,
1616
+ pinsCommand,
1617
+ contextCommand,
1618
+ syncInstructionsCommand,
1619
+ typesCommand
1620
+ ];
1621
+ var KB_COMMANDS_BY_NAME = new Map(
1622
+ KB_COMMANDS.map((command) => [command.name, command])
1623
+ );
1624
+
1625
+ // src/markdown.ts
1626
+ import matter from "gray-matter";
1627
+ function stringifyMarkdownWithFrontmatter(content, frontmatter) {
1628
+ return matter.stringify(content, frontmatter);
1629
+ }
1630
+ function splitMarkdownFrontmatter(text) {
1631
+ const file = matter(text);
1632
+ return {
1633
+ content: file.content,
1634
+ // Everything gray-matter consumed: the fences, the YAML, and the blank line
1635
+ // after them. Kept so a caller can rewrite a body without touching the head.
1636
+ prefix: text.slice(0, text.length - file.content.length),
1637
+ raw: file.data
1638
+ };
1639
+ }
1640
+ function parseMarkdownWithFrontmatter(text, schema) {
1641
+ const { content, prefix, raw } = splitMarkdownFrontmatter(text);
1642
+ return {
1643
+ content,
1644
+ prefix,
1645
+ raw,
1646
+ frontmatter: schema.safeParse(raw)
1647
+ };
1648
+ }
1649
+
1650
+ // src/errors.ts
1651
+ var Fault = /* @__PURE__ */ ((Fault2) => {
1652
+ Fault2["Configuration"] = "Configuration";
1653
+ Fault2["System"] = "System";
1654
+ Fault2["User"] = "User";
1655
+ return Fault2;
1656
+ })(Fault || {});
1657
+ var ErrorTypes = /* @__PURE__ */ ((ErrorTypes2) => {
1658
+ ErrorTypes2["KbRecordAlreadyExists"] = "KbRecordAlreadyExists";
1659
+ ErrorTypes2["KbInvalidConceptId"] = "KbInvalidConceptId";
1660
+ ErrorTypes2["KbRecordNotFound"] = "KbRecordNotFound";
1661
+ ErrorTypes2["KbWriteConflict"] = "KbWriteConflict";
1662
+ return ErrorTypes2;
1663
+ })(ErrorTypes || {});
1664
+ var BaseError = class extends Error {
1665
+ code;
1666
+ errorType;
1667
+ fault;
1668
+ retriable;
1669
+ reportToUser;
1670
+ details;
1671
+ constructor(props) {
1672
+ super(props.message);
1673
+ this.name = props.name ?? this.constructor.name;
1674
+ this.code = props.code ?? 500;
1675
+ this.errorType = props.errorType;
1676
+ this.fault = props.fault;
1677
+ this.retriable = props.retriable ?? true;
1678
+ this.reportToUser = props.reportToUser ?? false;
1679
+ this.details = props.details;
1680
+ }
1681
+ };
1682
+
1683
+ // src/kb-errors.ts
1684
+ var KbRecordAlreadyExistsError = class extends BaseError {
1685
+ constructor(conceptId2) {
1686
+ super({
1687
+ message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
1688
+ errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
1689
+ code: 409,
1690
+ fault: "User" /* User */,
1691
+ retriable: false,
1692
+ reportToUser: true,
1693
+ details: { conceptId: conceptId2 }
1694
+ });
1695
+ this.conceptId = conceptId2;
1696
+ }
1697
+ conceptId;
1698
+ };
1699
+ var KbRecordNotFoundError = class extends BaseError {
1700
+ constructor(conceptId2) {
1701
+ super({
1702
+ message: `kb: ${conceptId2} does not exist`,
1703
+ errorType: "KbRecordNotFound" /* KbRecordNotFound */,
1704
+ code: 404,
1705
+ fault: "User" /* User */,
1706
+ retriable: false,
1707
+ reportToUser: true,
1708
+ details: { conceptId: conceptId2 }
1709
+ });
1710
+ this.conceptId = conceptId2;
1711
+ }
1712
+ conceptId;
1713
+ };
1714
+ var KbWriteConflictError = class extends BaseError {
1715
+ constructor(conceptId2) {
1716
+ super({
1717
+ message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
1718
+ errorType: "KbWriteConflict" /* KbWriteConflict */,
1719
+ code: 409,
1720
+ fault: "System" /* System */,
1721
+ retriable: true,
1722
+ reportToUser: true,
1723
+ details: { conceptId: conceptId2 }
1724
+ });
1725
+ this.conceptId = conceptId2;
1726
+ }
1727
+ conceptId;
1728
+ };
1729
+ var KbInvalidConceptIdError = class extends BaseError {
1730
+ constructor(message, details) {
1731
+ super({
1732
+ message: `kb: ${message}`,
1733
+ errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
1734
+ code: 400,
1735
+ fault: "User" /* User */,
1736
+ retriable: false,
1737
+ reportToUser: true,
1738
+ details
1739
+ });
1740
+ }
1741
+ };
1742
+
1743
+ // src/search-index.ts
1744
+ import { stat } from "fs/promises";
1745
+ import { join as join3 } from "path";
1746
+ var SEARCH_INDEX_FILE = ".index.sqlite";
1747
+ var COLLECTION = "kb";
1748
+ async function searchBase(bundlePath2, query, options = {}) {
1749
+ const qmd = options.qmd ?? await loadQmd(options.logger);
1750
+ if (!qmd) return null;
1751
+ let store = null;
1752
+ try {
1753
+ store = await qmd.createStore({
1754
+ dbPath: join3(bundlePath2, SEARCH_INDEX_FILE),
1755
+ config: {
1756
+ collections: {
1757
+ [COLLECTION]: {
1758
+ path: bundlePath2,
1759
+ pattern: "**/*.md",
1760
+ // Both store-owned files are markdown and neither is a record.
1761
+ ignore: [INDEX_FILE, LOG_FILE]
1762
+ }
1763
+ }
1764
+ }
1765
+ });
1766
+ if (await isStale(bundlePath2)) {
1767
+ await store.update({ collections: [COLLECTION] });
1768
+ }
1769
+ const hits = await store.searchLex(query, {
1770
+ collection: COLLECTION,
1771
+ ...options.limit ? { limit: options.limit } : {}
1772
+ });
1773
+ return hits.map((hit) => ({
1774
+ displayPath: hit.displayPath ?? hit.filepath ?? "",
1775
+ score: hit.score ?? 0
1776
+ })).filter((hit) => hit.displayPath.length > 0);
1777
+ } catch (error) {
1778
+ options.logger?.warn?.({
1779
+ operation: "kb.search",
1780
+ outcome: "unavailable",
1781
+ error: error instanceof Error ? error.message : "unknown"
1782
+ });
1783
+ return null;
1784
+ } finally {
1785
+ await store?.close().catch(() => void 0);
1786
+ }
1787
+ }
1788
+ async function isStale(bundlePath2) {
1789
+ const indexAt = await stat(join3(bundlePath2, SEARCH_INDEX_FILE)).then((s) => s.mtimeMs).catch(() => 0);
1790
+ if (!indexAt) return true;
1791
+ const { readdir: readdir2 } = await import("fs/promises");
1792
+ const names = await readdir2(bundlePath2).catch(() => []);
1793
+ for (const name of names) {
1794
+ if (!name.endsWith(".md") || name === INDEX_FILE) continue;
1795
+ const at = await stat(join3(bundlePath2, name)).then((s) => s.mtimeMs).catch(() => 0);
1796
+ if (at > indexAt) return true;
1797
+ }
1798
+ return false;
1799
+ }
1800
+ function resolveHits(hits, records) {
1801
+ const byName = /* @__PURE__ */ new Map();
1802
+ for (const record of records) {
1803
+ const name = flatten(record.conceptId);
1804
+ if (byName.has(name)) byName.delete(name);
1805
+ else byName.set(name, record);
1806
+ }
1807
+ const resolved = [];
1808
+ for (const hit of hits) {
1809
+ const file = hit.displayPath.split("/").pop() ?? "";
1810
+ const name = file.endsWith(".md") ? file.slice(0, -".md".length) : file;
1811
+ const record = byName.get(flatten(name));
1812
+ if (record) resolved.push(record);
1813
+ }
1814
+ return resolved;
1815
+ }
1816
+ function flatten(value) {
1817
+ return value.replace(/[.]/g, "-").toLowerCase();
1818
+ }
1819
+ var QMD_MODULE = "@tobilu/qmd";
1820
+ async function loadQmd(logger) {
1821
+ try {
1822
+ return await import(QMD_MODULE);
1823
+ } catch {
1824
+ logger?.warn?.({ operation: "kb.search", outcome: "qmd-unavailable" });
1825
+ return null;
1826
+ }
1827
+ }
1828
+
1829
+ // src/kb-store.ts
1830
+ import { createHash } from "crypto";
1831
+ import {
1832
+ appendFile,
1833
+ link,
1834
+ mkdir as mkdir2,
1835
+ readdir,
1836
+ readFile as readFile3,
1837
+ rename,
1838
+ unlink,
1839
+ writeFile as writeFile3
1840
+ } from "fs/promises";
1841
+ import { join as join4, resolve as resolve4, sep as sep2 } from "path";
1842
+ var KB_DIR = join4(".strauss", "kb");
1843
+ var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
1844
+ var DEFAULT_LOAD_BUDGET = 25e3;
1845
+ var KbStore = class {
1846
+ constructor(logger = {}) {
1847
+ this.logger = logger;
1848
+ }
1849
+ logger;
1850
+ /**
1851
+ * Writes one record. `type` and `slug` compose both the filename and the
1852
+ * concept id, so a caller cannot produce a file whose identity disagrees with
1853
+ * its contents.
1854
+ */
1855
+ async write(bundlePath2, input, actor = "unknown") {
1856
+ if (!KB_SLUG_PATTERN.test(input.slug)) {
1857
+ throw new KbInvalidConceptIdError("slug must be kebab-case", {
1858
+ slug: input.slug
1859
+ });
1860
+ }
1861
+ if (!KB_SLUG_PATTERN.test(input.type)) {
1862
+ throw new KbInvalidConceptIdError("type must be kebab-case", {
1863
+ type: input.type
1864
+ });
1865
+ }
1866
+ const frontmatter = kbRecordFrontmatterSchema.parse({
1867
+ ...input.frontmatter,
1868
+ type: input.type
1869
+ });
1870
+ const conceptId2 = `${input.type}.${input.slug}`;
1871
+ const root = this.root(bundlePath2);
1872
+ const target = this.recordPath(bundlePath2, conceptId2);
1873
+ await mkdir2(root, { recursive: true });
1874
+ await this.publish(
1875
+ target,
1876
+ stringifyMarkdownWithFrontmatter(input.body, frontmatter),
1877
+ input.overwrite ?? false,
1878
+ conceptId2
1879
+ );
1880
+ await this.record(root, {
1881
+ operation: input.overwrite ? "overwrite" : "write",
1882
+ conceptId: conceptId2,
1883
+ by: actor
1884
+ });
1885
+ this.logger.info?.({
1886
+ operation: "kb.write",
1887
+ bundlePath: root,
1888
+ conceptId: conceptId2,
1889
+ anchors: frontmatter.strauss_anchors?.length ?? 0
1890
+ });
1891
+ return { conceptId: conceptId2, frontmatter, body: input.body };
1892
+ }
1893
+ /** One record by concept id, or null when it does not exist. */
1894
+ async read(bundlePath2, conceptId2) {
1895
+ const target = this.recordPath(bundlePath2, conceptId2);
1896
+ let raw;
1897
+ try {
1898
+ raw = await readFile3(target, "utf8");
1899
+ } catch {
1900
+ return null;
1901
+ }
1902
+ return this.parse(conceptId2, raw);
1903
+ }
1904
+ /**
1905
+ * Every record in the bundle, optionally narrowed to one type.
1906
+ *
1907
+ * A file that fails to parse is skipped and logged rather than thrown: one
1908
+ * malformed record — hand-edited, or written by a producer we don't know —
1909
+ * must not make the whole bundle unreadable.
1910
+ */
1911
+ async list(bundlePath2, type) {
1912
+ const root = this.root(bundlePath2);
1913
+ let names;
1914
+ try {
1915
+ names = await readdir(root);
1916
+ } catch {
1917
+ return [];
1918
+ }
1919
+ const wanted = names.sort().filter((name) => name.endsWith(".md") && !STORE_OWNED.has(name)).map((name) => ({ name, conceptId: name.slice(0, -".md".length) })).filter(({ conceptId: conceptId2 }) => !type || conceptId2.startsWith(`${type}.`));
1920
+ const records = await Promise.all(
1921
+ wanted.map(
1922
+ async ({ name, conceptId: conceptId2 }) => this.parse(conceptId2, await readFile3(join4(root, name), "utf8"))
1923
+ )
1924
+ );
1925
+ return records.filter((record) => record !== null);
1926
+ }
1927
+ /**
1928
+ * Moves a record's status, preserving everything else.
1929
+ *
1930
+ * Read-modify-write on one file is the one place two agents genuinely race,
1931
+ * and the fix is a compare-and-swap rather than a lock: hash on read, verify
1932
+ * the file is unchanged immediately before writing, fail if it moved. A lock
1933
+ * would buy the same guarantee and add a stale-lock failure mode — a writer
1934
+ * killed mid-hold blocks every later one until someone reasons about
1935
+ * timeouts.
1936
+ */
1937
+ async setStatus(bundlePath2, conceptId2, status, actor = "unknown") {
1938
+ return this.mutate(
1939
+ bundlePath2,
1940
+ conceptId2,
1941
+ (frontmatter) => ({ ...frontmatter, strauss_status: status }),
1942
+ { operation: `status:${status}`, by: actor }
1943
+ );
1944
+ }
1945
+ /**
1946
+ * Marks `conceptId` superseded by `replacementId`, and links both directions.
1947
+ *
1948
+ * Writing one side and letting a validator notice the other is missing was
1949
+ * the previous arrangement; doing both here means the backlink cannot drift
1950
+ * in normal use, and validation drops to catching hand-edits.
1951
+ */
1952
+ async supersede(bundlePath2, conceptId2, replacementId, actor = "unknown") {
1953
+ const replacement = await this.read(bundlePath2, replacementId);
1954
+ if (!replacement) throw new KbRecordNotFoundError(replacementId);
1955
+ const superseded = await this.mutate(
1956
+ bundlePath2,
1957
+ conceptId2,
1958
+ (frontmatter) => ({
1959
+ ...frontmatter,
1960
+ strauss_status: "superseded",
1961
+ strauss_superseded_by: replacementId
1962
+ }),
1963
+ { operation: "supersede", by: actor, target: replacementId }
1964
+ );
1965
+ await this.mutate(
1966
+ bundlePath2,
1967
+ replacementId,
1968
+ (frontmatter) => ({
1969
+ ...frontmatter,
1970
+ strauss_supersedes: [
1971
+ .../* @__PURE__ */ new Set([...frontmatter.strauss_supersedes ?? [], conceptId2])
1972
+ ]
1973
+ }),
1974
+ { operation: "supersedes", by: actor, target: conceptId2 }
1975
+ );
1976
+ return superseded;
1977
+ }
1978
+ /** Resolves an open question, stamping who answered and when. */
1979
+ async answer(bundlePath2, conceptId2, answer, actor = "unknown", at = (/* @__PURE__ */ new Date()).toISOString()) {
1980
+ return this.mutate(
1981
+ bundlePath2,
1982
+ conceptId2,
1983
+ (frontmatter) => ({
1984
+ ...frontmatter,
1985
+ strauss_status: "resolved",
1986
+ strauss_answered: { by: actor, at }
1987
+ }),
1988
+ { operation: "answer", by: actor },
1989
+ (body) => `${body.trimEnd()}
1990
+
1991
+ ## Answer
1992
+
1993
+ ${answer}
1994
+ `
1995
+ );
1996
+ }
1997
+ /**
1998
+ * Records matching a text query, each carrying its standing.
1999
+ *
2000
+ * Relevance comes from qmd's BM25 where an index is available and from a
2001
+ * substring scan where it is not. What never moves to the ranker is the
2002
+ * adjudication below it: a ranker answers relevance, and relevance is not
2003
+ * standing — a superseded record is the older, longer, more general one, so
2004
+ * ranking alone prefers what is no longer true.
2005
+ *
2006
+ * The fallback is deliberate. A search index is an optimisation, so losing it
2007
+ * degrades recall and must never change the answer's shape or fail the call.
2008
+ */
2009
+ async query(bundlePath2, text, options = {}) {
2010
+ const bundle = await this.list(bundlePath2);
2011
+ const needle = text.trim();
2012
+ const hits = needle ? await this.rank(bundlePath2, needle, bundle) : bundle;
2013
+ const adjudicated = adjudicate(
2014
+ options.type ? hits.filter((r) => r.frontmatter.type === options.type) : hits,
2015
+ bundle
2016
+ );
2017
+ if (options.includeNonCurrent) return adjudicated;
2018
+ const present = new Set(adjudicated.map((hit) => hit.record.conceptId));
2019
+ return adjudicated.filter(
2020
+ (hit) => hit.standing !== "superseded" || !hit.heads.some((head) => present.has(head.conceptId))
2021
+ );
2022
+ }
2023
+ async rank(bundlePath2, needle, bundle) {
2024
+ const ranked = await searchBase(this.root(bundlePath2), needle, {
2025
+ logger: this.logger
2026
+ });
2027
+ if (ranked) {
2028
+ const found = resolveHits(ranked, bundle);
2029
+ if (found.length) return found;
2030
+ }
2031
+ const lowered = needle.toLowerCase();
2032
+ return bundle.filter((record) => matches(record, lowered));
2033
+ }
2034
+ /**
2035
+ * The whole base, adjudicated, when it is small enough to hand over.
2036
+ *
2037
+ * At the sizes these reach — twenty records is about three thousand tokens —
2038
+ * loading everything beats searching it, and measurably: on nine questions
2039
+ * whose wording appears in no record, a reader holding the base answered
2040
+ * eight against an embedding search's four. Two of those differences are
2041
+ * structural. A reader can say no record answers the question; vector search
2042
+ * returns its nearest neighbour whatever the distance. And a reader picks the
2043
+ * record that answers the question rather than the one nearest the topic.
2044
+ * See the README's retrieval section for the measurements.
2045
+ *
2046
+ * Load it for a question, not for a session — a base read into a long
2047
+ * conversation is summarised away by the end of it.
2048
+ *
2049
+ * Refuses rather than truncates when the base is too large. A truncated base
2050
+ * is indistinguishable from a complete one, so a caller would answer "that
2051
+ * was never decided" from a slice it did not know was a slice.
2052
+ */
2053
+ async load(bundlePath2, options = {}) {
2054
+ const budgetTokens = options.budgetTokens ?? DEFAULT_LOAD_BUDGET;
2055
+ const bundle = await this.list(bundlePath2);
2056
+ const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
2057
+ const adjudicated = adjudicate(wanted, bundle);
2058
+ const records = adjudicated.filter((hit) => hit.standing !== "superseded");
2059
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
2060
+ const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
2061
+ if (approxTokens2 > budgetTokens) {
2062
+ return {
2063
+ loaded: false,
2064
+ recordCount: wanted.length,
2065
+ approxTokens: approxTokens2,
2066
+ budgetTokens
2067
+ };
2068
+ }
2069
+ return {
2070
+ loaded: true,
2071
+ recordCount: wanted.length,
2072
+ approxTokens: approxTokens2,
2073
+ budgetTokens,
2074
+ records,
2075
+ superseded
2076
+ };
2077
+ }
2078
+ /** How a position was arrived at, as a timeline. See `trace.ts`. */
2079
+ async trace(bundlePath2, seedId, options = {}) {
2080
+ return trace(seedId, await this.list(bundlePath2), options);
2081
+ }
2082
+ /**
2083
+ * The stored index, rebuilt if it disagrees with the records.
2084
+ *
2085
+ * Repair on read is what makes the lock-free write path safe: a writer whose
2086
+ * scan predated another writer's record publishes a momentarily stale index,
2087
+ * and the next reader through here settles it.
2088
+ */
2089
+ async readIndex(bundlePath2) {
2090
+ const root = this.root(bundlePath2);
2091
+ const expected = renderIndex(await this.list(bundlePath2));
2092
+ const stored = await readFile3(join4(root, INDEX_FILE), "utf8").catch(
2093
+ () => null
2094
+ );
2095
+ if (indexIsStale(stored, expected)) {
2096
+ await this.publish(join4(root, INDEX_FILE), expected, true, INDEX_FILE);
2097
+ this.logger.info?.({
2098
+ operation: "kb.index.repair",
2099
+ bundlePath: root,
2100
+ reason: stored === null ? "missing" : "stale"
2101
+ });
2102
+ }
2103
+ return expected;
2104
+ }
2105
+ /**
2106
+ * The log, with unparseable lines reported rather than repaired.
2107
+ *
2108
+ * The log is the bundle's only artifact that cannot be reconstructed — the
2109
+ * records rebuild the index, and the code outlives both, but nothing else
2110
+ * knows which agent touched what. So a bad line is surfaced and left alone.
2111
+ */
2112
+ async readLog(bundlePath2) {
2113
+ const raw = await readFile3(
2114
+ join4(this.root(bundlePath2), LOG_FILE),
2115
+ "utf8"
2116
+ ).catch(() => "");
2117
+ const result = parseLog(raw);
2118
+ for (const bad of result.malformed) {
2119
+ this.logger.warn?.({
2120
+ operation: "kb.log.parse",
2121
+ line: bad.line,
2122
+ outcome: "skipped"
2123
+ });
2124
+ }
2125
+ return result;
2126
+ }
2127
+ async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
2128
+ const target = this.recordPath(bundlePath2, conceptId2);
2129
+ const before = await readFile3(target, "utf8").catch(() => null);
2130
+ if (before === null) throw new KbRecordNotFoundError(conceptId2);
2131
+ const parsed = this.parse(conceptId2, before);
2132
+ if (!parsed) throw new KbRecordNotFoundError(conceptId2);
2133
+ const frontmatter = change(parsed.frontmatter);
2134
+ const body = changeBody(parsed.body);
2135
+ const contents = stringifyMarkdownWithFrontmatter(body, frontmatter);
2136
+ const witness = await readFile3(target, "utf8").catch(() => null);
2137
+ if (witness === null || digest(witness) !== digest(before)) {
2138
+ throw new KbWriteConflictError(conceptId2);
2139
+ }
2140
+ await this.publish(target, contents, true, conceptId2);
2141
+ await this.record(this.root(bundlePath2), { ...entry, conceptId: conceptId2 });
2142
+ return { conceptId: conceptId2, frontmatter, body };
2143
+ }
2144
+ /**
2145
+ * Two guarantees, both about writers running in parallel.
2146
+ *
2147
+ * The record is written to a staging file and only then published, so a
2148
+ * concurrent reader sees the whole record or no record — never half of one. A
2149
+ * plain write is not atomic, and `list()` skips what it cannot parse, so a
2150
+ * torn read would be silently reported as a malformed record.
2151
+ *
2152
+ * Publishing uses `link` rather than `rename` unless the caller asked to
2153
+ * overwrite: `link` fails with EEXIST instead of replacing, which turns "two
2154
+ * writers chose the same concept id" from silent data loss into a collision
2155
+ * the caller has to answer. Both are atomic; only `rename` clobbers.
2156
+ *
2157
+ * The staging name deliberately does not end in `.md` — `list()` would
2158
+ * otherwise try to read it mid-write.
2159
+ */
2160
+ async publish(target, contents, overwrite, conceptId2) {
2161
+ const staging = `${target}.${process.pid}.tmp`;
2162
+ await writeFile3(staging, contents, "utf8");
2163
+ try {
2164
+ if (overwrite) {
2165
+ await rename(staging, target);
2166
+ return;
2167
+ }
2168
+ await link(staging, target);
2169
+ } catch (error) {
2170
+ if (error.code === "EEXIST") {
2171
+ throw new KbRecordAlreadyExistsError(conceptId2);
2172
+ }
2173
+ throw error;
2174
+ } finally {
2175
+ await unlink(staging).catch(() => void 0);
2176
+ }
2177
+ }
2178
+ /** Appends one log line. Failing to log must not fail the mutation. */
2179
+ async record(root, entry) {
2180
+ const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
2181
+ await appendFile(join4(root, LOG_FILE), line, "utf8").catch((error) => {
2182
+ this.logger.warn?.({
2183
+ operation: "kb.log.append",
2184
+ outcome: "failed",
2185
+ error: error instanceof Error ? error.message : "unknown"
2186
+ });
2187
+ });
2188
+ }
2189
+ parse(conceptId2, raw) {
2190
+ const parsed = parseMarkdownWithFrontmatter(raw, kbRecordFrontmatterSchema);
2191
+ if (!parsed.frontmatter.success) {
2192
+ this.logger.warn?.({
2193
+ operation: "kb.parse",
2194
+ conceptId: conceptId2,
2195
+ outcome: "skipped",
2196
+ error: parsed.frontmatter.error.issues[0]?.message ?? "invalid"
2197
+ });
2198
+ return null;
2199
+ }
2200
+ return {
2201
+ conceptId: conceptId2,
2202
+ frontmatter: parsed.frontmatter.data,
2203
+ body: parsed.content
2204
+ };
2205
+ }
2206
+ root(bundlePath2) {
2207
+ return resolve4(bundlePath2);
2208
+ }
2209
+ // Concept ids are `<type>.<slug>` and map to a single file directly under the
2210
+ // bundle root; anything carrying a separator would escape it.
2211
+ recordPath(bundlePath2, conceptId2) {
2212
+ if (conceptId2.includes(sep2) || conceptId2.includes("/")) {
2213
+ throw new KbInvalidConceptIdError(
2214
+ "concept id must not contain a path separator",
2215
+ { conceptId: conceptId2 }
2216
+ );
2217
+ }
2218
+ return join4(this.root(bundlePath2), `${conceptId2}.md`);
2219
+ }
2220
+ };
2221
+ function estimateTokens(record) {
2222
+ return Math.ceil(
2223
+ (record.body.length + JSON.stringify(record.frontmatter).length) / 4
2224
+ );
2225
+ }
2226
+ function estimateStubTokens(entry) {
2227
+ return Math.ceil(JSON.stringify(entry).length / 4);
2228
+ }
2229
+ function stub(hit) {
2230
+ return {
2231
+ conceptId: hit.record.conceptId,
2232
+ title: hit.record.frontmatter.title ?? null,
2233
+ supersededBy: hit.heads.map((head) => head.conceptId),
2234
+ at: hit.record.frontmatter.generated?.at ?? null
2235
+ };
2236
+ }
2237
+ function matches(record, needle) {
2238
+ const { title, description } = record.frontmatter;
2239
+ return [record.conceptId, title, description, record.body].some(
2240
+ (field) => field?.toLowerCase().includes(needle)
2241
+ );
2242
+ }
2243
+ function digest(contents) {
2244
+ return createHash("sha256").update(contents).digest("hex");
2245
+ }
2246
+
2247
+ export {
2248
+ kbSourceSchema,
2249
+ kbActorStampSchema,
2250
+ kbAnchorSchema,
2251
+ KB_RECORD_TYPES,
2252
+ KB_SLUG_PATTERN,
2253
+ KB_CONCEPT_ID_PATTERN,
2254
+ kbConceptIdSchema,
2255
+ KB_RECORD_STATUSES,
2256
+ KB_MATERIALITIES,
2257
+ KB_CONFIDENCES,
2258
+ kbRecordFrontmatterSchema,
2259
+ RECORD_TYPES,
2260
+ isKbRecordType,
2261
+ composeInputSchema,
2262
+ composeRecord,
2263
+ DECISION_TYPE,
2264
+ NO_DECISION_SLUG,
2265
+ decisionInputSchema,
2266
+ composeDecisionRecord,
2267
+ composeNoDecisionRecord,
2268
+ isNoDecisionRecord,
2269
+ selectDecisions,
2270
+ contextProfileBudgets,
2271
+ mergedContextBudgets,
2272
+ KbPinsMalformedError,
2273
+ KbBaseFrozenError,
2274
+ PINS_FILE,
2275
+ PINS_LOCAL_FILE,
2276
+ PIN_LAYERS,
2277
+ readPinsLayer,
2278
+ resolvePinPath,
2279
+ readMergedPins,
2280
+ assertBaseNotFrozen,
2281
+ listPins,
2282
+ pinBase,
2283
+ unpinBase,
2284
+ adjudicate,
2285
+ resolveHeads,
2286
+ INDEX_FILE,
2287
+ renderIndex,
2288
+ renderIndexLine,
2289
+ indexIsStale,
2290
+ CONTEXT_PROFILES,
2291
+ buildContext,
2292
+ toHookJson,
2293
+ CONTEXT_BEGIN,
2294
+ CONTEXT_END,
2295
+ syncInstructions,
2296
+ LOG_FILE,
2297
+ kbLogEntrySchema,
2298
+ renderLogEntry,
2299
+ parseLog,
2300
+ kbJsonSchemas,
2301
+ TRACE_EDGES,
2302
+ trace,
2303
+ validateBundle,
2304
+ KB_COMMANDS,
2305
+ KB_COMMANDS_BY_NAME,
2306
+ stringifyMarkdownWithFrontmatter,
2307
+ splitMarkdownFrontmatter,
2308
+ parseMarkdownWithFrontmatter,
2309
+ Fault,
2310
+ ErrorTypes,
2311
+ BaseError,
2312
+ KbRecordAlreadyExistsError,
2313
+ KbRecordNotFoundError,
2314
+ KbWriteConflictError,
2315
+ KbInvalidConceptIdError,
2316
+ SEARCH_INDEX_FILE,
2317
+ searchBase,
2318
+ resolveHits,
2319
+ loadQmd,
2320
+ KB_DIR,
2321
+ KbStore
2322
+ };
2323
+ //# sourceMappingURL=chunk-HYNAEAPM.js.map