bantamkit-mcp 0.35.7 → 0.35.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/assets.js +28 -4
- package/dist/cli.js +83 -24
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +22 -2
- package/dist/eventlog.js +30 -3
- package/dist/hookadapter.js +263 -43
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +318 -84
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +177 -41
- package/dist/mcp/status.js +11 -4
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyargparse.js +48 -2
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -400,13 +400,17 @@ feature lands in both in one change, and a conformance case compares their answe
|
|
|
400
400
|
are deliberate and each is ruled in the
|
|
401
401
|
[divergence table](https://github.com/Ink01101011/bantamkit/blob/main/docs/porting.md#where-the-two-runtimes-deliberately-differ):
|
|
402
402
|
|
|
403
|
-
- **Concurrent writes are safe for the memory store and
|
|
404
|
-
runtimes.** `index.md` is derived from `facts/`, so the last writer re-enumerates
|
|
405
|
-
and the index self-heals: 8 trials of two processes × 15 saves, zero
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
same
|
|
403
|
+
- **Concurrent writes are safe for the memory store and, since job65, for the checkpoint, in
|
|
404
|
+
both runtimes.** `index.md` is derived from `facts/`, so the last writer re-enumerates
|
|
405
|
+
everything and the index self-heals: 8 trials of two processes × 15 saves, zero
|
|
406
|
+
disagreements. A checkpoint is read-modify-write, and until job65 (#116) every writer shared
|
|
407
|
+
one temp file, so two concurrent clock-outs — on the same unit OR on two different units of
|
|
408
|
+
one wave — lost `ok` answers: 10 of 20 in 10 trials, on Node, on Python and across the two.
|
|
409
|
+
`shiftwork_clock_out` now holds `<checkpoint>.lock` (O_EXCL, the same protocol in both
|
|
410
|
+
runtimes) around the whole call and writes through a per-writer temp name: two clock-outs of
|
|
411
|
+
different units both land, and a second clock-out of the same unit is refused as not the
|
|
412
|
+
cursor unit. A writer that cannot take the lock within 5 s answers `result: error` and writes
|
|
413
|
+
nothing.
|
|
410
414
|
- **Do not hand-edit a fact file's `name`, `description` or `type` to a bare non-string.**
|
|
411
415
|
`description: 2026` is a YAML integer. Python interpolates it and carries on; this port refuses
|
|
412
416
|
the whole store with `malformed fact file …`. Quote it: `description: '2026'`. Every fact the
|
|
@@ -441,7 +445,16 @@ all ship inside `dist/` and none of them is reachable through an import — they
|
|
|
441
445
|
running `bantamkit-mcp` and `bantamkit-memory`. Importing the package starts no server and
|
|
442
446
|
opens no transport. The vendored `assets/` is not importable either, but it is *readable*:
|
|
443
447
|
`assetsRoot()` resolves to the `assets/` directory inside the installed package, which is how
|
|
444
|
-
`loadTool('memory_save')` answers from a consumer that never unpacked anything itself.
|
|
448
|
+
`loadTool('memory_save')` answers from a consumer that never unpacked anything itself. The
|
|
449
|
+
loaders take a **bare** asset name: one with `/`, `\`, `..`, a drive colon or a NUL in it is
|
|
450
|
+
refused with `AssetNotFound: <kind> asset name is not a bare name: <name>` before the disk is
|
|
451
|
+
touched, so a name taken from user input cannot read a file outside the pack (the Python
|
|
452
|
+
loaders refuse it in the same sentence).
|
|
453
|
+
|
|
454
|
+
**The types need no `@types/node`.** The package declares no dependency on it, so nothing
|
|
455
|
+
reachable from `index.d.ts` names a Node-only global: a `strict` consumer with `types: []`
|
|
456
|
+
and `skipLibCheck: false` type-checks `import ... from 'bantamkit-mcp'` clean.
|
|
457
|
+
`encodeRecord` is declared to return `Uint8Array` (the value is a `Buffer`, a subclass of it).
|
|
445
458
|
|
|
446
459
|
**What the one entry point exports**, grouped by the module each name comes from:
|
|
447
460
|
|
|
@@ -526,7 +539,9 @@ node tools/conformance/npx-cold-start.mjs --offline # + the no-network arms (
|
|
|
526
539
|
The conformance harness shells out to a CPython for the reference side. That is a
|
|
527
540
|
**development-time** dependency of the test tooling; nothing under `tools/` ships, and
|
|
528
541
|
`files: ["dist", "assets"]` is the whole published surface. Runtime dependencies: exactly one,
|
|
529
|
-
`@modelcontextprotocol/sdk`, pinned to `1.
|
|
542
|
+
`@modelcontextprotocol/sdk`, pinned to `1.31.0`, the first release outside advisory
|
|
543
|
+
GHSA-6qxp-vccf-f47h (`>= 1.12.0, < 1.31.0`, the SDK's OAuth client, which this package never
|
|
544
|
+
imports).
|
|
530
545
|
|
|
531
546
|
## Measurements and history
|
|
532
547
|
|
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
"properties": {
|
|
11
11
|
"reserve": {
|
|
12
12
|
"type": "integer",
|
|
13
|
-
"
|
|
14
|
-
"description": "Bytes of index headroom to free below the budget. Omit for the default: the size of the largest index line the store holds, capped at half the budget."
|
|
13
|
+
"description": "Bytes of index headroom to free below the budget: the target is `budget - reserve`, a negative value counts as 0, any value is capped at half the budget, and 0 compacts to the budget itself. Omit for the default: the room the degraded-index warning demands (the budget minus `(90 * budget - 1) // 100`) plus the size of the largest index line the store holds -- at the default 24000-byte budget with a 39-byte largest line that is 2401 + 39 = 2440, a 21560-byte target."
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
16
|
},
|
|
@@ -17,9 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"k": {
|
|
19
19
|
"type": "integer",
|
|
20
|
-
"
|
|
21
|
-
"maximum": 5,
|
|
22
|
-
"description": "Facts wanted. Raised to the store default of 3 when smaller; never lowered below it."
|
|
20
|
+
"description": "Facts wanted, clamped to 1..5. Raised to the store default of 3 when smaller; never lowered below it."
|
|
23
21
|
}
|
|
24
22
|
}
|
|
25
23
|
},
|
|
@@ -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. The unit briefed is the one at plan.cursor, or, when unit_id is given, that unit —
|
|
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. The unit briefed is the one at plan.cursor, or, when unit_id is given, that unit — either way it must appear in shiftwork_plan's `ready`, the batch the dependency graph says may run now: the cursor unit is held to the same ready test as unit_id. A unit that is not ready (a done or dropped cursor, a cursor whose dependencies have not run, a unit_id naming no unit) is refused with {\"result\": \"error\", \"reason\": \"unit <id> is not ready; ready is <a, b>\"}, and on both paths the refusals of the batch view itself (cycle, unknown dependency, unreadable checkpoint) pass through verbatim; a cursor naming no unit still escalates. Clock-in still never moves plan.cursor; clock_out does. 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
|
],
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"anyOf": [
|
|
88
88
|
{
|
|
89
89
|
"type": "object",
|
|
90
|
-
"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
|
|
90
|
+
"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, except the five the runtime writes itself — unit, role, status, ts, event — which are refused (result=error) before anything is written; a self-reported briefed is overwritten, not refused. Lines written before this schema existed may spell these differently; the schema governs new writes only.",
|
|
91
91
|
"properties": {
|
|
92
92
|
"tokens": {
|
|
93
93
|
"type": "integer",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "validate_json",
|
|
3
|
-
"description": "Validate candidate output text against a JSON Schema. Returns {valid, feedback}; when invalid, feed the feedback back to the model and retry.",
|
|
3
|
+
"description": "Validate candidate output text against a JSON Schema. Returns {valid, feedback}; when invalid, feed the feedback back to the model and retry. The output may be any JSON value; otherwise the first fenced block, or the first object or array in the text, is used. $schema selects draft-04, draft-06, draft-07, 2019-09 or 2020-12 (default); $ref resolves only inside the one schema (\"#\", \"#/...\", \"#name\"), so a remote reference or an embedded $id is refused. pattern is Python re syntax, without inline flags, look-behind, backreferences, atomic or possessive groups, conditional groups or \\N{...}; a document's patterns get 500 ms in total.",
|
|
4
4
|
"surfaces": [
|
|
5
5
|
"mcp"
|
|
6
6
|
],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "work_plan",
|
|
3
|
-
"description": "Work plan: turn a dependency graph into the batches that may run in parallel. Takes `nodes`, each `{id, depends_on, priority}` — `priority` defaults to 0 — and answers `{\"result\": \"plan\", \"batches\", \"sequence\", \"width\"}`: batch k holds every node whose dependencies all appear in batches below k, `sequence` is those batches flattened in order, and `width` is the largest batch length — the widest fan-out, which is the number an orchestrator needs to decide whether it can afford the batch. Order inside a batch is priority descending, then the order the nodes were given; both halves are contract, because determinism inside a batch is what two implementations are held to. Empty `nodes` is an ANSWER, not a refusal: no batches, no sequence, width 0.
|
|
3
|
+
"description": "Work plan: turn a dependency graph into the batches that may run in parallel. Takes `nodes`, each `{id, depends_on, priority}` — `depends_on` absent or null means none, `priority` defaults to 0 — and answers `{\"result\": \"plan\", \"batches\", \"sequence\", \"width\"}`: batch k holds every node whose dependencies all appear in batches below k, `sequence` is those batches flattened in order, and `width` is the largest batch length — the widest fan-out, which is the number an orchestrator needs to decide whether it can afford the batch. Order inside a batch is priority descending, then the order the nodes were given; both halves are contract, because determinism inside a batch is what two implementations are held to. Empty `nodes` is an ANSWER, not a refusal: no batches, no sequence, width 0. Four refusals, checked in this order and one sentence each: `nodes[<i>].<field> must be ...` for a node whose `id` is not a string, whose `depends_on` is not a list of strings or whose `priority` is not an integer (named by position, never coerced), `duplicate node id <id>`, `node <id> depends on <dep>, which no node declares`, and `the graph has a cycle: <a> -> <b> -> <a>`. It reports what the graph permits and decides nothing about what may actually be dispatched together — `depends_on` encodes logical order, not file contention, so a batch this tool calls parallel may still hold work that writes one file. Pure computation over the nodes it was handed: it opens no file and takes no path. Never mutates.",
|
|
4
4
|
"surfaces": [
|
|
5
5
|
"mcp"
|
|
6
6
|
],
|
|
@@ -17,11 +17,18 @@
|
|
|
17
17
|
"description": "The node's identifier, unique across `nodes`"
|
|
18
18
|
},
|
|
19
19
|
"depends_on": {
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
"anyOf": [
|
|
21
|
+
{
|
|
22
|
+
"type": "array",
|
|
23
|
+
"items": {
|
|
24
|
+
"type": "string"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"type": "null"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"description": "Ids this node waits for; every one of them must be declared by some node. Optional: absent or null means none"
|
|
25
32
|
},
|
|
26
33
|
"priority": {
|
|
27
34
|
"type": "integer",
|
|
@@ -29,8 +36,7 @@
|
|
|
29
36
|
}
|
|
30
37
|
},
|
|
31
38
|
"required": [
|
|
32
|
-
"id"
|
|
33
|
-
"depends_on"
|
|
39
|
+
"id"
|
|
34
40
|
]
|
|
35
41
|
}
|
|
36
42
|
}
|
package/dist/assets.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* reference; where this file deviates the deviation is a comment, not an accident.
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, realpathSync } from 'node:fs';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
8
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { BantamError } from './errors.js';
|
|
11
11
|
import { pyReadText } from './memory/pyfs.js';
|
|
@@ -61,6 +61,30 @@ export function assetsRoot() {
|
|
|
61
61
|
}
|
|
62
62
|
throw new AssetNotFound('no assets directory found; set BANTAMKIT_ASSETS');
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* job66/#145: the one path every loader below builds, and the containment check on it.
|
|
66
|
+
*
|
|
67
|
+
* `name` is a BARE asset name — `memory`, `shiftwork-checkpoint`, `memory_recall` — and
|
|
68
|
+
* nothing else. Before this, each loader joined `name` under its directory verbatim, so a
|
|
69
|
+
* caller passing user input could read any `.json`/`.md` on disk: `loadSchema('../../x')`
|
|
70
|
+
* parsed a file outside the pack, and `loadTool('../schemas/shiftwork-checkpoint')` returned
|
|
71
|
+
* a Tool whose three fields were all `undefined`. A name holding a path separator (`/` or
|
|
72
|
+
* `\`, either platform's), `..`, a drive colon or a NUL is refused BEFORE any filesystem
|
|
73
|
+
* access, and the joined path is then checked LEXICALLY to sit directly inside
|
|
74
|
+
* `<root>/<kind>s/` — lexically, not by `realpath`, because a pack is allowed to be a
|
|
75
|
+
* symlink farm and the check is about the name, not about the disk. The refusal is an
|
|
76
|
+
* `AssetNotFound`, so the MCP resource handler's existing `unknown skill asset: <name>`
|
|
77
|
+
* answer is unchanged; the sentence is the reference's, byte for byte
|
|
78
|
+
* (`runtime-py/src/bantamkit/assets.py::_asset_path`).
|
|
79
|
+
*/
|
|
80
|
+
function assetPath(kind, name, ext) {
|
|
81
|
+
const dir = join(assetsRoot(), `${kind}s`);
|
|
82
|
+
const path = join(dir, `${name}${ext}`);
|
|
83
|
+
if (/[/\\:\0]/.test(name) || name.includes('..') || dirname(resolve(path)) !== resolve(dir)) {
|
|
84
|
+
throw new AssetNotFound(`${kind} asset name is not a bare name: ${name}`);
|
|
85
|
+
}
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
64
88
|
/**
|
|
65
89
|
* Return one tool asset VERBATIM — every key, including the ones no surface uses.
|
|
66
90
|
*
|
|
@@ -70,7 +94,7 @@ export function assetsRoot() {
|
|
|
70
94
|
* and neither belongs on the agent-facing `Tool`.
|
|
71
95
|
*/
|
|
72
96
|
export function loadToolAsset(name) {
|
|
73
|
-
const path =
|
|
97
|
+
const path = assetPath('tool', name, '.json');
|
|
74
98
|
if (!existsSync(path)) {
|
|
75
99
|
throw new AssetNotFound(`tool asset not found: ${path}`);
|
|
76
100
|
}
|
|
@@ -94,14 +118,14 @@ export function loadTool(name) {
|
|
|
94
118
|
}
|
|
95
119
|
/** Return a JSON Schema asset (parsed) — e.g. the shift-work checkpoint contract. */
|
|
96
120
|
export function loadSchema(name) {
|
|
97
|
-
const path =
|
|
121
|
+
const path = assetPath('schema', name, '.json');
|
|
98
122
|
if (!existsSync(path)) {
|
|
99
123
|
throw new AssetNotFound(`schema asset not found: ${path}`);
|
|
100
124
|
}
|
|
101
125
|
return JSON.parse(readTextUniversal(path));
|
|
102
126
|
}
|
|
103
127
|
export function loadSkill(name) {
|
|
104
|
-
const path =
|
|
128
|
+
const path = assetPath('skill', name, '.md');
|
|
105
129
|
if (!existsSync(path)) {
|
|
106
130
|
throw new AssetNotFound(`skill asset not found: ${path}`);
|
|
107
131
|
}
|
package/dist/cli.js
CHANGED
|
@@ -327,9 +327,40 @@ const PARSER = {
|
|
|
327
327
|
// line alone would silently make two unrelated flags mutually exclusive.
|
|
328
328
|
groups: [[13, 14]],
|
|
329
329
|
};
|
|
330
|
+
/**
|
|
331
|
+
* job66/#139: the flags that each ACT and then exit, in registration order — the reference's
|
|
332
|
+
* `_ONE_SHOT_DESTS`. At most one may be given. WAS: `main` ran the first in registration
|
|
333
|
+
* order and dropped the rest at exit 0.
|
|
334
|
+
*/
|
|
335
|
+
const ONE_SHOT_DESTS = [
|
|
336
|
+
'assets_root',
|
|
337
|
+
'hook',
|
|
338
|
+
'mcp_report',
|
|
339
|
+
'statusline',
|
|
340
|
+
'update',
|
|
341
|
+
'install',
|
|
342
|
+
'install_hooks',
|
|
343
|
+
'remove_hooks',
|
|
344
|
+
];
|
|
345
|
+
/**
|
|
346
|
+
* `_refuse_stacked_one_shots`: two one-shot flags together are a USAGE error — the usage
|
|
347
|
+
* block, argparse's mutually-exclusive sentence, exit 2. Not a `groups` entry, for the
|
|
348
|
+
* reference's measured reason: the eight are not contiguous, and CPython 3.14 brackets a
|
|
349
|
+
* non-contiguous group in the usage line where 3.12/3.13 omit it, so a real group would make
|
|
350
|
+
* the reference's `-h` depend on its interpreter. Checked after the parse, naming the two
|
|
351
|
+
* EARLIEST-REGISTERED flags given, exactly as the reference does.
|
|
352
|
+
*/
|
|
353
|
+
function refuseStackedOneShots(values) {
|
|
354
|
+
const given = PARSER.actions.filter((action) => ONE_SHOT_DESTS.includes(action.dest) && values[action.dest] !== null && values[action.dest] !== false);
|
|
355
|
+
if (given.length > 1) {
|
|
356
|
+
const [first, second] = given.map((action) => action.optionStrings.join('/'));
|
|
357
|
+
throw new ArgvError(PARSER, `argument ${second}: not allowed with argument ${first}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
330
360
|
/** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
|
|
331
361
|
export function parseArgs(argv) {
|
|
332
362
|
const values = parseWithSpec(PARSER, argv);
|
|
363
|
+
refuseStackedOneShots(values);
|
|
333
364
|
return {
|
|
334
365
|
k: values['k'],
|
|
335
366
|
indexBudget: values['index_budget'],
|
|
@@ -497,8 +528,36 @@ function buildMemory(options) {
|
|
|
497
528
|
checkStoreFlag(options.store);
|
|
498
529
|
return new Memory(options.store, shared);
|
|
499
530
|
}
|
|
531
|
+
if (options.start !== null)
|
|
532
|
+
checkStartFlag(options.start);
|
|
500
533
|
return Memory.layered(options.start, shared);
|
|
501
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* job65/#142: `--start` must name a directory that is there.
|
|
537
|
+
*
|
|
538
|
+
* WAS: unchecked. `pyResolve` of a path that does not exist answers the path anyway, the walk
|
|
539
|
+
* then climbed its nonexistent parents to the first real one and bound whatever store sat
|
|
540
|
+
* above it — a typo'd `--start` silently bound an ancestor's store as the WRITABLE project
|
|
541
|
+
* layer. Unlike `--store`, a missing `--start` is not a designation: nothing is ever created
|
|
542
|
+
* at a start directory, so a missing one can only be a mistake, and it is refused by name
|
|
543
|
+
* before a transport exists. Same `stat` and the same sentence family as `checkStoreFlag`.
|
|
544
|
+
*/
|
|
545
|
+
function checkStartFlag(raw) {
|
|
546
|
+
const start = pyJoin(raw);
|
|
547
|
+
let isDir;
|
|
548
|
+
try {
|
|
549
|
+
isDir = pyStatIsDir(start);
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
if (!(e instanceof PyOSError))
|
|
553
|
+
throw e;
|
|
554
|
+
if (e.name === 'FileNotFoundError')
|
|
555
|
+
throw new Refusal(`--start does not exist: ${start}`);
|
|
556
|
+
throw new Refusal(`--start is unreachable: ${start}: ${e.strerror}`);
|
|
557
|
+
}
|
|
558
|
+
if (!isDir)
|
|
559
|
+
throw new Refusal(`--start is not a directory: ${start}`);
|
|
560
|
+
}
|
|
502
561
|
/**
|
|
503
562
|
* `--store` may name a store that is missing, never one that is not a store.
|
|
504
563
|
*
|
|
@@ -585,6 +644,25 @@ function version() {
|
|
|
585
644
|
}
|
|
586
645
|
async function main(argv) {
|
|
587
646
|
const options = parseArgs(argv);
|
|
647
|
+
// job66/#139: THE MODIFIERS ARE CHECKED BEFORE ANY FLAG ACTS, in the reference's order and
|
|
648
|
+
// with its sentences. WAS: the two refusals sat after every acting arm, so `--hook --yes`,
|
|
649
|
+
// `--hook --force` and `--statusline --force` dropped the modifier at exit 0.
|
|
650
|
+
//
|
|
651
|
+
// `--force` alone is a typo with a plausible reading — somebody meant to install and
|
|
652
|
+
// dropped the flag that says where. Refusing names the missing half.
|
|
653
|
+
if (options.force && (options.install ?? null) === null)
|
|
654
|
+
throw new Refusal('--force is only meaningful with --install');
|
|
655
|
+
// The same reading for the consent flag; the sentence names BOTH flags since the ruling of
|
|
656
|
+
// 2026-09-20.
|
|
657
|
+
if (options.yes && !(options.installHooks || options.removeHooks)) {
|
|
658
|
+
throw new Refusal('--yes is only meaningful with --install-hooks or --remove-hooks');
|
|
659
|
+
}
|
|
660
|
+
// REFUSED, NOT HONOURED: the hook binds the store from the cwd the HOST put in the event,
|
|
661
|
+
// and `runHook()` takes no options, so `--hook --store X` used to inject nothing from X.
|
|
662
|
+
if (options.hook && options.store !== null)
|
|
663
|
+
throw new Refusal('--store is not meaningful with --hook');
|
|
664
|
+
if (options.hook && options.start !== null)
|
|
665
|
+
throw new Refusal('--start is not meaningful with --hook');
|
|
588
666
|
if (options.assetsRoot) {
|
|
589
667
|
const root = assetsRoot();
|
|
590
668
|
// `packFileCount`, not a second walk: the count printed here and the count
|
|
@@ -594,10 +672,8 @@ async function main(argv) {
|
|
|
594
672
|
return 0;
|
|
595
673
|
}
|
|
596
674
|
if (options.hook) {
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
// nothing. `--hook` is registered directly after `--index-budget`, so it is checked
|
|
600
|
-
// directly after `--assets-root`.
|
|
675
|
+
// ALONE ON ITS COMMAND LINE (job66/#139): another one-shot flag beside it is a usage
|
|
676
|
+
// error, and `--store`/`--start`/`--yes`/`--force` beside it are refused above.
|
|
601
677
|
//
|
|
602
678
|
// EXIT 0 ALWAYS, AND THAT IS THE CONTRACT, not a convenience. A hook that exits non-zero
|
|
603
679
|
// or lets a stack reach stderr is rendered by the host as an error on the user's screen,
|
|
@@ -651,11 +727,9 @@ async function main(argv) {
|
|
|
651
727
|
// that record no origin, and it is not a guess: it is where the code being executed lives.
|
|
652
728
|
return await runUpdate((text) => process.stdout.write(text), (text) => process.stderr.write(text), version(), dirname(fileURLToPath(import.meta.url)));
|
|
653
729
|
}
|
|
654
|
-
// ORDER
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
// earlier here made the same argv write a file on one runtime and not the other: one
|
|
658
|
-
// command line, two different states on the user's disk. Reviewed and moved.
|
|
730
|
+
// ORDER WAS OBSERVABLE, and it was the reference's order: `--mcp-report --install cursor`
|
|
731
|
+
// printed a report and wrote NOTHING. Since job66/#139 that argv is a usage error on both
|
|
732
|
+
// sides (`refuseStackedOneShots`), so at most one arm below is ever reached.
|
|
659
733
|
if (options.install !== null && options.install !== undefined) {
|
|
660
734
|
// Before any store or transport exists, the shape `--assets-root` established. The command
|
|
661
735
|
// is resolved INSIDE the try: on an `npx` cache it makes the kept install first, and npm
|
|
@@ -671,9 +745,6 @@ async function main(argv) {
|
|
|
671
745
|
}
|
|
672
746
|
return 0;
|
|
673
747
|
}
|
|
674
|
-
// REGISTRATION ORDER AGAIN, which is what makes `--mcp-report --install-hooks` print a
|
|
675
|
-
// report and write nothing, exactly as `--mcp-report --install cursor` already does.
|
|
676
|
-
//
|
|
677
748
|
// THREE EXITS, AND THE TWO THAT WRITE NOTHING ARE NOT THE SAME EXIT (S1 RULING Q3.3/Q3.4):
|
|
678
749
|
//
|
|
679
750
|
// 0 written, or already installed and matching
|
|
@@ -744,18 +815,6 @@ async function main(argv) {
|
|
|
744
815
|
}
|
|
745
816
|
return 0;
|
|
746
817
|
}
|
|
747
|
-
// `--force` alone is a typo with a plausible reading — somebody meant to install and
|
|
748
|
-
// dropped the flag that says where. Refusing names the missing half.
|
|
749
|
-
if (options.force)
|
|
750
|
-
throw new Refusal('--force is only meaningful with --install');
|
|
751
|
-
// The same reading, and the same refusal, for the consent flag: `--yes` on its own is
|
|
752
|
-
// somebody who meant to install or remove hooks and dropped the flag that says which. It is
|
|
753
|
-
// checked AFTER `--force` because that is registration order, and both are checked after
|
|
754
|
-
// every flag that acts, so `--install-hooks --yes` never reaches either of them. The
|
|
755
|
-
// sentence names BOTH flags since the ruling of 2026-09-20: naming only one would send an
|
|
756
|
-
// operator who mistyped `--remove-hooks` looking for a flag they already had.
|
|
757
|
-
if (options.yes)
|
|
758
|
-
throw new Refusal('--yes is only meaningful with --install-hooks or --remove-hooks');
|
|
759
818
|
// A PERSON TYPED IT. `typedBareAtATerminal` carries the whole argument; what belongs here is
|
|
760
819
|
// only that this sits BEFORE `buildMemory`, which is what creates a store. Somebody who typed
|
|
761
820
|
// a command to see what it does has not asked for a `.bantamkit/memory` directory in whatever
|
package/dist/contract.d.ts
CHANGED
|
@@ -120,15 +120,6 @@ export declare function documentError(detail: unknown): string;
|
|
|
120
120
|
* `Error`'s `message` is that string here, which is what `SkillAuditError` carries.
|
|
121
121
|
*/
|
|
122
122
|
export declare function toolFailed(tool: string, detail: unknown): string;
|
|
123
|
-
/**
|
|
124
|
-
* `extract_json`: a fenced block if there is one, otherwise the first `{`/`[` onward.
|
|
125
|
-
*
|
|
126
|
-
* Both arms are here because both reach the model. The fence is `re.search` with `DOTALL`
|
|
127
|
-
* and a non-greedy body, so it takes the FIRST fence and prose on either side is discarded;
|
|
128
|
-
* with no fence, `find` takes the earliest brace or bracket — including one inside a string
|
|
129
|
-
* of prose, which is faithful and occasionally wrong in exactly Python's way. `raw_decode`
|
|
130
|
-
* then stops at the end of that one value, which is why `{"a": 1} — anything else?` parses.
|
|
131
|
-
*/
|
|
132
123
|
export declare function extractJson(text: string): PyValue;
|
|
133
124
|
/**
|
|
134
125
|
* `schema_error`: a pointed validation error for `output`, or `null` if it satisfies
|
package/dist/contract.js
CHANGED
|
@@ -35,8 +35,8 @@ import { BantamError } from './errors.js';
|
|
|
35
35
|
import { pyStrip } from './memory/factfile.js';
|
|
36
36
|
import { pyReadText } from './memory/pyfs.js';
|
|
37
37
|
import { resolveImplicitTag } from './memory/pyyaml.js';
|
|
38
|
-
import { fromJs, PyValueError, rawDecode } from './pyjson.js';
|
|
39
|
-
import { absolutePath, validate } from './pyjsonschema.js';
|
|
38
|
+
import { fromJs, parseJson, PyValueError, rawDecode, reprValue } from './pyjson.js';
|
|
39
|
+
import { absolutePath, clipText, PyJsonSchemaRefusal, validate } from './pyjsonschema.js';
|
|
40
40
|
/**
|
|
41
41
|
* `contract.REQUIRED_KEYS`, in Python's order — the order the error message lists them.
|
|
42
42
|
*
|
|
@@ -541,7 +541,8 @@ export function toolFailed(tool, detail) {
|
|
|
541
541
|
* of prose, which is faithful and occasionally wrong in exactly Python's way. `raw_decode`
|
|
542
542
|
* then stops at the end of that one value, which is why `{"a": 1} — anything else?` parses.
|
|
543
543
|
*/
|
|
544
|
-
|
|
544
|
+
/** `_unfenced`: the text `extract_json` searches — stripped, and the first fence's body if any. */
|
|
545
|
+
function unfenced(text) {
|
|
545
546
|
let body = pyStrip(text);
|
|
546
547
|
// `re.DOTALL` — JS needs `[\s\S]`, and `\s*` after the opening fence is Python's `\s*`.
|
|
547
548
|
const fence = /```(?:json)?[\s\S]*?```/.exec(body);
|
|
@@ -549,6 +550,10 @@ export function extractJson(text) {
|
|
|
549
550
|
const inner = /```(?:json)?\s*([\s\S]*?)```/.exec(body);
|
|
550
551
|
body = pyStrip(inner[1]);
|
|
551
552
|
}
|
|
553
|
+
return body;
|
|
554
|
+
}
|
|
555
|
+
export function extractJson(text) {
|
|
556
|
+
const body = unfenced(text);
|
|
552
557
|
const startBrace = body.indexOf('{');
|
|
553
558
|
const startBracket = body.indexOf('[');
|
|
554
559
|
let start;
|
|
@@ -580,24 +585,58 @@ export function extractJson(text) {
|
|
|
580
585
|
* loss.
|
|
581
586
|
*/
|
|
582
587
|
export function schemaError(output, schema) {
|
|
588
|
+
const isValueError = (e) => e instanceof Error && (e.name === 'PyValueError' || e.name === 'PyJSONDecodeError');
|
|
583
589
|
let data;
|
|
584
590
|
try {
|
|
585
|
-
|
|
591
|
+
// #152: a whole-text JSON document of any type first — `"x"`, `1.0` and `null` are JSON
|
|
592
|
+
// texts — and only then the object/array search `extract_json` does (`json.loads` then
|
|
593
|
+
// `extract_json` on the reference).
|
|
594
|
+
data = parseJson(unfenced(output));
|
|
586
595
|
}
|
|
587
|
-
catch (
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
596
|
+
catch (whole) {
|
|
597
|
+
if (!isValueError(whole))
|
|
598
|
+
throw whole;
|
|
599
|
+
try {
|
|
600
|
+
data = extractJson(output);
|
|
601
|
+
}
|
|
602
|
+
catch (e) {
|
|
603
|
+
// `except ValueError` — and `json.JSONDecodeError` IS a `ValueError`, which is how
|
|
604
|
+
// CPython's decoder text reaches the model.
|
|
605
|
+
if (isValueError(e))
|
|
606
|
+
return parseErrorMessage(e);
|
|
607
|
+
throw e;
|
|
592
608
|
}
|
|
593
|
-
throw e;
|
|
594
609
|
}
|
|
595
610
|
const asPy = isPyValue(schema) ? schema : fromJs(schema);
|
|
596
|
-
|
|
611
|
+
let error;
|
|
612
|
+
try {
|
|
613
|
+
error = validate(data, asPy);
|
|
614
|
+
}
|
|
615
|
+
catch (e) {
|
|
616
|
+
// A schema the validator will not resolve — a reference outside the one resource, a
|
|
617
|
+
// draft-3 `$schema` — is ANSWERED with its sentence, not raised (#125). The Python side
|
|
618
|
+
// returns the same sentence from `_unsupported_schema` and from the `referencing`
|
|
619
|
+
// exceptions, so `validate_json` says `{"valid": false, "feedback": …}` on both.
|
|
620
|
+
if (e instanceof PyJsonSchemaRefusal)
|
|
621
|
+
return e.message;
|
|
622
|
+
throw e;
|
|
623
|
+
}
|
|
597
624
|
if (error === null)
|
|
598
625
|
return null;
|
|
599
626
|
const where = absolutePath(error).map(String).join('/') || 'root';
|
|
600
|
-
return validationErrorMessage(where, error.message);
|
|
627
|
+
return validationErrorMessage(clipText(where), clipDetail(error.message, error.instance));
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* #153: the offending value quoted at most 200 codepoints, the sentence at most 1000 —
|
|
631
|
+
* `contract._clip_detail`. Every `repr` of the instance is clipped first, so a `maxLength`
|
|
632
|
+
* failure on a 1 MiB string still says `is too long`; the whole-sentence cap then bounds
|
|
633
|
+
* what a message quotes from PARTS of the instance (an unexpected property's name).
|
|
634
|
+
*/
|
|
635
|
+
function clipDetail(message, instance) {
|
|
636
|
+
const quoted = reprValue(instance);
|
|
637
|
+
if ([...quoted].length > 200)
|
|
638
|
+
message = message.split(quoted).join(clipText(quoted));
|
|
639
|
+
return clipText(message, 1000);
|
|
601
640
|
}
|
|
602
641
|
function isPyValue(value) {
|
|
603
642
|
return (typeof value === 'object' &&
|
package/dist/eventlog.d.ts
CHANGED
|
@@ -50,8 +50,15 @@ export declare function formatTimestamp(epochMs: number): string;
|
|
|
50
50
|
* by CODE POINT and JavaScript's default comparator by UTF-16 code UNIT, and those two
|
|
51
51
|
* disagree above the BMP. Every key written today is ASCII; the comparator is what keeps
|
|
52
52
|
* that a fact about the data instead of a difference between the runtimes.
|
|
53
|
+
*
|
|
54
|
+
* job66/#144: the DECLARED return type is `Uint8Array`, not `Buffer`. The value is still a
|
|
55
|
+
* `Buffer` (a `Uint8Array` subclass), so nothing changes at runtime, but `Buffer` is a name
|
|
56
|
+
* only `@types/node` defines, and this signature is reachable from the package entry point
|
|
57
|
+
* (`index.d.ts` re-exports it). Spelled `Buffer`, it made a strict consumer without
|
|
58
|
+
* `@types/node` fail `tsc` on `import ... from 'bantamkit-mcp'` with TS2591 — a dependency
|
|
59
|
+
* the package never declared. `test/packaging.test.mjs` type-checks such a consumer.
|
|
53
60
|
*/
|
|
54
|
-
export declare function encodeRecord(epochMs: number, tool: string, outcome: string, detail: Readonly<Record<string, DetailValue>>):
|
|
61
|
+
export declare function encodeRecord(epochMs: number, tool: string, outcome: string, detail: Readonly<Record<string, DetailValue>>): Uint8Array;
|
|
55
62
|
/**
|
|
56
63
|
* `<store>/events/mcp.jsonl` — provably outside everything `MemoryStore` reads.
|
|
57
64
|
*
|
|
@@ -98,7 +105,20 @@ export declare class EventLog {
|
|
|
98
105
|
* two runtimes.
|
|
99
106
|
*/
|
|
100
107
|
writeFailed: boolean;
|
|
101
|
-
|
|
108
|
+
/**
|
|
109
|
+
* job65/#154: the directory that must already exist before a record is written, or `null`.
|
|
110
|
+
*
|
|
111
|
+
* `fromEnv` sets it to the store root when the switch is `on` (the default file lives
|
|
112
|
+
* INSIDE the store). WAS: the `mkdir` in `append` brought a never-saved store into
|
|
113
|
+
* existence — `--store <absent>` plus one `work_plan` call left `<store>/events/mcp.jsonl`
|
|
114
|
+
* behind, and the next layered session walking up through it bound that directory as its
|
|
115
|
+
* store. The log does not create the store: while the store is not there, a record has
|
|
116
|
+
* nowhere to go and is DROPPED — not redirected, and not counted as `writeFailed`, because
|
|
117
|
+
* nothing was offered to the filesystem. The first save creates the store; records after it
|
|
118
|
+
* land. A literal path is the operator's own choice and keeps `requiresDir === null`.
|
|
119
|
+
*/
|
|
120
|
+
readonly requiresDir: string | null;
|
|
121
|
+
constructor(path: string | null, capBytes?: number, clock?: () => number, requiresDir?: string | null);
|
|
102
122
|
static fromEnv(storeRoot: string, env?: Record<string, string | undefined>, clock?: () => number): EventLog;
|
|
103
123
|
get enabled(): boolean;
|
|
104
124
|
/**
|
package/dist/eventlog.js
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
* notice — the failure mode a diagnostic can least afford.
|
|
59
59
|
*/
|
|
60
60
|
import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
61
|
-
import { cmpCodepoint, osErrorClassName, pyExists, PyOSError, pyJoin, pyName, pyParent } from './memory/pyfs.js';
|
|
61
|
+
import { cmpCodepoint, osErrorClassName, pyExists, pyIsDir, PyOSError, pyJoin, pyName, pyParent } from './memory/pyfs.js';
|
|
62
62
|
import { bantamkitDirFor, ensureBantamkitGitignore } from './memory/store.js';
|
|
63
63
|
/**
|
|
64
64
|
* Environment switch. Unset or `off`/`0`/`false`/`no`/empty -> disabled. `on`/`1`/`true`/
|
|
@@ -120,6 +120,13 @@ export function formatTimestamp(epochMs) {
|
|
|
120
120
|
* by CODE POINT and JavaScript's default comparator by UTF-16 code UNIT, and those two
|
|
121
121
|
* disagree above the BMP. Every key written today is ASCII; the comparator is what keeps
|
|
122
122
|
* that a fact about the data instead of a difference between the runtimes.
|
|
123
|
+
*
|
|
124
|
+
* job66/#144: the DECLARED return type is `Uint8Array`, not `Buffer`. The value is still a
|
|
125
|
+
* `Buffer` (a `Uint8Array` subclass), so nothing changes at runtime, but `Buffer` is a name
|
|
126
|
+
* only `@types/node` defines, and this signature is reachable from the package entry point
|
|
127
|
+
* (`index.d.ts` re-exports it). Spelled `Buffer`, it made a strict consumer without
|
|
128
|
+
* `@types/node` fail `tsc` on `import ... from 'bantamkit-mcp'` with TS2591 — a dependency
|
|
129
|
+
* the package never declared. `test/packaging.test.mjs` type-checks such a consumer.
|
|
123
130
|
*/
|
|
124
131
|
export function encodeRecord(epochMs, tool, outcome, detail) {
|
|
125
132
|
const sorted = {};
|
|
@@ -185,13 +192,29 @@ export class EventLog {
|
|
|
185
192
|
* two runtimes.
|
|
186
193
|
*/
|
|
187
194
|
writeFailed = false;
|
|
188
|
-
|
|
195
|
+
/**
|
|
196
|
+
* job65/#154: the directory that must already exist before a record is written, or `null`.
|
|
197
|
+
*
|
|
198
|
+
* `fromEnv` sets it to the store root when the switch is `on` (the default file lives
|
|
199
|
+
* INSIDE the store). WAS: the `mkdir` in `append` brought a never-saved store into
|
|
200
|
+
* existence — `--store <absent>` plus one `work_plan` call left `<store>/events/mcp.jsonl`
|
|
201
|
+
* behind, and the next layered session walking up through it bound that directory as its
|
|
202
|
+
* store. The log does not create the store: while the store is not there, a record has
|
|
203
|
+
* nowhere to go and is DROPPED — not redirected, and not counted as `writeFailed`, because
|
|
204
|
+
* nothing was offered to the filesystem. The first save creates the store; records after it
|
|
205
|
+
* land. A literal path is the operator's own choice and keeps `requiresDir === null`.
|
|
206
|
+
*/
|
|
207
|
+
requiresDir;
|
|
208
|
+
constructor(path, capBytes = CAP_BYTES, clock = defaultClock, requiresDir = null) {
|
|
189
209
|
this.path = path;
|
|
190
210
|
this.capBytes = capBytes;
|
|
191
211
|
this.clock = clock;
|
|
212
|
+
this.requiresDir = requiresDir;
|
|
192
213
|
}
|
|
193
214
|
static fromEnv(storeRoot, env = process.env, clock = defaultClock) {
|
|
194
|
-
|
|
215
|
+
const raw = env[EVENT_LOG_ENV];
|
|
216
|
+
const inStore = raw !== undefined && raw !== null && ON.has(raw.trim().toLowerCase());
|
|
217
|
+
return new EventLog(resolvePath(storeRoot, raw), CAP_BYTES, clock, inStore ? storeRoot : null);
|
|
195
218
|
}
|
|
196
219
|
get enabled() {
|
|
197
220
|
return this.path !== null;
|
|
@@ -246,6 +269,10 @@ export class EventLog {
|
|
|
246
269
|
* by construction, so that branch is a statement of intent, not a live path.
|
|
247
270
|
*/
|
|
248
271
|
append(path, payload) {
|
|
272
|
+
// job65/#154: `pyIsDir` raises for EACCES exactly as `Path.is_dir()` does, and that lands
|
|
273
|
+
// in `record`'s catch as a lost record — the same verdict the reference reaches.
|
|
274
|
+
if (this.requiresDir !== null && !pyIsDir(this.requiresDir))
|
|
275
|
+
return;
|
|
249
276
|
// AUDIT FINDING (J51-1, mirrored from `runtime-py/src/bantamkit/eventlog.py`): the
|
|
250
277
|
// `mkdirSync` below can bring a whole `.bantamkit` directory into existence on its own
|
|
251
278
|
// -- `BANTAMKIT_EVENT_LOG=on` with a project store that was never saved to reaches here
|