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 +2 -2
- package/assets/schemas/shiftwork-checkpoint.json +5 -1
- package/assets/tools/bantamkit_read.json +1 -3
- package/assets/tools/repo_map.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +38 -3
- package/assets/tools/skill_audit.json +1 -1
- package/assets/tools/token_ledger.json +1 -1
- package/dist/eventlog.js +5 -4
- package/dist/mcp/pyargs.js +5 -0
- package/dist/mcp/server.d.ts +20 -8
- package/dist/mcp/server.js +47 -14
- package/dist/memory/cli.js +1 -1
- package/dist/shiftwork.d.ts +19 -2
- package/dist/shiftwork.js +268 -10
- package/package.json +1 -1
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
|
|
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
|
|
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/
|
|
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
|
-
"
|
|
35
|
-
"
|
|
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
|
|
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
|
|
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.
|
|
36
|
-
* unbounded free text and
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
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.
|
package/dist/mcp/pyargs.js
CHANGED
|
@@ -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')],
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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
|
|
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, `
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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", "
|
|
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
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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, `
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
|
|
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);
|
package/dist/memory/cli.js
CHANGED
|
@@ -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
|
|
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`
|
package/dist/shiftwork.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
373
|
-
//
|
|
374
|
-
|
|
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(
|
|
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(
|
|
664
|
+
['log', str(log)],
|
|
407
665
|
]);
|
|
408
666
|
}
|
|
409
667
|
// ------------------------------------------------------------------------------- status
|