@saasontools/strauss-kb 0.1.7 → 0.1.9
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 +89 -0
- package/README.md +132 -5
- package/dist/{chunk-GKCG4P3L.js → chunk-KVEEISYQ.js} +24 -10
- package/dist/chunk-KVEEISYQ.js.map +1 -0
- package/dist/{chunk-LCQKARFK.js → chunk-MWWDD23L.js} +2 -2
- package/dist/{chunk-GKUQOJEK.js → chunk-OFDWRMY6.js} +622 -154
- package/dist/chunk-OFDWRMY6.js.map +1 -0
- package/dist/cli-main.cjs +663 -197
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +559 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -3
- package/dist/index.d.ts +120 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +641 -189
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-GKCG4P3L.js.map +0 -1
- package/dist/chunk-GKUQOJEK.js.map +0 -1
- /package/dist/{chunk-LCQKARFK.js.map → chunk-MWWDD23L.js.map} +0 -0
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
|
|
@@ -224,6 +265,8 @@ strauss-kb [--bundle PATH] <command> [args]
|
|
|
224
265
|
index The index, rebuilt if it disagrees with the records.
|
|
225
266
|
log What touched what, and when.
|
|
226
267
|
validate Cross-record checks. Exits 1 when it reports a problem.
|
|
268
|
+
doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]
|
|
269
|
+
Health sweep: what expired, went unconfirmed, aged, or was orphaned.
|
|
227
270
|
schema JSON Schema for the format.
|
|
228
271
|
types The twelve types, their sections and initial status.
|
|
229
272
|
pin [bundle-path] [flags] Pin a base. --mode, --profiles, --frozen; --local/--user pick the layer.
|
|
@@ -233,14 +276,20 @@ strauss-kb [--bundle PATH] <command> [args]
|
|
|
233
276
|
sync-instructions <file> Plant the context block between sentinels in an instruction file.
|
|
234
277
|
|
|
235
278
|
--bundle PATH defaults to ./.strauss/kb
|
|
279
|
+
--json the machine shape, where a command prints a table
|
|
280
|
+
-- everything after it is text, not flags
|
|
236
281
|
STRAUSS_KB_ACTOR names the writer in the log
|
|
237
282
|
```
|
|
238
283
|
|
|
239
284
|
Results go to stdout as JSON — `index` and `pack` are markdown, which is what
|
|
240
|
-
they are
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
285
|
+
they are, and `doctor` prints a table unless `--json` asks for the object
|
|
286
|
+
behind it. `--json` is refused rather than ignored on the commands that have
|
|
287
|
+
only one form, since a flag that quietly does nothing reads as one that
|
|
288
|
+
worked; `--` ends flag parsing, for the verbs that end in free prose. Errors
|
|
289
|
+
go to stderr and exit 1. `validate` and `doctor --strict` are the commands
|
|
290
|
+
whose exit code is not just "did it run": a check that reports a problem
|
|
291
|
+
succeeded as a command and failed as a check, so it exits 1 with its findings
|
|
292
|
+
on stdout.
|
|
244
293
|
|
|
245
294
|
```bash
|
|
246
295
|
strauss-kb --bundle .strauss/kb write fact <<'JSON'
|
|
@@ -262,7 +311,8 @@ strauss-kb validate || echo "problems above"
|
|
|
262
311
|
`strauss-kb-mcp` speaks stdio and takes no API key and no required environment.
|
|
263
312
|
Every CLI verb is a tool: `kb_write`, `kb_write_decision`, `kb_no_decision`,
|
|
264
313
|
`kb_status`, `kb_supersede`, `kb_answer`, `kb_verify`, `kb_load`, `kb_pack`, `kb_query`,
|
|
265
|
-
`kb_trace`, `kb_list`, `kb_index`, `kb_log`, `kb_validate`, `
|
|
314
|
+
`kb_trace`, `kb_list`, `kb_index`, `kb_log`, `kb_validate`, `kb_doctor`,
|
|
315
|
+
`kb_schema`, `kb_types`,
|
|
266
316
|
`kb_pin`, `kb_unpin`, `kb_pins`, `kb_context`. Most tools take a `bundlePath`;
|
|
267
317
|
`kb_schema` and `kb_types` describe the format rather than any one base, and
|
|
268
318
|
`kb_pins` and `kb_context` read the workspace pin manifests instead. The one
|
|
@@ -401,6 +451,83 @@ fact; a missing replacement is `broken-chain` with no head — the case that nee
|
|
|
401
451
|
the most care, because returning the stale record unmarked looks exactly like
|
|
402
452
|
success.
|
|
403
453
|
|
|
454
|
+
## Health
|
|
455
|
+
|
|
456
|
+
`doctor` sweeps a whole base and reports what has decayed. It is read-only —
|
|
457
|
+
nothing is re-dated, re-verified, superseded, or deleted — because every
|
|
458
|
+
finding is a judgment somebody has to make: whether a claim still holds, which
|
|
459
|
+
question is worth answering, which island to link or drop.
|
|
460
|
+
|
|
461
|
+
It exists because decay is invisible from inside a single record. A stale
|
|
462
|
+
record reads exactly like a live one, a question nobody answered reads exactly
|
|
463
|
+
like one nobody asked, and a record nothing links to is reachable only by
|
|
464
|
+
someone who already knows it is there. `validate` is the narrower neighbour:
|
|
465
|
+
it asks only whether pointers between records agree.
|
|
466
|
+
|
|
467
|
+
| Check | Reports |
|
|
468
|
+
| ---------------------- | ------------------------------------------------------------------------------ |
|
|
469
|
+
| `expired` | `stale_after` is in the past — or is not a readable date, which is no better. |
|
|
470
|
+
| `expiring` | `stale_after` falls inside the next `--expiring-days` (30). |
|
|
471
|
+
| `unverified` | `verified[]` is empty and the record is over `--unverified-days` (90) old. |
|
|
472
|
+
| `aging` | Still `open` or `proposed` after `--aging-days` (90). |
|
|
473
|
+
| `orphaned` | No other record links to it, by body link or supersession. |
|
|
474
|
+
| `broken-supersession` | A chain that does not resolve: no replacement, a missing one, a cycle, a fork. |
|
|
475
|
+
| `superseded-but-cited` | A record that still holds, whose body links to one that does not. |
|
|
476
|
+
|
|
477
|
+
The last check's name is for its common case: a rejected target counts too, and
|
|
478
|
+
is the worse half — a superseded record at least names its replacement, while a
|
|
479
|
+
rejected one is a well-formed assertion of what someone decided _not_ to do,
|
|
480
|
+
cited by a record the reader trusts.
|
|
481
|
+
|
|
482
|
+
```bash
|
|
483
|
+
strauss-kb doctor # the table
|
|
484
|
+
strauss-kb doctor --json # the object behind it
|
|
485
|
+
strauss-kb doctor --strict # exit 1 if anything has expired
|
|
486
|
+
strauss-kb doctor --unverified-days 30 # a stricter confirmation window
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
All seven groups are reported even when empty. A check that found nothing and
|
|
490
|
+
a check that never ran look identical in a report that only lists findings,
|
|
491
|
+
which is the whole value of a sweep.
|
|
492
|
+
|
|
493
|
+
Judgments the checks make, worth knowing before reading a report:
|
|
494
|
+
|
|
495
|
+
- **Superseded and rejected records sit out the freshness checks.** A replaced
|
|
496
|
+
record whose date has passed needs no repair, and reporting it would bury the
|
|
497
|
+
records that do. They stay in the graph checks, where standing is not the
|
|
498
|
+
question.
|
|
499
|
+
- **A date-only `stale_after` expires at UTC midnight.** `2026-09-01` parses as
|
|
500
|
+
`2026-09-01T00:00:00Z`, so a record goes stale at the start of its date: a
|
|
501
|
+
sweep run at exactly that instant still calls it expiring, and one a minute
|
|
502
|
+
later calls it expired. That is `adjudicate`'s comparison rather than a
|
|
503
|
+
second one — two readings of the same field disagreeing about the day would
|
|
504
|
+
be worse than either.
|
|
505
|
+
- **Age is read from `generated.at`, exclusively.** A record carrying no
|
|
506
|
+
timestamp is not reported as aging or unverified — without a start there is
|
|
507
|
+
no duration, and inventing one would flag every foreign record as overdue
|
|
508
|
+
(adjudication still warns `unverified` on it at read time). Exactly N days
|
|
509
|
+
old is not yet "older than N".
|
|
510
|
+
- **`orphaned` counts incoming links only, and reads supersession one way.** A
|
|
511
|
+
record that cites five others and is cited by none is precisely the island:
|
|
512
|
+
reachable if you already know it exists. The replacement references what it
|
|
513
|
+
replaced, never the reverse — taken symmetrically, a dead record would vouch
|
|
514
|
+
for its own replacement and an old→new pair nothing else touches would rescue
|
|
515
|
+
itself. Shared anchors and shared sources are co-location rather than
|
|
516
|
+
reference, so they do not rescue a record either.
|
|
517
|
+
- **A record citing the one it replaced is not superseded-but-cited.** That
|
|
518
|
+
link is the history working as designed, and reporting it would put a finding
|
|
519
|
+
on every correctly performed supersession.
|
|
520
|
+
- **A replacement pointer is checked whatever the status says.** The store
|
|
521
|
+
writes `strauss_status` and `strauss_superseded_by` in one mutation, so a
|
|
522
|
+
record left `accepted` while naming a replacement was hand-edited — and
|
|
523
|
+
adjudication reads it as current no matter what the pointer says, which is
|
|
524
|
+
what makes it worth naming.
|
|
525
|
+
|
|
526
|
+
`--strict` gates on expiry alone. The other six report debt a reader decides
|
|
527
|
+
about; an expired record is the base itself saying it would stop standing
|
|
528
|
+
behind something, which is the one finding a pipeline can act on without a
|
|
529
|
+
judgment call.
|
|
530
|
+
|
|
404
531
|
## Living in an agent session
|
|
405
532
|
|
|
406
533
|
Long sessions lose a knowledge base twice over: attention decays, and
|
|
@@ -4,13 +4,14 @@ import {
|
|
|
4
4
|
KB_DIR,
|
|
5
5
|
KbStore,
|
|
6
6
|
VERSION
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-OFDWRMY6.js";
|
|
8
8
|
|
|
9
9
|
// src/cli.ts
|
|
10
10
|
import { join } from "path";
|
|
11
11
|
async function runKbCli(argv) {
|
|
12
|
-
const {
|
|
13
|
-
const
|
|
12
|
+
const { flags, literal } = takeLiteral(argv);
|
|
13
|
+
const { bundle, rest: withFlags } = takeBundle(flags);
|
|
14
|
+
const name = withFlags[0] ?? "";
|
|
14
15
|
if (!name || name === "-h" || name === "--help") {
|
|
15
16
|
process.stdout.write(usage());
|
|
16
17
|
return;
|
|
@@ -22,6 +23,14 @@ async function runKbCli(argv) {
|
|
|
22
23
|
}
|
|
23
24
|
const command = KB_COMMANDS_BY_NAME.get(name);
|
|
24
25
|
if (!command) die(`unknown command ${name}`);
|
|
26
|
+
const json = withFlags.includes("--json");
|
|
27
|
+
if (json && !command.render) {
|
|
28
|
+
die(`${name} takes no --json: its result is already the machine shape`);
|
|
29
|
+
}
|
|
30
|
+
const rest = [
|
|
31
|
+
...json ? withFlags.filter((argument) => argument !== "--json") : withFlags,
|
|
32
|
+
...literal
|
|
33
|
+
];
|
|
25
34
|
const raw = await command.fromArgv(rest, bundle, readStdin);
|
|
26
35
|
const parsed = command.input.safeParse(raw);
|
|
27
36
|
if (!parsed.success) {
|
|
@@ -41,13 +50,16 @@ async function runKbCli(argv) {
|
|
|
41
50
|
},
|
|
42
51
|
parsed.data
|
|
43
52
|
);
|
|
44
|
-
if (command.failsWhen?.(result)) process.exitCode = 1;
|
|
53
|
+
if (command.failsWhen?.(result, parsed.data)) process.exitCode = 1;
|
|
45
54
|
if (result === "") return;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
`
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
const text = command.render && !json ? command.render(result) : typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
56
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
function takeLiteral(argv) {
|
|
60
|
+
const at = argv.indexOf("--");
|
|
61
|
+
if (at === -1) return { flags: argv, literal: [] };
|
|
62
|
+
return { flags: argv.slice(0, at), literal: argv.slice(at + 1) };
|
|
51
63
|
}
|
|
52
64
|
function takeBundle(argv) {
|
|
53
65
|
const at = argv.indexOf("--bundle");
|
|
@@ -89,6 +101,8 @@ function usage() {
|
|
|
89
101
|
),
|
|
90
102
|
"",
|
|
91
103
|
` --bundle PATH defaults to ./${KB_DIR}`,
|
|
104
|
+
" --json the machine shape, where a command prints a table",
|
|
105
|
+
" -- everything after it is text, not flags",
|
|
92
106
|
" --version the installed package version",
|
|
93
107
|
" STRAUSS_KB_ACTOR names the writer in the log",
|
|
94
108
|
""
|
|
@@ -98,4 +112,4 @@ function usage() {
|
|
|
98
112
|
export {
|
|
99
113
|
runKbCli
|
|
100
114
|
};
|
|
101
|
-
//# sourceMappingURL=chunk-
|
|
115
|
+
//# sourceMappingURL=chunk-KVEEISYQ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts"],"sourcesContent":["/**\n * strauss-kb — a knowledge base's command line.\n *\n * A dispatcher over `KB_COMMANDS`, which the MCP server also projects. Nothing\n * command-specific lives here beyond turning argv into the object both\n * surfaces pass.\n */\nimport { join } from \"node:path\";\nimport { KB_COMMANDS, KB_COMMANDS_BY_NAME } from \"./commands/index.js\";\nimport { KB_DIR, KbStore } from \"./kb-store.js\";\nimport { VERSION } from \"./version.js\";\n\nexport async function runKbCli(argv: string[]): Promise<void> {\n const { flags, literal } = takeLiteral(argv);\n const { bundle, rest: withFlags } = takeBundle(flags);\n const name = withFlags[0] ?? \"\";\n\n if (!name || name === \"-h\" || name === \"--help\") {\n process.stdout.write(usage());\n return;\n }\n\n // The plugin in front of this CLI updates from a marketplace while the CLI\n // updates from npm, and neither prompts for the other. Answering \"which one\n // is installed\" is what makes that skew diagnosable instead of mysterious.\n if (name === \"--version\" || name === \"-v\") {\n process.stdout.write(`${VERSION}\\n`);\n return;\n }\n\n const command = KB_COMMANDS_BY_NAME.get(name);\n if (!command) die(`unknown command ${name}`);\n\n // Output shape, not an argument: stripped before the command sees argv, so a\n // positional adapter never has to know the flag exists. Refused rather than\n // ignored where a command has only one form — a flag that silently does\n // nothing teaches a caller that it worked.\n const json = withFlags.includes(\"--json\");\n if (json && !command.render) {\n die(`${name} takes no --json: its result is already the machine shape`);\n }\n const rest = [\n ...(json\n ? withFlags.filter((argument) => argument !== \"--json\")\n : withFlags),\n ...literal,\n ];\n\n const raw = await command.fromArgv(rest, bundle, readStdin);\n const parsed = command.input.safeParse(raw);\n if (!parsed.success) {\n die(\n `${name}: ${parsed.error.issues\n .map((issue) => `${issue.path.join(\".\") || \"(root)\"}: ${issue.message}`)\n .join(\"; \")}`,\n );\n }\n\n const store = new KbStore({\n warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}\\n`),\n });\n const result = await command.run(\n {\n store,\n actor: process.env.STRAUSS_KB_ACTOR ?? \"unknown\",\n now: () => new Date().toISOString(),\n },\n parsed.data,\n );\n\n // A check reporting a problem succeeded as a command and failed as a check;\n // the command says which, rather than the dispatcher knowing their names.\n if (command.failsWhen?.(result, parsed.data)) process.exitCode = 1;\n // An empty string is deliberate silence — `context` with nothing pinned\n // runs from hooks at every session start, and even a bare newline is noise\n // injected into a fresh context.\n if (result === \"\") return;\n const text =\n command.render && !json\n ? command.render(result)\n : typeof result === \"string\"\n ? result\n : JSON.stringify(result, null, 2);\n process.stdout.write(text.endsWith(\"\\n\") ? text : `${text}\\n`);\n}\n\n/**\n * Everything after a bare `--` is text, never flags.\n *\n * Several verbs end in free prose — `no-decision`, `answer`, `query` — and a\n * reason that happens to contain `--json` or `--bundle` would otherwise lose a\n * word to the flag scan, or two. The sentinel is the usual escape, and the\n * token itself is dropped while the order of everything else is kept, so the\n * positional adapters still index the same way.\n */\nfunction takeLiteral(argv: string[]): { flags: string[]; literal: string[] } {\n const at = argv.indexOf(\"--\");\n if (at === -1) return { flags: argv, literal: [] };\n return { flags: argv.slice(0, at), literal: argv.slice(at + 1) };\n}\n\n/**\n * `--bundle` addresses a base directly; without it the command works on the\n * one under the current directory. A base belongs to whatever prompted it, so\n * the default cannot be the only option.\n */\nfunction takeBundle(argv: string[]): { bundle: string; rest: string[] } {\n const at = argv.indexOf(\"--bundle\");\n if (at === -1) {\n return { bundle: join(process.cwd(), KB_DIR), rest: argv };\n }\n const bundle = argv[at + 1];\n if (!bundle) die(\"--bundle requires a path\");\n return { bundle, rest: [...argv.slice(0, at), ...argv.slice(at + 2)] };\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve, reject) => {\n let text = \"\";\n process.stdin.setEncoding(\"utf8\");\n process.stdin.on(\"data\", (chunk) => (text += chunk));\n process.stdin.on(\"end\", () => resolve(text));\n process.stdin.on(\"error\", reject);\n });\n}\n\nfunction die(message: string): never {\n process.stderr.write(`strauss-kb: error: ${message}\\n`);\n process.exit(1);\n}\n\n/** First sentence, capped — the full text is what an MCP client shows. */\nfunction summarise(description: string): string {\n const first = description.split(\"\\n\")[0] ?? \"\";\n const sentence = first.includes(\". \")\n ? `${first.slice(0, first.indexOf(\". \"))}.`\n : first;\n return sentence.length > 78 ? `${sentence.slice(0, 75)}…` : sentence;\n}\n\nfunction usage(): string {\n const width = Math.max(...KB_COMMANDS.map((command) => command.usage.length));\n return [\n \"strauss-kb — knowledge base commands\",\n \"\",\n \"Usage: strauss-kb [--bundle PATH] <command> [args]\",\n \"\",\n ...KB_COMMANDS.map(\n (command) =>\n ` ${command.usage.padEnd(width)} ${summarise(command.description)}`,\n ),\n \"\",\n ` --bundle PATH defaults to ./${KB_DIR}`,\n \" --json the machine shape, where a command prints a table\",\n \" -- everything after it is text, not flags\",\n \" --version the installed package version\",\n \" STRAUSS_KB_ACTOR names the writer in the log\",\n \"\",\n ].join(\"\\n\");\n}\n"],"mappings":"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","names":[]}
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
KB_COMMANDS,
|
|
3
3
|
KbStore,
|
|
4
4
|
VERSION
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-OFDWRMY6.js";
|
|
6
6
|
|
|
7
7
|
// src/mcp.ts
|
|
8
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -46,4 +46,4 @@ export {
|
|
|
46
46
|
createKbMcpServer,
|
|
47
47
|
runKbMcpServer
|
|
48
48
|
};
|
|
49
|
-
//# sourceMappingURL=chunk-
|
|
49
|
+
//# sourceMappingURL=chunk-MWWDD23L.js.map
|