@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/ARCHITECTURE.md CHANGED
@@ -72,6 +72,95 @@ replacement's first log format was `·`-delimited, with a splitter to read it
72
72
  back. Both are gone: the log is JSONL and the schema is emitted from Zod, so
73
73
  `strauss-kb schema` is the contract rather than a description of one.
74
74
 
75
+ ## Cross-worktree log safety
76
+
77
+ `log.jsonl` is append-only and the one artifact nothing can rebuild, so how it
78
+ merges across worktrees matters more than how any record file does. Records
79
+ already don't need this: one concept id is one file, so two writers choosing
80
+ distinct ids never merge at all, and `link`-based publish (above) turns the
81
+ one case where they collide into a 409 rather than a merge. The log has no
82
+ such escape — every writer appends into the same file by design — so it needs
83
+ an actual merge strategy, not just atomicity per write.
84
+
85
+ The append itself already had it: `record()` uses `appendFile`, which opens
86
+ `O_APPEND` and is one `write(2)` for an entry this small, so two processes
87
+ appending locally interleave whole lines, never a torn one. What was missing
88
+ was git's merge of two worktrees' independently-appended logs — the default
89
+ line-level merge can conflict, or silently keep one side, on lines that were
90
+ never in conflict, since both sides only ever added, never edited, a line.
91
+
92
+ The fix is `.gitattributes: log.jsonl text eol=lf merge=union`, written by
93
+ `record()` — every path that appends a log line (`write`, `setStatus`,
94
+ `verify`, `supersede`), not `write()` alone — on first use
95
+ (`kb-store.ts#ensureGitattributes`). `union` is a merge driver git ships —
96
+ nothing to configure beyond the attribute — that keeps both sides' added
97
+ lines; `eol=lf` closes a second divergence path, below. The alternative
98
+ considered and dropped was a lock file coordinating writes across worktrees
99
+ the way `mutate`'s CAS coordinates a single record: it would need to span
100
+ process boundaries and survive a crashed holder, which is the same
101
+ stale-lock failure mode rejected above, for a smaller problem than the one it
102
+ solves there.
103
+
104
+ Decisions worth naming because a later reader could reopen them:
105
+
106
+ - **A `.gitattributes` that exists but declares no merge strategy for the
107
+ log gets the line appended, not left alone.** The alternative — leave a
108
+ user's own file untouched — reads as more conservative, but a bundle that
109
+ already has a `.gitattributes` in front of it is exactly the bundle most
110
+ likely to be shared across worktrees or forks, so leaving it without union
111
+ merge is the worse of the two failure modes. A file that _does_ already
112
+ declare a merge strategy for `log.jsonl` — this one or a user's own, such
113
+ as `merge=ours` — is left alone entirely: gitattributes resolves repeated
114
+ lines for one pattern by "last one wins", so appending a second `merge=`
115
+ line would silently override rather than coexist. Recognizing "already
116
+ declared" needed a real tokenizer (`hasMergeDeclaration`) rather than
117
+ exact-string matching against the line this module writes — the first cut
118
+ missed a tab or doubled space between tokens (false negative, a harmless
119
+ duplicate line) and could never recognize a user's own `merge=ours` as a
120
+ decision already made (the one case where appending anything is wrong).
121
+ - **A `.gitattributes` that fails to _read_ is never treated as "missing".**
122
+ The first cut folded every `readFile` failure — permissions, a transient
123
+ `EMFILE`, the path being a directory — into "doesn't exist yet" and took
124
+ the create branch, which is a truncating write: a real `.gitattributes`
125
+ hit by a transient read error would be silently replaced with just the
126
+ union-merge line. Only `ENOENT` means missing; anything else is reported
127
+ as a failure and the file is left exactly as it was. The create branch
128
+ also uses `wx` (exclusive create) rather than a plain write, so a
129
+ concurrent writer that created the file between the read and this write
130
+ fails loudly into the same best-effort catch instead of the second writer
131
+ truncating the first one's file.
132
+ - **Union merge does not preserve line order, so `kb_log`'s reader sorts by
133
+ `at` rather than trusting file order.** Sorting there, once, is cheaper
134
+ than trying to make every future merge order-preserving. `at` is now
135
+ validated as an actual ISO-8601 timestamp (`z.iso.datetime()`, matching
136
+ exactly what `record()` writes) rather than any non-empty string — a value
137
+ that parses as JSON and matches the schema's shape but isn't really a
138
+ timestamp would otherwise sort unpredictably instead of failing, and
139
+ `parseLog` already has a place for "well-formed but wrong" to go: reported
140
+ as malformed, same as any other schema mismatch, never silently repaired.
141
+ - **A union merge can keep the exact same line twice** — a cherry-pick or
142
+ rebase that carried one worktree's entry into the other's history before
143
+ the merge, not two independent writes agreeing by chance: `record()` mints
144
+ its own `at` per call, so two entries equal on every field including `at`
145
+ cannot be genuine. `parseLog` dedupes entries that are byte-for-byte equal
146
+ after parsing and keeps everything else, including two entries that agree
147
+ on every field except `at` — that pair is two real events. The
148
+ alternative — leave duplicates visible and call it "genuine repeat
149
+ ambiguity" — was rejected: there's no ambiguity to preserve, since the
150
+ only way to produce an exact duplicate is the merge itself.
151
+ - **A read-then-append race across processes on the append branch — two
152
+ processes both reading a `.gitattributes` without the line, both
153
+ appending it — is left unguarded, not a reason to lock.** `appendFile` is
154
+ `O_APPEND`, so the outcome is two copies of the same line, never a torn
155
+ write, and `hasMergeDeclaration` sees a duplicate declaration as "already
156
+ declared" on the very next call. Cheap residue, not corruption — the same
157
+ trade the lock-file alternative above was rejected for, at a smaller
158
+ scale.
159
+
160
+ GitHub does not run merge drivers for a PR it merges server-side — see the
161
+ README's "Cross-worktree writes" section. The driver only helps a merge a
162
+ local git client actually performs.
163
+
75
164
  ## Rejected for now: a base registry
76
165
 
77
166
  Cross-base questions are unaskable by construction — supersession, traces, and
package/README.md CHANGED
@@ -54,6 +54,7 @@ bundling can `require()` it without depending on its Node version honouring
54
54
  <type>.<slug>.md records
55
55
  INDEX.md index derived, store-owned
56
56
  log.jsonl history primary, append-only
57
+ .gitattributes merge store-owned, written on first write
57
58
  .index.sqlite search derived, gitignored
58
59
  ```
59
60
 
@@ -76,6 +77,46 @@ Repair-on-read, not coordination, is what lets both exist without a lock. The
76
77
  index is _eventually_ correct: a writer whose scan predated another's record
77
78
  publishes a briefly stale index, and the next read through the store settles it.
78
79
 
80
+ ### Cross-worktree writes
81
+
82
+ A committed base is routinely written from more than one worktree at once —
83
+ each records into the same `log.jsonl`, and a plain git merge of two branches
84
+ that both appended lines resolves that file at the line level, same as any
85
+ other text file. That is the wrong merge for an append-only log: git's default
86
+ picks a side, or conflicts, on lines that both branches only ever meant to add
87
+ to.
88
+
89
+ So the first write to a base (through `write`, or whichever call happens to
90
+ append the first log line) declares a merge driver for its log: it writes
91
+ `log.jsonl text eol=lf merge=union` into the base's `.gitattributes` if that
92
+ file does not exist yet, and appends the line if the file exists but declares
93
+ no merge strategy for `log.jsonl` yet — a `.gitattributes` a user put there
94
+ first is respected, never overwritten wholesale, and a line that already
95
+ gives `log.jsonl` _any_ merge strategy — this one or the user's own choice
96
+ such as `merge=ours` — is left alone rather than layered under a second,
97
+ possibly conflicting one. `union` is one of git's built-in merge drivers; the
98
+ attribute alone is enough; nothing else needs configuring. `eol=lf` pins line
99
+ endings to `\n` regardless of a checkout's `core.autocrlf`, so a Windows
100
+ checkout normalizing the file on checkout can't leave it with mixed endings
101
+ against the raw `\n` every append writes. With it, a merge of two branches
102
+ that both appended to `log.jsonl` keeps both sides' lines instead of picking
103
+ one.
104
+
105
+ A union merge does not preserve line order, and can occasionally keep the
106
+ same line twice (a cherry-pick or rebase that carried one side's entry into
107
+ the other's history before the merge). `kb_log`'s reader (`kb-log.ts`) sorts
108
+ entries by `at` and drops exact duplicates before returning them, so neither
109
+ is something a caller has to account for.
110
+
111
+ **This applies to a local `git merge`, not to GitHub.** GitHub computes pull
112
+ request merges (and the merge/squash/rebase buttons) through its own service,
113
+ which does not read `.gitattributes` merge-driver declarations — a PR that
114
+ merges two branches' `log.jsonl` appends on GitHub gets git's ordinary
115
+ line-level merge (or a conflict) even with the attribute in place. The union
116
+ driver only fires for a merge actually run by a local git client, which covers
117
+ worktrees pulling from and pushing to each other directly, but not a merge
118
+ GitHub itself performs.
119
+
79
120
  ## Records
80
121
 
81
122
  The filename is the identity. `fact.auth-retries.md` has concept id
@@ -215,7 +256,9 @@ strauss-kb [--bundle PATH] <command> [args]
215
256
  supersede <concept-id> <replacement-id> Mark a record superseded, linking both directions.
216
257
  answer <concept-id> <answer...> Resolve an open question and append the answer.
217
258
  verify <concept-id> --note <text> Append a verified[] event — who checked, when, and what the check found.
218
- load [type] [--budget N | --all] Hand over the whole base, each record with its standing.
259
+ load [type] [--budget N] [--all]
260
+ Hand over the whole base, each record with its standing.
261
+ catalog [type] Every record in one line — id, type, title, standing, stale flag.
219
262
  pack <conceptId> [--hops N] [--max-nodes N] [--budget N]
220
263
  The bounded neighbourhood around one record, every cut named.
221
264
  query <text...> Search; every match arrives flagged with its standing.
@@ -240,7 +283,7 @@ strauss-kb [--bundle PATH] <command> [args]
240
283
  STRAUSS_KB_ACTOR names the writer in the log
241
284
  ```
242
285
 
243
- Results go to stdout as JSON — `index` and `pack` are markdown, which is what
286
+ Results go to stdout as JSON — `index`, `catalog` and `pack` are markdown, which is what
244
287
  they are, and `doctor` prints a table unless `--json` asks for the object
245
288
  behind it. `--json` is refused rather than ignored on the commands that have
246
289
  only one form, since a flag that quietly does nothing reads as one that
@@ -250,6 +293,10 @@ whose exit code is not just "did it run": a check that reports a problem
250
293
  succeeded as a command and failed as a check, so it exits 1 with its findings
251
294
  on stdout.
252
295
 
296
+ A flag accepts either spelling — `--budget 4000` or `--budget=4000` — and a
297
+ flag given no value is an error rather than a silent fallback to the default,
298
+ so a trailing typo cannot look like success.
299
+
253
300
  ```bash
254
301
  strauss-kb --bundle .strauss/kb write fact <<'JSON'
255
302
  {
@@ -269,9 +316,9 @@ strauss-kb validate || echo "problems above"
269
316
 
270
317
  `strauss-kb-mcp` speaks stdio and takes no API key and no required environment.
271
318
  Every CLI verb is a tool: `kb_write`, `kb_write_decision`, `kb_no_decision`,
272
- `kb_status`, `kb_supersede`, `kb_answer`, `kb_verify`, `kb_load`, `kb_pack`, `kb_query`,
273
- `kb_trace`, `kb_list`, `kb_index`, `kb_log`, `kb_validate`, `kb_doctor`,
274
- `kb_schema`, `kb_types`,
319
+ `kb_status`, `kb_supersede`, `kb_answer`, `kb_verify`, `kb_load`, `kb_catalog`,
320
+ `kb_pack`, `kb_query`, `kb_trace`, `kb_list`, `kb_index`, `kb_log`, `kb_validate`,
321
+ `kb_doctor`, `kb_schema`, `kb_types`,
275
322
  `kb_pin`, `kb_unpin`, `kb_pins`, `kb_context`. Most tools take a `bundlePath`;
276
323
  `kb_schema` and `kb_types` describe the format rather than any one base, and
277
324
  `kb_pins` and `kb_context` read the workspace pin manifests instead. The one
@@ -355,40 +402,66 @@ Read for a question, not for a session: a base loaded at the start of a long
355
402
  conversation is summarised away by the end of it, and reloading costs about
356
403
  three thousand tokens. Read it again at the point of use.
357
404
 
358
- `load` refuses rather than truncating when a base exceeds its budget (25,000
359
- tokens by default). A truncated base is indistinguishable from a complete one,
360
- so a caller would answer "that was never decided" from a slice it did not know
361
- was a slice. `context` refuses the same way at its own, tighter budget (4,000
362
- by default). Superseded records come back as name, replacement and date only —
363
- their bodies no longer hold, and a body read later in a long session outlives
364
- the qualifier that said so. `trace` still reaches them by id.
365
-
366
- `--all` (`all: true` over MCP) is the escape hatch: it bypasses the refusal
367
- outright and hands back the entire bundle whatever its size. A loaded result
368
- carries `tokensLoaded`, the same estimate the budget is held against, and
369
- `budgetTokens: null` marks that no ceiling was applied; `--all` is mutually
370
- exclusive with `--budget`. That refusal is the guardrail an agent needs so a
371
- wide base does not silently consume its whole context; `--all` is for a
372
- deliberate operator who has decided the size is worth the tokens, not a
373
- setting to reach for by default. A reader that does not actually need every
374
- record is better served by a narrower `type` filter or a `query` than by
375
- turning the guardrail off.
376
-
377
- **Pack is the middle rung.** Under budget, load the base whole perfect
378
- recall beats any ranking. Over budget, when the work centres on a record you
379
- can name, `pack` hands over that record's bounded neighbourhood instead:
380
- everything within `--hops` of the root, walked over the base's edges — body
381
- links (a `relatedConceptIds` entry is stored as one), supersession in both
382
- directions, shared code anchors, and shared sources ranked and cut to
383
- `--max-nodes`. Standing travels with it: superseded neighbours arrive as the
384
- same name, replacement and date stubs `load` emits. Every record the cut
385
- dropped is named under Excluded, because a named gap is knowable and a silent
386
- one is not, and past its own token budget `pack` refuses exactly as `load`
387
- does naming what was already cut, so the caller can narrow the walk or
388
- raise the ceiling. Below the header, the only place a timestamp appears, the
389
- output is byte-identical across runs over an unchanged base: two packs diff,
390
- and a changed byte means changed knowledge. With neither a budget problem nor
391
- a root record in hand, the question is a point lookup, and that is `query`.
405
+ **The three rungs, in one rule.** While the base fits the budget, `load` it
406
+ whole. Once `load` refuses, `catalog` then `pack` the record that matters. For
407
+ a lookup by wording, `query`.
408
+
409
+ ```bash
410
+ strauss-kb load # under the budget: everything, with standing
411
+ strauss-kb catalog # past it: one line per record, ~30 tokens each
412
+ strauss-kb pack decision.cursor-v2 # then the neighbourhood around the one that matters
413
+ strauss-kb query cursor pagination # or a point lookup by wording
414
+ ```
415
+
416
+ A whole read gives perfect recall and can say _no record answers this_, which
417
+ no ranker can. `catalog` keeps that at a fraction of the cost by naming every
418
+ record instead of every body; `query` gives up both, returning its nearest hit
419
+ whatever the distance.
420
+
421
+ `load` refuses rather than truncating past its token budget (`--budget` /
422
+ `budgetTokens`, 25,000 by default, held against the estimated size of what is
423
+ handed back) — a truncated base reads as a complete one, so a caller would
424
+ answer "never decided" from a slice it did not know was a slice. `context`
425
+ refuses the same way at its own, tighter budget (4,000 by default). Superseded
426
+ records come back as name, replacement and date only; `trace` still reaches
427
+ them by id.
428
+
429
+ A refusal reports `approxTokens` against `budgetTokens` and carries a `message`
430
+ naming the budget and the next calls. A successful load reports `budgetTokens`
431
+ too, so a caller can see how close it came before crossing the line.
432
+
433
+ `--all` (`all: true` over MCP) bypasses the budget and hands back the entire
434
+ bundle regardless of size. A loaded result carries `tokensLoaded`, and
435
+ `budgetTokens: null` marks that no ceiling applied; `--all` is mutually
436
+ exclusive with `--budget`. It is for an operator who has decided the size is
437
+ worth the tokens a narrower `type` filter, `catalog`, or `query` fits better
438
+ when it is not.
439
+
440
+ **Catalog is the rung that keeps the base knowable.** One line per record —
441
+ concept id, type, title, standing, stale flag — sorted by type then title, at
442
+ roughly thirty tokens each, so a base far past `load`'s budget still fits in
443
+ one call. Superseded records show the replacement in place of a body. The
444
+ header sums record counts by standing and reports staleness separately (a
445
+ current record can be stale). Bodies live in `load`, `pack`, and `trace`.
446
+
447
+ `catalog` alone has no ceiling and never refuses — cost is linear at roughly
448
+ thirty tokens a record (a thousand-record base is about 30k, five thousand
449
+ about 150k); narrow with `type` at that scale. Output is deterministic given
450
+ a fixed clock — no timestamp, ordering total down to the concept id — so two
451
+ catalogs of an unchanged base diff to nothing except a stale flag flipping as
452
+ `stale_after` passes. Pass an explicit `now` (library callers) to hold
453
+ byte-equality across that boundary.
454
+
455
+ **Pack is the middle rung.** Under the budget, load the base whole. Past it, when the work centres on a record you can name (`catalog` is how you
456
+ name it), `pack` hands over that record's bounded neighbourhood: everything
457
+ within `--hops` of the root, walked over the base's edges — body links (a
458
+ `relatedConceptIds` entry is one), supersession in both directions, shared
459
+ code anchors, and shared sources — ranked and cut to `--max-nodes`. Standing
460
+ travels with it: superseded neighbours arrive as the same stubs `load` emits.
461
+ Every dropped record is named under Excluded, and past its own token budget
462
+ `pack` refuses exactly as `load` does. Output is byte-identical across runs
463
+ over an unchanged base below the header. With neither a size problem nor a
464
+ root record in hand, the question is a point lookup — `query`.
392
465
 
393
466
  **Flag, never filter.** `query` returns every hit with its standing, because a
394
467
  filtered result set is invisible — the caller cannot tell it missed anything.