@saasontools/strauss-kb 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -31,6 +31,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  BaseError: () => BaseError,
34
+ CONTEXT_BEGIN: () => CONTEXT_BEGIN,
35
+ CONTEXT_END: () => CONTEXT_END,
36
+ CONTEXT_PROFILES: () => CONTEXT_PROFILES,
34
37
  DECISION_TYPE: () => DECISION_TYPE,
35
38
  ErrorTypes: () => ErrorTypes,
36
39
  Fault: () => Fault,
@@ -44,21 +47,29 @@ __export(index_exports, {
44
47
  KB_RECORD_STATUSES: () => KB_RECORD_STATUSES,
45
48
  KB_RECORD_TYPES: () => KB_RECORD_TYPES,
46
49
  KB_SLUG_PATTERN: () => KB_SLUG_PATTERN,
50
+ KbBaseFrozenError: () => KbBaseFrozenError,
47
51
  KbInvalidConceptIdError: () => KbInvalidConceptIdError,
52
+ KbPinsMalformedError: () => KbPinsMalformedError,
48
53
  KbRecordAlreadyExistsError: () => KbRecordAlreadyExistsError,
49
54
  KbRecordNotFoundError: () => KbRecordNotFoundError,
50
55
  KbStore: () => KbStore,
51
56
  KbWriteConflictError: () => KbWriteConflictError,
52
57
  LOG_FILE: () => LOG_FILE,
53
58
  NO_DECISION_SLUG: () => NO_DECISION_SLUG,
59
+ PINS_FILE: () => PINS_FILE,
60
+ PINS_LOCAL_FILE: () => PINS_LOCAL_FILE,
61
+ PIN_LAYERS: () => PIN_LAYERS,
54
62
  RECORD_TYPES: () => RECORD_TYPES,
55
63
  SEARCH_INDEX_FILE: () => SEARCH_INDEX_FILE,
56
64
  TRACE_EDGES: () => TRACE_EDGES,
57
65
  adjudicate: () => adjudicate,
66
+ assertBaseNotFrozen: () => assertBaseNotFrozen,
67
+ buildContext: () => buildContext,
58
68
  composeDecisionRecord: () => composeDecisionRecord,
59
69
  composeInputSchema: () => composeInputSchema,
60
70
  composeNoDecisionRecord: () => composeNoDecisionRecord,
61
71
  composeRecord: () => composeRecord,
72
+ contextProfileBudgets: () => contextProfileBudgets,
62
73
  createKbMcpServer: () => createKbMcpServer,
63
74
  decisionInputSchema: () => decisionInputSchema,
64
75
  indexIsStale: () => indexIsStale,
@@ -71,21 +82,31 @@ __export(index_exports, {
71
82
  kbLogEntrySchema: () => kbLogEntrySchema,
72
83
  kbRecordFrontmatterSchema: () => kbRecordFrontmatterSchema,
73
84
  kbSourceSchema: () => kbSourceSchema,
85
+ listPins: () => listPins,
74
86
  loadQmd: () => loadQmd,
75
87
  matchToDiff: () => matchToDiff,
88
+ mergedContextBudgets: () => mergedContextBudgets,
76
89
  parseLog: () => parseLog,
77
90
  parseMarkdownWithFrontmatter: () => parseMarkdownWithFrontmatter,
91
+ pinBase: () => pinBase,
92
+ readMergedPins: () => readMergedPins,
93
+ readPinsLayer: () => readPinsLayer,
78
94
  renderIndex: () => renderIndex,
95
+ renderIndexLine: () => renderIndexLine,
79
96
  renderLogEntry: () => renderLogEntry,
80
97
  resolveHeads: () => resolveHeads,
81
98
  resolveHits: () => resolveHits,
99
+ resolvePinPath: () => resolvePinPath,
82
100
  runKbCli: () => runKbCli,
83
101
  runKbMcpServer: () => runKbMcpServer,
84
102
  searchBase: () => searchBase,
85
103
  selectDecisions: () => selectDecisions,
86
104
  splitMarkdownFrontmatter: () => splitMarkdownFrontmatter,
87
105
  stringifyMarkdownWithFrontmatter: () => stringifyMarkdownWithFrontmatter,
106
+ syncInstructions: () => syncInstructions,
107
+ toHookJson: () => toHookJson,
88
108
  trace: () => trace,
109
+ unpinBase: () => unpinBase,
89
110
  validateBundle: () => validateBundle
90
111
  });
91
112
  module.exports = __toCommonJS(index_exports);
@@ -249,7 +270,7 @@ var KbRecordAlreadyExistsError = class extends BaseError {
249
270
  fault: "User" /* User */,
250
271
  retriable: false,
251
272
  reportToUser: true,
252
- details: { conceptId: conceptId2 }
273
+ details: { conceptId: conceptId2, action: "refused" }
253
274
  });
254
275
  this.conceptId = conceptId2;
255
276
  }
@@ -303,18 +324,19 @@ var KbInvalidConceptIdError = class extends BaseError {
303
324
  var INDEX_FILE = "INDEX.md";
304
325
  var HEADING = "# KB Index";
305
326
  function renderIndex(records) {
306
- const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map((record) => {
307
- const { frontmatter: fm } = record;
308
- const parts = [fm.type, fm.strauss_status];
309
- if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
310
- if (fm.description) parts.push(fm.description);
311
- return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
312
- });
327
+ const lines = [...records].sort((left, right) => left.conceptId.localeCompare(right.conceptId)).map(renderIndexLine);
313
328
  return `${HEADING}
314
329
 
315
330
  ${lines.join("\n")}
316
331
  `;
317
332
  }
333
+ function renderIndexLine(record) {
334
+ const { frontmatter: fm } = record;
335
+ const parts = [fm.type, fm.strauss_status];
336
+ if (fm.tags?.length) parts.push(`tags: ${fm.tags.join(", ")}`);
337
+ if (fm.description) parts.push(fm.description);
338
+ return `- [${fm.title ?? record.conceptId}](${record.conceptId}.md) \u2014 ${parts.join(" \xB7 ")}`;
339
+ }
318
340
  function indexIsStale(stored, expected) {
319
341
  return stored !== expected;
320
342
  }
@@ -653,13 +675,27 @@ var KbStore = class {
653
675
  conceptId: conceptId2,
654
676
  by: actor
655
677
  });
678
+ const targets = new Set(frontmatter.strauss_supersedes ?? []);
679
+ targets.delete(conceptId2);
680
+ const supersededIds = [];
681
+ for (const old of targets) {
682
+ if (await this.markSupersededRetrying(bundlePath2, old, conceptId2, actor)) {
683
+ supersededIds.push(old);
684
+ }
685
+ }
656
686
  this.logger.info?.({
657
687
  operation: "kb.write",
658
688
  bundlePath: root,
659
689
  conceptId: conceptId2,
660
690
  anchors: frontmatter.strauss_anchors?.length ?? 0
661
691
  });
662
- return { conceptId: conceptId2, frontmatter, body: input.body };
692
+ return {
693
+ conceptId: conceptId2,
694
+ frontmatter,
695
+ body: input.body,
696
+ action: supersededIds.length ? "superseded-prior" : "created",
697
+ supersededIds
698
+ };
663
699
  }
664
700
  /** One record by concept id, or null when it does not exist. */
665
701
  async read(bundlePath2, conceptId2) {
@@ -723,15 +759,11 @@ var KbStore = class {
723
759
  async supersede(bundlePath2, conceptId2, replacementId, actor = "unknown") {
724
760
  const replacement = await this.read(bundlePath2, replacementId);
725
761
  if (!replacement) throw new KbRecordNotFoundError(replacementId);
726
- const superseded = await this.mutate(
762
+ const superseded = await this.markSuperseded(
727
763
  bundlePath2,
728
764
  conceptId2,
729
- (frontmatter) => ({
730
- ...frontmatter,
731
- strauss_status: "superseded",
732
- strauss_superseded_by: replacementId
733
- }),
734
- { operation: "supersede", by: actor, target: replacementId }
765
+ replacementId,
766
+ actor
735
767
  );
736
768
  await this.mutate(
737
769
  bundlePath2,
@@ -828,19 +860,19 @@ ${answer}
828
860
  const adjudicated = adjudicate(wanted, bundle);
829
861
  const records = adjudicated.filter((hit) => hit.standing !== "superseded");
830
862
  const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(stub);
831
- const approxTokens = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
832
- if (approxTokens > budgetTokens) {
863
+ const approxTokens2 = records.reduce((total, hit) => total + estimateTokens(hit.record), 0) + superseded.reduce((total, entry) => total + estimateStubTokens(entry), 0);
864
+ if (approxTokens2 > budgetTokens) {
833
865
  return {
834
866
  loaded: false,
835
867
  recordCount: wanted.length,
836
- approxTokens,
868
+ approxTokens: approxTokens2,
837
869
  budgetTokens
838
870
  };
839
871
  }
840
872
  return {
841
873
  loaded: true,
842
874
  recordCount: wanted.length,
843
- approxTokens,
875
+ approxTokens: approxTokens2,
844
876
  budgetTokens,
845
877
  records,
846
878
  superseded
@@ -895,6 +927,42 @@ ${answer}
895
927
  }
896
928
  return result;
897
929
  }
930
+ /**
931
+ * `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
932
+ * a missing target (a broken link, legal per compose.ts) or a CAS conflict
933
+ * from a concurrent writer touching the same target. A conflict is retried
934
+ * a bounded number of times — each attempt re-reads the target fresh — and
935
+ * on the last, `false` reports "not marked" rather than throwing: the
936
+ * caller's own record is already published, so failing here would leave
937
+ * that publish unreported instead of undone. kb_validate's existing
938
+ * "not marked superseded" check is what surfaces the residue.
939
+ */
940
+ async markSupersededRetrying(bundlePath2, conceptId2, replacementId, actor, retries = 3) {
941
+ for (let attempt = 0; attempt <= retries; attempt++) {
942
+ try {
943
+ await this.markSuperseded(bundlePath2, conceptId2, replacementId, actor);
944
+ return true;
945
+ } catch (error) {
946
+ if (error instanceof KbRecordNotFoundError) return false;
947
+ if (!(error instanceof KbWriteConflictError)) throw error;
948
+ if (attempt === retries) return false;
949
+ }
950
+ }
951
+ return false;
952
+ }
953
+ /** The one-directional half of `supersede`: marks `conceptId` superseded. */
954
+ async markSuperseded(bundlePath2, conceptId2, replacementId, actor) {
955
+ return this.mutate(
956
+ bundlePath2,
957
+ conceptId2,
958
+ (frontmatter) => ({
959
+ ...frontmatter,
960
+ strauss_status: "superseded",
961
+ strauss_superseded_by: replacementId
962
+ }),
963
+ { operation: "supersede", by: actor, target: replacementId }
964
+ );
965
+ }
898
966
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
899
967
  const target = this.recordPath(bundlePath2, conceptId2);
900
968
  const before = await (0, import_promises2.readFile)(target, "utf8").catch(() => null);
@@ -1096,12 +1164,22 @@ var composeInputSchema = import_zod3.z.object({
1096
1164
  sources: import_zod3.z.array(kbSourceSchema).optional(),
1097
1165
  /** No source exists, as a claim rather than a sentinel in `sources`. */
1098
1166
  assumption: import_zod3.z.boolean().optional(),
1167
+ /**
1168
+ * OKF `stale_after`: the absolute date this record stops being trusted.
1169
+ * Anything the outside world can change — pricing, quotas, versions,
1170
+ * reception counts — should carry one.
1171
+ */
1172
+ stale_after: import_zod3.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
1173
+ message: "stale_after must be YYYY-MM-DD"
1174
+ }).refine((date) => !Number.isNaN(Date.parse(date)), {
1175
+ message: "stale_after must be a real date"
1176
+ }).optional(),
1099
1177
  verify: import_zod3.z.array(import_zod3.z.string().min(1)).optional(),
1100
1178
  tags: import_zod3.z.array(import_zod3.z.string().min(1)).optional(),
1101
1179
  /** Concept ids this record relates to; rendered as body links. */
1102
1180
  relatedConceptIds: import_zod3.z.array(kbConceptIdSchema).optional(),
1103
1181
  /** Concept ids this record replaces. The store settles the backlinks. */
1104
- supersedes: import_zod3.z.array(kbConceptIdSchema).optional(),
1182
+ supersedes: import_zod3.z.array(kbConceptIdSchema).max(32).optional(),
1105
1183
  materiality: import_zod3.z.enum(KB_MATERIALITIES).optional(),
1106
1184
  confidence: import_zod3.z.enum(KB_CONFIDENCES).optional(),
1107
1185
  owner: import_zod3.z.string().min(1).optional()
@@ -1128,6 +1206,7 @@ function composeRecord(type, input, writtenBy, writtenAt) {
1128
1206
  verified: [],
1129
1207
  strauss_status: spec.initialStatus
1130
1208
  };
1209
+ if (parsed.stale_after) frontmatter.stale_after = parsed.stale_after;
1131
1210
  if (parsed.anchors?.length) frontmatter.strauss_anchors = parsed.anchors;
1132
1211
  if (parsed.verify?.length) frontmatter.strauss_verify = parsed.verify;
1133
1212
  if (parsed.tags?.length) frontmatter.tags = parsed.tags;
@@ -1163,15 +1242,545 @@ ${text}`);
1163
1242
  };
1164
1243
  }
1165
1244
 
1166
- // src/json-schema.ts
1245
+ // src/kb-pins/budgets.ts
1246
+ function asBudgets(value) {
1247
+ if (value === null || typeof value !== "object") return {};
1248
+ const table = value;
1249
+ const pick = (key2, min) => {
1250
+ const raw = table[key2];
1251
+ return typeof raw === "number" && Number.isInteger(raw) && raw >= min ? raw : void 0;
1252
+ };
1253
+ const budgetTokens = pick("budgetTokens", 1);
1254
+ const fullUnderTokens = pick("fullUnderTokens", 0);
1255
+ return {
1256
+ ...budgetTokens ? { budgetTokens } : {},
1257
+ ...fullUnderTokens !== void 0 ? { fullUnderTokens } : {}
1258
+ };
1259
+ }
1260
+ function contextProfileBudgets(manifest, profile) {
1261
+ const table = manifest.context;
1262
+ if (table === null || typeof table !== "object") return {};
1263
+ const entries = table;
1264
+ return {
1265
+ ...asBudgets(entries["default"]),
1266
+ ...profile ? asBudgets(entries[profile]) : {}
1267
+ };
1268
+ }
1269
+ function mergedContextBudgets(merged, profile) {
1270
+ const layered = ["user", "local", "project"].map((layer) => {
1271
+ const manifest = merged.manifests[layer];
1272
+ return manifest ? contextProfileBudgets(manifest, profile) : {};
1273
+ });
1274
+ return { ...layered[0], ...layered[1], ...layered[2] };
1275
+ }
1276
+
1277
+ // src/kb-pins/errors.ts
1278
+ var KbPinsMalformedError = class extends Error {
1279
+ constructor(file, cause) {
1280
+ super(`pin manifest ${file} is not readable (${cause}) \u2014 fix or remove it`);
1281
+ this.name = "KbPinsMalformedError";
1282
+ }
1283
+ };
1284
+ var KbBaseFrozenError = class extends Error {
1285
+ constructor(bundlePath2, layer) {
1286
+ super(
1287
+ `${bundlePath2} is frozen (read-only) by this workspace's ${layer} pin manifest \u2014 re-pin with --unfreeze, or unpin, to change it`
1288
+ );
1289
+ this.name = "KbBaseFrozenError";
1290
+ }
1291
+ };
1292
+
1293
+ // src/kb-pins/frozen.ts
1294
+ var import_node_path5 = require("path");
1295
+
1296
+ // src/kb-pins/layers.ts
1297
+ var import_promises3 = require("fs/promises");
1298
+ var import_node_os = require("os");
1299
+ var import_node_path4 = require("path");
1300
+
1301
+ // src/kb-pins/model.ts
1302
+ var import_node_path3 = require("path");
1167
1303
  var import_zod4 = require("zod");
1304
+ var PINS_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.json");
1305
+ var PINS_LOCAL_FILE = (0, import_node_path3.join)(".strauss", "kb-pins.local.json");
1306
+ var PIN_LAYERS = ["project", "local", "user"];
1307
+ var pinSchema = import_zod4.z.object({
1308
+ /** Relative to the manifest's root, so the file is committable. */
1309
+ path: import_zod4.z.string().min(1),
1310
+ pinnedAt: import_zod4.z.string().min(1).optional(),
1311
+ /**
1312
+ * How `context` renders this base. `full` preloads the whole base into
1313
+ * the block regardless of the full-under threshold — for a base whose
1314
+ * contents should simply be present, the way an ADR base should be —
1315
+ * still answering to the block budget, with an index fallback that says
1316
+ * so when it cannot fit. `index` never upgrades, whatever the threshold.
1317
+ * Absent: the profile's full-under threshold decides. Invalid values
1318
+ * degrade to absent rather than failing the manifest.
1319
+ */
1320
+ mode: import_zod4.z.enum(["full", "index"]).optional().catch(void 0),
1321
+ /**
1322
+ * Context profiles this pin surfaces in (e.g. only at session-start,
1323
+ * not per turn). Absent: every profile. A run without a profile sees
1324
+ * every pin. A base that only matters to one skill is better loaded by
1325
+ * that skill at point of use than pinned at all — pins are what every
1326
+ * session should see.
1327
+ */
1328
+ profiles: import_zod4.z.array(import_zod4.z.string()).optional().catch(void 0),
1329
+ /**
1330
+ * The base is concluded — a finished piece of research, a frozen ADR
1331
+ * set. Write commands against it refuse while this workspace holds the
1332
+ * pin, and `context` labels it read-only. Workspace policy, not base
1333
+ * state: the base itself stays copyable and writable elsewhere.
1334
+ */
1335
+ frozen: import_zod4.z.boolean().optional().catch(void 0)
1336
+ }).passthrough();
1337
+ var pinsManifestSchema = import_zod4.z.object({
1338
+ pins: import_zod4.z.array(pinSchema).default([]),
1339
+ /**
1340
+ * Per-repo budgets for the `context` command, keyed by profile —
1341
+ * `"session-start"`, `"compact"`, `"turn"`, or `"default"` for all of
1342
+ * them. Deliberately untyped here: a typo'd budget must degrade to the
1343
+ * built-in default, not make the whole manifest unreadable and silence
1344
+ * the index at every session start. `contextProfileBudgets` does the
1345
+ * tolerant read.
1346
+ */
1347
+ context: import_zod4.z.unknown().optional()
1348
+ }).passthrough();
1349
+
1350
+ // src/kb-pins/layers.ts
1351
+ function userRoot() {
1352
+ return process.env.STRAUSS_KB_USER_ROOT || (0, import_node_os.homedir)();
1353
+ }
1354
+ function layerRoot(workspaceDir, layer) {
1355
+ return layer === "user" ? userRoot() : (0, import_node_path4.resolve)(workspaceDir);
1356
+ }
1357
+ function layerFile(workspaceDir, layer) {
1358
+ return (0, import_node_path4.join)(
1359
+ layerRoot(workspaceDir, layer),
1360
+ layer === "local" ? PINS_LOCAL_FILE : PINS_FILE
1361
+ );
1362
+ }
1363
+ async function readPinsLayer(workspaceDir, layer) {
1364
+ const file = layerFile(workspaceDir, layer);
1365
+ let raw;
1366
+ try {
1367
+ raw = await (0, import_promises3.readFile)(file, "utf8");
1368
+ } catch {
1369
+ return { pins: [] };
1370
+ }
1371
+ let parsed;
1372
+ try {
1373
+ parsed = JSON.parse(raw);
1374
+ } catch (error) {
1375
+ throw new KbPinsMalformedError(
1376
+ file,
1377
+ error instanceof Error ? error.message : "invalid JSON"
1378
+ );
1379
+ }
1380
+ const manifest = pinsManifestSchema.safeParse(parsed);
1381
+ if (!manifest.success) {
1382
+ throw new KbPinsMalformedError(
1383
+ file,
1384
+ manifest.error.issues[0]?.message ?? "invalid shape"
1385
+ );
1386
+ }
1387
+ return manifest.data;
1388
+ }
1389
+ async function writePinsLayer(workspaceDir, layer, manifest) {
1390
+ const file = layerFile(workspaceDir, layer);
1391
+ await (0, import_promises3.mkdir)((0, import_node_path4.dirname)(file), { recursive: true });
1392
+ await (0, import_promises3.writeFile)(file, `${JSON.stringify(manifest, null, 2)}
1393
+ `, "utf8");
1394
+ }
1395
+ function resolvePinPath(rootDir, path) {
1396
+ return (0, import_node_path4.isAbsolute)(path) ? (0, import_node_path4.resolve)(path) : (0, import_node_path4.resolve)(rootDir, path.split("/").join(import_node_path4.sep));
1397
+ }
1398
+ function storablePath(rootDir, bundlePath2) {
1399
+ const rel = (0, import_node_path4.relative)((0, import_node_path4.resolve)(rootDir), (0, import_node_path4.resolve)(bundlePath2));
1400
+ return (rel === "" ? "." : rel).split(import_node_path4.sep).join("/");
1401
+ }
1402
+ async function readMergedPins(workspaceDir) {
1403
+ const manifests = {};
1404
+ const pins = [];
1405
+ const seen = /* @__PURE__ */ new Set();
1406
+ for (const layer of PIN_LAYERS) {
1407
+ let manifest;
1408
+ try {
1409
+ manifest = await readPinsLayer(workspaceDir, layer);
1410
+ } catch {
1411
+ continue;
1412
+ }
1413
+ manifests[layer] = manifest;
1414
+ const root = layerRoot(workspaceDir, layer);
1415
+ for (const entry of manifest.pins) {
1416
+ const absolutePath = resolvePinPath(root, entry.path);
1417
+ if (seen.has(absolutePath)) continue;
1418
+ seen.add(absolutePath);
1419
+ pins.push({ ...entry, layer, absolutePath });
1420
+ }
1421
+ }
1422
+ return { pins, manifests };
1423
+ }
1424
+
1425
+ // src/kb-pins/frozen.ts
1426
+ async function assertBaseNotFrozen(workspaceDir, bundlePath2) {
1427
+ const merged = await readMergedPins(workspaceDir);
1428
+ const absolute = (0, import_node_path5.resolve)(bundlePath2);
1429
+ const pin = merged.pins.find((entry) => entry.absolutePath === absolute);
1430
+ if (pin?.frozen === true) {
1431
+ throw new KbBaseFrozenError(pin.path, pin.layer);
1432
+ }
1433
+ }
1434
+
1435
+ // src/kb-pins/list.ts
1436
+ async function listPins(store, workspaceDir) {
1437
+ const merged = await readMergedPins(workspaceDir);
1438
+ return Promise.all(
1439
+ merged.pins.map(async (entry) => {
1440
+ const records = await store.list(entry.absolutePath);
1441
+ return {
1442
+ path: entry.path,
1443
+ layer: entry.layer,
1444
+ pinnedAt: entry.pinnedAt ?? null,
1445
+ absolutePath: entry.absolutePath,
1446
+ valid: records.length > 0,
1447
+ recordCount: records.length,
1448
+ mode: entry.mode ?? null,
1449
+ profiles: entry.profiles ?? null,
1450
+ frozen: entry.frozen === true
1451
+ };
1452
+ })
1453
+ );
1454
+ }
1455
+
1456
+ // src/kb-pins/pin.ts
1457
+ async function pinBase(store, workspaceDir, bundlePath2, at, options = {}) {
1458
+ const layer = options.layer ?? "project";
1459
+ const root = layerRoot(workspaceDir, layer);
1460
+ const manifest = await readPinsLayer(workspaceDir, layer);
1461
+ const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
1462
+ const existing = manifest.pins.find(
1463
+ (entry2) => resolvePinPath(root, entry2.path) === absolute
1464
+ );
1465
+ const records = await store.list(absolute);
1466
+ const warning = records.length === 0 ? `no records found at ${absolute} \u2014 pinned anyway; bases are routinely pinned before they are populated` : void 0;
1467
+ const fields = {
1468
+ ...options.mode ? { mode: options.mode } : {},
1469
+ ...options.profiles?.length ? { profiles: options.profiles } : {},
1470
+ ...options.frozen !== void 0 ? { frozen: options.frozen } : {}
1471
+ };
1472
+ if (existing) {
1473
+ const updated = { ...existing, ...fields };
1474
+ if (Object.keys(fields).length) {
1475
+ await writePinsLayer(workspaceDir, layer, {
1476
+ ...manifest,
1477
+ pins: manifest.pins.map(
1478
+ (entry2) => entry2 === existing ? updated : entry2
1479
+ )
1480
+ });
1481
+ }
1482
+ return {
1483
+ path: existing.path,
1484
+ layer,
1485
+ pinnedAt: existing.pinnedAt ?? at,
1486
+ alreadyPinned: true,
1487
+ ...updated.mode ? { mode: updated.mode } : {},
1488
+ ...updated.profiles ? { profiles: updated.profiles } : {},
1489
+ ...updated.frozen !== void 0 ? { frozen: updated.frozen } : {},
1490
+ ...warning ? { warning } : {}
1491
+ };
1492
+ }
1493
+ const entry = {
1494
+ path: storablePath(root, bundlePath2),
1495
+ pinnedAt: at,
1496
+ ...fields
1497
+ };
1498
+ await writePinsLayer(workspaceDir, layer, {
1499
+ ...manifest,
1500
+ pins: [...manifest.pins, entry]
1501
+ });
1502
+ return {
1503
+ path: entry.path,
1504
+ layer,
1505
+ pinnedAt: at,
1506
+ alreadyPinned: false,
1507
+ ...fields,
1508
+ ...warning ? { warning } : {}
1509
+ };
1510
+ }
1511
+
1512
+ // src/kb-pins/unpin.ts
1513
+ var import_node_path6 = require("path");
1514
+ async function unpinBase(workspaceDir, bundlePath2) {
1515
+ const layers = [];
1516
+ for (const layer of PIN_LAYERS) {
1517
+ const root = layerRoot(workspaceDir, layer);
1518
+ let manifest;
1519
+ try {
1520
+ manifest = await readPinsLayer(workspaceDir, layer);
1521
+ } catch {
1522
+ continue;
1523
+ }
1524
+ const absolute = resolvePinPath(root, storablePath(root, bundlePath2));
1525
+ const kept = manifest.pins.filter(
1526
+ (entry) => resolvePinPath(root, entry.path) !== absolute
1527
+ );
1528
+ if (kept.length !== manifest.pins.length) {
1529
+ await writePinsLayer(workspaceDir, layer, { ...manifest, pins: kept });
1530
+ layers.push(layer);
1531
+ }
1532
+ }
1533
+ return {
1534
+ path: storablePath((0, import_node_path6.resolve)(workspaceDir), bundlePath2),
1535
+ removed: layers.length > 0,
1536
+ layers
1537
+ };
1538
+ }
1539
+
1540
+ // src/kb-context.ts
1541
+ var import_promises4 = require("fs/promises");
1542
+ var HEADING2 = "## Knowledge bases (pinned)";
1543
+ var DEFAULT_CONTEXT_BUDGET = 4e3;
1544
+ var CONTEXT_PROFILES = {
1545
+ "session-start": { fullUnderTokens: 1500 },
1546
+ compact: { budgetTokens: 2500 },
1547
+ turn: { budgetTokens: 2500 }
1548
+ };
1549
+ function approxTokens(text) {
1550
+ return Math.ceil(text.length / 4);
1551
+ }
1552
+ function preamble() {
1553
+ return [
1554
+ HEADING2,
1555
+ "",
1556
+ "What follows is an index of this workspace's pinned knowledge bases \u2014",
1557
+ "concept ids, titles and standing only. The record bodies are NOT in this",
1558
+ "context.",
1559
+ "",
1560
+ "Consult records only through the strauss-kb MCP tools: `kb_load` (the",
1561
+ "preferred first call), `kb_query`, and `kb_trace`, passing the",
1562
+ "`bundlePath` listed with each base. Do not read record files directly:",
1563
+ "a raw file read bypasses supersession resolution, and a superseded or",
1564
+ "rejected record file reads exactly like a current one \u2014 only the store",
1565
+ "resolves chains and standing.",
1566
+ "",
1567
+ "KB content loaded earlier in a long session may have been compacted",
1568
+ "away. Before answering a question one of these bases governs, load it",
1569
+ "again at the point of use \u2014 reloading a small base costs a few thousand",
1570
+ "tokens."
1571
+ ].join("\n");
1572
+ }
1573
+ async function renderBase(store, path, absolutePath, fullUnderTokens, pinMode, budgetTokens) {
1574
+ const bundle = await store.list(absolutePath);
1575
+ if (bundle.length === 0) {
1576
+ return {
1577
+ path,
1578
+ absolutePath,
1579
+ mode: "empty",
1580
+ body: "No readable records yet \u2014 pinned ahead of being populated."
1581
+ };
1582
+ }
1583
+ const fullCap = pinMode === "full" ? budgetTokens : pinMode === "index" ? 0 : fullUnderTokens;
1584
+ let degradedFrom;
1585
+ if (fullCap > 0) {
1586
+ const full = await store.load(absolutePath, {
1587
+ budgetTokens: fullCap
1588
+ });
1589
+ if (!full.loaded && pinMode === "full") {
1590
+ degradedFrom = { approxTokens: full.approxTokens };
1591
+ }
1592
+ if (full.loaded) {
1593
+ const records = full.records.map(
1594
+ (hit) => [
1595
+ `#### ${hit.record.conceptId} \u2014 ${hit.record.frontmatter.title ?? "(untitled)"} (${hit.standing})`,
1596
+ "",
1597
+ hit.record.body.trim()
1598
+ ].join("\n")
1599
+ );
1600
+ const superseded2 = full.superseded.map(
1601
+ (entry) => `- \`${entry.conceptId}\` \u2192 superseded by ${entry.supersededBy.map((id) => `\`${id}\``).join(", ") || "(missing replacement)"}`
1602
+ );
1603
+ return {
1604
+ path,
1605
+ absolutePath,
1606
+ mode: "full",
1607
+ body: [
1608
+ ...records,
1609
+ ...superseded2.length ? [
1610
+ "#### Superseded (bodies withheld \u2014 kb_trace reaches them)",
1611
+ ...superseded2
1612
+ ] : []
1613
+ ].join("\n\n")
1614
+ };
1615
+ }
1616
+ }
1617
+ const adjudicated = adjudicate(bundle, bundle);
1618
+ const lines = adjudicated.filter((hit) => hit.standing !== "superseded").map((hit) => renderIndexLine(hit.record));
1619
+ const superseded = adjudicated.filter((hit) => hit.standing === "superseded").map(
1620
+ (hit) => `- \`${hit.record.conceptId}\` \u2192 superseded by ${hit.heads.map((head) => `\`${head.conceptId}\``).join(", ") || "(missing replacement)"}`
1621
+ );
1622
+ return {
1623
+ path,
1624
+ absolutePath,
1625
+ mode: "index",
1626
+ body: [...lines, ...superseded].join("\n"),
1627
+ ...degradedFrom ? { degradedFrom } : {}
1628
+ };
1629
+ }
1630
+ async function buildContext(store, workspaceDir, options = {}) {
1631
+ const builtin = options.profile ? CONTEXT_PROFILES[options.profile] ?? {} : {};
1632
+ let budgetTokens = options.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
1633
+ let fullUnderTokens = options.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
1634
+ const merged = await readMergedPins(workspaceDir);
1635
+ const fromManifest = mergedContextBudgets(merged, options.profile);
1636
+ budgetTokens = options.budgetTokens ?? fromManifest.budgetTokens ?? builtin.budgetTokens ?? DEFAULT_CONTEXT_BUDGET;
1637
+ fullUnderTokens = options.fullUnderTokens ?? fromManifest.fullUnderTokens ?? builtin.fullUnderTokens ?? 0;
1638
+ const pins = merged.pins.filter(
1639
+ (pin) => !pin.profiles?.length || !options.profile || pin.profiles.includes(options.profile)
1640
+ );
1641
+ if (pins.length === 0) {
1642
+ return {
1643
+ block: "",
1644
+ refused: false,
1645
+ approxTokens: 0,
1646
+ budgetTokens,
1647
+ bases: []
1648
+ };
1649
+ }
1650
+ const sections = await Promise.all(
1651
+ pins.map(async (pin) => ({
1652
+ section: await renderBase(
1653
+ store,
1654
+ pin.path,
1655
+ pin.absolutePath,
1656
+ fullUnderTokens,
1657
+ pin.mode,
1658
+ budgetTokens
1659
+ ),
1660
+ frozen: pin.frozen === true
1661
+ }))
1662
+ );
1663
+ const modeLabel = {
1664
+ index: "index only \u2014 record bodies are not here",
1665
+ full: "full records \u2014 this base arrives whole",
1666
+ empty: "empty"
1667
+ };
1668
+ for (const { section } of sections) {
1669
+ if (section.degradedFrom) {
1670
+ options.warn?.({
1671
+ operation: "kb.context.full-pin-degraded",
1672
+ path: section.path,
1673
+ approxTokens: section.degradedFrom.approxTokens,
1674
+ budgetTokens
1675
+ });
1676
+ }
1677
+ }
1678
+ const rendered = sections.map(({ section, frozen }) => {
1679
+ 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];
1680
+ return [
1681
+ `### ${section.path} (${label}${frozen ? " \xB7 frozen, read-only" : ""})`,
1682
+ "",
1683
+ `bundlePath: \`${section.absolutePath}\``,
1684
+ "",
1685
+ section.body
1686
+ ].join("\n");
1687
+ });
1688
+ const block = [preamble(), "", rendered.join("\n\n"), ""].join("\n");
1689
+ const bases = sections.map(({ section }) => ({
1690
+ path: section.path,
1691
+ absolutePath: section.absolutePath,
1692
+ approxTokens: approxTokens(section.body)
1693
+ }));
1694
+ const total = approxTokens(block);
1695
+ if (total > budgetTokens) {
1696
+ options.warn?.({
1697
+ operation: "kb.context.refused",
1698
+ approxTokens: total,
1699
+ budgetTokens,
1700
+ bases: bases.map((base) => base.path)
1701
+ });
1702
+ const refusal = [
1703
+ HEADING2,
1704
+ "",
1705
+ `The pinned index runs to ~${total} tokens, past the ${budgetTokens}-token`,
1706
+ "budget, and was not emitted \u2014 a truncated index is indistinguishable",
1707
+ "from a complete one. The pinned bases:",
1708
+ "",
1709
+ ...bases.map(
1710
+ (base) => `- ${base.path} \u2014 ~${base.approxTokens} tokens (bundlePath: \`${base.absolutePath}\`)`
1711
+ ),
1712
+ "",
1713
+ "For the question at hand, read what you need now \u2014 `kb_load` a base",
1714
+ "(its own budget is separate), or `kb_index` for one base's shape.",
1715
+ "",
1716
+ "To bring this block back under budget, in order of preference:",
1717
+ "- supersede or resolve stale records \u2014 the base shrinks, the knowledge keeps",
1718
+ "- force a large base to index lines: `strauss-kb pin <path> --mode index`",
1719
+ "- scope a pin to the profiles that need it: `strauss-kb pin <path> --profiles session-start`",
1720
+ "- raise this profile's budget under `context` in .strauss/kb-pins.json",
1721
+ "- unpin what no session actually needs",
1722
+ ""
1723
+ ].join("\n");
1724
+ return {
1725
+ block: refusal,
1726
+ refused: true,
1727
+ approxTokens: total,
1728
+ budgetTokens,
1729
+ bases
1730
+ };
1731
+ }
1732
+ return { block, refused: false, approxTokens: total, budgetTokens, bases };
1733
+ }
1734
+ function toHookJson(block, event) {
1735
+ return JSON.stringify({
1736
+ hookSpecificOutput: {
1737
+ hookEventName: event,
1738
+ additionalContext: block
1739
+ }
1740
+ });
1741
+ }
1742
+ var CONTEXT_BEGIN = "<!-- strauss-kb:begin -->";
1743
+ var CONTEXT_END = "<!-- strauss-kb:end -->";
1744
+ async function syncInstructions(file, block) {
1745
+ const existing = await (0, import_promises4.readFile)(file, "utf8").catch(() => null);
1746
+ const region = block ? `${CONTEXT_BEGIN}
1747
+ ${block.trim()}
1748
+ ${CONTEXT_END}` : null;
1749
+ if (existing === null) {
1750
+ if (!region) return { file, action: "unchanged" };
1751
+ await (0, import_promises4.writeFile)(file, `${region}
1752
+ `, "utf8");
1753
+ return { file, action: "created" };
1754
+ }
1755
+ const begin = existing.indexOf(CONTEXT_BEGIN);
1756
+ const end = existing.indexOf(CONTEXT_END);
1757
+ if (begin !== -1 && end !== -1 && end >= begin) {
1758
+ const before = existing.slice(0, begin);
1759
+ const after = existing.slice(end + CONTEXT_END.length);
1760
+ const next = region ? `${before}${region}${after}` : `${before.replace(/\n+$/, "\n")}${after.replace(/^\n+/, "\n")}`;
1761
+ if (next === existing) return { file, action: "unchanged" };
1762
+ await (0, import_promises4.writeFile)(file, next, "utf8");
1763
+ return { file, action: region ? "replaced" : "removed" };
1764
+ }
1765
+ if (!region) return { file, action: "unchanged" };
1766
+ await (0, import_promises4.writeFile)(
1767
+ file,
1768
+ `${existing.replace(/\n*$/, "\n\n")}${region}
1769
+ `,
1770
+ "utf8"
1771
+ );
1772
+ return { file, action: "appended" };
1773
+ }
1774
+
1775
+ // src/json-schema.ts
1776
+ var import_zod5 = require("zod");
1168
1777
  function kbJsonSchemas() {
1169
1778
  return {
1170
- recordFrontmatter: import_zod4.z.toJSONSchema(kbRecordFrontmatterSchema, {
1779
+ recordFrontmatter: import_zod5.z.toJSONSchema(kbRecordFrontmatterSchema, {
1171
1780
  io: "input"
1172
1781
  }),
1173
- composeInput: import_zod4.z.toJSONSchema(composeInputSchema, { io: "input" }),
1174
- logEntry: import_zod4.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
1782
+ composeInput: import_zod5.z.toJSONSchema(composeInputSchema, { io: "input" }),
1783
+ logEntry: import_zod5.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
1175
1784
  };
1176
1785
  }
1177
1786
 
@@ -1291,12 +1900,12 @@ function validateBundle(records) {
1291
1900
  }
1292
1901
 
1293
1902
  // src/decision-record.ts
1294
- var import_zod5 = require("zod");
1903
+ var import_zod6 = require("zod");
1295
1904
  var DECISION_TYPE = "decision";
1296
1905
  var NO_DECISION_SLUG = "none";
1297
1906
  var decisionInputSchema = composeInputSchema.omit({ sections: true }).extend({
1298
- alternative: import_zod5.z.string().min(1).optional(),
1299
- impact: import_zod5.z.string().min(1).optional()
1907
+ alternative: import_zod6.z.string().min(1).optional(),
1908
+ impact: import_zod6.z.string().min(1).optional()
1300
1909
  }).strict();
1301
1910
  function composeDecisionRecord(input, writtenBy, writtenAt) {
1302
1911
  const { alternative, impact, ...rest } = input;
@@ -1337,300 +1946,545 @@ function selectDecisions(records) {
1337
1946
  );
1338
1947
  }
1339
1948
 
1340
- // src/commands.ts
1341
- var import_zod6 = require("zod");
1342
- var bundlePath = import_zod6.z.string().min(1).describe("Absolute path to the knowledge base directory.");
1343
- var conceptId = import_zod6.z.string().min(1).describe("e.g. decision.cursor-v2");
1949
+ // src/commands/answer.ts
1950
+ var import_zod8 = require("zod");
1951
+
1952
+ // src/commands/model.ts
1953
+ var import_zod7 = require("zod");
1954
+ var bundlePath = import_zod7.z.string().min(1).describe("Absolute path to the knowledge base directory.");
1955
+ var conceptId = import_zod7.z.string().min(1).describe("e.g. decision.cursor-v2");
1344
1956
  function define(command) {
1345
1957
  return command;
1346
1958
  }
1347
- var KB_COMMANDS = [
1348
- define({
1349
- name: "write",
1350
- tool: "kb_write",
1351
- usage: "write <type> < record.json",
1352
- description: [
1353
- "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.",
1354
- "",
1355
- "Judgment the tool cannot enforce for you:",
1356
- "- 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.",
1357
- "- 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.",
1358
- "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
1359
- "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
1360
- ].join("\n"),
1361
- input: import_zod6.z.object({
1362
- bundlePath,
1363
- type: import_zod6.z.enum(KB_RECORD_TYPES),
1364
- input: composeInputSchema
1365
- }),
1366
- fromArgv: async (argv, path, stdin) => ({
1367
- bundlePath: path,
1368
- type: argv[1],
1369
- input: JSON.parse(await stdin())
1370
- }),
1371
- run: async ({ store, actor, now }, { bundlePath: path, type, input }) => {
1372
- const record = await store.write(
1373
- path,
1374
- composeRecord(type, input, actor, now()),
1375
- actor
1376
- );
1377
- return { conceptId: record.conceptId };
1378
- }
1959
+ function argvFlag(argv, name) {
1960
+ const at = argv.indexOf(name);
1961
+ return at !== -1 ? argv[at + 1] : void 0;
1962
+ }
1963
+
1964
+ // src/commands/answer.ts
1965
+ var answerCommand = define({
1966
+ name: "answer",
1967
+ tool: "kb_answer",
1968
+ usage: "answer <concept-id> <answer...>",
1969
+ 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.",
1970
+ input: import_zod8.z.object({ bundlePath, conceptId, answer: import_zod8.z.string().min(1) }),
1971
+ fromArgv: (argv, path) => ({
1972
+ bundlePath: path,
1973
+ conceptId: argv[1],
1974
+ answer: argv.slice(2).join(" ").trim()
1379
1975
  }),
1380
- define({
1381
- name: "write-decision",
1382
- tool: "kb_write_decision",
1383
- usage: "write-decision < decision.json",
1384
- description: [
1385
- "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.",
1386
- "",
1387
- "What belongs in one:",
1388
- '- 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.',
1389
- "- `alternative` is what you turned down and why, not a list of everything considered.",
1390
- "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
1391
- ].join("\n"),
1392
- input: import_zod6.z.object({ bundlePath, input: decisionInputSchema }),
1393
- fromArgv: async (_argv, path, stdin) => ({
1394
- bundlePath: path,
1395
- input: JSON.parse(await stdin())
1396
- }),
1397
- run: async ({ store, actor, now }, { bundlePath: path, input }) => {
1398
- const record = await store.write(
1399
- path,
1400
- composeDecisionRecord(input, actor, now()),
1401
- actor
1402
- );
1403
- return { conceptId: record.conceptId };
1404
- }
1976
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, answer }) => {
1977
+ await assertBaseNotFrozen(process.cwd(), path);
1978
+ const record = await store.answer(path, id, answer, actor);
1979
+ return { conceptId: record.conceptId };
1980
+ }
1981
+ });
1982
+
1983
+ // src/commands/context.ts
1984
+ var import_zod9 = require("zod");
1985
+ var contextCommand = define({
1986
+ name: "context",
1987
+ tool: "kb_context",
1988
+ usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
1989
+ 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.",
1990
+ input: import_zod9.z.object({
1991
+ budgetTokens: import_zod9.z.number().int().positive().optional().describe(
1992
+ "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
1993
+ ),
1994
+ fullUnderTokens: import_zod9.z.number().int().positive().optional().describe(
1995
+ "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."
1996
+ ),
1997
+ profile: import_zod9.z.string().optional().describe(
1998
+ "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."
1999
+ ),
2000
+ format: import_zod9.z.enum(["markdown", "json"]).optional().describe(
2001
+ "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
2002
+ ),
2003
+ event: import_zod9.z.string().optional().describe(
2004
+ "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
2005
+ )
1405
2006
  }),
1406
- define({
1407
- name: "no-decision",
1408
- tool: "kb_no_decision",
1409
- usage: "no-decision <reason...>",
1410
- 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.',
1411
- input: import_zod6.z.object({ bundlePath, reason: import_zod6.z.string().min(1) }),
1412
- fromArgv: (argv, path) => ({
1413
- bundlePath: path,
1414
- reason: argv.slice(1).join(" ").trim()
1415
- }),
1416
- run: async ({ store, actor, now }, { bundlePath: path, reason }) => {
1417
- const record = await store.write(
1418
- path,
1419
- { ...composeNoDecisionRecord(reason, actor, now()), overwrite: true },
1420
- actor
1421
- );
1422
- return { conceptId: record.conceptId };
1423
- }
2007
+ fromArgv: (argv) => {
2008
+ const budget = argvFlag(argv, "--budget");
2009
+ const fullUnder = argvFlag(argv, "--full-under");
2010
+ const profile = argvFlag(argv, "--profile");
2011
+ const format = argvFlag(argv, "--format");
2012
+ const event = argvFlag(argv, "--event");
2013
+ return {
2014
+ ...budget ? { budgetTokens: Number(budget) } : {},
2015
+ ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
2016
+ ...profile ? { profile } : {},
2017
+ ...format ? { format } : {},
2018
+ ...event ? { event } : {}
2019
+ };
2020
+ },
2021
+ run: async ({ store }, { budgetTokens, fullUnderTokens, profile, format, event }) => {
2022
+ const result = await buildContext(store, process.cwd(), {
2023
+ ...budgetTokens ? { budgetTokens } : {},
2024
+ ...fullUnderTokens ? { fullUnderTokens } : {},
2025
+ ...profile ? { profile } : {},
2026
+ // Degradations — a full pin that could not fit, a refused block — go
2027
+ // to stderr as well as into the block itself: stderr is diagnostics on
2028
+ // both surfaces (hooks discard it, MCP logs it), so an operator can
2029
+ // see budget pressure without reading injected context.
2030
+ warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
2031
+ `)
2032
+ });
2033
+ if (!result.block) return "";
2034
+ return format === "json" ? toHookJson(result.block, event ?? "SessionStart") : result.block;
2035
+ }
2036
+ });
2037
+
2038
+ // src/commands/list.ts
2039
+ var import_zod10 = require("zod");
2040
+ var listCommand = define({
2041
+ name: "list",
2042
+ tool: "kb_list",
2043
+ usage: "list [type]",
2044
+ description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
2045
+ input: import_zod10.z.object({ bundlePath, type: import_zod10.z.enum(KB_RECORD_TYPES).optional() }),
2046
+ fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
2047
+ run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
2048
+ conceptId: record.conceptId,
2049
+ title: record.frontmatter.title ?? null,
2050
+ description: record.frontmatter.description ?? null,
2051
+ status: record.frontmatter.strauss_status,
2052
+ anchors: record.frontmatter.strauss_anchors ?? []
2053
+ }))
2054
+ });
2055
+
2056
+ // src/commands/load.ts
2057
+ var import_zod11 = require("zod");
2058
+ var loadCommand = define({
2059
+ name: "load",
2060
+ tool: "kb_load",
2061
+ usage: "load [type] [--budget N]",
2062
+ 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.",
2063
+ input: import_zod11.z.object({
2064
+ bundlePath,
2065
+ type: import_zod11.z.enum(KB_RECORD_TYPES).optional(),
2066
+ budgetTokens: import_zod11.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000.")
1424
2067
  }),
1425
- define({
1426
- name: "status",
1427
- tool: "kb_status",
1428
- usage: "status <concept-id> <status>",
1429
- 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.",
1430
- input: import_zod6.z.object({
1431
- bundlePath,
1432
- conceptId,
1433
- status: import_zod6.z.enum(KB_RECORD_STATUSES)
1434
- }),
1435
- fromArgv: (argv, path) => ({
2068
+ fromArgv: (argv, path) => {
2069
+ const budget = argvFlag(argv, "--budget");
2070
+ return {
1436
2071
  bundlePath: path,
1437
- conceptId: argv[1],
1438
- status: argv[2]
1439
- }),
1440
- run: async ({ store, actor }, { bundlePath: path, conceptId: id, status }) => {
1441
- const record = await store.setStatus(path, id, status, actor);
1442
- return { conceptId: record.conceptId, status };
1443
- }
2072
+ ...argv[1] && argv[1] !== "--budget" ? { type: argv[1] } : {},
2073
+ ...budget ? { budgetTokens: Number(budget) } : {}
2074
+ };
2075
+ },
2076
+ run: async ({ store }, { bundlePath: path, type, budgetTokens }) => {
2077
+ const result = await store.load(path, {
2078
+ ...type ? { type } : {},
2079
+ ...budgetTokens ? { budgetTokens } : {}
2080
+ });
2081
+ if (!result.loaded) return result;
2082
+ return {
2083
+ ...result,
2084
+ records: result.records.map((hit) => ({
2085
+ conceptId: hit.record.conceptId,
2086
+ title: hit.record.frontmatter.title ?? null,
2087
+ standing: hit.standing,
2088
+ supersededBy: hit.heads.map((head) => head.conceptId),
2089
+ warnings: hit.warnings,
2090
+ anchors: hit.record.frontmatter.strauss_anchors ?? [],
2091
+ body: hit.record.body
2092
+ }))
2093
+ };
2094
+ }
2095
+ });
2096
+
2097
+ // src/commands/log.ts
2098
+ var import_zod12 = require("zod");
2099
+ var logCommand = define({
2100
+ name: "log",
2101
+ tool: "kb_log",
2102
+ usage: "log",
2103
+ 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.",
2104
+ input: import_zod12.z.object({ bundlePath }),
2105
+ fromArgv: (_argv, path) => ({ bundlePath: path }),
2106
+ run: ({ store }, { bundlePath: path }) => store.readLog(path)
2107
+ });
2108
+
2109
+ // src/commands/no-decision.ts
2110
+ var import_zod13 = require("zod");
2111
+ var noDecisionCommand = define({
2112
+ name: "no-decision",
2113
+ tool: "kb_no_decision",
2114
+ usage: "no-decision <reason...>",
2115
+ 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.',
2116
+ input: import_zod13.z.object({ bundlePath, reason: import_zod13.z.string().min(1) }),
2117
+ fromArgv: (argv, path) => ({
2118
+ bundlePath: path,
2119
+ reason: argv.slice(1).join(" ").trim()
1444
2120
  }),
1445
- define({
1446
- name: "supersede",
1447
- tool: "kb_supersede",
1448
- usage: "supersede <concept-id> <replacement-id>",
1449
- 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.",
1450
- input: import_zod6.z.object({ bundlePath, conceptId, replacementId: conceptId }),
1451
- fromArgv: (argv, path) => ({
1452
- bundlePath: path,
1453
- conceptId: argv[1],
1454
- replacementId: argv[2]
1455
- }),
1456
- run: async ({ store, actor }, { bundlePath: path, conceptId: id, replacementId }) => {
1457
- await store.supersede(path, id, replacementId, actor);
1458
- return { superseded: id, replacedBy: replacementId };
1459
- }
2121
+ run: async ({ store, actor, now }, { bundlePath: path, reason }) => {
2122
+ await assertBaseNotFrozen(process.cwd(), path);
2123
+ const record = await store.write(
2124
+ path,
2125
+ { ...composeNoDecisionRecord(reason, actor, now()), overwrite: true },
2126
+ actor
2127
+ );
2128
+ return { conceptId: record.conceptId };
2129
+ }
2130
+ });
2131
+
2132
+ // src/commands/pin.ts
2133
+ var import_zod14 = require("zod");
2134
+ var pinCommand = define({
2135
+ name: "pin",
2136
+ tool: "kb_pin",
2137
+ usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
2138
+ 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.",
2139
+ input: import_zod14.z.object({
2140
+ bundlePath,
2141
+ mode: import_zod14.z.enum(["full", "index"]).optional().describe(
2142
+ "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
2143
+ ),
2144
+ profiles: import_zod14.z.array(import_zod14.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
2145
+ layer: import_zod14.z.enum(["project", "local", "user"]).optional().describe(
2146
+ "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
2147
+ ),
2148
+ frozen: import_zod14.z.boolean().optional().describe(
2149
+ "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
2150
+ )
1460
2151
  }),
1461
- define({
1462
- name: "answer",
1463
- tool: "kb_answer",
1464
- usage: "answer <concept-id> <answer...>",
1465
- 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.",
1466
- input: import_zod6.z.object({ bundlePath, conceptId, answer: import_zod6.z.string().min(1) }),
1467
- fromArgv: (argv, path) => ({
1468
- bundlePath: path,
1469
- conceptId: argv[1],
1470
- answer: argv.slice(2).join(" ").trim()
1471
- }),
1472
- run: async ({ store, actor }, { bundlePath: path, conceptId: id, answer }) => {
1473
- const record = await store.answer(path, id, answer, actor);
1474
- return { conceptId: record.conceptId };
1475
- }
2152
+ fromArgv: (argv, path) => {
2153
+ const positional = argv[1] && !argv[1].startsWith("--") ? argv[1] : path;
2154
+ const mode = argvFlag(argv, "--mode");
2155
+ const profiles = argvFlag(argv, "--profiles");
2156
+ const layer = argv.includes("--user") ? "user" : argv.includes("--local") ? "local" : void 0;
2157
+ const frozen = argv.includes("--frozen") ? true : argv.includes("--unfreeze") ? false : void 0;
2158
+ return {
2159
+ bundlePath: positional,
2160
+ ...mode ? { mode } : {},
2161
+ ...profiles ? {
2162
+ profiles: profiles.split(",").map((p) => p.trim()).filter(Boolean)
2163
+ } : {},
2164
+ ...layer ? { layer } : {},
2165
+ ...frozen !== void 0 ? { frozen } : {}
2166
+ };
2167
+ },
2168
+ run: ({ store, now }, { bundlePath: path, mode, profiles, layer, frozen }) => pinBase(store, process.cwd(), path, now(), {
2169
+ ...mode ? { mode } : {},
2170
+ ...profiles ? { profiles } : {},
2171
+ ...layer ? { layer } : {},
2172
+ ...frozen !== void 0 ? { frozen } : {}
2173
+ })
2174
+ });
2175
+
2176
+ // src/commands/pins.ts
2177
+ var import_zod15 = require("zod");
2178
+ var pinsCommand = define({
2179
+ name: "pins",
2180
+ tool: "kb_pins",
2181
+ usage: "pins",
2182
+ 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.",
2183
+ input: import_zod15.z.object({}),
2184
+ fromArgv: () => ({}),
2185
+ run: ({ store }) => listPins(store, process.cwd())
2186
+ });
2187
+
2188
+ // src/commands/query.ts
2189
+ var import_zod16 = require("zod");
2190
+ var queryCommand = define({
2191
+ name: "query",
2192
+ tool: "kb_query",
2193
+ usage: "query <text...>",
2194
+ 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.",
2195
+ input: import_zod16.z.object({
2196
+ bundlePath,
2197
+ text: import_zod16.z.string().optional(),
2198
+ type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
2199
+ includeNonCurrent: import_zod16.z.boolean().optional()
1476
2200
  }),
1477
- define({
1478
- name: "load",
1479
- tool: "kb_load",
1480
- usage: "load [type] [--budget N]",
1481
- 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.",
1482
- input: import_zod6.z.object({
1483
- bundlePath,
1484
- type: import_zod6.z.enum(KB_RECORD_TYPES).optional(),
1485
- budgetTokens: import_zod6.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000.")
1486
- }),
1487
- fromArgv: (argv, path) => {
1488
- const at = argv.indexOf("--budget");
1489
- return {
1490
- bundlePath: path,
1491
- ...argv[1] && argv[1] !== "--budget" ? { type: argv[1] } : {},
1492
- ...at !== -1 && argv[at + 1] ? { budgetTokens: Number(argv[at + 1]) } : {}
1493
- };
1494
- },
1495
- run: async ({ store }, { bundlePath: path, type, budgetTokens }) => {
1496
- const result = await store.load(path, {
1497
- ...type ? { type } : {},
1498
- ...budgetTokens ? { budgetTokens } : {}
1499
- });
1500
- if (!result.loaded) return result;
1501
- return {
1502
- ...result,
1503
- records: result.records.map((hit) => ({
1504
- conceptId: hit.record.conceptId,
1505
- title: hit.record.frontmatter.title ?? null,
1506
- standing: hit.standing,
1507
- supersededBy: hit.heads.map((head) => head.conceptId),
1508
- warnings: hit.warnings,
1509
- anchors: hit.record.frontmatter.strauss_anchors ?? [],
1510
- body: hit.record.body
1511
- }))
1512
- };
1513
- }
2201
+ fromArgv: (argv, path) => ({
2202
+ bundlePath: path,
2203
+ text: argv.slice(1).join(" ").trim(),
2204
+ includeNonCurrent: true
1514
2205
  }),
1515
- define({
1516
- name: "query",
1517
- tool: "kb_query",
1518
- usage: "query <text...>",
1519
- 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 this over reading record files directly \u2014 relevance and standing are different questions, and a bare match answers only the first.",
1520
- input: import_zod6.z.object({
1521
- bundlePath,
1522
- text: import_zod6.z.string().optional(),
1523
- type: import_zod6.z.enum(KB_RECORD_TYPES).optional(),
1524
- includeNonCurrent: import_zod6.z.boolean().optional()
1525
- }),
1526
- fromArgv: (argv, path) => ({
1527
- bundlePath: path,
1528
- text: argv.slice(1).join(" ").trim(),
1529
- includeNonCurrent: true
1530
- }),
1531
- run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
1532
- ...type ? { type } : {},
1533
- includeNonCurrent: includeNonCurrent === true
1534
- })).map((hit) => ({
1535
- conceptId: hit.record.conceptId,
1536
- title: hit.record.frontmatter.title ?? null,
1537
- description: hit.record.frontmatter.description ?? null,
1538
- standing: hit.standing,
1539
- supersededBy: hit.heads.map((head) => head.conceptId),
1540
- warnings: hit.warnings,
1541
- body: hit.record.body
1542
- }))
2206
+ run: async ({ store }, { bundlePath: path, text, type, includeNonCurrent }) => (await store.query(path, text ?? "", {
2207
+ ...type ? { type } : {},
2208
+ includeNonCurrent: includeNonCurrent === true
2209
+ })).map((hit) => ({
2210
+ conceptId: hit.record.conceptId,
2211
+ title: hit.record.frontmatter.title ?? null,
2212
+ description: hit.record.frontmatter.description ?? null,
2213
+ standing: hit.standing,
2214
+ supersededBy: hit.heads.map((head) => head.conceptId),
2215
+ warnings: hit.warnings,
2216
+ body: hit.record.body
2217
+ }))
2218
+ });
2219
+
2220
+ // src/commands/read-index.ts
2221
+ var import_zod17 = require("zod");
2222
+ var readIndexCommand = define({
2223
+ name: "index",
2224
+ tool: "kb_index",
2225
+ usage: "index",
2226
+ 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.",
2227
+ input: import_zod17.z.object({ bundlePath }),
2228
+ fromArgv: (_argv, path) => ({ bundlePath: path }),
2229
+ run: ({ store }, { bundlePath: path }) => store.readIndex(path)
2230
+ });
2231
+
2232
+ // src/commands/schema.ts
2233
+ var import_zod18 = require("zod");
2234
+ var schemaCommand = define({
2235
+ name: "schema",
2236
+ tool: "kb_schema",
2237
+ usage: "schema",
2238
+ 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.",
2239
+ input: import_zod18.z.object({}),
2240
+ fromArgv: () => ({}),
2241
+ run: () => Promise.resolve(kbJsonSchemas())
2242
+ });
2243
+
2244
+ // src/commands/status.ts
2245
+ var import_zod19 = require("zod");
2246
+ var statusCommand = define({
2247
+ name: "status",
2248
+ tool: "kb_status",
2249
+ usage: "status <concept-id> <status>",
2250
+ 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.",
2251
+ input: import_zod19.z.object({
2252
+ bundlePath,
2253
+ conceptId,
2254
+ status: import_zod19.z.enum(KB_RECORD_STATUSES)
1543
2255
  }),
1544
- define({
1545
- name: "trace",
1546
- tool: "kb_trace",
1547
- usage: "trace <concept-id> [edges...]",
1548
- 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".',
1549
- input: import_zod6.z.object({
1550
- bundlePath,
1551
- conceptId,
1552
- edges: import_zod6.z.array(import_zod6.z.enum(TRACE_EDGES)).optional(),
1553
- depth: import_zod6.z.number().int().positive().optional()
1554
- }),
1555
- fromArgv: (argv, path) => ({
1556
- bundlePath: path,
1557
- conceptId: argv[1],
1558
- edges: argv.slice(2).filter((edge) => TRACE_EDGES.includes(edge))
1559
- }),
1560
- run: async ({ store }, { bundlePath: path, conceptId: id, edges, depth }) => (await store.trace(path, id, {
1561
- ...edges?.length ? { edges } : {},
1562
- ...depth ? { depth } : {}
1563
- })).map((step) => ({
1564
- conceptId: step.record.conceptId,
1565
- at: step.record.frontmatter.generated?.at ?? null,
1566
- status: step.record.frontmatter.strauss_status,
1567
- title: step.record.frontmatter.title ?? null,
1568
- depth: step.depth,
1569
- via: step.via,
1570
- body: step.record.body
1571
- }))
2256
+ fromArgv: (argv, path) => ({
2257
+ bundlePath: path,
2258
+ conceptId: argv[1],
2259
+ status: argv[2]
1572
2260
  }),
1573
- define({
1574
- name: "list",
1575
- tool: "kb_list",
1576
- usage: "list [type]",
1577
- description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
1578
- input: import_zod6.z.object({ bundlePath, type: import_zod6.z.enum(KB_RECORD_TYPES).optional() }),
1579
- fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
1580
- run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
1581
- conceptId: record.conceptId,
1582
- title: record.frontmatter.title ?? null,
1583
- description: record.frontmatter.description ?? null,
1584
- status: record.frontmatter.strauss_status,
1585
- anchors: record.frontmatter.strauss_anchors ?? []
1586
- }))
2261
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, status }) => {
2262
+ await assertBaseNotFrozen(process.cwd(), path);
2263
+ const record = await store.setStatus(path, id, status, actor);
2264
+ return { conceptId: record.conceptId, status };
2265
+ }
2266
+ });
2267
+
2268
+ // src/commands/supersede.ts
2269
+ var import_zod20 = require("zod");
2270
+ var supersedeCommand = define({
2271
+ name: "supersede",
2272
+ tool: "kb_supersede",
2273
+ usage: "supersede <concept-id> <replacement-id>",
2274
+ 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.",
2275
+ input: import_zod20.z.object({ bundlePath, conceptId, replacementId: conceptId }),
2276
+ fromArgv: (argv, path) => ({
2277
+ bundlePath: path,
2278
+ conceptId: argv[1],
2279
+ replacementId: argv[2]
1587
2280
  }),
1588
- define({
1589
- name: "index",
1590
- tool: "kb_index",
1591
- usage: "index",
1592
- 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.",
1593
- input: import_zod6.z.object({ bundlePath }),
1594
- fromArgv: (_argv, path) => ({ bundlePath: path }),
1595
- run: ({ store }, { bundlePath: path }) => store.readIndex(path)
2281
+ run: async ({ store, actor }, { bundlePath: path, conceptId: id, replacementId }) => {
2282
+ await assertBaseNotFrozen(process.cwd(), path);
2283
+ await store.supersede(path, id, replacementId, actor);
2284
+ return { superseded: id, replacedBy: replacementId };
2285
+ }
2286
+ });
2287
+
2288
+ // src/commands/sync-instructions.ts
2289
+ var import_zod21 = require("zod");
2290
+ var syncInstructionsCommand = define({
2291
+ name: "sync-instructions",
2292
+ usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
2293
+ 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.",
2294
+ input: import_zod21.z.object({
2295
+ file: import_zod21.z.string().min(1).describe("The instruction file to edit in place."),
2296
+ budgetTokens: import_zod21.z.number().int().positive().optional(),
2297
+ fullUnderTokens: import_zod21.z.number().int().positive().optional(),
2298
+ profile: import_zod21.z.string().optional()
1596
2299
  }),
1597
- define({
1598
- name: "log",
1599
- tool: "kb_log",
1600
- usage: "log",
1601
- 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.",
1602
- input: import_zod6.z.object({ bundlePath }),
1603
- fromArgv: (_argv, path) => ({ bundlePath: path }),
1604
- run: ({ store }, { bundlePath: path }) => store.readLog(path)
2300
+ fromArgv: (argv) => {
2301
+ const budget = argvFlag(argv, "--budget");
2302
+ const fullUnder = argvFlag(argv, "--full-under");
2303
+ const profile = argvFlag(argv, "--profile");
2304
+ return {
2305
+ file: argv[1],
2306
+ ...budget ? { budgetTokens: Number(budget) } : {},
2307
+ ...fullUnder ? { fullUnderTokens: Number(fullUnder) } : {},
2308
+ ...profile ? { profile } : {}
2309
+ };
2310
+ },
2311
+ run: async ({ store }, { file, budgetTokens, fullUnderTokens, profile }) => {
2312
+ const result = await buildContext(store, process.cwd(), {
2313
+ ...budgetTokens ? { budgetTokens } : {},
2314
+ ...fullUnderTokens ? { fullUnderTokens } : {},
2315
+ ...profile ? { profile } : {},
2316
+ warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}
2317
+ `)
2318
+ });
2319
+ return syncInstructions(file, result.block);
2320
+ }
2321
+ });
2322
+
2323
+ // src/commands/trace.ts
2324
+ var import_zod22 = require("zod");
2325
+ var traceCommand = define({
2326
+ name: "trace",
2327
+ tool: "kb_trace",
2328
+ usage: "trace <concept-id> [edges...]",
2329
+ 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.',
2330
+ input: import_zod22.z.object({
2331
+ bundlePath,
2332
+ conceptId,
2333
+ edges: import_zod22.z.array(import_zod22.z.enum(TRACE_EDGES)).optional(),
2334
+ depth: import_zod22.z.number().int().positive().optional()
1605
2335
  }),
1606
- define({
1607
- name: "validate",
1608
- tool: "kb_validate",
1609
- usage: "validate",
1610
- 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.",
1611
- input: import_zod6.z.object({ bundlePath }),
1612
- fromArgv: (_argv, path) => ({ bundlePath: path }),
1613
- run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
1614
- failsWhen: (result) => Array.isArray(result) && result.length > 0
2336
+ fromArgv: (argv, path) => ({
2337
+ bundlePath: path,
2338
+ conceptId: argv[1],
2339
+ edges: argv.slice(2).filter((edge) => TRACE_EDGES.includes(edge))
1615
2340
  }),
1616
- define({
1617
- name: "schema",
1618
- tool: "kb_schema",
1619
- usage: "schema",
1620
- 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.",
1621
- input: import_zod6.z.object({}),
1622
- fromArgv: () => ({}),
1623
- run: () => Promise.resolve(kbJsonSchemas())
2341
+ run: async ({ store }, { bundlePath: path, conceptId: id, edges, depth }) => (await store.trace(path, id, {
2342
+ ...edges?.length ? { edges } : {},
2343
+ ...depth ? { depth } : {}
2344
+ })).map((step) => ({
2345
+ conceptId: step.record.conceptId,
2346
+ at: step.record.frontmatter.generated?.at ?? null,
2347
+ status: step.record.frontmatter.strauss_status,
2348
+ title: step.record.frontmatter.title ?? null,
2349
+ depth: step.depth,
2350
+ via: step.via,
2351
+ body: step.record.body
2352
+ }))
2353
+ });
2354
+
2355
+ // src/commands/types.ts
2356
+ var import_zod23 = require("zod");
2357
+ var typesCommand = define({
2358
+ name: "types",
2359
+ tool: "kb_types",
2360
+ usage: "types",
2361
+ 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.",
2362
+ input: import_zod23.z.object({}),
2363
+ fromArgv: () => ({}),
2364
+ run: () => Promise.resolve(RECORD_TYPES)
2365
+ });
2366
+
2367
+ // src/commands/unpin.ts
2368
+ var import_zod24 = require("zod");
2369
+ var unpinCommand = define({
2370
+ name: "unpin",
2371
+ tool: "kb_unpin",
2372
+ usage: "unpin [bundle-path]",
2373
+ 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.",
2374
+ input: import_zod24.z.object({ bundlePath }),
2375
+ fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
2376
+ run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
2377
+ });
2378
+
2379
+ // src/commands/validate.ts
2380
+ var import_zod25 = require("zod");
2381
+ var validateCommand = define({
2382
+ name: "validate",
2383
+ tool: "kb_validate",
2384
+ usage: "validate",
2385
+ 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.",
2386
+ input: import_zod25.z.object({ bundlePath }),
2387
+ fromArgv: (_argv, path) => ({ bundlePath: path }),
2388
+ run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
2389
+ failsWhen: (result) => Array.isArray(result) && result.length > 0
2390
+ });
2391
+
2392
+ // src/commands/write.ts
2393
+ var import_zod26 = require("zod");
2394
+ var writeCommand = define({
2395
+ name: "write",
2396
+ tool: "kb_write",
2397
+ usage: "write <type> < record.json",
2398
+ description: [
2399
+ "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.",
2400
+ "",
2401
+ "Judgment the tool cannot enforce for you:",
2402
+ "- 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.",
2403
+ "- 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.",
2404
+ "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
2405
+ "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
2406
+ ].join("\n"),
2407
+ input: import_zod26.z.object({
2408
+ bundlePath,
2409
+ type: import_zod26.z.enum(KB_RECORD_TYPES),
2410
+ input: composeInputSchema
1624
2411
  }),
1625
- define({
1626
- name: "types",
1627
- tool: "kb_types",
1628
- usage: "types",
1629
- 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.",
1630
- input: import_zod6.z.object({}),
1631
- fromArgv: () => ({}),
1632
- run: () => Promise.resolve(RECORD_TYPES)
1633
- })
2412
+ fromArgv: async (argv, path, stdin) => ({
2413
+ bundlePath: path,
2414
+ type: argv[1],
2415
+ input: JSON.parse(await stdin())
2416
+ }),
2417
+ run: async ({ store, actor, now }, { bundlePath: path, type, input }) => {
2418
+ await assertBaseNotFrozen(process.cwd(), path);
2419
+ const record = await store.write(
2420
+ path,
2421
+ composeRecord(type, input, actor, now()),
2422
+ actor
2423
+ );
2424
+ return {
2425
+ conceptId: record.conceptId,
2426
+ action: record.action,
2427
+ supersededIds: record.supersededIds
2428
+ };
2429
+ }
2430
+ });
2431
+
2432
+ // src/commands/write-decision.ts
2433
+ var import_zod27 = require("zod");
2434
+ var writeDecisionCommand = define({
2435
+ name: "write-decision",
2436
+ tool: "kb_write_decision",
2437
+ usage: "write-decision < decision.json",
2438
+ description: [
2439
+ "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.",
2440
+ "",
2441
+ "What belongs in one:",
2442
+ '- 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.',
2443
+ "- `alternative` is what you turned down and why, not a list of everything considered.",
2444
+ "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
2445
+ ].join("\n"),
2446
+ input: import_zod27.z.object({ bundlePath, input: decisionInputSchema }),
2447
+ fromArgv: async (_argv, path, stdin) => ({
2448
+ bundlePath: path,
2449
+ input: JSON.parse(await stdin())
2450
+ }),
2451
+ run: async ({ store, actor, now }, { bundlePath: path, input }) => {
2452
+ await assertBaseNotFrozen(process.cwd(), path);
2453
+ const record = await store.write(
2454
+ path,
2455
+ composeDecisionRecord(input, actor, now()),
2456
+ actor
2457
+ );
2458
+ return {
2459
+ conceptId: record.conceptId,
2460
+ action: record.action,
2461
+ supersededIds: record.supersededIds
2462
+ };
2463
+ }
2464
+ });
2465
+
2466
+ // src/commands/index.ts
2467
+ var KB_COMMANDS = [
2468
+ writeCommand,
2469
+ writeDecisionCommand,
2470
+ noDecisionCommand,
2471
+ statusCommand,
2472
+ supersedeCommand,
2473
+ answerCommand,
2474
+ loadCommand,
2475
+ queryCommand,
2476
+ traceCommand,
2477
+ listCommand,
2478
+ readIndexCommand,
2479
+ logCommand,
2480
+ validateCommand,
2481
+ schemaCommand,
2482
+ pinCommand,
2483
+ unpinCommand,
2484
+ pinsCommand,
2485
+ contextCommand,
2486
+ syncInstructionsCommand,
2487
+ typesCommand
1634
2488
  ];
1635
2489
  var KB_COMMANDS_BY_NAME = new Map(
1636
2490
  KB_COMMANDS.map((command) => [command.name, command])
@@ -1651,6 +2505,7 @@ function createKbMcpServer() {
1651
2505
  now: () => (/* @__PURE__ */ new Date()).toISOString()
1652
2506
  };
1653
2507
  for (const command of KB_COMMANDS) {
2508
+ if (!command.tool) continue;
1654
2509
  server.registerTool(
1655
2510
  command.tool,
1656
2511
  { description: command.description, inputSchema: command.input.shape },
@@ -1674,7 +2529,7 @@ async function runKbMcpServer() {
1674
2529
  }
1675
2530
 
1676
2531
  // src/cli.ts
1677
- var import_node_path3 = require("path");
2532
+ var import_node_path7 = require("path");
1678
2533
  async function runKbCli(argv) {
1679
2534
  const { bundle, rest } = takeBundle(argv);
1680
2535
  const name = rest[0] ?? "";
@@ -1704,6 +2559,7 @@ async function runKbCli(argv) {
1704
2559
  parsed.data
1705
2560
  );
1706
2561
  if (command.failsWhen?.(result)) process.exitCode = 1;
2562
+ if (result === "") return;
1707
2563
  process.stdout.write(
1708
2564
  typeof result === "string" ? result.endsWith("\n") ? result : `${result}
1709
2565
  ` : `${JSON.stringify(result, null, 2)}
@@ -1713,18 +2569,18 @@ async function runKbCli(argv) {
1713
2569
  function takeBundle(argv) {
1714
2570
  const at = argv.indexOf("--bundle");
1715
2571
  if (at === -1) {
1716
- return { bundle: (0, import_node_path3.join)(process.cwd(), KB_DIR), rest: argv };
2572
+ return { bundle: (0, import_node_path7.join)(process.cwd(), KB_DIR), rest: argv };
1717
2573
  }
1718
2574
  const bundle = argv[at + 1];
1719
2575
  if (!bundle) die("--bundle requires a path");
1720
2576
  return { bundle, rest: [...argv.slice(0, at), ...argv.slice(at + 2)] };
1721
2577
  }
1722
2578
  function readStdin() {
1723
- return new Promise((resolve2, reject) => {
2579
+ return new Promise((resolve5, reject) => {
1724
2580
  let text = "";
1725
2581
  process.stdin.setEncoding("utf8");
1726
2582
  process.stdin.on("data", (chunk) => text += chunk);
1727
- process.stdin.on("end", () => resolve2(text));
2583
+ process.stdin.on("end", () => resolve5(text));
1728
2584
  process.stdin.on("error", reject);
1729
2585
  });
1730
2586
  }
@@ -1757,6 +2613,9 @@ function usage() {
1757
2613
  // Annotate the CommonJS export names for ESM import in node:
1758
2614
  0 && (module.exports = {
1759
2615
  BaseError,
2616
+ CONTEXT_BEGIN,
2617
+ CONTEXT_END,
2618
+ CONTEXT_PROFILES,
1760
2619
  DECISION_TYPE,
1761
2620
  ErrorTypes,
1762
2621
  Fault,
@@ -1770,21 +2629,29 @@ function usage() {
1770
2629
  KB_RECORD_STATUSES,
1771
2630
  KB_RECORD_TYPES,
1772
2631
  KB_SLUG_PATTERN,
2632
+ KbBaseFrozenError,
1773
2633
  KbInvalidConceptIdError,
2634
+ KbPinsMalformedError,
1774
2635
  KbRecordAlreadyExistsError,
1775
2636
  KbRecordNotFoundError,
1776
2637
  KbStore,
1777
2638
  KbWriteConflictError,
1778
2639
  LOG_FILE,
1779
2640
  NO_DECISION_SLUG,
2641
+ PINS_FILE,
2642
+ PINS_LOCAL_FILE,
2643
+ PIN_LAYERS,
1780
2644
  RECORD_TYPES,
1781
2645
  SEARCH_INDEX_FILE,
1782
2646
  TRACE_EDGES,
1783
2647
  adjudicate,
2648
+ assertBaseNotFrozen,
2649
+ buildContext,
1784
2650
  composeDecisionRecord,
1785
2651
  composeInputSchema,
1786
2652
  composeNoDecisionRecord,
1787
2653
  composeRecord,
2654
+ contextProfileBudgets,
1788
2655
  createKbMcpServer,
1789
2656
  decisionInputSchema,
1790
2657
  indexIsStale,
@@ -1797,21 +2664,31 @@ function usage() {
1797
2664
  kbLogEntrySchema,
1798
2665
  kbRecordFrontmatterSchema,
1799
2666
  kbSourceSchema,
2667
+ listPins,
1800
2668
  loadQmd,
1801
2669
  matchToDiff,
2670
+ mergedContextBudgets,
1802
2671
  parseLog,
1803
2672
  parseMarkdownWithFrontmatter,
2673
+ pinBase,
2674
+ readMergedPins,
2675
+ readPinsLayer,
1804
2676
  renderIndex,
2677
+ renderIndexLine,
1805
2678
  renderLogEntry,
1806
2679
  resolveHeads,
1807
2680
  resolveHits,
2681
+ resolvePinPath,
1808
2682
  runKbCli,
1809
2683
  runKbMcpServer,
1810
2684
  searchBase,
1811
2685
  selectDecisions,
1812
2686
  splitMarkdownFrontmatter,
1813
2687
  stringifyMarkdownWithFrontmatter,
2688
+ syncInstructions,
2689
+ toHookJson,
1814
2690
  trace,
2691
+ unpinBase,
1815
2692
  validateBundle
1816
2693
  });
1817
2694
  //# sourceMappingURL=index.cjs.map