@saasontools/strauss-kb 0.1.8 → 0.1.10

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/mcp-main.cjs CHANGED
@@ -620,14 +620,162 @@ async function unpinBase(workspaceDir, bundlePath2) {
620
620
 
621
621
  // src/commands/model.ts
622
622
  var import_zod5 = require("zod");
623
+
624
+ // src/errors.ts
625
+ var BaseError = class extends Error {
626
+ code;
627
+ errorType;
628
+ fault;
629
+ retriable;
630
+ reportToUser;
631
+ details;
632
+ constructor(props) {
633
+ super(props.message);
634
+ this.name = props.name ?? this.constructor.name;
635
+ this.code = props.code ?? 500;
636
+ this.errorType = props.errorType;
637
+ this.fault = props.fault;
638
+ this.retriable = props.retriable ?? true;
639
+ this.reportToUser = props.reportToUser ?? false;
640
+ this.details = props.details;
641
+ }
642
+ };
643
+
644
+ // src/kb-errors.ts
645
+ var KbRecordAlreadyExistsError = class extends BaseError {
646
+ constructor(conceptId2) {
647
+ super({
648
+ message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
649
+ errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
650
+ code: 409,
651
+ fault: "User" /* User */,
652
+ retriable: false,
653
+ reportToUser: true,
654
+ details: { conceptId: conceptId2, action: "refused" }
655
+ });
656
+ this.conceptId = conceptId2;
657
+ }
658
+ conceptId;
659
+ };
660
+ var KbRecordNotFoundError = class extends BaseError {
661
+ constructor(conceptId2) {
662
+ super({
663
+ message: `kb: ${conceptId2} does not exist`,
664
+ errorType: "KbRecordNotFound" /* KbRecordNotFound */,
665
+ code: 404,
666
+ fault: "User" /* User */,
667
+ retriable: false,
668
+ reportToUser: true,
669
+ details: { conceptId: conceptId2 }
670
+ });
671
+ this.conceptId = conceptId2;
672
+ }
673
+ conceptId;
674
+ };
675
+ var KbWriteConflictError = class extends BaseError {
676
+ constructor(conceptId2) {
677
+ super({
678
+ message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
679
+ errorType: "KbWriteConflict" /* KbWriteConflict */,
680
+ code: 409,
681
+ fault: "System" /* System */,
682
+ retriable: true,
683
+ reportToUser: true,
684
+ details: { conceptId: conceptId2 }
685
+ });
686
+ this.conceptId = conceptId2;
687
+ }
688
+ conceptId;
689
+ };
690
+ var KbSelfVerificationError = class extends BaseError {
691
+ constructor(conceptId2, actor, generatedBy) {
692
+ super({
693
+ message: `kb: ${conceptId2} was generated by ${generatedBy}, and a record's generator cannot verify it \u2014 only a human or a different actor can`,
694
+ errorType: "KbSelfVerification" /* KbSelfVerification */,
695
+ code: 400,
696
+ fault: "User" /* User */,
697
+ retriable: false,
698
+ reportToUser: true,
699
+ details: { conceptId: conceptId2, actor, generatedBy, action: "refused" }
700
+ });
701
+ this.conceptId = conceptId2;
702
+ this.actor = actor;
703
+ this.generatedBy = generatedBy;
704
+ }
705
+ conceptId;
706
+ actor;
707
+ generatedBy;
708
+ };
709
+ var KbPackBudgetExceededError = class extends BaseError {
710
+ constructor(recordCount, approxTokens2, budgetTokens, excluded) {
711
+ super({
712
+ message: `kb: a pack of ${recordCount} records is ~${approxTokens2} tokens against a budget of ${budgetTokens} \u2014 lower hops or maxNodes, or raise the budget`,
713
+ errorType: "KbPackBudgetExceeded" /* KbPackBudgetExceeded */,
714
+ code: 400,
715
+ fault: "User" /* User */,
716
+ retriable: false,
717
+ reportToUser: true,
718
+ details: { recordCount, approxTokens: approxTokens2, budgetTokens, excluded }
719
+ });
720
+ this.recordCount = recordCount;
721
+ this.approxTokens = approxTokens2;
722
+ this.budgetTokens = budgetTokens;
723
+ this.excluded = excluded;
724
+ }
725
+ recordCount;
726
+ approxTokens;
727
+ budgetTokens;
728
+ excluded;
729
+ };
730
+ var KbMissingFlagValueError = class extends BaseError {
731
+ constructor(flag) {
732
+ super({
733
+ message: `kb: ${flag} needs a value \u2014 pass ${flag} <value> or ${flag}=<value>`,
734
+ errorType: "KbMissingFlagValue" /* KbMissingFlagValue */,
735
+ code: 400,
736
+ fault: "User" /* User */,
737
+ retriable: false,
738
+ reportToUser: true,
739
+ details: { flag }
740
+ });
741
+ this.flag = flag;
742
+ }
743
+ flag;
744
+ };
745
+ var KbInvalidConceptIdError = class extends BaseError {
746
+ constructor(message, details) {
747
+ super({
748
+ message: `kb: ${message}`,
749
+ errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
750
+ code: 400,
751
+ fault: "User" /* User */,
752
+ retriable: false,
753
+ reportToUser: true,
754
+ details
755
+ });
756
+ }
757
+ };
758
+
759
+ // src/commands/model.ts
623
760
  var bundlePath = import_zod5.z.string().min(1).describe("Absolute path to the knowledge base directory.");
624
761
  var conceptId = import_zod5.z.string().min(1).describe("e.g. decision.cursor-v2");
625
762
  function define(command) {
626
763
  return command;
627
764
  }
628
765
  function argvFlag(argv, name) {
766
+ const joined = argv.find((arg) => arg.startsWith(`${name}=`));
767
+ if (joined !== void 0) {
768
+ const value2 = joined.slice(name.length + 1);
769
+ if (!value2) throw new KbMissingFlagValueError(name);
770
+ return value2;
771
+ }
629
772
  const at = argv.indexOf(name);
630
- return at !== -1 ? argv[at + 1] : void 0;
773
+ if (at === -1) return void 0;
774
+ const value = argv[at + 1];
775
+ if (value === void 0 || value.startsWith("--")) {
776
+ throw new KbMissingFlagValueError(name);
777
+ }
778
+ return value;
631
779
  }
632
780
 
633
781
  // src/commands/answer.ts
@@ -649,12 +797,9 @@ var answerCommand = define({
649
797
  }
650
798
  });
651
799
 
652
- // src/commands/context.ts
800
+ // src/commands/catalog.ts
653
801
  var import_zod7 = require("zod");
654
802
 
655
- // src/kb-context.ts
656
- var import_promises2 = require("fs/promises");
657
-
658
803
  // src/adjudicate.ts
659
804
  var STANDING = {
660
805
  accepted: "current",
@@ -747,6 +892,120 @@ function successors(record, byId) {
747
892
  return { records, missing };
748
893
  }
749
894
 
895
+ // src/catalog.ts
896
+ var EMPTY_STANDINGS = {
897
+ current: 0,
898
+ superseded: 0,
899
+ rejected: 0,
900
+ unsettled: 0,
901
+ open: 0
902
+ };
903
+ function catalog(bundle, options = {}) {
904
+ const wanted = options.type ? bundle.filter((record) => record.frontmatter.type === options.type) : bundle;
905
+ const entries = adjudicate(wanted, bundle, options.now ?? /* @__PURE__ */ new Date()).map((hit) => ({
906
+ conceptId: hit.record.conceptId,
907
+ type: hit.record.frontmatter.type,
908
+ title: hit.record.frontmatter.title ?? null,
909
+ standing: hit.standing,
910
+ supersededBy: hit.heads.map((head) => head.conceptId),
911
+ stale: hit.warnings.some((warning) => warning.kind === "stale")
912
+ })).sort(byTypeThenTitle);
913
+ const standings = { ...EMPTY_STANDINGS };
914
+ for (const entry of entries) standings[entry.standing] += 1;
915
+ return {
916
+ entries,
917
+ recordCount: entries.length,
918
+ standings,
919
+ currentCount: standings.current,
920
+ supersededCount: standings.superseded,
921
+ staleCount: entries.filter((entry) => entry.stale).length
922
+ };
923
+ }
924
+ function byTypeThenTitle(left, right) {
925
+ return byCodeUnit(left.type, right.type) || byCodeUnit(left.title ?? "", right.title ?? "") || byCodeUnit(left.conceptId, right.conceptId);
926
+ }
927
+ function byCodeUnit(left, right) {
928
+ return left < right ? -1 : left > right ? 1 : 0;
929
+ }
930
+ function renderCatalogLine(entry) {
931
+ const parts = [
932
+ entry.conceptId,
933
+ entry.type,
934
+ entry.title ?? "(untitled)",
935
+ entry.standing === "superseded" ? `superseded \u2192 ${entry.supersededBy.join(", ") || "(no surviving head)"}` : entry.standing
936
+ ];
937
+ if (entry.stale) parts.push("stale");
938
+ return `- ${parts.join(" \xB7 ")}`;
939
+ }
940
+
941
+ // src/commands/catalog.ts
942
+ var catalogCommand = define({
943
+ name: "catalog",
944
+ tool: "kb_catalog",
945
+ usage: "catalog [type]",
946
+ description: "Lists every record as one line \u2014 concept id, type, title, standing, and a stale flag \u2014 at roughly thirty tokens each. Pick this over kb_load once kb_load refuses: kb_catalog never refuses. Superseded records show only their replacement; fetch bodies with kb_load, kb_pack, kb_query, or kb_trace.",
947
+ input: import_zod7.z.object({
948
+ bundlePath,
949
+ type: import_zod7.z.enum(KB_RECORD_TYPES).optional()
950
+ }),
951
+ fromArgv: (argv, path) => ({
952
+ bundlePath: path,
953
+ ...argv[1] && !argv[1].startsWith("--") ? { type: argv[1] } : {}
954
+ }),
955
+ run: async ({ store }, { bundlePath: path, type }) => render(
956
+ await store.catalog(path, { ...type ? { type } : {} }),
957
+ path,
958
+ type
959
+ )
960
+ });
961
+ function render(result, bundle, type) {
962
+ const lines = [
963
+ `# KB Catalog${type ? ` \u2014 ${type}` : ""}`,
964
+ `bundle: ${bundle}`,
965
+ `${count(result.recordCount, "record")}: ${standingCounts(result)}`
966
+ ];
967
+ if (result.staleCount) {
968
+ lines.push(
969
+ `${result.staleCount} stale \u2014 a flag over the standings above, not one of them`
970
+ );
971
+ }
972
+ lines.push("");
973
+ if (!result.entries.length) {
974
+ lines.push(
975
+ type ? `(no records of type ${type})` : "(no records \u2014 this base is empty)"
976
+ );
977
+ } else {
978
+ for (const entry of result.entries) lines.push(renderCatalogLine(entry));
979
+ }
980
+ lines.push(
981
+ "",
982
+ "Bodies are not here: kb_pack <conceptId> for the neighbourhood around one record, kb_load for the whole base when it fits the budget, kb_query for a lookup by wording, kb_trace <conceptId> for how a position was arrived at."
983
+ );
984
+ return lines.join("\n");
985
+ }
986
+ function standingCounts(result) {
987
+ const ORDER = [
988
+ "current",
989
+ "open",
990
+ "unsettled",
991
+ "rejected",
992
+ "superseded"
993
+ ];
994
+ const parts = ORDER.filter((standing) => result.standings[standing]).map(
995
+ (standing) => `${result.standings[standing]} ${standing}`
996
+ );
997
+ return parts.length ? parts.join(" \xB7 ") : "none";
998
+ }
999
+ function count(value, noun) {
1000
+ return `${value} ${value === 1 ? noun : `${noun}s`}`;
1001
+ }
1002
+
1003
+ // src/commands/context.ts
1004
+ var import_zod8 = require("zod");
1005
+
1006
+ // src/kb-context.ts
1007
+ var import_promises2 = require("fs/promises");
1008
+
750
1009
  // src/kb-index.ts
751
1010
  var INDEX_FILE = "INDEX.md";
752
1011
  var HEADING = "# KB Index";
@@ -1008,20 +1267,20 @@ var contextCommand = define({
1008
1267
  tool: "kb_context",
1009
1268
  usage: "context [--profile NAME] [--budget N] [--full-under N] [--format json] [--event NAME]",
1010
1269
  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.",
1011
- input: import_zod7.z.object({
1012
- budgetTokens: import_zod7.z.number().int().positive().optional().describe(
1270
+ input: import_zod8.z.object({
1271
+ budgetTokens: import_zod8.z.number().int().positive().optional().describe(
1013
1272
  "Ceiling on the whole emitted block; past it the command refuses with a list of bases rather than truncating. Defaults to 4000."
1014
1273
  ),
1015
- fullUnderTokens: import_zod7.z.number().int().positive().optional().describe(
1274
+ fullUnderTokens: import_zod8.z.number().int().positive().optional().describe(
1016
1275
  "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."
1017
1276
  ),
1018
- profile: import_zod7.z.string().optional().describe(
1277
+ profile: import_zod8.z.string().optional().describe(
1019
1278
  "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."
1020
1279
  ),
1021
- format: import_zod7.z.enum(["markdown", "json"]).optional().describe(
1280
+ format: import_zod8.z.enum(["markdown", "json"]).optional().describe(
1022
1281
  "CLI envelope for hook protocols that require strict JSON on stdout. MCP callers omit this \u2014 the block itself is identical."
1023
1282
  ),
1024
- event: import_zod7.z.string().optional().describe(
1283
+ event: import_zod8.z.string().optional().describe(
1025
1284
  "hookEventName stamped into the JSON envelope. Only meaningful with format=json."
1026
1285
  )
1027
1286
  }),
@@ -1057,7 +1316,7 @@ var contextCommand = define({
1057
1316
  });
1058
1317
 
1059
1318
  // src/commands/doctor.ts
1060
- var import_zod8 = require("zod");
1319
+ var import_zod9 = require("zod");
1061
1320
 
1062
1321
  // src/kb-edges.ts
1063
1322
  var KB_EDGE_KINDS = [
@@ -1414,13 +1673,13 @@ function ageInDays(record, now) {
1414
1673
  }
1415
1674
 
1416
1675
  // src/commands/doctor.ts
1417
- var days = (what, fallback) => import_zod8.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
1676
+ var days = (what, fallback) => import_zod9.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
1418
1677
  var doctorCommand = define({
1419
1678
  name: "doctor",
1420
1679
  tool: "kb_doctor",
1421
1680
  usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
1422
1681
  description: "A health sweep over a whole base: what the calendar has already retired, what nobody ever confirmed, what has been open or proposed long enough that the status is now the answer, and what the graph has dropped on the floor. Read-only \u2014 it never writes, never supersedes, and never re-dates anything; every finding names a record for a person to repair. Seven checks, grouped and counted: expired (past `stale_after`), expiring (inside the window), unverified (an empty `verified[]` on a record old enough to matter), aging (still `open` or `proposed`), orphaned (no other record links to it), broken supersession (a chain that does not resolve), and superseded-but-cited (a live record whose body links to a record that no longer holds). Every group is reported even when empty, because a check that found nothing and a check that never ran look identical in a report that only lists findings.\n\nThis is the question no reader thinks to ask, which is why it needs a command: decay is invisible from inside a single record \u2014 a stale one reads exactly like a live one, and a question nobody answered reads exactly like one nobody asked. Reach for it when picking up a base someone else kept, before trusting a base you have not touched in months, or on a schedule; kb_validate is the narrower neighbour, checking only whether pointers between records agree.",
1423
- input: import_zod8.z.object({
1682
+ input: import_zod9.z.object({
1424
1683
  bundlePath,
1425
1684
  expiringDays: days(
1426
1685
  "How far ahead `expiring` looks, in days.",
@@ -1434,7 +1693,7 @@ var doctorCommand = define({
1434
1693
  "How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
1435
1694
  DEFAULT_AGING_DAYS
1436
1695
  ),
1437
- strict: import_zod8.z.boolean().optional().describe(
1696
+ strict: import_zod9.z.boolean().optional().describe(
1438
1697
  "Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
1439
1698
  )
1440
1699
  }),
@@ -1464,14 +1723,14 @@ var doctorCommand = define({
1464
1723
  });
1465
1724
  return { bundlePath: path, checkedAt, ...report };
1466
1725
  },
1467
- render: (result) => render(result),
1726
+ render: (result) => render2(result),
1468
1727
  // Only expiry, and only under --strict. The other six checks report debt a
1469
1728
  // reader decides about; an expired record is the base asserting something it
1470
1729
  // already said it would stop standing behind, which is the one finding a
1471
1730
  // pipeline can act on without a judgment call.
1472
1731
  failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
1473
1732
  });
1474
- function render(result) {
1733
+ function render2(result) {
1475
1734
  const { thresholds } = result;
1476
1735
  const lines = [
1477
1736
  `# KB Doctor \u2014 ${result.bundlePath}`,
@@ -1503,13 +1762,13 @@ function render(result) {
1503
1762
  }
1504
1763
 
1505
1764
  // src/commands/list.ts
1506
- var import_zod9 = require("zod");
1765
+ var import_zod10 = require("zod");
1507
1766
  var listCommand = define({
1508
1767
  name: "list",
1509
1768
  tool: "kb_list",
1510
1769
  usage: "list [type]",
1511
1770
  description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
1512
- input: import_zod9.z.object({ bundlePath, type: import_zod9.z.enum(KB_RECORD_TYPES).optional() }),
1771
+ input: import_zod10.z.object({ bundlePath, type: import_zod10.z.enum(KB_RECORD_TYPES).optional() }),
1513
1772
  fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
1514
1773
  run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
1515
1774
  conceptId: record.conceptId,
@@ -1521,21 +1780,21 @@ var listCommand = define({
1521
1780
  });
1522
1781
 
1523
1782
  // src/commands/load.ts
1524
- var import_zod10 = require("zod");
1783
+ var import_zod11 = require("zod");
1525
1784
  var loadCommand = define({
1526
1785
  name: "load",
1527
1786
  tool: "kb_load",
1528
- usage: "load [type] [--budget N | --all]",
1529
- 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.\n\nThat refusal is the default guardrail, meant for an agent that would otherwise burn its whole context on one call. `all` bypasses it and loads everything regardless of size: a deliberate operator with the budget to spend, not something to reach for automatically. It is mutually exclusive with `budgetTokens`. When the reader does not need everything, kb_query or a narrower `type` filter is the better fit than either.",
1530
- input: import_zod10.z.object({
1787
+ usage: "load [type] [--budget N] [--all]",
1788
+ description: "Loads the whole knowledge base at once, each record with its standing. Superseded records arrive as stubs (name, replacement, date); rejected and open records arrive whole. Refuses past the token budget rather than truncating \u2014 call kb_catalog, then kb_pack on the record that matters, or narrow with `type`; kb_query for a lookup by wording. `all` bypasses the budget.",
1789
+ input: import_zod11.z.object({
1531
1790
  bundlePath,
1532
- type: import_zod10.z.enum(KB_RECORD_TYPES).optional(),
1533
- budgetTokens: import_zod10.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
1534
- all: import_zod10.z.boolean().optional().describe(
1535
- "Load the entire base regardless of size. The deliberate-operator escape hatch; mutually exclusive with budgetTokens."
1791
+ type: import_zod11.z.enum(KB_RECORD_TYPES).optional(),
1792
+ budgetTokens: import_zod11.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
1793
+ all: import_zod11.z.boolean().optional().describe(
1794
+ "Loads the entire base regardless of size, bypassing the token budget; mutually exclusive with budgetTokens."
1536
1795
  )
1537
1796
  }).refine((value) => !(value.all && value.budgetTokens !== void 0), {
1538
- message: "all and budgetTokens are mutually exclusive: pass a ceiling or none, not both."
1797
+ message: "all is mutually exclusive with budgetTokens: pass a ceiling or none, not both."
1539
1798
  }),
1540
1799
  fromArgv: (argv, path) => {
1541
1800
  const budget = argvFlag(argv, "--budget");
@@ -1569,25 +1828,25 @@ var loadCommand = define({
1569
1828
  });
1570
1829
 
1571
1830
  // src/commands/log.ts
1572
- var import_zod11 = require("zod");
1831
+ var import_zod12 = require("zod");
1573
1832
  var logCommand = define({
1574
1833
  name: "log",
1575
1834
  tool: "kb_log",
1576
1835
  usage: "log",
1577
1836
  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.",
1578
- input: import_zod11.z.object({ bundlePath }),
1837
+ input: import_zod12.z.object({ bundlePath }),
1579
1838
  fromArgv: (_argv, path) => ({ bundlePath: path }),
1580
1839
  run: ({ store }, { bundlePath: path }) => store.readLog(path)
1581
1840
  });
1582
1841
 
1583
1842
  // src/commands/no-decision.ts
1584
- var import_zod12 = require("zod");
1843
+ var import_zod13 = require("zod");
1585
1844
  var noDecisionCommand = define({
1586
1845
  name: "no-decision",
1587
1846
  tool: "kb_no_decision",
1588
1847
  usage: "no-decision <reason...>",
1589
1848
  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.',
1590
- input: import_zod12.z.object({ bundlePath, reason: import_zod12.z.string().min(1) }),
1849
+ input: import_zod13.z.object({ bundlePath, reason: import_zod13.z.string().min(1) }),
1591
1850
  fromArgv: (argv, path) => ({
1592
1851
  bundlePath: path,
1593
1852
  reason: argv.slice(1).join(" ").trim()
@@ -1604,20 +1863,20 @@ var noDecisionCommand = define({
1604
1863
  });
1605
1864
 
1606
1865
  // src/commands/pack.ts
1607
- var import_zod13 = require("zod");
1866
+ var import_zod14 = require("zod");
1608
1867
  var packCommand = define({
1609
1868
  name: "pack",
1610
1869
  tool: "kb_pack",
1611
1870
  usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
1612
1871
  description: "The bounded neighbourhood around one record: everything within `hops` of the root, ranked and cut to `maxNodes`, with every cut record named under Excluded \u2014 a named gap is knowable, a silent one is not. Prefer this over kb_load when the base is too large to hold whole and the work centres on one record; prefer it over kb_query when the question needs the governed neighbourhood \u2014 what was settled and what binds near this record \u2014 rather than a lookup by wording. Superseded records arrive as name, replacement and date stubs exactly as kb_load emits them: their bodies no longer hold, and kb_trace has the history. Refuses outright rather than truncating when the pack would exceed its token budget \u2014 a partial pack is indistinguishable from a complete one \u2014 reporting the record count and every already-cut id so the caller can lower hops or maxNodes, or raise the budget. The header carries the bundle, root, budget and a timestamp; everything below the header is byte-identical across runs over an unchanged base, so two packs can be diffed and a changed byte means changed knowledge. This tool (with kb_load, 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.",
1613
- input: import_zod13.z.object({
1872
+ input: import_zod14.z.object({
1614
1873
  bundlePath,
1615
1874
  conceptId,
1616
- hops: import_zod13.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
1617
- maxNodes: import_zod13.z.number().int().positive().optional().describe(
1875
+ hops: import_zod14.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
1876
+ maxNodes: import_zod14.z.number().int().positive().optional().describe(
1618
1877
  "How many records the pack may hold, root included. Defaults to 20."
1619
1878
  ),
1620
- budgetTokens: import_zod13.z.number().int().positive().optional().describe(
1879
+ budgetTokens: import_zod14.z.number().int().positive().optional().describe(
1621
1880
  "Approximate token ceiling over what is actually emitted. Defaults to 25000."
1622
1881
  )
1623
1882
  }),
@@ -1639,10 +1898,10 @@ var packCommand = define({
1639
1898
  ...maxNodes !== void 0 ? { maxNodes } : {},
1640
1899
  ...budgetTokens !== void 0 ? { budgetTokens } : {}
1641
1900
  });
1642
- return render2(result, path, now());
1901
+ return render3(result, path, now());
1643
1902
  }
1644
1903
  });
1645
- function render2(result, bundle, at) {
1904
+ function render3(result, bundle, at) {
1646
1905
  const lines = [
1647
1906
  `# KB Pack \u2014 ${result.root}`,
1648
1907
  `bundle: ${bundle}`,
@@ -1704,22 +1963,22 @@ function warningLabel(warning) {
1704
1963
  }
1705
1964
 
1706
1965
  // src/commands/pin.ts
1707
- var import_zod14 = require("zod");
1966
+ var import_zod15 = require("zod");
1708
1967
  var pinCommand = define({
1709
1968
  name: "pin",
1710
1969
  tool: "kb_pin",
1711
1970
  usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
1712
1971
  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.",
1713
- input: import_zod14.z.object({
1972
+ input: import_zod15.z.object({
1714
1973
  bundlePath,
1715
- mode: import_zod14.z.enum(["full", "index"]).optional().describe(
1974
+ mode: import_zod15.z.enum(["full", "index"]).optional().describe(
1716
1975
  "full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
1717
1976
  ),
1718
- profiles: import_zod14.z.array(import_zod14.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
1719
- layer: import_zod14.z.enum(["project", "local", "user"]).optional().describe(
1977
+ profiles: import_zod15.z.array(import_zod15.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
1978
+ layer: import_zod15.z.enum(["project", "local", "user"]).optional().describe(
1720
1979
  "Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
1721
1980
  ),
1722
- frozen: import_zod14.z.boolean().optional().describe(
1981
+ frozen: import_zod15.z.boolean().optional().describe(
1723
1982
  "true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
1724
1983
  )
1725
1984
  }),
@@ -1748,29 +2007,29 @@ var pinCommand = define({
1748
2007
  });
1749
2008
 
1750
2009
  // src/commands/pins.ts
1751
- var import_zod15 = require("zod");
2010
+ var import_zod16 = require("zod");
1752
2011
  var pinsCommand = define({
1753
2012
  name: "pins",
1754
2013
  tool: "kb_pins",
1755
2014
  usage: "pins",
1756
2015
  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.",
1757
- input: import_zod15.z.object({}),
2016
+ input: import_zod16.z.object({}),
1758
2017
  fromArgv: () => ({}),
1759
2018
  run: ({ store }) => listPins(store, process.cwd())
1760
2019
  });
1761
2020
 
1762
2021
  // src/commands/query.ts
1763
- var import_zod16 = require("zod");
2022
+ var import_zod17 = require("zod");
1764
2023
  var queryCommand = define({
1765
2024
  name: "query",
1766
2025
  tool: "kb_query",
1767
2026
  usage: "query <text...>",
1768
- 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.",
1769
- input: import_zod16.z.object({
2027
+ 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. This is the lookup-by-wording rung, and the narrowest of the three: use it when you know roughly what the record says. The decision rule around it \u2014 while the base fits kb_load's token budget, kb_load it whole, because 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; once kb_load refuses, kb_catalog for one line per record and then kb_pack on the record the work centres on; and kb_query when the question is a point lookup rather than a neighbourhood. A query cannot tell you that nothing was decided \u2014 it returns its nearest hit whatever the distance \u2014 so reach for kb_catalog when the question is what exists. Never read record files directly: this tool (with kb_load, kb_catalog, kb_pack 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.",
2028
+ input: import_zod17.z.object({
1770
2029
  bundlePath,
1771
- text: import_zod16.z.string().optional(),
1772
- type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
1773
- includeNonCurrent: import_zod16.z.boolean().optional()
2030
+ text: import_zod17.z.string().optional(),
2031
+ type: import_zod17.z.enum(KB_RECORD_TYPES).optional(),
2032
+ includeNonCurrent: import_zod17.z.boolean().optional()
1774
2033
  }),
1775
2034
  fromArgv: (argv, path) => ({
1776
2035
  bundlePath: path,
@@ -1792,33 +2051,41 @@ var queryCommand = define({
1792
2051
  });
1793
2052
 
1794
2053
  // src/commands/read-index.ts
1795
- var import_zod17 = require("zod");
2054
+ var import_zod18 = require("zod");
1796
2055
  var readIndexCommand = define({
1797
2056
  name: "index",
1798
2057
  tool: "kb_index",
1799
2058
  usage: "index",
1800
2059
  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.",
1801
- input: import_zod17.z.object({ bundlePath }),
2060
+ input: import_zod18.z.object({ bundlePath }),
1802
2061
  fromArgv: (_argv, path) => ({ bundlePath: path }),
1803
2062
  run: ({ store }, { bundlePath: path }) => store.readIndex(path)
1804
2063
  });
1805
2064
 
1806
2065
  // src/commands/schema.ts
1807
- var import_zod20 = require("zod");
2066
+ var import_zod21 = require("zod");
1808
2067
 
1809
2068
  // src/json-schema.ts
1810
- var import_zod19 = require("zod");
2069
+ var import_zod20 = require("zod");
1811
2070
 
1812
2071
  // src/kb-log.ts
1813
- var import_zod18 = require("zod");
2072
+ var import_zod19 = require("zod");
1814
2073
  var LOG_FILE = "log.jsonl";
1815
- var kbLogEntrySchema = import_zod18.z.object({
1816
- at: import_zod18.z.string().min(1),
1817
- by: import_zod18.z.string().min(1),
1818
- operation: import_zod18.z.string().min(1),
1819
- conceptId: import_zod18.z.string().min(1),
2074
+ var kbLogEntrySchema = import_zod19.z.object({
2075
+ // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
2076
+ // below), and a value that isn't actually chronological — a Unix
2077
+ // timestamp, a human-typed date, garbage — would sort wrong without
2078
+ // ever failing to parse. `z.iso.datetime()` accepts exactly what
2079
+ // `record()` writes (`Date#toISOString()`: full precision, `Z` offset)
2080
+ // and rejects everything else, including a non-`Z` offset — so a
2081
+ // malformed `at` is reported the same way a malformed line already is,
2082
+ // rather than silently sorting into the wrong place.
2083
+ at: import_zod19.z.iso.datetime(),
2084
+ by: import_zod19.z.string().min(1),
2085
+ operation: import_zod19.z.string().min(1),
2086
+ conceptId: import_zod19.z.string().min(1),
1820
2087
  /** Second concept id, where the operation relates two — supersession. */
1821
- target: import_zod18.z.string().min(1).optional()
2088
+ target: import_zod19.z.string().min(1).optional()
1822
2089
  }).strict();
1823
2090
  function renderLogEntry(entry) {
1824
2091
  return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
@@ -1827,6 +2094,7 @@ function renderLogEntry(entry) {
1827
2094
  function parseLog(raw) {
1828
2095
  const entries = [];
1829
2096
  const malformed = [];
2097
+ const seen = /* @__PURE__ */ new Set();
1830
2098
  raw.split("\n").forEach((text, index) => {
1831
2099
  if (!text.trim()) return;
1832
2100
  let value;
@@ -1841,19 +2109,25 @@ function parseLog(raw) {
1841
2109
  malformed.push({ line: index + 1, text });
1842
2110
  return;
1843
2111
  }
2112
+ const key = JSON.stringify(parsed.data);
2113
+ if (seen.has(key)) return;
2114
+ seen.add(key);
1844
2115
  entries.push(parsed.data);
1845
2116
  });
2117
+ entries.sort(
2118
+ (left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
2119
+ );
1846
2120
  return { entries, malformed };
1847
2121
  }
1848
2122
 
1849
2123
  // src/json-schema.ts
1850
2124
  function kbJsonSchemas() {
1851
2125
  return {
1852
- recordFrontmatter: import_zod19.z.toJSONSchema(kbRecordFrontmatterSchema, {
2126
+ recordFrontmatter: import_zod20.z.toJSONSchema(kbRecordFrontmatterSchema, {
1853
2127
  io: "input"
1854
2128
  }),
1855
- composeInput: import_zod19.z.toJSONSchema(composeInputSchema, { io: "input" }),
1856
- logEntry: import_zod19.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
2129
+ composeInput: import_zod20.z.toJSONSchema(composeInputSchema, { io: "input" }),
2130
+ logEntry: import_zod20.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
1857
2131
  };
1858
2132
  }
1859
2133
 
@@ -1863,22 +2137,22 @@ var schemaCommand = define({
1863
2137
  tool: "kb_schema",
1864
2138
  usage: "schema",
1865
2139
  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.",
1866
- input: import_zod20.z.object({}),
2140
+ input: import_zod21.z.object({}),
1867
2141
  fromArgv: () => ({}),
1868
2142
  run: () => Promise.resolve(kbJsonSchemas())
1869
2143
  });
1870
2144
 
1871
2145
  // src/commands/status.ts
1872
- var import_zod21 = require("zod");
2146
+ var import_zod22 = require("zod");
1873
2147
  var statusCommand = define({
1874
2148
  name: "status",
1875
2149
  tool: "kb_status",
1876
2150
  usage: "status <concept-id> <status>",
1877
2151
  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.",
1878
- input: import_zod21.z.object({
2152
+ input: import_zod22.z.object({
1879
2153
  bundlePath,
1880
2154
  conceptId,
1881
- status: import_zod21.z.enum(KB_RECORD_STATUSES)
2155
+ status: import_zod22.z.enum(KB_RECORD_STATUSES)
1882
2156
  }),
1883
2157
  fromArgv: (argv, path) => ({
1884
2158
  bundlePath: path,
@@ -1893,13 +2167,13 @@ var statusCommand = define({
1893
2167
  });
1894
2168
 
1895
2169
  // src/commands/supersede.ts
1896
- var import_zod22 = require("zod");
2170
+ var import_zod23 = require("zod");
1897
2171
  var supersedeCommand = define({
1898
2172
  name: "supersede",
1899
2173
  tool: "kb_supersede",
1900
2174
  usage: "supersede <concept-id> <replacement-id>",
1901
2175
  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.",
1902
- input: import_zod22.z.object({ bundlePath, conceptId, replacementId: conceptId }),
2176
+ input: import_zod23.z.object({ bundlePath, conceptId, replacementId: conceptId }),
1903
2177
  fromArgv: (argv, path) => ({
1904
2178
  bundlePath: path,
1905
2179
  conceptId: argv[1],
@@ -1913,16 +2187,16 @@ var supersedeCommand = define({
1913
2187
  });
1914
2188
 
1915
2189
  // src/commands/sync-instructions.ts
1916
- var import_zod23 = require("zod");
2190
+ var import_zod24 = require("zod");
1917
2191
  var syncInstructionsCommand = define({
1918
2192
  name: "sync-instructions",
1919
2193
  usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
1920
2194
  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.",
1921
- input: import_zod23.z.object({
1922
- file: import_zod23.z.string().min(1).describe("The instruction file to edit in place."),
1923
- budgetTokens: import_zod23.z.number().int().positive().optional(),
1924
- fullUnderTokens: import_zod23.z.number().int().positive().optional(),
1925
- profile: import_zod23.z.string().optional()
2195
+ input: import_zod24.z.object({
2196
+ file: import_zod24.z.string().min(1).describe("The instruction file to edit in place."),
2197
+ budgetTokens: import_zod24.z.number().int().positive().optional(),
2198
+ fullUnderTokens: import_zod24.z.number().int().positive().optional(),
2199
+ profile: import_zod24.z.string().optional()
1926
2200
  }),
1927
2201
  fromArgv: (argv) => {
1928
2202
  const budget = argvFlag(argv, "--budget");
@@ -1948,7 +2222,7 @@ var syncInstructionsCommand = define({
1948
2222
  });
1949
2223
 
1950
2224
  // src/commands/trace.ts
1951
- var import_zod24 = require("zod");
2225
+ var import_zod25 = require("zod");
1952
2226
 
1953
2227
  // src/trace.ts
1954
2228
  var TRACE_EDGES = ["supersession", "anchor", "source"];
@@ -1994,11 +2268,11 @@ var traceCommand = define({
1994
2268
  tool: "kb_trace",
1995
2269
  usage: "trace <concept-id> [edges...]",
1996
2270
  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.',
1997
- input: import_zod24.z.object({
2271
+ input: import_zod25.z.object({
1998
2272
  bundlePath,
1999
2273
  conceptId,
2000
- edges: import_zod24.z.array(import_zod24.z.enum(TRACE_EDGES)).optional(),
2001
- depth: import_zod24.z.number().int().positive().optional()
2274
+ edges: import_zod25.z.array(import_zod25.z.enum(TRACE_EDGES)).optional(),
2275
+ depth: import_zod25.z.number().int().positive().optional()
2002
2276
  }),
2003
2277
  fromArgv: (argv, path) => ({
2004
2278
  bundlePath: path,
@@ -2020,53 +2294,53 @@ var traceCommand = define({
2020
2294
  });
2021
2295
 
2022
2296
  // src/commands/types.ts
2023
- var import_zod25 = require("zod");
2297
+ var import_zod26 = require("zod");
2024
2298
  var typesCommand = define({
2025
2299
  name: "types",
2026
2300
  tool: "kb_types",
2027
2301
  usage: "types",
2028
2302
  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.",
2029
- input: import_zod25.z.object({}),
2303
+ input: import_zod26.z.object({}),
2030
2304
  fromArgv: () => ({}),
2031
2305
  run: () => Promise.resolve(RECORD_TYPES)
2032
2306
  });
2033
2307
 
2034
2308
  // src/commands/unpin.ts
2035
- var import_zod26 = require("zod");
2309
+ var import_zod27 = require("zod");
2036
2310
  var unpinCommand = define({
2037
2311
  name: "unpin",
2038
2312
  tool: "kb_unpin",
2039
2313
  usage: "unpin [bundle-path]",
2040
2314
  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.",
2041
- input: import_zod26.z.object({ bundlePath }),
2315
+ input: import_zod27.z.object({ bundlePath }),
2042
2316
  fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
2043
2317
  run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
2044
2318
  });
2045
2319
 
2046
2320
  // src/commands/validate.ts
2047
- var import_zod27 = require("zod");
2321
+ var import_zod28 = require("zod");
2048
2322
  var validateCommand = define({
2049
2323
  name: "validate",
2050
2324
  tool: "kb_validate",
2051
2325
  usage: "validate",
2052
2326
  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.",
2053
- input: import_zod27.z.object({ bundlePath }),
2327
+ input: import_zod28.z.object({ bundlePath }),
2054
2328
  fromArgv: (_argv, path) => ({ bundlePath: path }),
2055
2329
  run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
2056
2330
  failsWhen: (result) => Array.isArray(result) && result.length > 0
2057
2331
  });
2058
2332
 
2059
2333
  // src/commands/verify.ts
2060
- var import_zod28 = require("zod");
2334
+ var import_zod29 = require("zod");
2061
2335
  var verifyCommand = define({
2062
2336
  name: "verify",
2063
2337
  tool: "kb_verify",
2064
2338
  usage: "verify <concept-id> --note <text>",
2065
2339
  description: "Append one verified[] event \u2014 who checked the record, when, and what the check found. Appends only; prior events are never rewritten. A record's own generator is refused unless the actor is human: re-reading your own output is not an independent check.",
2066
- input: import_zod28.z.object({
2340
+ input: import_zod29.z.object({
2067
2341
  bundlePath,
2068
2342
  conceptId,
2069
- note: import_zod28.z.string().refine((s) => s.trim().length > 0, {
2343
+ note: import_zod29.z.string().refine((s) => s.trim().length > 0, {
2070
2344
  message: "note must say what the check found"
2071
2345
  })
2072
2346
  }),
@@ -2086,7 +2360,7 @@ var verifyCommand = define({
2086
2360
  });
2087
2361
 
2088
2362
  // src/commands/write.ts
2089
- var import_zod29 = require("zod");
2363
+ var import_zod30 = require("zod");
2090
2364
  var writeCommand = define({
2091
2365
  name: "write",
2092
2366
  tool: "kb_write",
@@ -2100,9 +2374,9 @@ var writeCommand = define({
2100
2374
  "- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
2101
2375
  "- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
2102
2376
  ].join("\n"),
2103
- input: import_zod29.z.object({
2377
+ input: import_zod30.z.object({
2104
2378
  bundlePath,
2105
- type: import_zod29.z.enum(KB_RECORD_TYPES),
2379
+ type: import_zod30.z.enum(KB_RECORD_TYPES),
2106
2380
  input: composeInputSchema
2107
2381
  }),
2108
2382
  fromArgv: async (argv, path, stdin) => ({
@@ -2126,7 +2400,7 @@ var writeCommand = define({
2126
2400
  });
2127
2401
 
2128
2402
  // src/commands/write-decision.ts
2129
- var import_zod30 = require("zod");
2403
+ var import_zod31 = require("zod");
2130
2404
  var writeDecisionCommand = define({
2131
2405
  name: "write-decision",
2132
2406
  tool: "kb_write_decision",
@@ -2139,7 +2413,7 @@ var writeDecisionCommand = define({
2139
2413
  "- `alternative` is what you turned down and why, not a list of everything considered.",
2140
2414
  "- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
2141
2415
  ].join("\n"),
2142
- input: import_zod30.z.object({ bundlePath, input: decisionInputSchema }),
2416
+ input: import_zod31.z.object({ bundlePath, input: decisionInputSchema }),
2143
2417
  fromArgv: async (_argv, path, stdin) => ({
2144
2418
  bundlePath: path,
2145
2419
  input: JSON.parse(await stdin())
@@ -2169,6 +2443,7 @@ var KB_COMMANDS = [
2169
2443
  answerCommand,
2170
2444
  verifyCommand,
2171
2445
  loadCommand,
2446
+ catalogCommand,
2172
2447
  packCommand,
2173
2448
  queryCommand,
2174
2449
  traceCommand,
@@ -2219,126 +2494,6 @@ function parseMarkdownWithFrontmatter(text, schema) {
2219
2494
  };
2220
2495
  }
2221
2496
 
2222
- // src/errors.ts
2223
- var BaseError = class extends Error {
2224
- code;
2225
- errorType;
2226
- fault;
2227
- retriable;
2228
- reportToUser;
2229
- details;
2230
- constructor(props) {
2231
- super(props.message);
2232
- this.name = props.name ?? this.constructor.name;
2233
- this.code = props.code ?? 500;
2234
- this.errorType = props.errorType;
2235
- this.fault = props.fault;
2236
- this.retriable = props.retriable ?? true;
2237
- this.reportToUser = props.reportToUser ?? false;
2238
- this.details = props.details;
2239
- }
2240
- };
2241
-
2242
- // src/kb-errors.ts
2243
- var KbRecordAlreadyExistsError = class extends BaseError {
2244
- constructor(conceptId2) {
2245
- super({
2246
- message: `kb: ${conceptId2} already exists \u2014 choose a more specific slug, or write with overwrite`,
2247
- errorType: "KbRecordAlreadyExists" /* KbRecordAlreadyExists */,
2248
- code: 409,
2249
- fault: "User" /* User */,
2250
- retriable: false,
2251
- reportToUser: true,
2252
- details: { conceptId: conceptId2, action: "refused" }
2253
- });
2254
- this.conceptId = conceptId2;
2255
- }
2256
- conceptId;
2257
- };
2258
- var KbRecordNotFoundError = class extends BaseError {
2259
- constructor(conceptId2) {
2260
- super({
2261
- message: `kb: ${conceptId2} does not exist`,
2262
- errorType: "KbRecordNotFound" /* KbRecordNotFound */,
2263
- code: 404,
2264
- fault: "User" /* User */,
2265
- retriable: false,
2266
- reportToUser: true,
2267
- details: { conceptId: conceptId2 }
2268
- });
2269
- this.conceptId = conceptId2;
2270
- }
2271
- conceptId;
2272
- };
2273
- var KbWriteConflictError = class extends BaseError {
2274
- constructor(conceptId2) {
2275
- super({
2276
- message: `kb: ${conceptId2} changed while it was being updated \u2014 re-read and retry`,
2277
- errorType: "KbWriteConflict" /* KbWriteConflict */,
2278
- code: 409,
2279
- fault: "System" /* System */,
2280
- retriable: true,
2281
- reportToUser: true,
2282
- details: { conceptId: conceptId2 }
2283
- });
2284
- this.conceptId = conceptId2;
2285
- }
2286
- conceptId;
2287
- };
2288
- var KbSelfVerificationError = class extends BaseError {
2289
- constructor(conceptId2, actor, generatedBy) {
2290
- super({
2291
- message: `kb: ${conceptId2} was generated by ${generatedBy}, and a record's generator cannot verify it \u2014 only a human or a different actor can`,
2292
- errorType: "KbSelfVerification" /* KbSelfVerification */,
2293
- code: 400,
2294
- fault: "User" /* User */,
2295
- retriable: false,
2296
- reportToUser: true,
2297
- details: { conceptId: conceptId2, actor, generatedBy, action: "refused" }
2298
- });
2299
- this.conceptId = conceptId2;
2300
- this.actor = actor;
2301
- this.generatedBy = generatedBy;
2302
- }
2303
- conceptId;
2304
- actor;
2305
- generatedBy;
2306
- };
2307
- var KbPackBudgetExceededError = class extends BaseError {
2308
- constructor(recordCount, approxTokens2, budgetTokens, excluded) {
2309
- super({
2310
- message: `kb: a pack of ${recordCount} records is ~${approxTokens2} tokens against a budget of ${budgetTokens} \u2014 lower hops or maxNodes, or raise the budget`,
2311
- errorType: "KbPackBudgetExceeded" /* KbPackBudgetExceeded */,
2312
- code: 400,
2313
- fault: "User" /* User */,
2314
- retriable: false,
2315
- reportToUser: true,
2316
- details: { recordCount, approxTokens: approxTokens2, budgetTokens, excluded }
2317
- });
2318
- this.recordCount = recordCount;
2319
- this.approxTokens = approxTokens2;
2320
- this.budgetTokens = budgetTokens;
2321
- this.excluded = excluded;
2322
- }
2323
- recordCount;
2324
- approxTokens;
2325
- budgetTokens;
2326
- excluded;
2327
- };
2328
- var KbInvalidConceptIdError = class extends BaseError {
2329
- constructor(message, details) {
2330
- super({
2331
- message: `kb: ${message}`,
2332
- errorType: "KbInvalidConceptId" /* KbInvalidConceptId */,
2333
- code: 400,
2334
- fault: "User" /* User */,
2335
- retriable: false,
2336
- reportToUser: true,
2337
- details
2338
- });
2339
- }
2340
- };
2341
-
2342
2497
  // src/search-index.ts
2343
2498
  var import_promises3 = require("fs/promises");
2344
2499
  var import_node_path5 = require("path");
@@ -2509,6 +2664,30 @@ function typeRank(record) {
2509
2664
  return index === -1 ? TYPE_PRIORITY.length : index;
2510
2665
  }
2511
2666
 
2667
+ // src/kb-gitattributes.ts
2668
+ var GITATTRIBUTES_FILE = ".gitattributes";
2669
+ var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
2670
+ function parseLine(line) {
2671
+ const trimmed = line.trim();
2672
+ if (!trimmed || trimmed.startsWith("#")) return null;
2673
+ const [pattern, ...attrs] = trimmed.split(/\s+/);
2674
+ return pattern === void 0 ? null : { pattern, attrs };
2675
+ }
2676
+ function hasMergeDeclaration(contents) {
2677
+ return contents.split("\n").some((line) => {
2678
+ const parsed = parseLine(line);
2679
+ if (!parsed || parsed.pattern !== LOG_FILE) return false;
2680
+ return parsed.attrs.some(
2681
+ (attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
2682
+ );
2683
+ });
2684
+ }
2685
+ function appendUnionMergeLine(contents) {
2686
+ const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
2687
+ return `${separator}${UNION_MERGE_LINE}
2688
+ `;
2689
+ }
2690
+
2512
2691
  // src/kb-store.ts
2513
2692
  var KB_DIR = (0, import_node_path6.join)(".strauss", "kb");
2514
2693
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
@@ -2765,9 +2944,16 @@ ${answer}
2765
2944
  * is indistinguishable from a complete one, so a caller would answer "that
2766
2945
  * was never decided" from a slice it did not know was a slice.
2767
2946
  *
2768
- * That refusal is the default guardrail. `all` bypasses it outright and
2769
- * always hands back the whole bundle: an explicit, never-accidental escape
2770
- * hatch for an operator who has the budget to spend, not a wider default.
2947
+ * A token budget decides that, measured over what is actually handed back.
2948
+ * The refusal names the estimate and the budget, because a caller told only
2949
+ * "too big" cannot tell whether to narrow the type filter, raise the budget,
2950
+ * or stop loading the base whole altogether. Past the budget the answer is
2951
+ * the catalog and then a pack, which is what the refusal says.
2952
+ *
2953
+ * That refusal is the default guardrail. `all` bypasses the budget outright
2954
+ * and always hands back the whole bundle: an explicit, never-accidental
2955
+ * escape hatch for an operator who has the budget to spend, not a wider
2956
+ * default.
2771
2957
  */
2772
2958
  async load(bundlePath2, options = {}) {
2773
2959
  const budgetTokens = options.budgetTokens ?? DEFAULT_LOAD_BUDGET;
@@ -2782,7 +2968,12 @@ ${answer}
2782
2968
  loaded: false,
2783
2969
  recordCount: wanted.length,
2784
2970
  approxTokens: approxTokens2,
2785
- budgetTokens
2971
+ budgetTokens,
2972
+ message: refusalMessage({
2973
+ approxTokens: approxTokens2,
2974
+ budgetTokens,
2975
+ type: options.type
2976
+ })
2786
2977
  };
2787
2978
  }
2788
2979
  return {
@@ -2798,6 +2989,10 @@ ${answer}
2798
2989
  async trace(bundlePath2, seedId, options = {}) {
2799
2990
  return trace(seedId, await this.list(bundlePath2), options);
2800
2991
  }
2992
+ /** Every record named in one line each. See `catalog.ts`. */
2993
+ async catalog(bundlePath2, options = {}) {
2994
+ return catalog(await this.list(bundlePath2), options);
2995
+ }
2801
2996
  /** A bounded neighbourhood around one record. See `pack.ts`. */
2802
2997
  async pack(bundlePath2, rootId, options = {}) {
2803
2998
  return pack(await this.list(bundlePath2), rootId, options);
@@ -2934,8 +3129,87 @@ ${answer}
2934
3129
  await (0, import_promises4.unlink)(staging).catch(() => void 0);
2935
3130
  }
2936
3131
  }
3132
+ /**
3133
+ * Declares union merge for the log, so two worktrees writing the same
3134
+ * bundle interleave their `log.jsonl` lines on merge rather than one
3135
+ * side's appends silently losing to git's ordinary line-level merge.
3136
+ *
3137
+ * Called from `record` — every path that appends a log line, not just
3138
+ * `write` — so a bundle only ever mutated through `setStatus`/`verify`/
3139
+ * `supersede` still gets it. There is no cheaper reliable signal for
3140
+ * "first write" than checking the file itself, and after the first call
3141
+ * the check is a no-op `readFile`.
3142
+ *
3143
+ * A missing `.gitattributes` is created outright, with `wx` (exclusive
3144
+ * create) rather than a plain write: if another process's `write()` won a
3145
+ * race and created the file between the `readFile` below and this call,
3146
+ * `wx` fails instead of truncating what that writer just wrote, and the
3147
+ * failure is swallowed by the catch below same as any other best-effort
3148
+ * miss. A file that exists but declares no merge strategy for the log
3149
+ * gets the line appended, never a wholesale rewrite; one that already
3150
+ * declares any merge strategy — this one or a user's own — is left alone
3151
+ * entirely (see `hasMergeDeclaration`).
3152
+ *
3153
+ * `readFile` failing is `existing === null` only for `ENOENT` — genuinely
3154
+ * missing. Any other error (a permission problem, a transient `EMFILE`,
3155
+ * the path being a directory) is *not* "missing" and must not fall into
3156
+ * the create branch, which would truncate whatever is actually there with
3157
+ * just the union-merge line: that is the file-destroying bug this
3158
+ * function exists to avoid, not commit. An unreadable existing file is
3159
+ * therefore left untouched and reported as a failure like any other.
3160
+ *
3161
+ * Two processes racing the append branch — both read a file without the
3162
+ * line, both append it — is possible and left unguarded: `appendFile` is
3163
+ * `O_APPEND`, so the result is two copies of the same line rather than a
3164
+ * torn write, and `hasMergeDeclaration` sees a duplicate declaration as
3165
+ * "already declared" on the next call. A cheap-to-detect, harmless-to-
3166
+ * leave residue, not a reason to add a cross-process lock (see
3167
+ * `ARCHITECTURE.md`'s rejection of one for the same trade on records).
3168
+ *
3169
+ * Best-effort, like the log append it precedes: failing to write this
3170
+ * file must not fail the mutation it guards.
3171
+ */
3172
+ async ensureGitattributes(root) {
3173
+ const target = (0, import_node_path6.join)(root, GITATTRIBUTES_FILE);
3174
+ try {
3175
+ let existing;
3176
+ try {
3177
+ existing = await (0, import_promises4.readFile)(target, "utf8");
3178
+ } catch (error) {
3179
+ if (error.code !== "ENOENT") throw error;
3180
+ existing = null;
3181
+ }
3182
+ if (existing === null) {
3183
+ await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
3184
+ encoding: "utf8",
3185
+ flag: "wx"
3186
+ });
3187
+ this.logger.info?.({
3188
+ operation: "kb.gitattributes.ensure",
3189
+ bundlePath: root,
3190
+ outcome: "created"
3191
+ });
3192
+ return;
3193
+ }
3194
+ if (!hasMergeDeclaration(existing)) {
3195
+ await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
3196
+ this.logger.info?.({
3197
+ operation: "kb.gitattributes.ensure",
3198
+ bundlePath: root,
3199
+ outcome: "appended"
3200
+ });
3201
+ }
3202
+ } catch (error) {
3203
+ this.logger.warn?.({
3204
+ operation: "kb.gitattributes.ensure",
3205
+ outcome: "failed",
3206
+ error: error instanceof Error ? error.message : "unknown"
3207
+ });
3208
+ }
3209
+ }
2937
3210
  /** Appends one log line. Failing to log must not fail the mutation. */
2938
3211
  async record(root, entry) {
3212
+ await this.ensureGitattributes(root);
2939
3213
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
2940
3214
  await (0, import_promises4.appendFile)((0, import_node_path6.join)(root, LOG_FILE), line, "utf8").catch((error) => {
2941
3215
  this.logger.warn?.({
@@ -2985,6 +3259,14 @@ function estimateTokens(record) {
2985
3259
  function estimateStubTokens(entry) {
2986
3260
  return Math.ceil(JSON.stringify(entry).length / 4);
2987
3261
  }
3262
+ function refusalMessage(refusal) {
3263
+ const scope = refusal.type ? ` of type ${refusal.type}` : "";
3264
+ return [
3265
+ `Refusing to load this base whole: ~${refusal.approxTokens} tokens is past the ${refusal.budgetTokens}-token budget.`,
3266
+ `Call kb_catalog for one line per record${scope} (id, type, title, standing), then kb_pack on the record that matters; kb_query works for a lookup by wording.`,
3267
+ `To load anyway: raise budgetTokens (currently ${refusal.budgetTokens}), or all=true to bypass the budget.`
3268
+ ].join(" ");
3269
+ }
2988
3270
  function stub(hit) {
2989
3271
  return {
2990
3272
  conceptId: hit.record.conceptId,
@@ -3009,7 +3291,7 @@ function digest(contents) {
3009
3291
  }
3010
3292
 
3011
3293
  // src/version.ts
3012
- var VERSION = true ? "0.1.8" : "0.0.0-dev";
3294
+ var VERSION = true ? "0.1.10" : "0.0.0-dev";
3013
3295
 
3014
3296
  // src/mcp.ts
3015
3297
  function createKbMcpServer() {