bantamkit-mcp 0.32.1 → 0.34.0

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # bantamkit-mcp
2
2
 
3
3
  The bantamkit MCP server as a pure-Node package: `npx bantamkit-mcp`, no Python, no
4
- `uv`, no `pipx`, no interpreter bootstrap. It serves the same fourteen tools, the same
4
+ `uv`, no `pipx`, no interpreter bootstrap. It serves the same twelve tools, the same
5
5
  `bantamkit_status` prompt and the same two resource templates as `runtime-py`'s server,
6
6
  reads and writes the same memory store, and is checked against the Python server frame by
7
7
  frame — 4500+ conformance cases, with every intentional difference written down as a
@@ -29,6 +29,13 @@ That is the whole install. No `pip`, no venv, no `PYTHONPATH`. `runtime-ts/mcp.j
29
29
  in the repository is the annotated version, with the four forms and the two measured
30
30
  questions that decide between them.
31
31
 
32
+ > **AMENDED 2026-09-15 (job51) — this is no longer the recommended form.** The block above
33
+ > resolves the package against the registry at every launch. When the registry cannot be
34
+ > reached, it hangs silently, even with a warm cache (measured below). From 0.34.0,
35
+ > `npx -y bantamkit-mcp@latest --install <host>` writes an entry that starts with no network.
36
+ > See *Install once, run offline*. `mcp.json.example` now has five forms, and the kept install
37
+ > is the first.
38
+
32
39
  **Pass `-y`.** `npx` historically prompted before installing a package it had not seen,
33
40
  and stdin here is the JSON-RPC channel: a prompt that consumed one frame looks like a
34
41
  server that lost a request. npm 11.6.2 was measured sending that prompt to stderr rather
@@ -49,6 +56,23 @@ npx -y bantamkit-mcp --install copilot # GitHub Copilot in VS Code
49
56
  npx -y bantamkit-mcp --install cursor # Cursor
50
57
  ```
51
58
 
59
+ > **ADDED 2026-09-15 (job51) — what these commands record changes in 0.34.0.** 0.33.0 and
60
+ > earlier record `npx -y bantamkit-mcp`. From 0.34.0 they record
61
+ > `<absolute node> <absolute dist/cli.js>`:
62
+ >
63
+ > - **Run from an npx cache**, as the lines above are, the `cli.js` belongs to a *kept
64
+ > install* at `~/.bantamkit/mcp`. On Windows that is `%USERPROFILE%\.bantamkit\mcp`, which was
65
+ > not measured. The kept install is made once with `npm install --prefix`, and only when it
66
+ > does not already report this version.
67
+ > - **Run from any other install** (`npm i -g`, `npm i --prefix`, a checkout), it is that
68
+ > install's own `dist/cli.js`.
69
+ >
70
+ > If npm fails, no host file is read or written and no backup is taken. Add `@latest`
71
+ > (`npx -y bantamkit-mcp@latest --install <host>`) so npx does not reuse an older cached
72
+ > resolve. Two further points are explained in *Install once, run offline*. Moving from an
73
+ > existing `npx` entry needs `--force`, and on Claude Code it needs `claude mcp remove`
74
+ > first. The recorded node path names one node version.
75
+
52
76
  `--install claude` runs `claude mcp add` rather than editing `~/.claude.json` directly:
53
77
  that file is the host's, and it carries state that is not MCP configuration. What happens on
54
78
  a second run there is Claude Code's decision, not this command's, and `--force` does not
@@ -63,6 +87,26 @@ not parse is refused rather than replaced.
63
87
  The rest of this section is what those commands write, for anyone who would rather do it by
64
88
  hand. Every host runs the same command; only the file and the key around it change.
65
89
 
90
+ **AMENDED 2026-09-15 (job51) — the forms below are the online ones.** They are what 0.33.0
91
+ and earlier write. From 0.34.0, `--install` writes the kept install instead, and that is also
92
+ the recommended form to write by hand. Make the install once, then register node and its
93
+ `cli.js`, both by absolute path:
94
+
95
+ ```bash
96
+ npm install --prefix ~/.bantamkit/mcp bantamkit-mcp@0.33.0
97
+ claude mcp add bantamkit -s user -- "$(node -p process.execPath)" ~/.bantamkit/mcp/node_modules/bantamkit-mcp/dist/cli.js
98
+ ```
99
+
100
+ Both lines were run against a scratch `HOME`. npm exited 0, and Claude Code recorded
101
+ `"command": "<…>/mise/installs/node/25.2.1/bin/node"` with the absolute `cli.js` as the one
102
+ argument. For the three JSON hosts below, keep the file and the key, keep Copilot's
103
+ `"type": "stdio"`, and use this `command`/`args` pair (`node -p process.execPath` prints the
104
+ first path):
105
+
106
+ ```json
107
+ {"command": "/absolute/path/to/node", "args": ["/Users/you/.bantamkit/mcp/node_modules/bantamkit-mcp/dist/cli.js"]}
108
+ ```
109
+
66
110
  **Claude Code** — one command, no file to edit. `-s user` makes it available in every
67
111
  project; drop it for this project only.
68
112
 
@@ -174,6 +218,121 @@ exactly when nobody is watching.
174
218
  If offline operation matters, do not use `npx`. Run `npm i -g bantamkit-mcp` once and
175
219
  point the config at the installed binary, or keep using `tools/bantamkit-mcp`.
176
220
 
221
+ > **AMENDED 2026-09-15 (job51) — "A warm cache does not have this problem" is false on npm
222
+ > 11.6.2 for every spec a host config actually types.** Both paragraphs are kept rather than
223
+ > rewritten, because the table above still holds. With a warm cache and the network cut,
224
+ > `npx -y bantamkit-mcp@0.33.0`, `npx -y bantamkit-mcp` and `npx -y bantamkit-mcp@latest` all
225
+ > hung silently, as the next section measures. "Point the config at the installed binary"
226
+ > has a trap of its own under a GUI host: see the `.bin` row in that table. The next section
227
+ > replaces both paragraphs.
228
+
229
+ ### Install once, run offline
230
+
231
+ `node tools/conformance/npx-cold-start.mjs --offline` printed every row below in a single run.
232
+ It ran on 2026-09-15 at commit `2f58af4`, with node v25.2.1 and npm 11.6.2, on macOS 26.6.2 on
233
+ Apple silicon, and took 299.94 s wall. It cuts the network by pointing `npm_config_proxy`
234
+ and `npm_config_https_proxy` at `http://127.0.0.1:1`. It does not use `npm_config_registry`,
235
+ which changes npm's cache key and would make a warm cache measure as cold. "GUI PATH" means
236
+ `/usr/bin:/bin:/usr/sbin:/sbin`, the PATH a GUI-launched host inherits on this machine.
237
+
238
+ | arm | outcome | wall | stdout |
239
+ |---|---|---|---|
240
+ | cold cache, registry refusing connections, `npx -y` | exit 1, 0 frames | 140.29 s | 0 bytes |
241
+ | registry spec, warm cache, network cut: `npx -y bantamkit-mcp@0.33.0` | **silent hang**, killed at the 45 s bound | 45.01 s | 0 bytes |
242
+ | the same, `npx -y bantamkit-mcp` | **silent hang** | 45.01 s | 0 bytes |
243
+ | the same, `npx -y bantamkit-mcp@latest` | **silent hang** | 45.02 s | 0 bytes |
244
+ | the same warm cache, network cut: `npx --offline -y bantamkit-mcp@0.33.0` | OK, 12 tools | 0.40 s | — |
245
+ | local tarball spec (`--package=<file>.tgz`), warm cache, network cut: `npx -y` | OK | 1.35 s | 27987 bytes |
246
+ | cold cache, `npx --offline -y` (tarball spec) | exit 1, `npm error code ENOTCACHED` | 2.69 s | — |
247
+ | kept install: `npm install --prefix <dir> <tarball>`, once, online | OK | 4.82 s | — |
248
+ | that install as `<abs node> <dir>/node_modules/bantamkit-mcp/dist/cli.js`, GUI PATH, network cut | OK, 12 tools | 0.09 s | — |
249
+ | that install's `node_modules/.bin/bantamkit-mcp`, same PATH | exit 127, `env: node: No such file or directory` | 0.16 s | — |
250
+
251
+ What the table says:
252
+
253
+ - **The hang comes from a registry spec, not from npx in general.** npx resolves a registry
254
+ spec against the registry at every start, so a warm cache does not save it. A local tarball
255
+ on its own warm cache answered in 1.35 s with the same cut. Do not read the three hung rows
256
+ as "every npx launch hangs offline".
257
+ - **`npx --offline` is the middle option.** It serves a warm cache without the registry, in
258
+ 0.40 s here. On a cache that has never seen the package it fails at once with
259
+ `ENOTCACHED`. It suits a machine that has already run that exact spec online once, and it
260
+ cannot start a new one.
261
+ - **Only a kept install launched as node plus `cli.js` needs nothing.** No package is
262
+ resolved and no PATH lookup happens. The `.bin` shim fails because its first line is
263
+ `#!/usr/bin/env node`, and a GUI host's PATH has no `node`. An `npm i -g` bin runs the
264
+ same file, so the global advice above hits the same trap unless `node` is on the host's
265
+ PATH.
266
+
267
+ **From 0.34.0, `--install` builds the kept install for you.** Run as
268
+ `npx -y bantamkit-mcp@latest --install <host>`, it finds that it is running from an npx cache.
269
+ It then installs `bantamkit-mcp@<its own version>` into `~/.bantamkit/mcp` with
270
+ `npm install --prefix`, and records `<process.execPath> ~/.bantamkit/mcp/node_modules/bantamkit-mcp/dist/cli.js`
271
+ with both paths absolute. It skips npm — and never downgrades — when the kept install already
272
+ reports this version or a newer one; an unparseable kept version sorts as newer and is left
273
+ alone too (J51-9a). Only an older, missing or blank kept version, or one whose `cli.js` is
274
+ gone, runs npm. Measured in this unit: a kept install's manifest was hand-edited to
275
+ `0.100.0` (newer than the `0.33.0` tarball below), then `--install`ed again with the network
276
+ cut (`npm_config_proxy`/`npm_config_https_proxy` pointed at a closed port) — exit 0 in 0.355 s,
277
+ npm never ran, and the kept manifest still read `0.100.0` afterward.
278
+
279
+ **This installs the registry's package for that version, not this tree's code.** The `npm
280
+ install --prefix` above asks the registry for `bantamkit-mcp@<version>`, so a checkout or an
281
+ `npm pack` tarball only supplies the version *number*; the bytes that end up in
282
+ `~/.bantamkit/mcp` are whatever the registry published under that number. Measured in this
283
+ unit: a `0.33.0` tarball packed from this tree carries the string `J51-9a` twice in
284
+ `dist/hostinstall.js` (`tar -xOzf bantamkit-mcp-0.33.0.tgz package/dist/hostinstall.js | grep
285
+ -c J51-9a` → `2`), but after `--install cursor` ran from that same tarball, the kept
286
+ `~/.bantamkit/mcp/node_modules/bantamkit-mcp/dist/hostinstall.js` carries it zero times — the
287
+ kept install is the published `0.33.0`, which predates J51-9a, not the tree that was packed.
288
+ Measured end to end from `npm pack` of this tree (version string still 0.33.0), in a scratch
289
+ `HOME` with an empty npm cache — read the table below as *when* the install happens, not
290
+ *whose code* it installs:
291
+
292
+ | command | result |
293
+ |---|---|
294
+ | `npx -y -p <tgz> bantamkit-mcp --install cursor` | exit 0 in 11.60 s; wrote `"command": "/Users/…/mise/installs/node/25.2.1/bin/node"` and `"args": ["<HOME>/.bantamkit/mcp/node_modules/bantamkit-mcp/dist/cli.js"]` |
295
+ | the same with `--install copilot`, network cut | exit 0 in 0.35 s; npm did not run |
296
+ | `--install cursor` over an existing `npx -y bantamkit-mcp` entry | exit 1: `cursor already has a bantamkit entry with different settings` … `re-run with --force to replace it` |
297
+ | the same with `--force` | exit 0, `backup : …/mcp.json.backup-2026-09-15` |
298
+
299
+ 0.34.0 was not yet on npm when this was written. Measured the same day,
300
+ `npx -y bantamkit-mcp@0.33.0 --install cursor` still writes
301
+ `"command": "npx", "args": ["-y", "bantamkit-mcp"]`. Until 0.34.0 is out, make the kept install
302
+ by hand, as shown in *Connect it to a host*.
303
+
304
+ **Two consequences to know.**
305
+
306
+ 1. **The recorded node is one version of node.** Under mise, `process.execPath` is
307
+ `~/.local/share/mise/installs/node/25.2.1/bin/node`. Uninstall or switch away from that
308
+ version and the host can no longer spawn the server. Re-run
309
+ `npx -y bantamkit-mcp@latest --install <host> --force` with the node you have now. nvm
310
+ also keeps one directory per version; that was not measured here.
311
+ 2. **Moving from an `npx` entry needs `--force`**, as the table shows. On Claude Code
312
+ `--force` does not help: `claude mcp add` refuses a name it already has
313
+ (`MCP server bantamkit already exists in user config`, exit 1, measured with Claude Code
314
+ 2.1.270 against a scratch `HOME`). Run `claude mcp remove bantamkit -s user` first.
315
+
316
+ **Updating the kept install.** From 0.34.0, `npx -y bantamkit-mcp@latest --update` run from
317
+ an npx cache finds `~/.bantamkit/mcp`. It compares that install's version with the registry,
318
+ and when they differ it runs `npm install --prefix ~/.bantamkit/mcp bantamkit-mcp@latest`.
319
+ With no kept install, it refuses exactly as it did before. Measured with a kept 0.32.1 in a
320
+ scratch `HOME`:
321
+
322
+ ```console
323
+ $ npx -y -p <tgz> bantamkit-mcp --update
324
+ bantamkit-mcp 0.32.1 is installed; the package index has 0.33.0.
325
+ updating from the package index: npm install --prefix <HOME>/.bantamkit/mcp bantamkit-mcp@latest
326
+ the command printed:
327
+ <npm's own output>
328
+ updated bantamkit-mcp from 0.32.1 to 0.33.0.
329
+ restart the server: a running bantamkit-mcp keeps serving the code it loaded at startup, so bantamkit_status will report 0.32.1 until the host reconnects.
330
+ ```
331
+
332
+ That run exited 0 in 1.58 s. A second run printed `up to date.` and exited 0. Then reconnect
333
+ the server in the host. Nothing checks for updates at server startup, and startup never
334
+ touches the network.
335
+
177
336
  ### Measured: `npx` is not on a login-less PATH
178
337
 
179
338
  ```sh
@@ -206,6 +365,12 @@ Two ways out, both in `runtime-ts/mcp.json.example`:
206
365
  { "command": "/usr/local/bin/bantamkit-mcp", "args": [] }
207
366
  ```
208
367
 
368
+ > **AMENDED 2026-09-15 (job51) — the second form has the same problem one level down.**
369
+ > `bantamkit-mcp` runs `dist/cli.js`, whose first line is `#!/usr/bin/env node`. Under this
370
+ > PATH the kept install's `.bin` shim exited 127 (`env: node: No such file or directory`). A
371
+ > global link runs the same file, though it was not launched separately here. Name node by
372
+ > absolute path instead, as *Install once, run offline* shows.
373
+
209
374
  ## What `npx` gives up versus `tools/bantamkit-mcp`
210
375
 
211
376
  The sh launcher is not obsolete. It guarantees things `npx` cannot, and this table is the
@@ -218,7 +383,7 @@ prep probe's, unsoftened.
218
383
  | code from the worktree, deps from the main checkout | yes (`PYTHONPATH` + `PYTHONSAFEPATH`) | **no Node analogue.** `NODE_PATH` is ignored by ESM and there is no `-P`. `npm link` and workspaces are a different failure surface, not the same one solved |
219
384
  | stdin is the JSON-RPC channel | yes | yes, with `-y` |
220
385
  | names the cause when dependencies are missing | yes | the analogous failure is *no network*, and it has no message at all — see the table above |
221
- | starts without a network | yes | **no**, on a cold cache |
386
+ | starts without a network | yes | **no**, on a cold cache. *Amended 2026-09-15:* nor on a warm one, for a registry spec (silent hang at a 45 s bound; *Install once, run offline*). From 0.34.0 `--install` records a kept install that does start offline |
222
387
  | `--which`, for diagnosing which endpoint answered | the flag exists | **not in the npx CLI** — but `tools/bantamkit-mcp-node --which` has it, see below |
223
388
  | one config line, no clone, no venv | no | **yes.** This is the whole reason the package exists |
224
389
 
@@ -280,31 +445,39 @@ that were imported (RB-P84).
280
445
  **Pin the version in the config** if you want this to be a non-issue:
281
446
  `"args": ["-y", "bantamkit-mcp@0.30.0"]`.
282
447
 
448
+ A kept install (what `--install` records from 0.34.0) does not float either. It serves the
449
+ version in `~/.bantamkit/mcp` until someone runs `--update`.
450
+
283
451
  ## Updating
284
452
 
285
- **There is no `bantamkit-mcp --update`, deliberately.** The package manager that installed
286
- this is the thing that updates it, and there are five install shapes with five different
287
- answers. Pick the row you are actually on — and note that **the running server keeps
288
- serving the code it loaded at startup**, so every row ends with restarting it in the host.
289
-
290
- > **AMENDED 2026-09-11 — there IS a `bantamkit-mcp --update` now, and the paragraph above is
291
- > kept rather than rewritten because everything in it is still true of what the flag does.**
292
- > `bantamkit-mcp --update` asks the npm registry for `latest` and compares it to the version
293
- > that is installed. If they match it prints both numbers and `up to date.` and stops. If the
294
- > index is ahead **and this is a registry install**, it runs the row below that applies to you
295
- > and prints what npm said. If the index is ahead and this is any OTHER shape, **it refuses,
296
- > exits 1, and names the row you are on** — it will not write a registry install into a tree
297
- > you manage with `git`, and a command that exits 0 having changed nothing is worse than one
298
- > that says no. Either way it ends by telling you to restart the server, for the reason the
299
- > paragraph above gives: a successful update does not change the process that is answering
300
- > you. The flag reaches the network and nothing else here does — not at startup, not on
301
- > `bantamkit_status`, not on any tool — with a 10-second timeout, and being offline is a named
302
- > refusal on stderr, never a traceback. The table below is still the reference for what to do
303
- > by hand, and it is what `--update` prints back at you when it will not act.
453
+ **Updating.** `bantamkit-mcp --update` — quoted verbatim from `--help`, run in this job
454
+ (`node runtime-ts/dist/cli.js --help`, the same line as `.venv/bin/bantamkit-mcp --help`):
455
+ `--update check the package index and update this install if it differs, then exit`. It asks
456
+ the npm registry for `latest` and compares it to the version installed. If they match, it
457
+ prints both numbers and `up to date.` and stops. If the index is ahead **and this is a
458
+ registry install, or, from 0.34.0, a kept install made by `--install`**, it runs the row below
459
+ that applies to you and prints what npm said. If the index is ahead and this is any OTHER
460
+ shape — a checkout, a linked tree, a local file, or an npx cache with no kept install — **it
461
+ refuses, exits 1, and names the row you are on**: it will not write a registry install into a
462
+ tree you manage with `git`, and a command that exits 0 having changed nothing is worse than
463
+ one that says no. Either way it ends by telling you to restart the server — **the running
464
+ server keeps serving the code it loaded at startup**, so every row ends with restarting it in
465
+ the host. The flag reaches the network and nothing else here does — not at startup, not on
466
+ `bantamkit_status`, not on any tool — with a 10-second timeout, and being offline is a named
467
+ refusal on stderr, never a traceback. The table below is the reference for what to do by hand,
468
+ and it is what `--update` prints back at you when it will not act.
469
+
470
+ > **J51-9b (2026-09-15).** This section used to open "There is no `bantamkit-mcp --update`,
471
+ > deliberately," amended below rather than rewritten (2026-09-11, when the flag first
472
+ > shipped). A review (F4) found that opening sentence sitting directly above this job's own
473
+ > `--update` table row and flagged it as actively wrong, not merely stale, so this time the
474
+ > paragraph is rewritten rather than amended again; it says nothing the 2026-09-11 amendment
475
+ > did not already say, plus the 0.34.0 kept-install route this job added to the table below.
304
476
 
305
477
  | how it was installed | how to update |
306
478
  |---|---|
307
479
  | `npx -y bantamkit-mcp` in the host config | nothing to update — but npx **caches the resolved version**, so add `@latest` (or a pinned `@0.30.0`) or it will keep serving what it resolved weeks ago. `rm -rf ~/.npm/_npx` forces a clean resolve. |
480
+ | `npx -y bantamkit-mcp --install <host>` from 0.34.0: a kept install at `~/.bantamkit/mcp` (added 2026-09-15) | `npx -y bantamkit-mcp@latest --update` patches it in place, or `npm i --prefix ~/.bantamkit/mcp bantamkit-mcp@latest`. The recorded paths do not change. Before 0.34.0, `--update` from an npx cache refuses instead |
308
481
  | `npm i -g bantamkit-mcp` | `npm i -g bantamkit-mcp@latest` |
309
482
  | `npm i --prefix <dir> bantamkit-mcp` | `npm i --prefix <dir> bantamkit-mcp@latest` |
310
483
  | PyPI (`pip install "bantamkit[mcp]"`) | `pip install -U "bantamkit[mcp]"` · pipx: `pipx upgrade bantamkit` · uv: `uv tool upgrade bantamkit` |
@@ -323,7 +496,7 @@ the `build_id` of *the code that is answering you*:
323
496
  ```
324
497
  bantamkit Active 🟢
325
498
  version 0.30.0, build sha256:4441619d…
326
- serving 14 tools, 1 prompt, 2 resource templates
499
+ serving 12 tools, 1 prompt, 2 resource templates
327
500
  ```
328
501
 
329
502
  A version string that moved and a `build_id` that did not means you are reading a config,
@@ -533,7 +706,7 @@ From the repository root:
533
706
  ```sh
534
707
  node tools/conformance/run.mjs --all # Node vs Python, 4300+ cases
535
708
  node tools/conformance/npx-cold-start.mjs # pack, cold npx, warm npx, PATH
536
- node tools/conformance/npx-cold-start.mjs --offline # + the no-registry probe (~2.5 min)
709
+ node tools/conformance/npx-cold-start.mjs --offline # + the no-network arms (299.94 s on 2026-09-15)
537
710
  ```
538
711
 
539
712
  The conformance harness shells out to a CPython to produce the reference side. That is a
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://bantamkit.dev/schemas/shiftwork-checkpoint.json",
4
4
  "title": "Shift-work checkpoint contract v1",
5
- "$comment": "Encodes checkpoint contract v1 (docs/superpowers/specs/2026-08-10-shift-work-checkpoint-contract-draft.md) plus the two driver-forced deltas: plan.units[].role (model dispatch without reading briefs) and state.external[].until_cmd (machine-checkable wait, replacing prose `until`). Re-planning needs no schema change: a planner unit is just a unit. STRICTNESS POSTURE: `version` is const-pinned to 1 so a reader can refuse unknown majors; every object the driver or a session reads by field name sets additionalProperties:false, and every field the draft's schema block shows on every instance is required (empty arrays are legal, so a fresh checkpoint is cheap to write). The two exceptions are `history[]` and `retro[]` items: those are the free-text annotation space the draft treats as notes, so they allow extra keys (e.g. a session adding `seq` or `tokens`) while still requiring their load-bearing fields. `units[].commits` is optional because the draft only carries it on completed units. `state.external[].status` is a non-empty string rather than an enum: the draft closes the enum for unit status (the driver's SUCCESS test) but never enumerates external status, and inventing values here would reject honest checkpoints. `plan.cursor` must name an existing unit id — JSON Schema cannot express that cross-reference, so the driver checks it structurally and escalates on a dangling cursor. Serialization: checkpoints are YAML for humans per the draft, but the v1 driver reads JSON only (stdlib-only rule), so sessions driven by driver.py write .json; this schema validates the parsed document either way. AMENDMENT (job46/AS-2, docs/roadmap-agent-stack.md): `job.roles` is a THIRD v1 addition, beyond the two driver-forced deltas named above, and it is OPTIONAL — a checkpoint that omits it is exactly the document the rest of this comment describes, and every checkpoint written before it existed still validates unchanged. Both CLAUDE.md files say the model a unit runs on is `per role, never random, always logged`; logged it was, in `history[]`/`retro[]`, which are the two objects here that allow extra keys, so nothing could ever compare the model a role was ALLOWED to use against the model that answered. `job.roles` is that declaration, and it sits on `job` because that is where a reader already looks for the rules binding every unit. It is a MAP (unit role -> the model identifiers that role may report), not a record the driver reads by fixed field name, so the closure the rest of this file spells `additionalProperties: false` is spelled here as `propertyNames` over the SAME three names `plan.units[].role` enumerates, with `additionalProperties` carrying the value schema once instead of three times: a key outside the role enum is refused, and so is a value that is not a non-empty array of non-empty strings (an empty list would be a rule no session could satisfy, which is a typo and not a policy). A role the map does not name is unconstrained — declaring one role does not silently forbid the others. The ENFORCEMENT is not here: this file states the contract and the runtimes state the refusal, so no example error text appears in these descriptions. `version` stays 1 because nothing already in the document changes meaning: a reader that ignored `roles` would still read every other field correctly. What it would not do is enforce the rule — and since `job` is closed, an older reader does not ignore the key, it refuses the whole file. That refusal is the intended behaviour and not a gap: opening `job` so an old reader could skip the key is what would make this check bypassable by running an older bantamkit.",
5
+ "$comment": "Encodes checkpoint contract v1 (docs/superpowers/specs/2026-08-10-shift-work-checkpoint-contract-draft.md) plus the two driver-forced deltas: plan.units[].role (model dispatch without reading briefs) and state.external[].until_cmd (machine-checkable wait, replacing prose `until`). Re-planning needs no schema change: a planner unit is just a unit. STRICTNESS POSTURE: `version` is const-pinned to 1 so a reader can refuse unknown majors; every object the driver or a session reads by field name sets additionalProperties:false, and every field the draft's schema block shows on every instance is required (empty arrays are legal, so a fresh checkpoint is cheap to write). The two exceptions are `history[]` and `retro[]` items: those are the free-text annotation space the draft treats as notes, so they allow extra keys (e.g. a session adding `seq` or `tokens`) while still requiring their load-bearing fields. `units[].commits` is optional because the draft only carries it on completed units. `state.external[].status` is a non-empty string rather than an enum: the draft closes the enum for unit status (the driver's SUCCESS test) but never enumerates external status, and inventing values here would reject honest checkpoints. `plan.cursor` must name an existing unit id — JSON Schema cannot express that cross-reference, so the driver checks it structurally and escalates on a dangling cursor. Serialization: checkpoints are YAML for humans per the draft, but the v1 driver reads JSON only (stdlib-only rule), so sessions driven by driver.py write .json; this schema validates the parsed document either way. AMENDMENT (job46/AS-2, docs/roadmap-agent-stack.md): `job.roles` is a THIRD v1 addition, beyond the two driver-forced deltas named above, and it is OPTIONAL — a checkpoint that omits it is exactly the document the rest of this comment describes, and every checkpoint written before it existed still validates unchanged. Both CLAUDE.md files say the model a unit runs on is `per role, never random, always logged`; logged it was, in `history[]`/`retro[]`, which are the two objects here that allow extra keys, so nothing could ever compare the model a role was ALLOWED to use against the model that answered. `job.roles` is that declaration, and it sits on `job` because that is where a reader already looks for the rules binding every unit. It is a MAP (unit role -> the model identifiers that role may report), not a record the driver reads by fixed field name, so the closure the rest of this file spells `additionalProperties: false` is spelled here as `propertyNames` over the SAME three names `plan.units[].role` enumerates, with `additionalProperties` carrying the value schema once instead of three times: a key outside the role enum is refused, and so is a value that is not a non-empty array of non-empty strings (an empty list would be a rule no session could satisfy, which is a typo and not a policy). A role the map does not name is unconstrained — declaring one role does not silently forbid the others. The ENFORCEMENT is not here: this file states the contract and the runtimes state the refusal, so no example error text appears in these descriptions. `version` stays 1 because nothing already in the document changes meaning: a reader that ignored `roles` would still read every other field correctly. What it would not do is enforce the rule — and since `job` is closed, an older reader does not ignore the key, it refuses the whole file. That refusal is the intended behaviour and not a gap: opening `job` so an old reader could skip the key is what would make this check bypassable by running an older bantamkit. AMENDMENT (job50/F8): `handoff.notes` is a FOURTH v1 addition and, like `job.roles`, OPTIONAL, so every checkpoint written before it validates unchanged. `handoff` was the one closed object with nowhere to put a fact that is not an imperative, a question, or a prohibition — a gate baseline, a last commit, a technique worth reusing — and the ledger shows sessions inventing a key for each such fact, being refused, and folding the fact into `next_action` prose instead. The field is a STRING and `handoff` stays `additionalProperties: false`: the defect was the absence of a place for a sentence, not the strictness, and a misspelled `next_action` is refused after this amendment exactly as it was before. The reason it is a string and not a map is in its own description.",
6
6
  "type": "object",
7
7
  "required": ["version", "job", "plan", "state", "history", "retro", "handoff"],
8
8
  "additionalProperties": false,
@@ -192,6 +192,10 @@
192
192
  "type": "array",
193
193
  "items": {"type": "string", "minLength": 1},
194
194
  "description": "Negative space — near-mistakes past sessions made."
195
+ },
196
+ "notes": {
197
+ "type": "string",
198
+ "description": "OPTIONAL. Free-form prose the next session should read that no other key names: a gate baseline, the last commit, a technique worth reusing. A STRING, not a map: every key sessions tried to carry here before this field existed was an attempt to say one sentence, and a map keyed by fact would grow a key per session — the journal the ≤ 2 KB index posture bans, one level down, and an object no schema can close. The same word means the same thing as `history[].notes`. Empty is legal: `handoff` is shallow-merged at clock-out, so a note outlives the unit that wrote it until a later session overwrites it, and the empty string is that overwrite. Declaring this key is what keeps `handoff` closed — a sentence has a home, so an unknown sibling is still a typo and still refused."
195
199
  }
196
200
  }
197
201
  }
@@ -1,9 +1,7 @@
1
1
  {
2
2
  "name": "bantamkit_read",
3
3
  "description": "Read a document through a program, never its raw bytes. Call with `path` alone for the manifest: the file's kind, every part (sheet, document, page) with its row count and byte size, and every omission the reader had to make (embedded images, blank rows, unmapped glyphs, unread pages). Then call with `part` (and `offset`) to page rows; row 0 is the header and each page ends with a continuation line naming the next offset or the end of the part. Do not page through a part to find a row: estimate its offset from the row count and jump. Supported kinds: text/markdown/code, docx, xlsx, html, mhtml; pdf on the Python server only. The Node server refuses pdf, doc and rtf until its reader is ported. A refusal names what the reader actually saw in the file rather than the format's error.",
4
- "surfaces": [
5
- "mcp"
6
- ],
4
+ "surfaces": [],
7
5
  "parameters": {
8
6
  "type": "object",
9
7
  "required": [
@@ -1,9 +1,7 @@
1
1
  {
2
2
  "name": "repo_map",
3
3
  "description": "A ranked map of a source tree: every file's definitions, ordered by how central that file is to the files you name in `focus`, truncated to a byte budget. Call it when you are about to work on a file and want to know which OTHER files matter to it — the ports, the callers, the module it reaches through a private helper — rather than reading a directory listing and guessing. `focus` is the file or files you are editing, relative to `root` and POSIX-separated; they are excluded from the listing because you already have them open, and an empty focus gives plain centrality over the whole tree. This is a PRECISION pass and it is not a token saving: the gate this feature was supposed to clear was refuted by measurement — discovery is 0.114% of real prompt tokens, because 97.8% of the bill is cache_read — so a map does not make a session cheaper. What it buys is the right file found sooner. Nothing the scanner cannot read is dropped silently: every unread file resolves into a named omission (unknown-language, unreadable-bytes, size-cap, no-definitions, unreachable, per-file-cap, budget) counted in a footer that is NOT charged to the budget. The budget is UTF-8 BYTES of the listing, not tokens; there is no model tokenizer in either runtime and this tool will not pretend to one.",
4
- "surfaces": [
5
- "mcp"
6
- ],
4
+ "surfaces": [],
7
5
  "parameters": {
8
6
  "type": "object",
9
7
  "required": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiftwork_clock_in",
3
- "description": "Shift-work clock-in: schema-validate the checkpoint file and return the brief for the unit at plan.cursor — {unit, role, invariants, handoff, do_not, files} — to hand to the spawned agent verbatim. Structured refusals, never exceptions: result=escalate when handoff.open_questions is non-empty, result=success when every unit is done or dropped, result=error when the checkpoint fails validation.",
3
+ "description": "Shift-work clock-in: schema-validate the checkpoint file and return the brief for the unit at plan.cursor — {unit, role, invariants, handoff, do_not, files} — to hand to the spawned agent verbatim. Structured refusals, never exceptions: result=escalate when handoff.open_questions is non-empty, result=success when every unit is done or dropped, result=error when the checkpoint fails validation. Clock-in also WRITES: on result=brief, and only then, it appends one line {event: brief, ts, unit, role} to <checkpoint>.log.jsonl — best-effort: a log it cannot write costs the brief nothing (the brief still returns, no error, only the record is lost). clock_out reads those lines back to write `briefed` on every accounting line, so the ledger is no longer one line per clock-out; a brief line carries `event` and no `status`. See docs/shiftwork.md.",
4
4
  "surfaces": [
5
5
  "mcp"
6
6
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiftwork_clock_out",
3
- "description": "Shift-work clock-out: record a finished unit — set its status, advance plan.cursor, merge handoff_patch, push history_entry onto the 5-entry ring — validating the whole mutated document against the checkpoint schema BEFORE an atomic write (a failure writes nothing and returns result=error). Every success appends one accounting line (unit, role, status, ts, plus your accounting fields, e.g. tokens/duration/model) to <checkpoint>.log.jsonl. If the checkpoint's job.roles names this unit's role, accounting.model must be one of that role's model identifiers, spelled exactly \u2014 a model that is not on the list, or none reported at all, is refused before anything is written; a role job.roles omits, and a checkpoint carrying no job.roles, are unconstrained.",
3
+ "description": "Shift-work clock-out: record a finished unit — set its status, advance plan.cursor, merge handoff_patch, push history_entry onto the 5-entry ring — validating the whole mutated document against the checkpoint schema BEFORE an atomic write (a failure writes nothing and returns result=error). Every success appends one accounting line (unit, role, status, ts, briefed, plus your accounting fields, e.g. tokens/duration_ms/model) to <checkpoint>.log.jsonl, beside the brief lines clock_in writes (a brief line carries `event` and no `status`). `briefed` is written by the runtime, never taken from you — a self-reported value is overwritten by the measured one: true when a brief was issued for this unit since its LAST clock-out (not ever), so a unit re-run inline after a blocked clock-out reads false. It records; it never refuses — a unit that was never clocked in still clocks out, with briefed=false. If the checkpoint's job.roles names this unit's role, accounting.model must be one of that role's model identifiers, spelled exactly \u2014 a model that is not on the list, or none reported at all, is refused before anything is written; a role job.roles omits, and a checkpoint carrying no job.roles, are unconstrained.",
4
4
  "surfaces": [
5
5
  "mcp"
6
6
  ],
@@ -31,8 +31,43 @@
31
31
  "accounting": {
32
32
  "anyOf": [
33
33
  {
34
- "additionalProperties": true,
35
- "type": "object"
34
+ "type": "object",
35
+ "description": "Per-unit cost. Written verbatim, beside ts/unit/role/status, as one line of <checkpoint>.log.jsonl - the ledger the N-sessions experiment reads. The keys named here have fixed meanings so a reader holding only the ledger knows what each number counts, in which unit, and which model produced it; any other key passes through unchanged (a refused key is friction, not safety). Lines written before this schema existed may spell these differently; the schema governs new writes only.",
36
+ "properties": {
37
+ "tokens": {
38
+ "type": "integer",
39
+ "minimum": 0,
40
+ "description": "Tokens the unit consumed as the harness's subagent counter reports them: every class it reports (input, output, cache creation) summed, EXCLUDING cache reads. Required, because a unit whose cost is unknown cannot be compared with any other; a rounded self-estimate is allowed only if `note` says it is one."
41
+ },
42
+ "cache_read_tokens": {
43
+ "type": "integer",
44
+ "minimum": 0,
45
+ "description": "Tokens the unit's requests served from prompt cache, kept OUT of `tokens` because they dominate a real session's traffic and would swamp the work signal. Optional: omit it when the harness reports no such figure - a written 0 means the unit read nothing from cache."
46
+ },
47
+ "duration_ms": {
48
+ "type": "integer",
49
+ "minimum": 0,
50
+ "description": "Wall-clock from spawning the subagent to its final message, in whole milliseconds. Required, and the only duration key with a defined meaning: older lines carry `duration`, `duration_min` or `duration_s`, which this schema neither reads nor renames."
51
+ },
52
+ "model": {
53
+ "type": "string",
54
+ "description": "The exact identifier of the model the subagent actually ran on (e.g. `claude-sonnet-5`) and nothing else - a caveat about how it was chosen belongs in `note`. Not required here: when the checkpoint's job.roles names this unit's role, clock_out already requires it and refuses a value off that role's list, spelled exactly; a checkpoint with no job.roles leaves it optional. This schema does not change that gate."
55
+ },
56
+ "tool_uses": {
57
+ "type": "integer",
58
+ "minimum": 0,
59
+ "description": "Tool calls the subagent made, as the harness counts them. Optional; it is the denominator that makes `tokens` comparable across units of different size (per-unit cost is linear in tool calls, not in unit length)."
60
+ },
61
+ "note": {
62
+ "type": "string",
63
+ "description": "Free text for what the fields above cannot say: that `tokens` is an estimate, that this is a retry or a second scope under the same unit id, what a harness counter excludes. Anything that is not a model identifier goes here, not in `model`."
64
+ }
65
+ },
66
+ "required": [
67
+ "tokens",
68
+ "duration_ms"
69
+ ],
70
+ "additionalProperties": true
36
71
  },
37
72
  {
38
73
  "type": "null"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill_audit",
3
- "description": "Price the skill catalogue every session pays for, and name the collisions in it. A skill's `description:` frontmatter is loaded into the agent's context in EVERY session; its body is read only when the skill is invoked, so the descriptions are the standing bill. Walks `root` for `<marketplace>/<plugin>/<version>/skills/<name>/SKILL.md` and answers one JSON document: `roots` (what was scanned), `skills` (how many were counted), `catalogue_bytes` (UTF-8 bytes of their `description:` values), `findings` and `omissions`. Counted skills plus omissions account for every SKILL.md found — an omission is `{subject, count, size, what}` with subject one of `plugin-not-enabled`, `duplicate-skill`, `stale-version`, `unreadable-file`, `unparsed-frontmatter`, and `what` naming the paths. Pass `enabled` with the plugin ids the host has switched on (`<plugin>@<marketplace>`, as settings.json spells them): the plugin cache also holds disabled plugins and stale version directories, and counting those inflated a real audit to 41 skills / 19,065 B against a true 34 / 14,515. Identity comes from the path: `<marketplace>/<plugin>/<version>/skills/<name>/SKILL.md` relative to `root`; any other shape is a skill outside a plugin, identified by its directory name alone and never excluded by `enabled`. Exactly ONE version directory is resolved per (marketplace, plugin) BEFORE anything is counted, and its skills are that plugin's skills: a name absent from the resolved version is absent, never merged in from another version directory. The winner is the version directory name that sorts LAST in byte order — a byte order and deliberately not a semver comparison, so two runtimes cannot disagree about which directory was read, and so that names which are not versions at all still have a total order (a real cache spells nine of one plugin's directories as content hashes plus the literal `unknown`). What byte order gets wrong is stated rather than hidden: `10.0.0` loses to `9.0.0`, and among non-version names the winner is deterministic but arbitrary; both losers are named in an omission record. Pass `versions` with the version directory the host actually serves, per `<plugin>@<marketplace>` — the `installPath` recorded in the same `installed_plugins.json` the caller reads `enabled` out of — and that plugin is resolved to the named directory whatever byte order would have said; a plugin absent from `versions` falls back to byte order. It is host truth the caller supplies, exactly as `enabled` is, and it is not refused when it names a plugin or a directory this `root` does not hold: a plugin with no version directory here is not resolved at all, and a directory that is not here means every directory that IS here loses and is named in a `stale-version` record. Measured 2026-09-05 on a real cache, byte order picks `unknown` for a plugin whose host serves `1dd995193ba2`, so the installed copy is discarded as a duplicate. The candidates are the version DIRECTORIES on disk — every path spelled `<marketplace>/<plugin>/<version>/skills` — and not the skills that survived reading: a version directory holding no `SKILL.md`, or only files that will not decode or will not parse, still exists and the host still serves it, so an older directory must not win by default. Every SKILL.md under a version directory that did not win is omitted, under one of two subjects: `duplicate-skill` when the resolved version has a skill of the same name standing in for it, `stale-version` when it does not — a skill the resolved version DROPPED, which is on disk, in nobody's bill, and would otherwise be resurrected by the audit. Deduping by the (marketplace, plugin, name) triple instead merges versions rather than choosing between them, and measured 2026-09-05 over a real cache holding a plugin's `0.4.0` (14 skills) and `0.5.0` (5, nine having moved to another plugin) it answered 31 skills / 9,280 bytes where the host serves 22 / 3,396. Inside the resolved version `duplicate-skill` still covers two files claiming the same (marketplace, plugin, name), which is reachable only for skills outside a plugin; the last in scan order wins. Every omission carries the bytes it would have added. A skill is identified as `<plugin>:<name>`, the spelling the host uses and the key `usage` is looked up by, or as `<name>` when it has no plugin. A `description:` folded over several lines is joined with single spaces before it is counted or scanned, and a value written as a whole-value quoted YAML scalar is UNWRAPPED after that fold and before either — the host's parser strips those quotes before a session sees the description, so they are not `catalogue_bytes`, and only quotes INSIDE the value delimit a trigger phrase. The unwrap applies to every frontmatter key, not just `description:`, so `name: \"s\"` is the name `s` and `router: \"true\"` is a router. A value is a whole-value scalar only when it begins with `\"` or `'` AND that opening quote's own closing quote is the value's LAST character, judged by YAML's two escape rules and no others: inside a `\"` scalar a backslash escapes the character after it, so `\\\"` does not close it and the sequences `\\\"` and `\\\\` resolve to `\"` and `\\` while every other `\\x` is left exactly as written; inside a `'` scalar a doubled `''` is one literal apostrophe, so it does not close it and resolves to `'`. Every other value is left as written and scanned literally: `\"a\" and \"b\"` begins and ends with `\"` and is NOT one scalar, because its opening quote closes at index 2, and stripping it would weld the value into one junk phrase and destroy both real ones. A value that OPENS with a quote and never closes one — including one whose last quote is escaped — is a LITERAL: it is not unwrapped, its quote character is counted, its unpaired quote opens no phrase, and it is not a `frontmatter-malformed` finding, which names failures of the BLOCK and not of one value. Measured 2026-09-05 over a real plugin cache: 17 of 31 enabled skills write `description: \"...\"`, so a reader that takes those quotes literally over-prices the majority of a catalogue and buries any phrase quoted inside them. Five findings, each `{kind, severity, skills, detail}`, severity fixed per kind: `shared-trigger-phrase` (high) — two or more skills quote the same literal phrase, so which one fires is a coin flip; `catalogue-over-budget` (high) — `catalogue_bytes` exceeds `budget`; `never-invoked` (low) — a counted skill has zero calls in `usage`; `frontmatter-malformed` (medium) — no frontmatter block, an unterminated one, or no `description:` key, where a block that cannot be parsed at all is also omitted as `unparsed-frontmatter` and adds nothing to `skills` while a parsed block with no description is a skill costing zero bytes; `name-mismatch` (medium) — the frontmatter `name:` is not the skill's directory name. Collisions are EXACT matches on literal quoted phrases and never a similarity score: Jaccard over description tokens was measured across 820 real pairs and refuted, topping out at 0.239 and ranking the two real collisions 118th and 791st of 820. Phrases are the text of the UNWRAPPED value between `\"` pairs, or between `'` pairs where the quote is not flanked by letters — `'don't'` is a contraction, not a phrase — and a phrase with no letter or digit in it is ignored. A router, a skill whose frontmatter carries `router: true`, quotes its siblings' phrases by design and is left out of the phrase index entirely. Four argument failures are refused with their own sentences and never as a partial answer: an unknown `check`, a negative `budget`, a `root` that is missing or is a file, and an EMPTY `root` — which passes JSON-schema `string` and would otherwise resolve to the server's own working directory. Nothing about the CONTENT of the tree refuses. Deterministic: the tool reads nothing but `root`. The three facts a directory of files cannot answer arrive from the caller — call counts in `usage` (`node tools/ledger/tool-usage.mjs --group skill --json`), switched-on plugins in `enabled`, served version directories in `versions` — never by reading the host's transcripts, settings or plugin registry.",
3
+ "description": "Price the skill catalogue every session pays for, and name the collisions in it. A skill's `description:` frontmatter is loaded into the agent's context in EVERY session; its body is read only when the skill is invoked, so the descriptions are the standing bill. Call it before adding or retiring a skill, or when two skills keep firing on the same phrase. Walks `root` for `<marketplace>/<plugin>/<version>/skills/<name>/SKILL.md` and answers one JSON document: `roots`, `skills` (counted), `catalogue_bytes` (UTF-8 bytes of their `description:` values), `findings` and `omissions`. Read `findings` first — each is `{kind, severity, skills, detail}`: `shared-trigger-phrase` (high; two skills quote the same literal phrase, so which fires is a coin flip), `catalogue-over-budget` (high; only when `budget` is given), `never-invoked` (low; only when `usage` is given), `frontmatter-malformed` and `name-mismatch` (medium). Then `omissions`: counted skills plus omissions account for every SKILL.md found, each `{subject, count, size, what}` with subject one of `plugin-not-enabled`, `duplicate-skill`, `stale-version`, `unreadable-file`, `unparsed-frontmatter`. The tool reads nothing but `root`; the three facts a directory cannot answer come from the caller — `enabled` (plugin ids the host has switched on, `<plugin>@<marketplace>`; the cache also holds disabled plugins, so without it the bill is inflated), `usage` (call counts per skill) and `versions` (the version directory the host serves per plugin; a plugin absent from it gets the directory name that sorts LAST in byte order, which is wrong for `10.0.0` against `9.0.0`). Refused with their own sentence, never as a partial answer: an unknown `check`, a negative `budget`, an EMPTY `root`, a `root` that is missing or is a file. Full rules in docs/skill-audit.md.",
4
4
  "surfaces": [
5
5
  "mcp"
6
6
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token_ledger",
3
- "description": "What a session actually cost, read off the host's own transcripts. Walks `root` for `*.jsonl` transcripts -- the host writes `<cwd-slug>/<session>.jsonl` and `<cwd-slug>/<session>/subagents/*.jsonl` beneath a projects directory, which on this machine is `~/.claude/projects` -- and answers ONE JSON document: `root` (echoed as given), `transcripts` (files read), `lines` (records seen), `requests` (distinct API requests counted), `totals` (the four token classes), `sessions` (one row each), `omissions` (every line that was read and not counted), and `cost` only when `model` is given. This is the ONLY reader of the token counts: `docs/eventlog.md`'s four event streams record decisions, injections, tool calls and the host's arrival log, and NONE of the four carries a token count. Every figure here is the API's own `usage` block and nothing is estimated -- the byte-derived `est` figures in `tools/ledger/token-ledger.mjs` (tool_result bytes, repeated Reads) are deliberately NOT served, because an estimate handed to a model through a tool is an estimate that will be quoted back as a fact. COUNTING IS PER `requestId`, NEVER PER LINE: the host writes one API response as several assistant records -- a text block, a tool_use block -- which all carry the SAME `usage`, so summing records overcounts by the number of content blocks. The dedupe spans the WHOLE walk and not one file, because a resumed session rewrites earlier records verbatim into a new file and a per-file rule counts that request twice; the first occurrence in walk order wins and every later copy is reported as a `duplicate-request` omission rather than dropped in silence. EVERY LINE IS COUNTED OR OMITTED AND THE TWO ADD UP: `lines` equals `requests` plus the sum of every omission's `count`, always, so 'your transcripts hold no usage' is distinguishable from 'I skipped most of your transcripts'. An omission is `{subject, count, what}` with subject one of `undecodable-file`, `unparsed-line`, `not-an-object`, `no-session-id`, `not-an-assistant-record`, `no-usage`, `malformed-usage`, `no-request-id`, `duplicate-request`, and `what` naming the FIRST site as `<relpath>:<line>` plus `and N more` -- bounded on purpose, because a real corpus omits tens of thousands of lines as `not-an-assistant-record` (the host writes attachments, mode changes and titles into the same file) and listing them all would hand back a megabyte of paths in place of an answer. A blank line is not a record and is counted nowhere. A record is counted only when it carries a non-empty `sessionId`, `type` of `assistant`, a `message.usage` object holding ALL FOUR of `input_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` and `output_tokens` as non-negative integers below 2**53, and a non-empty `requestId`; the host's other `usage` keys (`service_tier`, `iterations`, `server_tool_use`) are ignored, but a MISSING class is `malformed-usage` and never a zero, because a class defaulted to zero is a token invented. A session row carries `cwd` (the first one seen), `first` and `last` (the minimum and maximum timestamp, as strings), `requests`, `sidechain_requests` and the four classes. `sidechain_requests` is stated rather than hidden because it is an ASYMMETRY a caller would otherwise discover late: a subagent's transcript carries its PARENT's `sessionId` with `isSidechain: true`, so its cost lands in the parent session and is not a session of its own. Sessions are ordered by `first` as a STRING then by session id, both in code-point order, never by parsed time -- the host writes ISO-8601 with a `Z`, for which byte order already is time order. Directory listings are sorted by name in code-point order at every level, so the walk order -- which decides which copy of a duplicated `requestId` counts -- is a property of the tree and not of the filesystem. THERE IS NO TIME WINDOW, deliberately: the operator script has `--days N` and this has nothing, because a tool whose answer depends on the wall clock cannot be pinned by a gate that runs twice, and 'the last 7 days' is a question about a live machine rather than a fact about a corpus. `root` is the CALLER's, exactly as `skill_audit`'s is: this tool reads nothing but `root`, so it can be pointed at a frozen corpus and asked the same question twice. Pass `model` to convert the totals into money through the price table; `assets/pricing/default.json` ships with NO rates on purpose, so `{\"unavailable\": \"no rate recorded for model ...\"}` is the NORMAL answer and not an error path -- a rate enters only as an operator's own fact with its date and its source, through `$BANTAMKIT_PRICES` or through `prices`. `cost` carries `micros` (integer micro-USD), `amount` (a fixed-6-decimal string), a per-class `breakdown` and the `rate` that produced it; the four classes are priced separately because 98.1 % of a real prompt measured here was `cache_read`, and one averaged rate would be wrong in exactly that direction. Four argument failures are refused with their own sentences and never as a partial answer: an EMPTY `root` -- which passes JSON-schema `string` and would otherwise resolve to the server's own working directory -- a `root` that is missing, a `root` that is a file, and an EMPTY `model`, which would otherwise be reported as \"no rate recorded for model ''\" and read as a missing price rather than a missing argument. Nothing about the CONTENT of the tree refuses: a transcript that will not decode, a line that will not parse and a record with no usage are omissions and are counted, because a ledger that refused over one of eight hundred transcripts would have said nothing about the other seven hundred and ninety-nine. The one content failure that DOES refuse is a token total above 2**53-1, which is exact in Python and rounded in JavaScript: the place the two runtimes would part company is a refusal, not a wrong number. Deterministic: the tool reads `root`, and `prices` or `$BANTAMKIT_PRICES` when a `model` is named.",
3
+ "description": "What a session actually cost, read off the host's own transcripts. Walks `root` for `*.jsonl` transcripts — the host writes `<cwd-slug>/<session>.jsonl` and `<cwd-slug>/<session>/subagents/*.jsonl` beneath `~/.claude/projects` — and answers ONE JSON document: `root`, `transcripts`, `lines`, `requests` (distinct API requests), `totals` (the four classes: `input_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`), `sessions` (one row each), `omissions`, and `cost` only when `model` is given. Call it to answer 'what did this work cost' from the API's own `usage` blocks — nothing here is estimated. Point `root` at ONE project's slug directory, not the whole projects tree: there is no time window and no summary mode, so a whole-corpus call answers a row per session ever recorded and blows the host's tool-result cap. The four classes are reported and priced separately because on a real prompt 98.1 % of the tokens were `cache_read`. Counting is per `requestId`, never per line: one response is written as several records with the same `usage`, so the dedupe spans the whole walk and every later copy is a `duplicate-request` omission. A subagent's transcript carries its PARENT's `sessionId`, so its cost lands in the parent row as `sidechain_requests`. `lines` equals `requests` plus the sum of every omission's `count`, always. An omission is `{subject, count, what}`; `what` names the first site as `<relpath>:<line>` plus `and N more`. `cost` needs a rate: the shipped price table has none, so `{\"unavailable\": \"no rate recorded for model ...\"}` is the NORMAL answer, not an error; a rate enters through `$BANTAMKIT_PRICES` or `prices`. Refused with their own sentence: an EMPTY `root`, a `root` that is missing or is a file, an EMPTY `model`, and a token total above 2**53-1. The record rule and the nine omission subjects: docs/ledger.md.",
4
4
  "surfaces": [
5
5
  "mcp"
6
6
  ],
package/dist/cli.js CHANGED
@@ -81,7 +81,7 @@ import { buildServer } from './mcp/server.js';
81
81
  import { RawStdioTransport } from './mcp/transport.js';
82
82
  import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs as parseWithSpec, pyIntStrict, } from './pyargparse.js';
83
83
  import { pyJoin, PyOSError, pyRepr, pyStatIsDir } from './memory/pyfs.js';
84
- import { HOSTS, install as installHost, InstallError, thisCommand } from './hostinstall.js';
84
+ import { HOSTS, InstallError, installSelf } from './hostinstall.js';
85
85
  import { runUpdate } from './selfupdate.js';
86
86
  import { statusLine } from './statusline.js';
87
87
  /** `SystemExit("...")`: the message on stderr, exit 1. */
@@ -471,10 +471,11 @@ async function main(argv) {
471
471
  // earlier here made the same argv write a file on one runtime and not the other: one
472
472
  // command line, two different states on the user's disk. Reviewed and moved.
473
473
  if (options.install !== null && options.install !== undefined) {
474
- // Before any store or transport exists, the shape `--assets-root` established.
475
- const { command, args } = thisCommand();
474
+ // Before any store or transport exists, the shape `--assets-root` established. The command
475
+ // is resolved INSIDE the try: on an `npx` cache it makes the kept install first, and npm
476
+ // failing there is an `InstallError` like any other refusal — `error:` and exit 1.
476
477
  try {
477
- process.stdout.write(`${installHost(options.install, command, args, options.force)}\n`);
478
+ process.stdout.write(`${installSelf(options.install, options.force, { version: version() })}\n`);
478
479
  }
479
480
  catch (e) {
480
481
  if (!(e instanceof InstallError))
package/dist/eventlog.js CHANGED
@@ -32,10 +32,11 @@
32
32
  * one stray write breaks the wire suite. Nothing in this module names `process.stdout`,
33
33
  * `process.stderr` or `console`.
34
34
  * * **Metadata only.** Never a tool argument's value, never a memory body, never a
35
- * validated output, never a query, never a document row. Eight of the fourteen tools take
36
- * unbounded free text and seven take absolute paths (`bantamkit_read`'s `path` is one,
37
- * `skill_audit`'s `root` is another, `repo_map`'s `root` and `focus` are the third and
38
- * `token_ledger`'s `root` and `prices` are the fourth; each record carries counts and
35
+ * validated output, never a query, never a document row. Most of the twelve tools take
36
+ * unbounded free text and two take absolute paths (`skill_audit`'s `root`, and
37
+ * `token_ledger`'s `root` and `prices`; `bantamkit_read`'s `path` and `repo_map`'s `root`
38
+ * and `focus` were two more until both tools left the roster — job50 I5, 2026-09-12 — and
39
+ * their DORMANT handlers still record the same way); each record carries counts and
39
40
  * tokens from a closed set — never the path, never a part name, never a skill id, never a
40
41
  * mapped file, never a session id, never a model name).
41
42
  * Every value written here is an ASCII token from a closed set, a number, or a boolean.
@@ -54,7 +55,8 @@
54
55
  * notice — the failure mode a diagnostic can least afford.
55
56
  */
56
57
  import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
57
- import { cmpCodepoint, osErrorClassName, PyOSError, pyJoin, pyParent } from './memory/pyfs.js';
58
+ import { cmpCodepoint, osErrorClassName, pyExists, PyOSError, pyJoin, pyName, pyParent, pyParents } from './memory/pyfs.js';
59
+ import { ensureBantamkitGitignore } from './memory/store.js';
58
60
  /**
59
61
  * Environment switch. Unset or `off`/`0`/`false`/`no`/empty -> disabled. `on`/`1`/`true`/
60
62
  * `yes` -> the default file inside the memory store. Anything else is taken as the literal
@@ -241,11 +243,33 @@ export class EventLog {
241
243
  * by construction, so that branch is a statement of intent, not a live path.
242
244
  */
243
245
  append(path, payload) {
246
+ // AUDIT FINDING (J51-1, mirrored from `runtime-py/src/bantamkit/eventlog.py`): the
247
+ // `mkdirSync` below can bring a whole `.bantamkit` directory into existence on its own
248
+ // -- `BANTAMKIT_EVENT_LOG=on` with a project store that was never saved to reaches here
249
+ // first -- entirely bypassing `MemoryStore.ensureDirs`, which is the only other place a
250
+ // `.bantamkit` directory gets created. Without this call a store built that way would
251
+ // never get its self-ignoring `.gitignore`. Cheap and idempotent: a no-op unless one of
252
+ // `path`'s ancestors is literally named `.bantamkit`.
253
+ //
254
+ // USER RULING #2 (J51-8b): the ignore file is written only when THIS call is what
255
+ // creates `.bantamkit`, so existence has to be checked BEFORE the mkdir below -- after
256
+ // it, the directory unconditionally exists and the question is unanswerable.
257
+ let bantamkitDir = null;
258
+ for (const parent of pyParents(path)) {
259
+ if (pyName(parent) === '.bantamkit') {
260
+ bantamkitDir = parent;
261
+ break;
262
+ }
263
+ }
264
+ const bantamkitDirExistedBefore = bantamkitDir !== null && pyExists(bantamkitDir);
244
265
  // `pyParent` and not `node:path`'s `dirname`: this module is reached from a Windows
245
266
  // host through the same code, and the reference's `Path(...).parent` is the spelling
246
267
  // that understands a drive letter. `.`'s parent is `.` in both, so a bare filename
247
268
  // makes the directory that already exists rather than reaching for the root.
248
269
  mkdirSync(pyParent(path), { recursive: true });
270
+ if (bantamkitDir !== null) {
271
+ ensureBantamkitGitignore(bantamkitDir, !bantamkitDirExistedBefore);
272
+ }
249
273
  let size = 0;
250
274
  try {
251
275
  size = statSync(path).size;