bantamkit-mcp 0.32.1 β†’ 0.33.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
@@ -323,7 +323,7 @@ the `build_id` of *the code that is answering you*:
323
323
  ```
324
324
  bantamkit Active 🟒
325
325
  version 0.30.0, build sha256:4441619d…
326
- serving 14 tools, 1 prompt, 2 resource templates
326
+ serving 12 tools, 1 prompt, 2 resource templates
327
327
  ```
328
328
 
329
329
  A version string that moved and a `build_id` that did not means you are reading a config,
@@ -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/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.
@@ -112,6 +112,9 @@ export const ARG_MODELS = {
112
112
  // `maximum` are advisory to the client for `limit` and for `offset`'s floor; the handler
113
113
  // clamps `limit` to `[1, 200]` and `offset` to `>= 0` itself, the way `memory_recall`
114
114
  // clamps `k`. `offset`'s CEILING is bound in the model, as it is on the reference (F2).
115
+ // DORMANT β€” `bantamkit_read` left the roster (job50 I5, 2026-09-12). The model stays so the
116
+ // roster line can return with its validation intact; `tools/call` consults `MCP_TOOLS`
117
+ // before this table, so the entry serves nothing today.
115
118
  bantamkit_read: {
116
119
  model: 'bantamkit_readArguments',
117
120
  fields: [req('path', 'str'), opt('part', 'str'), opt('offset', 'int', { le: OFFSET_MAXIMUM }), opt('limit', 'int')],
@@ -144,6 +147,8 @@ export const ARG_MODELS = {
144
147
  // explicit `null` is the default. No `le`, because the reference binds none β€” the
145
148
  // manifest's `minimum: 0` is advisory to the client and the handler's own refusal is the
146
149
  // negative one, exactly as `skill_audit`'s `budget` works.
150
+ // DORMANT β€” `repo_map` left the roster (job50 I5, 2026-09-12); kept for the same reason as
151
+ // `bantamkit_read` above, and equally unreachable from the wire.
147
152
  repo_map: {
148
153
  model: 'repo_mapArguments',
149
154
  fields: [req('root', 'str'), opt('focus', 'listStr'), opt('budget', 'int')],
@@ -4,17 +4,25 @@ import { EventLog } from '../eventlog.js';
4
4
  import type { Memory } from '../memory/component.js';
5
5
  import type { RawStdioTransport } from './transport.js';
6
6
  /**
7
- * The thirteen, in the order `build_server` lists them β€” which is the order `tools/list` emits.
7
+ * The twelve, in the order `build_server` lists them β€” which is the order `tools/list` emits.
8
8
  *
9
9
  * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
10
- * beside `memory_save` where a reader would look for it, `bantamkit_read` after that,
11
- * `skill_audit` after that, `memory_dream` after that, `repo_map` after that and
12
- * `token_ledger` after that, for the
13
- * same reason the reference appends all six: registration order IS the served order, and
14
- * appending is the only edit that leaves the other twelve where every existing declaration
15
- * says they are.
10
+ * beside `memory_save` where a reader would look for it, `skill_audit` after that,
11
+ * `memory_dream` after that and `token_ledger` after that, for the same reason the reference
12
+ * appends: registration order IS the served order, and appending is the only edit that
13
+ * leaves the others where every existing declaration says they are.
14
+ *
15
+ * `bantamkit_read` (tenth) and `repo_map` (thirteenth) LEFT this list on the user's ruling
16
+ * of 2026-09-12 (job50, I5), the same ruling that took them out of the reference's
17
+ * `tools = [...]`: measured over the transcript corpus, neither was called β€” auto-mode routes
18
+ * discovery and reading through Bash β€” and every request re-sent their descriptions. Their
19
+ * assets claim NO surface now (`"surfaces": []`), so putting either name back here without
20
+ * also restoring `"mcp"` to its asset is refused by `fromManifest` at startup; and a name
21
+ * that is not here is `Unknown tool:` on `tools/call` even though `ARG_MODELS` still holds
22
+ * its argument model. The handlers below are DORMANT, not gone: a roster decision, not a
23
+ * deletion of working code.
16
24
  */
17
- export declare const MCP_TOOLS: readonly ["memory_save", "memory_recall", "validate_json", "shiftwork_clock_in", "shiftwork_clock_out", "shiftwork_status", "build_identity", "bantamkit_status", "memory_compact", "bantamkit_read", "skill_audit", "memory_dream", "repo_map", "token_ledger"];
25
+ export declare const MCP_TOOLS: readonly ["memory_save", "memory_recall", "validate_json", "shiftwork_clock_in", "shiftwork_clock_out", "shiftwork_status", "build_identity", "bantamkit_status", "memory_compact", "skill_audit", "memory_dream", "token_ledger"];
18
26
  /**
19
27
  * The advertisement for one tool, out of its manifest, with the surface checked.
20
28
  *
@@ -35,6 +43,10 @@ export declare function fromManifest(name: string): Record<string, unknown>;
35
43
  * The last paragraph of every `repo_map` reply, refusal excepted. FIXED AND MANDATORY, and
36
44
  * byte-identical to the reference's `REPO_MAP_TAIL`.
37
45
  *
46
+ * DORMANT β€” `repo_map` left the roster (job50 I5, 2026-09-12). The tail, `REPO_MAP_EMPTY`
47
+ * and `repoMapReply` below stay with the handler: a roster decision, not a deletion of
48
+ * working code.
49
+ *
38
50
  * Roadmap row 10's build gate was "build only after #4 shows discovery tokens dominate",
39
51
  * and #4 REFUTED it: discovery is 0.114 % of real prompt tokens because 97.8 % of the bill
40
52
  * is `cache_read`. The feature ships on an explicit ruling to build it anyway, as a
@@ -17,10 +17,14 @@
17
17
  * framing, request routing, the initialize handshake, cancellation, error codes.
18
18
  *
19
19
  * THE `surfaces` GATE IS LOAD-BEARING. `assets/tools/` serves the eval agent too, and three
20
- * of the thirteen manifests (`document_list`, `document_read`, `file_graph`) claim only `agent`.
21
- * `fromManifest` REFUSES those by name rather than filtering them out, because a filter is
22
- * indistinguishable from a typo: a manifest renamed or a surface dropped would silently
23
- * shrink the served set, and the field would be a comment.
20
+ * of the seventeen manifests (`document_list`, `document_read`, `file_graph`) claim only
21
+ * `agent`, while two (`bantamkit_read`, `repo_map`) claim NO surface at all since job50 I5
22
+ * (2026-09-12). `fromManifest` REFUSES those by name rather than filtering them out, because
23
+ * a filter is indistinguishable from a typo: a manifest renamed or a surface dropped would
24
+ * silently shrink the served set, and the field would be a comment. `MCP_TOOLS` below is the
25
+ * SAME literal list the reference's `build_server` holds, in the same order, and the asset is
26
+ * the gate on every name in it β€” not the source of the names, because the served ORDER is
27
+ * part of the wire and no asset carries it.
24
28
  */
25
29
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
26
30
  import { dirname, join } from 'node:path';
@@ -44,15 +48,23 @@ import { ARG_MODELS, PyValidationFailure, validateArguments } from './pyargs.js'
44
48
  import { sdkJson } from './sdkjson.js';
45
49
  import { degradedConditions, degradedNotice, SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES, statusReport, STATUS_NAME, STATUS_PROMPT_DESCRIPTION, STATUS_PROMPT_TAIL, STATUS_PROMPT_TITLE, } from './status.js';
46
50
  /**
47
- * The thirteen, in the order `build_server` lists them β€” which is the order `tools/list` emits.
51
+ * The twelve, in the order `build_server` lists them β€” which is the order `tools/list` emits.
48
52
  *
49
53
  * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
50
- * beside `memory_save` where a reader would look for it, `bantamkit_read` after that,
51
- * `skill_audit` after that, `memory_dream` after that, `repo_map` after that and
52
- * `token_ledger` after that, for the
53
- * same reason the reference appends all six: registration order IS the served order, and
54
- * appending is the only edit that leaves the other twelve where every existing declaration
55
- * says they are.
54
+ * beside `memory_save` where a reader would look for it, `skill_audit` after that,
55
+ * `memory_dream` after that and `token_ledger` after that, for the same reason the reference
56
+ * appends: registration order IS the served order, and appending is the only edit that
57
+ * leaves the others where every existing declaration says they are.
58
+ *
59
+ * `bantamkit_read` (tenth) and `repo_map` (thirteenth) LEFT this list on the user's ruling
60
+ * of 2026-09-12 (job50, I5), the same ruling that took them out of the reference's
61
+ * `tools = [...]`: measured over the transcript corpus, neither was called β€” auto-mode routes
62
+ * discovery and reading through Bash β€” and every request re-sent their descriptions. Their
63
+ * assets claim NO surface now (`"surfaces": []`), so putting either name back here without
64
+ * also restoring `"mcp"` to its asset is refused by `fromManifest` at startup; and a name
65
+ * that is not here is `Unknown tool:` on `tools/call` even though `ARG_MODELS` still holds
66
+ * its argument model. The handlers below are DORMANT, not gone: a roster decision, not a
67
+ * deletion of working code.
56
68
  */
57
69
  export const MCP_TOOLS = [
58
70
  'memory_save',
@@ -64,12 +76,12 @@ export const MCP_TOOLS = [
64
76
  'build_identity',
65
77
  'bantamkit_status',
66
78
  'memory_compact',
67
- 'bantamkit_read',
68
79
  'skill_audit',
69
80
  'memory_dream',
70
- 'repo_map',
71
81
  'token_ledger',
72
82
  ];
83
+ /** The served set, for the call path: a name outside it is unknown, whatever `ARG_MODELS` says. */
84
+ const SERVED = new Set(MCP_TOOLS);
73
85
  /**
74
86
  * The advertisement for one tool, out of its manifest, with the surface checked.
75
87
  *
@@ -169,6 +181,10 @@ const EXISTS_IGNORED = new Set(['ENOENT', 'ENOTDIR', 'EBADF', 'ELOOP']);
169
181
  * The last paragraph of every `repo_map` reply, refusal excepted. FIXED AND MANDATORY, and
170
182
  * byte-identical to the reference's `REPO_MAP_TAIL`.
171
183
  *
184
+ * DORMANT β€” `repo_map` left the roster (job50 I5, 2026-09-12). The tail, `REPO_MAP_EMPTY`
185
+ * and `repoMapReply` below stay with the handler: a roster decision, not a deletion of
186
+ * working code.
187
+ *
172
188
  * Roadmap row 10's build gate was "build only after #4 shows discovery tokens dominate",
173
189
  * and #4 REFUTED it: discovery is 0.114 % of real prompt tokens because 97.8 % of the bill
174
190
  * is `cache_read`. The feature ships on an explicit ruling to build it anyway, as a
@@ -243,6 +259,9 @@ function recordRaise(log, tool, body) {
243
259
  /**
244
260
  * The last `docread.extract` result this server produced, and what it was OF.
245
261
  *
262
+ * DORMANT β€” its one user, `bantamkit_read`, left the roster (job50 I5, 2026-09-12); kept
263
+ * with the handler so the roster line can come back without re-deriving the cache.
264
+ *
246
265
  * The port of `mcpserver._DocumentCache` (register entry (i), `docs/roadmap-toolbox.md` row
247
266
  * 8). `bantamkit_read` re-parsed the whole document on EVERY call, so a caller paging a
248
267
  * 12,001-row sheet in 200-row pages parsed the workbook once per page β€” paging was O(N^2) in
@@ -539,6 +558,12 @@ function runTool(name, memory, args, version, log, documents) {
539
558
  return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
540
559
  }
541
560
  case 'bantamkit_read': {
561
+ // DORMANT β€” NOT ON THE ROSTER since job50 I5 (user ruling, 2026-09-12): `bantamkit_read`
562
+ // left `MCP_TOOLS` because the transcript corpus showed it was never called. The reader
563
+ // (`docread`, `contract`) and its tests are untouched; this arm, `DocumentCache` and
564
+ // `OFFSET_MAXIMUM` are kept, unreachable from the wire (`SERVED` gates the call path),
565
+ // so the roster line can return without a rewrite.
566
+ //
542
567
  // The reader on the MCP surface (job43): `docread` digests, `contract` words it. The
543
568
  // handler makes the SAME `contract` calls the reference's `bantamkit_read` makes,
544
569
  // with the path standing in for the document name, so the two servers print the
@@ -736,6 +761,11 @@ function runTool(name, memory, args, version, log, documents) {
736
761
  });
737
762
  }
738
763
  case 'repo_map': {
764
+ // DORMANT β€” NOT ON THE ROSTER since job50 I5 (user ruling, 2026-09-12): `repo_map` left
765
+ // `MCP_TOOLS` because the transcript corpus showed it was never called. The engine
766
+ // (`repomap.ts`) and its tests are untouched; this arm is kept, unreachable from the
767
+ // wire (`SERVED` gates the call path), so the roster line can return without a rewrite.
768
+ //
739
769
  // The ranked definition map on the MCP surface (job45 row 10): `repomap` measures,
740
770
  // this serves it. Byte for byte the reference's `repo_map` handler.
741
771
  //
@@ -991,7 +1021,10 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
991
1021
  let structuredValue = null;
992
1022
  let isError;
993
1023
  try {
994
- const model = ARG_MODELS[name];
1024
+ // The roster decides what exists, not the argument table: `ARG_MODELS` still carries
1025
+ // the DORMANT `bantamkit_read` and `repo_map` models (job50 I5), and the reference
1026
+ // answers `Unknown tool:` for a handler it never registered.
1027
+ const model = SERVED.has(name) ? ARG_MODELS[name] : undefined;
995
1028
  if (model === undefined)
996
1029
  throw new BantamError(`Unknown tool: ${name}`);
997
1030
  const bound = validateArguments(model, args);
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * `docs/memory.md` states, as a design decision, that `lint`, `archived` and `restore`
6
6
  * are **not** agent tools β€” "lifecycle is an operator decision, not a model decision" β€”
7
- * and the fourteen-tool surface holds to it; `compact` is the one exception since job42
7
+ * and the twelve-tool surface holds to it; `compact` is the one exception since job42
8
8
  * (`memory_compact`, the on-refusal path the model reaches itself). `runtime-py` made that
9
9
  * position coherent in August 2026 by giving the operator `python -m bantamkit.memory`.
10
10
  * `runtime-ts` did not. MEASURED 2026-08-24 before this file existed: `node dist/cli.js -h`
@@ -1,9 +1,23 @@
1
1
  import { type PyValue } from './pyjson.js';
2
2
  export declare const SCHEMA_NAME = "shiftwork-checkpoint";
3
+ /**
4
+ * J50-9A/9B: the one sentence for a pack that cannot supply the accounting shape. FIXED β€”
5
+ * no path, no exception text, no unit β€” read out of `ACCOUNTING_SHAPE_UNREADABLE` in the
6
+ * Python module byte for byte, so a differential case can compare the two; which of the
7
+ * shapes failed is not said, because the property is the same for all of them (see
8
+ * `accountingSchema`).
9
+ */
10
+ export declare const ACCOUNTING_SHAPE_UNREADABLE: string;
3
11
  /** The driver's SUCCESS test. */
4
12
  export declare const TERMINAL_UNIT_STATUS: ReadonlySet<string>;
5
13
  /** The schema's `maxItems` β€” older entries fall off the ring. */
6
14
  export declare const HISTORY_RING_SIZE = 5;
15
+ /**
16
+ * F6: the `event` value of the ledger line `clockIn` appends when it issues a brief. The word
17
+ * is the register's own β€” `clock_in` answers `result: "brief"` β€” so the ledger names the
18
+ * thing it recorded with the same word the wire used. `BRIEF_EVENT` in `shiftwork.py`.
19
+ */
20
+ export declare const BRIEF_EVENT = "brief";
7
21
  /**
8
22
  * `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))` β€” UTC, SECOND precision.
9
23
  *
@@ -30,7 +44,7 @@ export interface ClockOptions {
30
44
  * all-terminal test before the cursor lookup β€” so an empty plan reports success rather than
31
45
  * escalating on its dangling cursor, because `all([])` is true.
32
46
  */
33
- export declare function clockIn(checkpoint: string): PyValue;
47
+ export declare function clockIn(checkpoint: string, options?: ClockOptions): PyValue;
34
48
  /**
35
49
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
36
50
  *
@@ -40,7 +54,10 @@ export declare function clockIn(checkpoint: string): PyValue;
40
54
  * any mutation and before the accounting line. Extended 2026-09-11 (job47): so is a
41
55
  * `job.roles` value for that role that is not a list of model identifiers β€” an unreadable
42
56
  * declaration allows no model, and it is refused in the same place, by the same structured
43
- * return, writing nothing. Mutations: set the unit's status, advance
57
+ * return, writing nothing. Extended 2026-09-13 (job50/F5): and so is an accounting line
58
+ * that does not fit the shape the `shiftwork_clock_out` asset declares β€” checked after the
59
+ * roles gate, before any mutation, same structured return, nothing written; a null
60
+ * accounting is not validated and stays legal. Mutations: set the unit's status, advance
44
61
  * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
45
62
  * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
46
63
  *
package/dist/shiftwork.js CHANGED
@@ -10,7 +10,9 @@
10
10
  * * the rewritten checkpoint β€” `json.dumps(document, indent=2) + "\n"`, `.tmp` then
11
11
  * `replace()`;
12
12
  * * the `.log.jsonl` line β€” `json.dumps(record, sort_keys=True) + "\n"`, append mode,
13
- * **never read back**, so a wrong line is never noticed by the code that wrote it;
13
+ * read back by exactly ONE thing since job50/F6 β€” `briefed()`, which looks at the `unit`
14
+ * and `event` keys and nothing else β€” so a wrong line is still never noticed by the
15
+ * code that wrote it;
14
16
  * * the returned dict, which the MCP surface hands straight to the model.
15
17
  *
16
18
  * THE ensure_ascii RULING. `json.dumps` defaults to `ensure_ascii=True` and
@@ -51,15 +53,33 @@
51
53
  * accounting line is appended BEFORE the atomic rename, so a partial failure can lose the
52
54
  * commit but never the accounting.
53
55
  */
54
- import { loadSchema } from './assets.js';
56
+ import { AssetNotFound, loadSchema, loadToolAsset } from './assets.js';
55
57
  import { schemaError } from './contract.js';
56
- import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, } from './memory/pyfs.js';
58
+ import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, PyUnicodeDecodeError, } from './memory/pyfs.js';
57
59
  import { dumpJson, fromJs, parseJson, PyJSONDecodeError, reprValue } from './pyjson.js';
60
+ import { checkSchema, PyJsonSchemaUnsupported } from './pyjsonschema.js';
58
61
  export const SCHEMA_NAME = 'shiftwork-checkpoint';
62
+ /** The accounting line's shape lives on the TOOL asset, not in a second copy here (F5). */
63
+ const TOOL_ASSET = 'shiftwork_clock_out';
64
+ /**
65
+ * J50-9A/9B: the one sentence for a pack that cannot supply the accounting shape. FIXED β€”
66
+ * no path, no exception text, no unit β€” read out of `ACCOUNTING_SHAPE_UNREADABLE` in the
67
+ * Python module byte for byte, so a differential case can compare the two; which of the
68
+ * shapes failed is not said, because the property is the same for all of them (see
69
+ * `accountingSchema`).
70
+ */
71
+ export const ACCOUNTING_SHAPE_UNREADABLE = "cannot clock out: the shiftwork_clock_out tool asset cannot be read as the " +
72
+ "accounting line's shape, so it allows no accounting line";
59
73
  /** The driver's SUCCESS test. */
60
74
  export const TERMINAL_UNIT_STATUS = new Set(['done', 'dropped']);
61
75
  /** The schema's `maxItems` β€” older entries fall off the ring. */
62
76
  export const HISTORY_RING_SIZE = 5;
77
+ /**
78
+ * F6: the `event` value of the ledger line `clockIn` appends when it issues a brief. The word
79
+ * is the register's own β€” `clock_in` answers `result: "brief"` β€” so the ledger names the
80
+ * thing it recorded with the same word the wire used. `BRIEF_EVENT` in `shiftwork.py`.
81
+ */
82
+ export const BRIEF_EVENT = 'brief';
63
83
  // ------------------------------------------------------------------- tagged-value sugar
64
84
  const str = (v) => ({ t: 'str', v });
65
85
  const int = (v) => ({ t: 'int', v: BigInt(v) });
@@ -161,6 +181,100 @@ export function timestamp(now) {
161
181
  return (`${pad(when.getUTCFullYear(), 4)}-${pad(when.getUTCMonth() + 1)}-${pad(when.getUTCDate())}` +
162
182
  `T${pad(when.getUTCHours())}:${pad(when.getUTCMinutes())}:${pad(when.getUTCSeconds())}Z`);
163
183
  }
184
+ // ------------------------------------------------------------------------ F6: the ledger
185
+ /**
186
+ * `Path(str(path) + ".log.jsonl")` β€” string concatenation on the NORMALIZED path, not
187
+ * `with_suffix`, so `cp.json` gets `cp.json.log.jsonl` and not `cp.log.jsonl`.
188
+ */
189
+ function logPath(path) {
190
+ return `${path}.log.jsonl`;
191
+ }
192
+ /**
193
+ * `_record_brief`: append the brief line β€” best effort, NEVER throws for a store that
194
+ * refuses writes, never changes the brief.
195
+ *
196
+ * One line per call. A unit clocked in twice before it clocks out (a relaunch after a
197
+ * crashed subagent) leaves two brief lines, and a reader should conclude exactly that. The
198
+ * ledger records events, not state.
199
+ *
200
+ * THE NARROWNESS IS THE PORT OF `except OSError`. `pyAppendText` converts every filesystem
201
+ * failure into a `PyOSError` β€” a read-only directory, a directory where the file belongs, a
202
+ * full disk β€” and that is the ONLY class swallowed here. Anything else out of this block (a
203
+ * `TypeError` from a record that could not be dumped, a bug in this function) is not an
204
+ * OSError in Python and is rethrown here, because hiding it would leave every unit
205
+ * `briefed: false` forever with no red anywhere. The same `instanceof PyOSError` test the
206
+ * two write legs of `clockOut` already use, for the same reason.
207
+ */
208
+ function recordBrief(log, unitId, role, now) {
209
+ const record = new Map([
210
+ ['event', str(BRIEF_EVENT)],
211
+ ['ts', str(timestamp(now))],
212
+ ['unit', str(unitId)],
213
+ ['role', role],
214
+ ]);
215
+ try {
216
+ pyAppendText(log, `${dumpJson({ t: 'dict', v: record }, { sortKeys: true })}\n`);
217
+ }
218
+ catch (e) {
219
+ if (!(e instanceof PyOSError))
220
+ throw e;
221
+ }
222
+ }
223
+ /**
224
+ * `str.splitlines()` β€” the boundaries CPython splits on, which are more than `\n`. The
225
+ * CRLF and bare CR forms are already folded by `pyReadText` (universal newlines, as
226
+ * `Path.read_text` folds them), so they do not need to be here, but nothing is lost by it.
227
+ */
228
+ const SPLITLINES = /\r\n|[\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029]/;
229
+ /**
230
+ * `_briefed`: was a brief issued for `unitId` since its last clock-out? Read off the ledger.
231
+ *
232
+ * Walk the ledger in order, tracking only lines that name this unit: a brief line sets the
233
+ * flag, an accounting line (any line that is not a brief event) clears it. The answer is the
234
+ * flag at the end β€” so a clock-out CONSUMES the brief: brief, blocked, re-run without a
235
+ * clock_in reads `false`; brief, blocked, clock_in, done reads `true`. Detectable false
236
+ * negatives (a retried clock-out's duplicate line) beat undetectable false positives (an
237
+ * inline re-run marked briefed because a brief was issued once, ever).
238
+ *
239
+ * Reads the ledger the way a reader must: a missing or unreadable log is `false`, a line
240
+ * that is not JSON or not an object is skipped, never fatal. `record.get("unit") != unit_id`
241
+ * is a Python `!=`, so a `unit` that is not a string never equals the id; and the read is
242
+ * `except OSError` β€” a ledger that does not DECODE as UTF-8 raises out of Python's
243
+ * `clock_out` (`UnicodeDecodeError` is a `ValueError`), so `PyUnicodeDecodeError` is
244
+ * rethrown here for the same reason `recordBrief` rethrows: the port does not swallow what
245
+ * the reference does not.
246
+ */
247
+ function briefed(log, unitId) {
248
+ let content;
249
+ try {
250
+ content = pyReadText(log);
251
+ }
252
+ catch (e) {
253
+ if (!(e instanceof PyOSError))
254
+ throw e;
255
+ return false;
256
+ }
257
+ let flag = false;
258
+ for (const line of content.split(SPLITLINES)) {
259
+ let record;
260
+ try {
261
+ record = parseJson(line);
262
+ }
263
+ catch (e) {
264
+ if (!(e instanceof PyJSONDecodeError))
265
+ throw e;
266
+ continue;
267
+ }
268
+ if (record.t !== 'dict')
269
+ continue;
270
+ const unit = record.v.get('unit');
271
+ if (unit === undefined || unit.t !== 'str' || unit.v !== unitId)
272
+ continue;
273
+ const event = record.v.get('event');
274
+ flag = event !== undefined && event.t === 'str' && event.v === BRIEF_EVENT;
275
+ }
276
+ return flag;
277
+ }
164
278
  // ----------------------------------------------------------------------------- clock_in
165
279
  /**
166
280
  * Validate the checkpoint and return the cursor unit's brief, or a refusal.
@@ -175,7 +289,7 @@ export function timestamp(now) {
175
289
  * all-terminal test before the cursor lookup β€” so an empty plan reports success rather than
176
290
  * escalating on its dangling cursor, because `all([])` is true.
177
291
  */
178
- export function clockIn(checkpoint) {
292
+ export function clockIn(checkpoint, options = {}) {
179
293
  const { document, refusal } = readValid(pyJoin(checkpoint));
180
294
  if (refusal !== null)
181
295
  return refusal;
@@ -204,6 +318,10 @@ export function clockIn(checkpoint) {
204
318
  ['reason', str(`cursor ${cursor} names no unit`)],
205
319
  ]);
206
320
  }
321
+ // F6: the brief is issued, so say so in the ledger β€” best effort, and only on this branch:
322
+ // a refusal above issued nothing and therefore records nothing. `pyJoin` is the same
323
+ // normalisation `clockOut` applies before it builds ITS log path, so the two land in one file.
324
+ recordBrief(logPath(pyJoin(checkpoint)), cursor, field(unit, 'role'), options.now ?? Date.now() / 1000);
207
325
  return dict([
208
326
  ['result', str('brief')],
209
327
  ['unit', unit],
@@ -305,6 +423,137 @@ function modelRefusal(document, unitId, unit, accounting) {
305
423
  }
306
424
  return null;
307
425
  }
426
+ /**
427
+ * The accounting line's shape, read off the clock_out TOOL asset β€” never a second copy.
428
+ *
429
+ * J50-7 put the definition on `parameters.properties.accounting` of the tool manifest,
430
+ * which is what the wire advertises; but the MCP call path enforces only the argument
431
+ * kind (`dict`), so a host is held to what it was promised only if this function reads
432
+ * the same file. The declared value is `anyOf: [object, null]`. The null arm is the
433
+ * caller's (a null line is legal and is not an audit record), so only the object arm is
434
+ * validated here, wrapped under the key `accounting` so the pointed error names
435
+ * `accounting` / `accounting/<key>` exactly the way `checkpoint invalid:` names a path β€”
436
+ * one renderer for every schema refusal this module makes, not a second one.
437
+ *
438
+ * Returns null when the pack CANNOT SUPPLY the shape (J50-9A, ported J50-9B). Reading the
439
+ * asset gave this module a dependency the roles gate never had, and a `BANTAMKIT_ASSETS`
440
+ * pack trimmed to `schemas/` β€” the very pack both runtimes' roles tests build β€” made
441
+ * `clockOut` die on `AssetNotFound` where the module promises a structured refusal. The
442
+ * caller turns null into `ACCOUNTING_SHAPE_UNREADABLE`, fail CLOSED, per J47-4: a
443
+ * declaration this code cannot read is not a licence. MISSING AND MALFORMED ARE ONE CASE,
444
+ * not two, because the property is one β€” the line cannot be checked, so it is not allowed β€”
445
+ * and a second sentence would either render an exception message the two runtimes spell
446
+ * differently, or split one property into per-shape cases that can each be missed. The
447
+ * shapes folded in, each MEASURED to throw out of `clockOut` before this existed: no
448
+ * `tools/` dir or no file (`AssetNotFound`); bytes that are not UTF-8
449
+ * (`PyUnicodeDecodeError`) or not JSON (`SyntaxError`); a manifest without
450
+ * `parameters.properties.accounting.anyOf` at each step of that path (`TypeError`, five
451
+ * ways); an `anyOf` with no object arm (a hand-thrown `Error`); and an object arm the
452
+ * validator refuses as a schema (`PyJsonSchemaUnsupported`, checked here so
453
+ * `accountingRefusal` only ever validates a schema).
454
+ */
455
+ function accountingSchema() {
456
+ let manifest;
457
+ try {
458
+ manifest = loadToolAsset(TOOL_ASSET);
459
+ }
460
+ catch (e) {
461
+ // `except (AssetNotFound, OSError, ValueError)`: no `tools/` dir or no file; a file the
462
+ // OS will not hand over; bytes that are not UTF-8 (`UnicodeDecodeError`, a `ValueError`
463
+ // there and `PyUnicodeDecodeError` here); text that is not JSON (`JSONDecodeError` there,
464
+ // `SyntaxError` out of `JSON.parse` here). Anything else is a defect and still escapes.
465
+ if (e instanceof AssetNotFound ||
466
+ e instanceof PyOSError ||
467
+ e instanceof PyUnicodeDecodeError ||
468
+ e instanceof SyntaxError) {
469
+ return null;
470
+ }
471
+ throw e;
472
+ }
473
+ // `isinstance(declared, dict) and key in declared`, at each step of the path. A JSON
474
+ // array is not a dict, and neither is `null` β€” `typeof null === 'object'` is not a policy.
475
+ let declared = manifest;
476
+ for (const key of ['parameters', 'properties', 'accounting', 'anyOf']) {
477
+ if (!isDict(declared) || !(key in declared))
478
+ return null;
479
+ declared = declared[key];
480
+ }
481
+ if (!Array.isArray(declared))
482
+ return null;
483
+ // `next((a for a in declared if isinstance(a, dict) and a.get("type") == "object"), None)`
484
+ const arm = declared.find((a) => isDict(a) && a.type === 'object');
485
+ if (arm === undefined)
486
+ return null;
487
+ const schema = { type: 'object', properties: { accounting: arm } };
488
+ // `check_schema`, taken HERE so `accountingRefusal` only ever validates a schema. The
489
+ // port's `validate` runs the same check first and would throw the same class out of
490
+ // `schemaError`; catching it there would make one property two sites.
491
+ try {
492
+ checkSchema(fromJs(schema));
493
+ }
494
+ catch (e) {
495
+ if (e instanceof PyJsonSchemaUnsupported)
496
+ return null;
497
+ throw e;
498
+ }
499
+ return schema;
500
+ }
501
+ /** `isinstance(x, dict)` for a value that came out of `JSON.parse`. */
502
+ function isDict(value) {
503
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
504
+ }
505
+ /**
506
+ * F5 (job50): an accounting line that does not fit its declared shape is refused.
507
+ *
508
+ * Returns the refusal sentence, or null when the clock-out may proceed. The sentence is
509
+ * a fixed frame around the SAME `schemaError` rendering the checkpoint refusals use
510
+ * (`checkpoint invalid: …`, `refused to write: …`), over the same `{"accounting": …}`
511
+ * wrapper the reference builds, so the differential compares like with like. The frame
512
+ * is J50-8's, read out of `_accounting_refusal` and reproduced to the character:
513
+ * `unit {unit_id} in role {role} reported an accounting line the schema refuses: {problem}`.
514
+ *
515
+ * Checked BEFORE anything is written: no accounting line, no cursor advance, the
516
+ * checkpoint byte-unchanged. Same posture as `modelRefusal`, same place in `clockOut`.
517
+ *
518
+ * THREE RULINGS, decided in Python and binding here:
519
+ *
520
+ * * ORDER. The roles gate runs first and keeps its sentence; this check runs second. A
521
+ * non-string `model` under a role `job.roles` names gets the ROLES sentence (`reported
522
+ * model 5, which job.roles.implementer does not allow…`); the schema's `model: string`
523
+ * fires only when the roles gate is silent (no map, or a role the map omits).
524
+ * * NULL STAYS LEGAL. `accounting: null` is not validated β€” the container is not
525
+ * required. In Node that is three spellings of nothing: the JS `null`, an omitted
526
+ * argument (`undefined`), and the tagged `{t: 'null'}` the MCP server hands over.
527
+ * * ODD KEYS PASS. `additionalProperties: true` β€” a key the schema does not name is
528
+ * written verbatim. Only the NAMED keys have a type, and only `tokens` and
529
+ * `duration_ms` are required.
530
+ * * NO SHAPE, NO LINE (J50-9A). When the pack cannot supply the shape the line is
531
+ * refused with `ACCOUNTING_SHAPE_UNREADABLE`, in this same place, writing nothing β€”
532
+ * not skipped. Skipping is the fail-open this file has already had once (job47's
533
+ * `modelRefusal` returning unconstrained on an unreadable declaration). A null line
534
+ * is still not validated, so a pack with no `tools/` can clock out a unit that
535
+ * reports no accounting.
536
+ *
537
+ * One measured fact reproduced rather than reasoned about: under 2020-12 semantics a float
538
+ * that is a whole number (`1234.0`) IS an `integer`; `12.5`, `"1234"`, `true` and `-1` are
539
+ * not. `pyjsonschema` already carries that rule (`instance.t === 'float' &&
540
+ * Number.isInteger(instance.v)`), and the float only survives to reach it on the
541
+ * parsed-from-bytes route β€” a JS-object `1234.0` is `1234` before this code sees it.
542
+ */
543
+ function accountingRefusal(unitId, unit, accounting) {
544
+ if (accounting === null || accounting === undefined)
545
+ return null;
546
+ const line = asPyValue(accounting);
547
+ if (line.t === 'null')
548
+ return null;
549
+ const schema = accountingSchema();
550
+ if (schema === null)
551
+ return ACCOUNTING_SHAPE_UNREADABLE;
552
+ const problem = schemaError(dumpJson(dict([['accounting', line]])), schema);
553
+ if (problem === null)
554
+ return null;
555
+ return `unit ${unitId} in role ${text(field(unit, 'role'))} reported an accounting line the schema refuses: ${problem}`;
556
+ }
308
557
  /**
309
558
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
310
559
  *
@@ -314,7 +563,10 @@ function modelRefusal(document, unitId, unit, accounting) {
314
563
  * any mutation and before the accounting line. Extended 2026-09-11 (job47): so is a
315
564
  * `job.roles` value for that role that is not a list of model identifiers β€” an unreadable
316
565
  * declaration allows no model, and it is refused in the same place, by the same structured
317
- * return, writing nothing. Mutations: set the unit's status, advance
566
+ * return, writing nothing. Extended 2026-09-13 (job50/F5): and so is an accounting line
567
+ * that does not fit the shape the `shiftwork_clock_out` asset declares β€” checked after the
568
+ * roles gate, before any mutation, same structured return, nothing written; a null
569
+ * accounting is not validated and stays legal. Mutations: set the unit's status, advance
318
570
  * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
319
571
  * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
320
572
  *
@@ -344,6 +596,9 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
344
596
  const wrongModel = modelRefusal(doc, unitId, unit, accounting);
345
597
  if (wrongModel !== null)
346
598
  return errorResult(wrongModel);
599
+ const badLine = accountingRefusal(unitId, unit, accounting);
600
+ if (badLine !== null)
601
+ return errorResult(badLine);
347
602
  unit.v.set('status', str(status));
348
603
  const remaining = subList(plan, 'units').v.filter((u) => !TERMINAL_UNIT_STATUS.has(text(u.v.get('status'))));
349
604
  plan.v.set('cursor', remaining.length > 0 ? field(remaining[0], 'id') : str(unitId));
@@ -369,11 +624,14 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
369
624
  ]);
370
625
  for (const [key, value] of asPatch(accounting))
371
626
  record.set(key, value);
372
- // `Path(str(path) + ".log.jsonl")` β€” string concatenation on the NORMALIZED path, not
373
- // `with_suffix`, so `cp.json` gets `cp.json.log.jsonl` and not `cp.log.jsonl`.
374
- const logPath = `${path}.log.jsonl`;
627
+ const log = logPath(path);
628
+ // F6: `briefed` is MEASURED off the ledger, AFTER the orchestrator's keys are merged, so the
629
+ // runtime's answer wins over a self-reported one β€” the order is load-bearing. It is written
630
+ // on every line, `accounting: null` included: the base shape (ts/unit/role/status) is the
631
+ // runtime's, and so is this field. Never a refusal.
632
+ record.set('briefed', { t: 'bool', v: briefed(log, unitId) });
375
633
  try {
376
- pyAppendText(logPath, `${dumpJson({ t: 'dict', v: record }, { sortKeys: true })}\n`);
634
+ pyAppendText(log, `${dumpJson({ t: 'dict', v: record }, { sortKeys: true })}\n`);
377
635
  }
378
636
  catch (e) {
379
637
  if (!(e instanceof PyOSError))
@@ -403,7 +661,7 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
403
661
  ['unit', str(unitId)],
404
662
  ['status', str(status)],
405
663
  ['cursor', field(plan, 'cursor')],
406
- ['log', str(logPath)],
664
+ ['log', str(log)],
407
665
  ]);
408
666
  }
409
667
  // ------------------------------------------------------------------------------- status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bantamkit-mcp",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "bantamkit MCP server β€” pure Node, runnable with npx",
5
5
  "type": "module",
6
6
  "bin": {