bantamkit-mcp 0.29.2 → 0.31.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 eleven tools, the same
4
+ `uv`, no `pipx`, no interpreter bootstrap. It serves the same fourteen 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
@@ -278,7 +278,74 @@ no repository, and reading a checkout's HEAD would describe the tree rather than
278
278
  that were imported (RB-P84).
279
279
 
280
280
  **Pin the version in the config** if you want this to be a non-issue:
281
- `"args": ["-y", "bantamkit-mcp@0.25.0"]`.
281
+ `"args": ["-y", "bantamkit-mcp@0.30.0"]`.
282
+
283
+ ## Updating
284
+
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.
304
+
305
+ | how it was installed | how to update |
306
+ |---|---|
307
+ | `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. |
308
+ | `npm i -g bantamkit-mcp` | `npm i -g bantamkit-mcp@latest` |
309
+ | `npm i --prefix <dir> bantamkit-mcp` | `npm i --prefix <dir> bantamkit-mcp@latest` |
310
+ | PyPI (`pip install "bantamkit[mcp]"`) | `pip install -U "bantamkit[mcp]"` · pipx: `pipx upgrade bantamkit` · uv: `uv tool upgrade bantamkit` |
311
+ | a checkout, via `tools/bantamkit-mcp-node` | `git pull && npm ci --prefix runtime-ts && npm run build --prefix runtime-ts` — this one never touches a registry, and `runtime-ts/dist/` is build output, so a pull alone changes nothing |
312
+
313
+ **Then restart the server in your host**, or you will keep talking to the old build. In
314
+ Claude Code that is `/mcp` → reconnect; in Claude Desktop it is a full restart of the app.
315
+ Measured 2026-09-07 (served-tools: dated — the surface was eleven then; 0.30.0's
316
+ `memory_dream` and `repo_map` made it thirteen): a checkout whose `dist/` had just been
317
+ rebuilt at 0.30.0 kept answering `version 0.29.1, serving 11 tools` until the host
318
+ reconnected — the disk was current and the process was not.
319
+
320
+ **Verify with `bantamkit_status`, not with the install log.** It reports the version and
321
+ the `build_id` of *the code that is answering you*:
322
+
323
+ ```
324
+ bantamkit Active 🟢
325
+ version 0.30.0, build sha256:4441619d…
326
+ serving 14 tools, 1 prompt, 2 resource templates
327
+ ```
328
+
329
+ A version string that moved and a `build_id` that did not means you are reading a config,
330
+ not a process.
331
+
332
+ ### The failure this table exists for
333
+
334
+ Measured on a real machine, 2026-09-07: a Claude Desktop entry pointed at
335
+ `~/.local/share/bantamkit-mcp/node_modules/.bin/bantamkit-mcp`, which was **0.25.0** — five
336
+ releases stale — and its `package.json` declared
337
+
338
+ ```json
339
+ "bantamkit-mcp": "file:/private/tmp/.../scratchpad/bantamkit-mcp-0.25.0.tgz"
340
+ ```
341
+
342
+ a **local tarball in a temp directory that no longer existed**. `npm update` in that
343
+ directory cannot help: the dependency does not name a registry. The fix is to install over
344
+ it from the registry (`npm i --prefix ~/.local/share/bantamkit-mcp bantamkit-mcp@latest`),
345
+ which restores a normal semver dependency and leaves the host config's path valid.
346
+
347
+ If you install from a local `.tgz` to test a build, **install over it from the registry
348
+ afterwards**, or that machine is pinned to a file that will be deleted.
282
349
 
283
350
  ## The second bin: `bantamkit-memory`
284
351
 
@@ -300,6 +367,20 @@ second bin instead of a subcommand: lifecycle output on the server's stdout woul
300
367
  the transport, and `bantamkit-mcp`'s help is a byte-compared artifact against
301
368
  `python -m bantamkit.mcpserver -h`, which a subparsers action would move.
302
369
 
370
+ **Amendment, 2026-09-11 (J46-26/J46-27).** "Not a thing you run by hand" is now half
371
+ true and the half that changed is worth knowing. Typing `bantamkit-mcp` at a prompt
372
+ with nothing after it no longer opens a mute server and blocks — it prints the help,
373
+ on stdout, exit 0, the same bytes `-h` prints. The discrimination is whether **stdin is
374
+ a terminal** and nothing else, so every host launch is unchanged: `"args": []` down a
375
+ pipe still starts the server and still answers `initialize`. The paragraph above stays
376
+ true of what it was describing — stdout is the wire, and nothing but frames goes down
377
+ it when a host is on the other end. What is new is that typing the command to see what
378
+ it does is now a reasonable thing to do. An argument after the command is an operator
379
+ asking for a configured server and still gets one: `bantamkit-mcp --store /tmp/x` at a
380
+ terminal serves. Compared between the two runtimes by
381
+ `tools/conformance/suites/cli.mjs` (`bare-at-a-tty`, `flagged-at-a-tty`,
382
+ `bare-over-a-pipe`).
383
+
303
384
  Five subcommands, scoped to the writable **project** layer only — read-only grants and the
304
385
  profile store are out of its reach by the code path, not by convention:
305
386
 
@@ -0,0 +1,7 @@
1
+ {
2
+ "schema_version": 1,
3
+ "currency": "USD",
4
+ "unit": "micro_usd_per_million_tokens",
5
+ "note": "Ships with NO rates, deliberately. bantamkit is offline and nothing in this repository or on the machine it was built on carries a published price, so every rate here would have had to be recalled rather than sourced - which is the unfalsifiable figure this project exists to refuse. The mechanism is complete and the refusal path is therefore the DEFAULT: price_tokens answers {\"unavailable\": ...} for every model until an operator records a rate. To record one, copy this file, add an entry under 'rates', and point BANTAMKIT_PRICES at the copy. Each entry needs 'recorded' (YYYY-MM-DD, the day YOU read the rate), 'source' (where you read it, so the next reader can re-derive it), and one or more of the four token classes priced in micro-USD per 1,000,000 tokens - so $3.00 per million input tokens is 3000000. The loader REFUSES a rate that has no date or no source: a number in this file without both is not a fact.",
6
+ "rates": {}
7
+ }
@@ -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.",
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.",
6
6
  "type": "object",
7
7
  "required": ["version", "job", "plan", "state", "history", "retro", "handoff"],
8
8
  "additionalProperties": false,
@@ -27,6 +27,17 @@
27
27
  "type": "array",
28
28
  "items": {"type": "string", "minLength": 1},
29
29
  "description": "Invariants binding every unit, copied verbatim into each session."
30
+ },
31
+ "roles": {
32
+ "type": "object",
33
+ "description": "OPTIONAL. Per unit role, the model identifiers a session in that role may report. Omit it and the job behaves as it always has: the model is recorded and nothing compares it to anything. A role this map does not name is unconstrained; only a role named here is held to its list. Keys are the `plan.units[].role` enum, so a role that is not a role is refused rather than quietly ignored.",
34
+ "propertyNames": {"enum": ["planner", "implementer", "reviewer"]},
35
+ "additionalProperties": {
36
+ "type": "array",
37
+ "minItems": 1,
38
+ "items": {"type": "string", "minLength": 1},
39
+ "description": "The model identifiers this role may report, spelled exactly as a session logs them. Non-empty: a role allowed no model is a typo, not a policy."
40
+ }
30
41
  }
31
42
  }
32
43
  },
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "memory_dream",
3
+ "description": "Consolidate the memories the project layer and the machine-wide profile layer hold under the SAME NAME, and say exactly what changed. Byte-identical copies collapse to one; a pair that has diverged is UNIONED — every claim from both copies survives, never a winner-takes-all — and a relative date in a body is annotated with the day it resolves to against that fact's own mtime, never against today. The survivor stays in the writable project layer and the profile copy MOVES to that store's archive/: nothing is deleted and any consumed memory can be restored by name. This is a correctness pass, not a token saving: the profile index is derived at read time and is not loaded from a prompt, so consolidating it frees approximately no bytes — what it buys is one copy of a ruling instead of two that can disagree. It defaults to a DRY RUN because the profile store is machine-wide: a memory archived out of it stops answering for every other project on this machine that has no store of its own. Read the plan first, then call it again with dry_run false to apply it.",
4
+ "surfaces": ["mcp"],
5
+ "parameters": {
6
+ "type": "object",
7
+ "properties": {
8
+ "dry_run": {
9
+ "type": "boolean",
10
+ "description": "Preview only. True (the default) performs every read, merge and projection and writes nothing. Pass false to apply the plan."
11
+ }
12
+ }
13
+ },
14
+ "output_schema": {
15
+ "properties": { "result": { "title": "Result", "type": "string" } },
16
+ "required": ["result"],
17
+ "type": "object",
18
+ "title": "memory_dreamOutput"
19
+ }
20
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "repo_map",
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
+ ],
7
+ "parameters": {
8
+ "type": "object",
9
+ "required": [
10
+ "root"
11
+ ],
12
+ "properties": {
13
+ "root": {
14
+ "type": "string",
15
+ "description": "Directory to map, absolute or relative to the server's working directory"
16
+ },
17
+ "focus": {
18
+ "type": "array",
19
+ "items": {
20
+ "type": "string"
21
+ },
22
+ "description": "The files you are working on, relative to `root` and POSIX-separated. They are excluded from the listing. A name that is not a scanned source is ignored rather than refused."
23
+ },
24
+ "budget": {
25
+ "type": "integer",
26
+ "minimum": 0,
27
+ "description": "UTF-8 bytes of listing to render; default 4000. The omission footer is not charged against it."
28
+ }
29
+ }
30
+ },
31
+ "output_schema": {
32
+ "properties": {
33
+ "result": {
34
+ "title": "Result",
35
+ "type": "string"
36
+ }
37
+ },
38
+ "required": [
39
+ "result"
40
+ ],
41
+ "type": "object",
42
+ "title": "repo_mapOutput"
43
+ }
44
+ }
@@ -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.",
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.",
4
4
  "surfaces": [
5
5
  "mcp"
6
6
  ],
@@ -0,0 +1,40 @@
1
+ {
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.",
4
+ "surfaces": [
5
+ "mcp"
6
+ ],
7
+ "parameters": {
8
+ "type": "object",
9
+ "required": [
10
+ "root"
11
+ ],
12
+ "properties": {
13
+ "root": {
14
+ "type": "string",
15
+ "description": "Directory of host transcripts to walk, absolute or relative to the server's working directory; `~/.claude/projects` is where the host keeps them on this machine. It must name a directory: the empty string is refused rather than resolved, because resolving it would read whatever directory the server happens to be standing in"
16
+ },
17
+ "model": {
18
+ "type": "string",
19
+ "description": "Model to price the totals as, e.g. the id an assistant record's `message.model` carries. Omit it and no `cost` key is reported at all; name one with no recorded rate and `cost` is `{\"unavailable\": ...}`, which is what the shipped table answers for every model"
20
+ },
21
+ "prices": {
22
+ "type": "string",
23
+ "description": "Price table to read instead of `$BANTAMKIT_PRICES` or the shipped one; only consulted when `model` is given. A malformed table is a configuration fault and stops with its own sentence, rather than being reported as an unpriced model"
24
+ }
25
+ }
26
+ },
27
+ "output_schema": {
28
+ "properties": {
29
+ "result": {
30
+ "title": "Result",
31
+ "type": "string"
32
+ }
33
+ },
34
+ "required": [
35
+ "result"
36
+ ],
37
+ "type": "object",
38
+ "title": "token_ledgerOutput"
39
+ }
40
+ }
package/dist/cli.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface Options {
8
8
  assetsRoot: boolean;
9
9
  mcpReport: boolean;
10
10
  statusline: boolean;
11
+ update: boolean;
11
12
  install: Host | null;
12
13
  force: boolean;
13
14
  }
package/dist/cli.js CHANGED
@@ -2,6 +2,17 @@
2
2
  /**
3
3
  * The `bantamkit-mcp` entry point: the stdio MCP server, and six flags.
4
4
  *
5
+ * AMENDED 2026-09-11, J46-31 — "six flags" WAS ALREADY WRONG BEFORE THIS JOB AND IS WRONGER
6
+ * NOW. It was written at `0d067fa`; the parser had nine by the time `--update` was proposed
7
+ * and has ten today, plus `-h/--help`: `--assets-root`, `--k`, `--index-budget`,
8
+ * `--mcp-report`, `--statusline`, `--update`, `--install`, `--force`, `--store`, `--start`.
9
+ * The sentence is kept rather than renumbered because WHAT IT WAS COUNTING AT `0d067fa`
10
+ * cannot be recovered — six is not the count of anything in `PARSER` at that commit either —
11
+ * and a number quietly changed to a different wrong number is worse than one a reader can
12
+ * date. DO NOT RENUMBER IT AGAIN. `PARSER` below is the count; the paragraph after this one
13
+ * is the reason a second copy of a generated fact does not belong in a comment, and this
14
+ * amendment is that reason happening to the comment itself.
15
+ *
5
16
  * THE COMMAND LINE IS DATA, NOT PRINTED TEXT. `PARSER` below is the whole description of
6
17
  * this CLI, and `pyargparse.ts` renders it — the help table, the usage line at whatever
7
18
  * width the terminal is, and every `bantamkit-mcp: error: …` block — with CPython
@@ -19,6 +30,12 @@
19
30
  * that every line a real session put on stdout parses as a JSON-RPC frame, that stdout never
20
31
  * ends mid-line, and that a clean session writes nothing to stderr either.
21
32
  *
33
+ * THE BARE FORM STILL SERVES — WITH ONE QUALIFICATION ADDED BY J46-27, and it does not touch
34
+ * the sentence above. A bare invocation whose STDIN IS A TERMINAL is a person, not a host, and
35
+ * gets the help instead of a mute server; a bare invocation over a pipe is unchanged, byte for
36
+ * byte, and that is the arm `test/server.test.mjs` drives with a real `initialize`. See
37
+ * `typedBareAtATerminal` below for why the discrimination is stdin's tty-ness and nothing else.
38
+ *
22
39
  * PRODUCTION RUNS LAYERED. No `--store` means `Memory.layered`, which prepends `[project] `
23
40
  * to a recall line; a build validated only against `--store` would ship a recall string the
24
41
  * real deployment never produces.
@@ -52,6 +69,8 @@
52
69
  * and `docs/install.md` carry the same correction in their own words.
53
70
  */
54
71
  import { readFileSync } from 'node:fs';
72
+ import { dirname } from 'node:path';
73
+ import { fileURLToPath } from 'node:url';
55
74
  import { assetsRoot } from './assets.js';
56
75
  import { BantamError } from './errors.js';
57
76
  import { buildReport, resolveEventLogPath } from './mcpreport.js';
@@ -63,6 +82,7 @@ import { RawStdioTransport } from './mcp/transport.js';
63
82
  import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs as parseWithSpec, pyIntStrict, } from './pyargparse.js';
64
83
  import { pyRepr } from './memory/pyfs.js';
65
84
  import { HOSTS, install as installHost, InstallError, thisCommand } from './hostinstall.js';
85
+ import { runUpdate } from './selfupdate.js';
66
86
  import { statusLine } from './statusline.js';
67
87
  /** `SystemExit("...")`: the message on stderr, exit 1. */
68
88
  class Refusal extends Error {
@@ -153,6 +173,25 @@ const PARSER = {
153
173
  help: 'print one status line for a host status bar, then exit',
154
174
  defaultValue: false,
155
175
  },
176
+ // THE ONE FLAG ON THIS PARSER THAT TOUCHES THE NETWORK, and the placement is what keeps
177
+ // that from spreading. `--assets-root` sits first because it needs NOTHING; this one needs
178
+ // the most of any flag here, so it does NOT go there — and the second reason is the same
179
+ // measured one the comments above give: the FIRST line of the 80-column usage is pinned by
180
+ // `test/cli-surface.test.mjs` and by the `cli` conformance suite, and a flag registered
181
+ // before `--mcp-report` would move it and turn a differential suite into a re-baselining
182
+ // one. Registered HERE — after `--statusline`, before `--install`, the reference's own
183
+ // position at `a590df8` — it grows the SECOND usage line only.
184
+ //
185
+ // DEFAULTS OFF, like every other flag on this parser, which is the whole of AS-7(3): the
186
+ // network is reached when a person asks for it by name and on no other path. There is no
187
+ // startup check, nothing on `bantamkit_status`, and no background poller.
188
+ {
189
+ optionStrings: ['--update'],
190
+ dest: 'update',
191
+ kind: 'storeTrue',
192
+ help: 'check the package index and update this install if it differs, then exit',
193
+ defaultValue: false,
194
+ },
156
195
  // THE SAME PLACE AND THE SAME REASON AS THE TWO ABOVE, and the position is copied from
157
196
  // the reference rather than chosen: argparse prints optionals in registration order, so
158
197
  // this line decides where `[--install {...}]` sits in the generated usage. After
@@ -203,9 +242,10 @@ const PARSER = {
203
242
  },
204
243
  ],
205
244
  // `--store`/`--start` moved from 6/7 to 8/9 when `--install` and `--force` were added
206
- // ahead of them. These are POSITIONS, not names, so adding an action above the group and
207
- // leaving this line alone would silently make two unrelated flags mutually exclusive.
208
- groups: [[8, 9]],
245
+ // ahead of them, and from 8/9 to 9/10 when `--update` was. These are POSITIONS, not names,
246
+ // so adding an action above the group and leaving this line alone would silently make two
247
+ // unrelated flags mutually exclusive.
248
+ groups: [[9, 10]],
209
249
  };
210
250
  /** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
211
251
  export function parseArgs(argv) {
@@ -218,10 +258,68 @@ export function parseArgs(argv) {
218
258
  assetsRoot: values['assets_root'],
219
259
  mcpReport: values['mcp_report'],
220
260
  statusline: values['statusline'],
261
+ update: values['update'],
221
262
  install: values['install'],
222
263
  force: values['force'],
223
264
  };
224
265
  }
266
+ /**
267
+ * `_typed_bare_at_a_terminal`: True when a PERSON typed `bantamkit-mcp` with nothing after
268
+ * it. Never for a host.
269
+ *
270
+ * WHY THERE IS A DISCRIMINATION HERE AT ALL. Typing the command at a prompt used to open a
271
+ * stdio JSON-RPC server and block: no output, no prompt back, Ctrl-C the only way out. To a
272
+ * person that is indistinguishable from a hang, and it is what the user asked to be fixed on
273
+ * 2026-09-11 — "เพิ่ม task set default when call bantamkit-mcp only ให้แสดงเหมือน --help".
274
+ *
275
+ * WHY IT IS NOT SIMPLY "NO ARGUMENTS -> PRINT HELP". The bare invocation IS the production
276
+ * launch path, and this file's own header says so above: "the production invocation passes NO
277
+ * arguments at all (`.mcp.json` and the user-scope config both call the launcher bare), so the
278
+ * bare form is the one that must serve." On THIS side that is sharper than on the Python side:
279
+ * the user-scope registration on this machine points at `tools/bantamkit-mcp-node`, so the Node
280
+ * server is the one a broken bare path takes down.
281
+ *
282
+ * SO THE SIGNAL IS stdin's tty-ness, AND IT IS THE ONLY SIGNAL. A host wires stdin to a pipe or
283
+ * a socket; a person at a keyboard has a terminal on it. Deliberately NOT `process.stdout.isTTY`:
284
+ * stdout is the JSON-RPC channel and a host may redirect the two streams differently, so a tty
285
+ * on stdout says nothing about who is asking. Deliberately not a flag either — a flag to opt out
286
+ * means the bare form is no longer bare, and the bare form is the one under discussion.
287
+ *
288
+ * AND THE EMPTY ARGV IS A SCOPE, NOT A SECOND SIGNAL. What the user asked for is the command
289
+ * "only". `bantamkit-mcp --store /tmp/x` typed at a terminal is an operator explicitly asking
290
+ * for a configured server and keeps getting one; hand-driving the line-delimited protocol at a
291
+ * prompt stays possible. `argv` says WHICH invocation is in scope, the tty says WHO is on the
292
+ * other end of it. `runtime-py/src/bantamkit/mcpserver.py` holds the same pair.
293
+ *
294
+ * THE ONE THING THAT IS NOT A TRANSLATION OF THE REFERENCE. `sys.stdin.isatty()` returns a
295
+ * bool; `process.stdin.isTTY` is `true` on a terminal and **`undefined`** — not `false` — on a
296
+ * pipe, a file or `/dev/null` (measured on Node v25.2.1: `isTTY= undefined undefined`). So this
297
+ * is a TRUTHINESS test. A `=== false` test would be the same bug inverted and far worse than a
298
+ * missing feature: `undefined === false` is `false`, so every host launch would take the person
299
+ * branch and be handed the help table on the JSON-RPC channel.
300
+ *
301
+ * The reference's two "not a person, and must not be allowed to raise" arms — `sys.stdin is
302
+ * None` and `isatty()` on a closed stream raising `ValueError` — have Node counterparts on the
303
+ * same getter: `process.stdin` is documented to be able to be `null` where no stdin exists, and
304
+ * the getter itself can throw for an fd type the platform cannot wrap. Probed here on macOS with
305
+ * fd 0 closed (`0<&-`) it neither threw nor answered null, so the guards are not decoration for
306
+ * a measured case — they are on the path EVERY host takes, and a serving invocation must not be
307
+ * turnable into a stack trace by the check that decides it is serving.
308
+ */
309
+ function typedBareAtATerminal(argv) {
310
+ if (argv.length > 0)
311
+ return false;
312
+ let stream;
313
+ try {
314
+ stream = process.stdin;
315
+ }
316
+ catch {
317
+ return false;
318
+ }
319
+ if (stream === null || stream === undefined)
320
+ return false;
321
+ return Boolean(stream.isTTY);
322
+ }
225
323
  /** `_build_memory`, including the two refusals argparse cannot express. */
226
324
  function buildMemory(options) {
227
325
  if (options.k < 1)
@@ -277,8 +375,22 @@ async function main(argv) {
277
375
  process.stdout.write(`${statusLine(process.env, options.store, options.start)}\n`);
278
376
  return 0;
279
377
  }
378
+ if (options.update) {
379
+ // Same discipline and the same place as the three above, and the same place the reference
380
+ // dispatches it: after `--statusline`, before `--install`. It is the one flag here that
381
+ // reaches the network, and it still returns before `buildMemory` — somebody who typed
382
+ // `--update` has not asked for a `.bantamkit/memory` directory in whatever cwd they were
383
+ // standing in, and has certainly not asked for a stdio server on a process that is about
384
+ // to be replaced on disk.
385
+ //
386
+ // `import.meta.url`'s DIRECTORY is the running package directory — `dist/` in an install,
387
+ // the built tree in a checkout — which is the counterpart of the reference's
388
+ // `_running_package_file().resolve().parent`. It stands in for `source` on the two shapes
389
+ // that record no origin, and it is not a guess: it is where the code being executed lives.
390
+ return await runUpdate((text) => process.stdout.write(text), (text) => process.stderr.write(text), version(), dirname(fileURLToPath(import.meta.url)));
391
+ }
280
392
  // ORDER IS OBSERVABLE, and it is the reference's order. `main` in `mcpserver.py` checks
281
- // assets_root, then mcp_report, then statusline, then install, then force — so
393
+ // assets_root, then mcp_report, then statusline, then update, then install, then force — so
282
394
  // `--mcp-report --install cursor` prints a report and writes NOTHING. Dispatching install
283
395
  // earlier here made the same argv write a file on one runtime and not the other: one
284
396
  // command line, two different states on the user's disk. Reviewed and moved.
@@ -300,6 +412,25 @@ async function main(argv) {
300
412
  // dropped the flag that says where. Refusing names the missing half.
301
413
  if (options.force)
302
414
  throw new Refusal('--force is only meaningful with --install');
415
+ // A PERSON TYPED IT. `typedBareAtATerminal` carries the whole argument; what belongs here is
416
+ // only that this sits BEFORE `buildMemory`, which is what creates a store. Somebody who typed
417
+ // a command to see what it does has not asked for a `.bantamkit/memory` directory in whatever
418
+ // cwd they were standing in, and every flag above returns before a transport for the same
419
+ // class of reason. It is also after `--force`, which is the reference's order.
420
+ //
421
+ // STDOUT AND EXIT 0, i.e. byte-for-byte what `-h` does, because the request was
422
+ // "ให้แสดงเหมือน --help" — show it the way `--help` shows it. `formatHelp(PARSER, helpWidth())`
423
+ // is the SAME CALL the `HelpRequested` arm at the bottom of this file makes, over the same
424
+ // `PARSER` object, so the two cannot diverge: there is one parser and two callers, never two
425
+ // strings. That is the defect this file's header records having already shipped once.
426
+ //
427
+ // The alternative reading — stderr and exit 2, "a bare invocation is a usage error" — was
428
+ // considered and refused by J46-26: it is not an error, it is the documented answer to the
429
+ // documented request, and nothing non-interactive can reach this line at all.
430
+ if (typedBareAtATerminal(argv)) {
431
+ process.stdout.write(formatHelp(PARSER, helpWidth()));
432
+ return 0;
433
+ }
303
434
  const memory = buildMemory(options);
304
435
  const wire = new RawStdioTransport();
305
436
  const server = buildServer(memory, wire, version());
package/dist/eventlog.js CHANGED
@@ -32,10 +32,12 @@
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. Six of the eleven tools take
36
- * unbounded free text and five take absolute paths (`bantamkit_read`'s `path` is one and
37
- * `skill_audit`'s `root` is another; each record carries counts and tokens from a closed
38
- * set — never the path, never a part name, never a skill id).
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
39
+ * tokens from a closed set — never the path, never a part name, never a skill id, never a
40
+ * mapped file, never a session id, never a model name).
39
41
  * Every value written here is an ASCII token from a closed set, a number, or a boolean.
40
42
  * * **Never `String(err)`.** Only the constructor name. This is not hypothetical: the host
41
43
  * itself has already persisted `input_value={'schema_path': '/Users/k...
package/dist/index.d.ts CHANGED
@@ -13,11 +13,13 @@ export type { PyOrder, YamlScalar, YamlValue } from './memory/pyyaml.js';
13
13
  export { countFacts, discoverProjectStore, loadGrants, MEMORY_DIR_ENV, PROJECT_STORE, resolveProjectStore, } from './memory/layers.js';
14
14
  export type { StoreBinding } from './memory/layers.js';
15
15
  export { layerLabel, Memory, normalizeName, profileStore } from './memory/component.js';
16
- export type { CompactOutcome, MemoryOptions, RecallOutcome, SaveOutcome } from './memory/component.js';
16
+ export type { CompactOutcome, DreamOutcome, MemoryOptions, RecallOutcome, SaveOutcome, } from './memory/component.js';
17
+ export { absolutise, blockKey, claimSlot, collapse, dream, formatFixed3, mergeBodies, mergeDescriptions, mergeLinks, PROFILE_LAYER, PROJECT_LAYER, pyIntDigits, sortedNames, splitBlocks, SUPERSEDED_HEADING, } from './memory/dream.js';
18
+ export type { DateHit, DreamMerge, DreamResult, SimilarPair, Superseded } from './memory/dream.js';
17
19
  export { CAP_BYTES, DEFAULT_RELATIVE_PATH, defaultPath, encodeRecord, EVENT_LOG_ENV, EventLog, formatTimestamp, resolvePath, SCHEMA_VERSION, } from './eventlog.js';
18
20
  export type { DetailValue } from './eventlog.js';
19
- export { DEFAULT_INDEX_BUDGET, DUPLICATE_JACCARD, jaccard, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyCompareLt, pyEqualValue, pyHashKey, pyText, sortScored, tokens, VALID_TYPES, } from './memory/store.js';
20
- export type { MemoryStoreOptions, SaveResult } from './memory/store.js';
21
+ export { DEFAULT_INDEX_BUDGET, DUPLICATE_JACCARD, jaccard, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyCompareLt, pyEqualValue, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, sortScored, tokens, VALID_TYPES, } from './memory/store.js';
22
+ export type { MemoryStoreOptions, SaveResult, StoreInternals } from './memory/store.js';
21
23
  export { asPyOSError, cmpCodepoint, matchesMd, normcase, pyDecodeUtf8, pyCwd, pyExists, pyExpanduser, pyHome, pyIsAbsolute, pyIsDir, pyIsFile, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, PyOSError, pyParent, pyParents, pyReadText, pyReplace, pyRepr, pyResolve, PyRuntimeError, pyScandirNames, PyUnicodeDecodeError, pyAppendText, pyStatIsDir, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, sortedPathNames, sortedPathParts, STRERROR_NAMES, } from './memory/pyfs.js';
22
24
  export { clockIn, clockOut, HISTORY_RING_SIZE, SCHEMA_NAME, status, TERMINAL_UNIT_STATUS, timestamp, } from './shiftwork.js';
23
25
  export type { ClockOptions } from './shiftwork.js';
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ export { decodeFactBytes, FactParseError, formatFact, parseFactText, parseFrontm
7
7
  export { constructPlain, PyScalar, resolveImplicitTag, safeDumpMapping, YamlConstructError, YamlEmitError, } from './memory/pyyaml.js';
8
8
  export { countFacts, discoverProjectStore, loadGrants, MEMORY_DIR_ENV, PROJECT_STORE, resolveProjectStore, } from './memory/layers.js';
9
9
  export { layerLabel, Memory, normalizeName, profileStore } from './memory/component.js';
10
+ export { absolutise, blockKey, claimSlot, collapse, dream, formatFixed3, mergeBodies, mergeDescriptions, mergeLinks, PROFILE_LAYER, PROJECT_LAYER, pyIntDigits, sortedNames, splitBlocks, SUPERSEDED_HEADING, } from './memory/dream.js';
10
11
  export { CAP_BYTES, DEFAULT_RELATIVE_PATH, defaultPath, encodeRecord, EVENT_LOG_ENV, EventLog, formatTimestamp, resolvePath, SCHEMA_VERSION, } from './eventlog.js';
11
- export { DEFAULT_INDEX_BUDGET, DUPLICATE_JACCARD, jaccard, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyCompareLt, pyEqualValue, pyHashKey, pyText, sortScored, tokens, VALID_TYPES, } from './memory/store.js';
12
+ export { DEFAULT_INDEX_BUDGET, DUPLICATE_JACCARD, jaccard, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyCompareLt, pyEqualValue, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, sortScored, tokens, VALID_TYPES, } from './memory/store.js';
12
13
  export { asPyOSError, cmpCodepoint, matchesMd, normcase, pyDecodeUtf8, pyCwd, pyExists, pyExpanduser, pyHome, pyIsAbsolute, pyIsDir, pyIsFile, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, PyOSError, pyParent, pyParents, pyReadText, pyReplace, pyRepr, pyResolve, PyRuntimeError, pyScandirNames, PyUnicodeDecodeError, pyAppendText, pyStatIsDir, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, sortedPathNames, sortedPathParts, STRERROR_NAMES, } from './memory/pyfs.js';
13
14
  export { clockIn, clockOut, HISTORY_RING_SIZE, SCHEMA_NAME, status, TERMINAL_UNIT_STATUS, timestamp, } from './shiftwork.js';