bantamkit-mcp 0.35.3 → 0.35.4
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 +106 -5
- package/assets/tools/shiftwork_clock_in.json +13 -1
- package/assets/tools/shiftwork_clock_out.json +2 -2
- package/assets/tools/shiftwork_plan.json +1 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +273 -6
- package/dist/hookadapter.d.ts +29 -0
- package/dist/hookadapter.js +2009 -0
- package/dist/hostinstall.d.ts +115 -0
- package/dist/hostinstall.js +393 -0
- package/dist/mcp/pyargs.d.ts +39 -0
- package/dist/mcp/pyargs.js +112 -4
- package/dist/mcp/server.js +8 -2
- package/dist/selfupdate.d.ts +4 -1
- package/dist/selfupdate.js +6 -3
- package/dist/shiftwork.d.ts +40 -10
- package/dist/shiftwork.js +146 -36
- package/dist/updatecheck.d.ts +3 -3
- package/dist/updatecheck.js +45 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,9 +6,13 @@ command, connect it to **Claude Code**, **Claude Desktop**, **Cursor** or **GitH
|
|
|
6
6
|
Code**, and every later launch starts **offline**. No Python, `pip`, `uv`, `pipx` or venv.
|
|
7
7
|
|
|
8
8
|
It serves the same fourteen tools, `bantamkit_status` prompt and two resource templates as the
|
|
9
|
-
Python server on
|
|
10
|
-
store. The two are compared frame by frame: 8,130 conformance cases on
|
|
11
|
-
intentional difference written down as a ruling.
|
|
9
|
+
Python server — [`bantamkit` on PyPI](https://pypi.org/project/bantamkit/) — and reads and
|
|
10
|
+
writes the same memory store. The two are compared frame by frame: 8,130 conformance cases on
|
|
11
|
+
2026-09-15, with every intentional difference written down as a ruling.
|
|
12
|
+
|
|
13
|
+
**This page is the npm package's.** Every command on it runs `bantamkit-mcp` from npm. The
|
|
14
|
+
Python package is named where the two differ, but its own install, update and CLI commands live
|
|
15
|
+
on [its page](https://pypi.org/project/bantamkit/); run them here and you get nothing.
|
|
12
16
|
|
|
13
17
|
## Contents
|
|
14
18
|
|
|
@@ -31,6 +35,7 @@ intentional difference written down as a ruling.
|
|
|
31
35
|
| [Memory stores are layered by default](#memory-stores-are-layered-by-default) | Why not to add `--store` by reflex |
|
|
32
36
|
| [The asset pack](#the-asset-pack) | What ships in `assets/` and how it is found |
|
|
33
37
|
| [Where the Python and Node servers differ](#where-the-python-and-node-servers-differ) | One store; concurrent writes, one hand-edit to avoid |
|
|
38
|
+
| [Module API](#module-api) | The one entry point, all 139 exports it reaches, and the one break in `clockIn` |
|
|
34
39
|
| [Development](#development) | Build, test, conformance |
|
|
35
40
|
| [Measurements and history](#measurements-and-history) | Where the measured numbers behind this page live |
|
|
36
41
|
|
|
@@ -107,7 +112,6 @@ Each host below has the same steps: **1** command, **2** file, **3** entry, **4*
|
|
|
107
112
|
| Route | `command` | `args` |
|
|
108
113
|
|---|---|---|
|
|
109
114
|
| npm, install once | `/absolute/path/to/node` | `["/Users/you/.bantamkit/mcp/node_modules/bantamkit-mcp/dist/cli.js"]` |
|
|
110
|
-
| Python venv | `/absolute/path/to/env/bin/bantamkit-mcp` | `[]` |
|
|
111
115
|
| npx every launch | `npx` | `["-y", "bantamkit-mcp"]` |
|
|
112
116
|
|
|
113
117
|
A JSON file cannot expand `~`, so spell both paths out. To check a recorded command, run it in
|
|
@@ -226,9 +230,12 @@ It is the only network access here — not at startup, not on `bantamkit_status`
|
|
|
226
230
|
| `npx -y bantamkit-mcp --install <host>` from 0.34.0: a kept install at `~/.bantamkit/mcp` | `npx -y bantamkit-mcp@latest --update` patches it in place, or `npm i --prefix ~/.bantamkit/mcp bantamkit-mcp@latest`. The recorded paths do not change. Before 0.34.0, `--update` from an npx cache refuses instead |
|
|
227
231
|
| `npm i -g bantamkit-mcp` | `npm i -g bantamkit-mcp@latest` |
|
|
228
232
|
| `npm i --prefix <dir> bantamkit-mcp` | `npm i --prefix <dir> bantamkit-mcp@latest` |
|
|
229
|
-
| PyPI (`pip install "bantamkit[mcp]"`) | `pip install -U "bantamkit[mcp]"` · pipx: `pipx upgrade bantamkit` · uv: `uv tool upgrade bantamkit` |
|
|
230
233
|
| a checkout, via `tools/bantamkit-mcp-node` | `git pull && npm ci --prefix runtime-ts && npm run build --prefix runtime-ts` — no registry, and `runtime-ts/dist/` is build output, so a pull alone changes nothing |
|
|
231
234
|
|
|
235
|
+
No row above is the Python package. If what you have is **`bantamkit` from PyPI** instead, it
|
|
236
|
+
updates by its own route, written on [its PyPI page](https://pypi.org/project/bantamkit/); the
|
|
237
|
+
two sets of commands are not interchangeable.
|
|
238
|
+
|
|
232
239
|
**Then restart the server in your host** — a running server keeps serving the code it loaded at
|
|
233
240
|
startup. In Claude Code: `/mcp` → reconnect. In Claude Desktop: a full restart of the app.
|
|
234
241
|
|
|
@@ -405,6 +412,100 @@ are deliberate and each is ruled in the
|
|
|
405
412
|
the whole store with `malformed fact file …`. Quote it: `description: '2026'`. Every fact the
|
|
406
413
|
tools *write* is quoted correctly (`src/memory/factfile.ts:257`).
|
|
407
414
|
|
|
415
|
+
## Module API
|
|
416
|
+
|
|
417
|
+
The package is a server first, but it is importable too: `exports` maps exactly one entry
|
|
418
|
+
point — `.` — to `dist/index.js` and `dist/index.d.ts`, so `import { clockIn } from
|
|
419
|
+
'bantamkit-mcp'` is a supported call, and the names and arities behind that entry point are
|
|
420
|
+
part of what is published.
|
|
421
|
+
|
|
422
|
+
Everything in this section was measured against the **tarball**, not the source tree: `npm
|
|
423
|
+
pack`, extracted into a throwaway package's `node_modules`, and imported by `node`. A name in
|
|
424
|
+
`src/` that no entry point reaches is not API; the table below is what an importer gets.
|
|
425
|
+
|
|
426
|
+
**One entry point, and nothing beside it.** There are no subpaths, so every deep import is
|
|
427
|
+
refused by Node's resolver:
|
|
428
|
+
|
|
429
|
+
| specifier | result |
|
|
430
|
+
| --- | --- |
|
|
431
|
+
| `bantamkit-mcp` | 139 value exports, 26 type-only exports |
|
|
432
|
+
| `bantamkit-mcp/dist/cli.js` | `ERR_PACKAGE_PATH_NOT_EXPORTED` |
|
|
433
|
+
| `bantamkit-mcp/dist/shiftwork.js` | `ERR_PACKAGE_PATH_NOT_EXPORTED` |
|
|
434
|
+
| `bantamkit-mcp/package.json` | `ERR_PACKAGE_PATH_NOT_EXPORTED` |
|
|
435
|
+
| `bantamkit-mcp/assets/tools/memory_save.json` | `ERR_PACKAGE_PATH_NOT_EXPORTED` |
|
|
436
|
+
|
|
437
|
+
**So what you can import is the library half, not the server.** The MCP server and its
|
|
438
|
+
transport, both CLIs, the host-install and self-update code, the hook adapter, the document
|
|
439
|
+
reader, the repo map, the skill audit, the token ledger, the pricing table and the work planner
|
|
440
|
+
all ship inside `dist/` and none of them is reachable through an import — they are reached by
|
|
441
|
+
running `bantamkit-mcp` and `bantamkit-memory`. Importing the package starts no server and
|
|
442
|
+
opens no transport. The vendored `assets/` is not importable either, but it is *readable*:
|
|
443
|
+
`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.
|
|
445
|
+
|
|
446
|
+
**What the one entry point exports**, grouped by the module each name comes from:
|
|
447
|
+
|
|
448
|
+
| module | what it is | exported names |
|
|
449
|
+
| --- | --- | --- |
|
|
450
|
+
| `errors.js` | The base error every other one extends | `BantamError` |
|
|
451
|
+
| `contract.js` | The JSON-answer contract: load it, pull JSON out of a model reply, compose the retry feedback | `ContractParseError`, `extractJson`, `loadContract`, `parseContractDocument`, `parseErrorMessage`, `REQUIRED_KEYS`, `schemaError`, `schemaRetryFeedback`, `validationErrorMessage` |
|
|
452
|
+
| `pyjson.js` | CPython `json` semantics over a `PyValue` tree — float repr and decode errors included | `dumpJson`, `fromJs`, `parseJson`, `PY_NONE`, `pyFloatRepr`, `PyJSONDecodeError`, `PyValueError`, `rawDecode`, `reprValue`, `toJs`<br>*types:* `DumpOptions`, `PyValue` |
|
|
453
|
+
| `pyjsonschema.js` | The JSON Schema validator, with CPython `jsonschema`'s error ordering and best-match | `absolutePath`, `bestMatch`, `iterErrors`, `pyEqual`, `PyJsonSchemaUnsupported`, `validate`<br>*types:* `PyValidationError` |
|
|
454
|
+
| `assets.js` | The vendored asset pack: schemas, skills and tool definitions | `AssetNotFound`, `assetsRoot`, `loadSchema`, `loadSkill`, `loadTool`, `loadToolAsset`<br>*types:* `Tool` |
|
|
455
|
+
| `memory/factfile.js` | One fact file: frontmatter, parse, format | `decodeFactBytes`, `FactParseError`, `formatFact`, `parseFactText`, `parseFrontmatter`, `pySplit`, `pyStrip`, `todayLocal`<br>*types:* `Fact`, `FactMeta`, `FactValue` |
|
|
456
|
+
| `memory/pyyaml.js` | The YAML subset PyYAML's safe loader and dumper accept | `constructPlain`, `PyScalar`, `resolveImplicitTag`, `safeDumpMapping`, `YamlConstructError`, `YamlEmitError`<br>*types:* `PyOrder`, `YamlScalar`, `YamlValue` |
|
|
457
|
+
| `memory/layers.js` | Which stores a recall reads, and where the project store is | `countFacts`, `discoverProjectStore`, `loadGrants`, `MEMORY_DIR_ENV`, `PROJECT_STORE`, `resolveProjectStore`<br>*types:* `StoreBinding` |
|
|
458
|
+
| `memory/component.js` | `Memory`, the save/recall/compact/dream component the server calls | `layerLabel`, `Memory`, `normalizeName`, `profileStore`<br>*types:* `CompactOutcome`, `DreamOutcome`, `MemoryOptions`, `RecallOutcome`, `SaveOutcome` |
|
|
459
|
+
| `memory/dream.js` | The consolidation pass: similarity, merge, supersede | `absolutise`, `blockKey`, `claimSlot`, `collapse`, `dream`, `formatFixed3`, `mergeBodies`, `mergeDescriptions`, `mergeLinks`, `PROFILE_LAYER`, `PROJECT_LAYER`, `pyIntDigits`, `sortedNames`, `splitBlocks`, `SUPERSEDED_HEADING`<br>*types:* `DateHit`, `DreamMerge`, `DreamResult`, `SimilarPair`, `Superseded` |
|
|
460
|
+
| `eventlog.js` | The JSONL event log | `CAP_BYTES`, `DEFAULT_RELATIVE_PATH`, `defaultPath`, `encodeRecord`, `EVENT_LOG_ENV`, `EventLog`, `formatTimestamp`, `resolvePath`, `SCHEMA_VERSION`<br>*types:* `DetailValue` |
|
|
461
|
+
| `memory/store.js` | `MemoryStore`: the on-disk store, its budgets and its dedupe | `BANTAMKIT_GITIGNORE_TEXT`, `DEFAULT_INDEX_BUDGET`, `DUPLICATE_JACCARD`, `ensureBantamkitGitignore`, `jaccard`, `MemoryBudgetExceeded`, `MemoryStore`, `MemoryValidationError`, `pyCompareLt`, `pyEqualValue`, `pyHashKey`, `pyText`, `RECALL_MIN_SCORE_RATIO`, `sortScored`, `tokens`, `VALID_TYPES`<br>*types:* `MemoryStoreOptions`, `SaveResult`, `StoreInternals` |
|
|
462
|
+
| `memory/pyfs.js` | CPython `pathlib` and `os.path` semantics over `node:fs`, error numbering included | `asPyOSError`, `cmpCodepoint`, `matchesMd`, `normcase`, `pyAppendText`, `pyCwd`, `pyDecodeUtf8`, `pyExists`, `pyExpanduser`, `pyHome`, `pyIsAbsolute`, `pyIsDir`, `pyIsFile`, `pyJoin`, `pyLexists`, `pyMkdirParents`, `pyMtimeDate`, `pyName`, `PyOSError`, `pyParent`, `pyParents`, `pyReadText`, `pyReplace`, `pyRepr`, `pyResolve`, `PyRuntimeError`, `pyScandirNames`, `pyStatIsDir`, `pySuffix`, `PyUnicodeDecodeError`, `pyUnlink`, `pyWithSuffix`, `pyWriteText`, `sortedPathNames`, `sortedPathParts`, `STRERROR_NAMES` |
|
|
463
|
+
| `shiftwork.js` | The shift-work checkpoint tools | `clockIn`, `clockOut`, `HISTORY_RING_SIZE`, `SCHEMA_NAME`, `status`, `TERMINAL_UNIT_STATUS`, `timestamp`<br>*types:* `ClockOptions` |
|
|
464
|
+
| **14 modules** | | **139 values, 26 types** |
|
|
465
|
+
|
|
466
|
+
Two things that table is easy to misread. The `py*` names are not a Python bridge — they are
|
|
467
|
+
this port's re-implementations of CPython semantics (`pyfs.js` alone is 36 of the 139), and
|
|
468
|
+
they are exported because the conformance harness and the operator tooling call them, not
|
|
469
|
+
because an application should. And `Function.length` is not the signature: `clockIn.length` is
|
|
470
|
+
`1` because two of its three parameters have defaults.
|
|
471
|
+
|
|
472
|
+
**One export changed shape.** `clockIn` took a unit id as its **second positional parameter**,
|
|
473
|
+
ahead of the options object:
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
clockIn(checkpoint: string, unitId: string | null = null, options: ClockOptions = {}): PyValue
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
A caller who wrote `clockIn(path, { now })` must now write `clockIn(path, null, { now })`, and
|
|
480
|
+
`clockIn(path, 'U3')` briefs a named ready unit. TypeScript rejects the old shape at the type;
|
|
481
|
+
plain JavaScript does not — the object lands in the id slot, the call asks for a unit named
|
|
482
|
+
`[object Object]`, and `now` is dropped, which is a wrong answer rather than an error.
|
|
483
|
+
`clockOut` is unchanged. The reasoning and the measurement behind it are in
|
|
484
|
+
[docs/porting.md → The published export surface](https://github.com/Ink01101011/bantamkit/blob/main/docs/porting.md#the-published-export-surface-and-the-one-break-in-it);
|
|
485
|
+
`docs/` is not in the npm tarball (`files: ["dist", "assets"]`), which is why the migration line
|
|
486
|
+
is repeated here.
|
|
487
|
+
|
|
488
|
+
**Rerun the whole table.** From a clone, in `runtime-ts/`:
|
|
489
|
+
|
|
490
|
+
```sh
|
|
491
|
+
npm pack --pack-destination /tmp/bk
|
|
492
|
+
mkdir -p /tmp/bk/consumer/node_modules/bantamkit-mcp
|
|
493
|
+
tar -xzf /tmp/bk/bantamkit-mcp-*.tgz -C /tmp/bk/consumer/node_modules/bantamkit-mcp --strip-components=1
|
|
494
|
+
printf '{"name":"c","private":true,"type":"module"}' > /tmp/bk/consumer/package.json
|
|
495
|
+
cd /tmp/bk/consumer && node -e "import('bantamkit-mcp').then((m) => console.log(Object.keys(m).length))"
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
It prints `139`, with no `npm install` and nothing fetched: `dist/index.js` pulls in none of the
|
|
499
|
+
server code, so the one runtime dependency is never reached. The 26 type-only exports are in the
|
|
500
|
+
same tarball — from `/tmp/bk/consumer`:
|
|
501
|
+
|
|
502
|
+
```sh
|
|
503
|
+
node -e "const t=require('fs').readFileSync('node_modules/bantamkit-mcp/dist/index.d.ts','utf8');console.log([...t.matchAll(/export type \{([^}]*)\}/g)].flatMap((m) => m[1].split(',')).map((x) => x.trim()).filter(Boolean).length)"
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Both numbers move whenever `src/index.ts` does, which is why they are quoted with the command
|
|
507
|
+
that prints them rather than kept in prose.
|
|
508
|
+
|
|
408
509
|
## Development
|
|
409
510
|
|
|
410
511
|
```sh
|
|
@@ -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. 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.",
|
|
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 — which must appear in shiftwork_plan's `ready`, the batch the dependency graph says may run now. A unit_id that is not ready, including one naming no unit, is refused with {\"result\": \"error\", \"reason\": \"unit <id> is not ready; ready is <a, b>\"}, and the refusals of the batch view itself (cycle, unknown dependency, unreadable checkpoint) pass through verbatim. 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
|
],
|
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
"checkpoint": {
|
|
10
10
|
"title": "Checkpoint",
|
|
11
11
|
"type": "string"
|
|
12
|
+
},
|
|
13
|
+
"unit_id": {
|
|
14
|
+
"anyOf": [
|
|
15
|
+
{
|
|
16
|
+
"type": "string"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"type": "null"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"default": null,
|
|
23
|
+
"title": "Unit Id"
|
|
12
24
|
}
|
|
13
25
|
},
|
|
14
26
|
"required": [
|
|
@@ -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). `unit_id` must be `plan.cursor
|
|
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). `unit_id` must be `plan.cursor` — which needs no brief, clocking out with briefed=false — OR a unit briefed since its own last clock-out; any other id keeps its refusal (`unit X is not the cursor unit Y`). plan.cursor then advances to the head of the ready batch recomputed on the mutated document, falling back to plan.units order over the remaining non-terminal units when the graph cannot batch (cycle or unknown dependency), and to unit_id when none remain. `handoff_patch` and `history_entry` are both required arguments: `handoff_patch` is shallow-merged into `handoff` and takes only its four keys (pass `{}` to leave it unchanged); `history_entry` needs `unit` and `outcome`; `status` is one of the five unit statuses — a value outside these shapes is refused by the schema check, nothing written. 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`). `accounting` is optional: null, the default, logs a line with no cost figures at all; a non-null line must fit the accounting shape below. `briefed` is written by the runtime, never taken from you: true when a brief was issued for this unit since its LAST clock-out (not ever), so a unit re-run after a blocked clock-out reads false. If job.roles names this unit's role, accounting.model must be one of that role's model identifiers, spelled exactly, or nothing is written — see the `model` property below.",
|
|
4
4
|
"surfaces": [
|
|
5
5
|
"mcp"
|
|
6
6
|
],
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"unit_id": {
|
|
14
14
|
"title": "Unit Id",
|
|
15
15
|
"type": "string",
|
|
16
|
-
"description": "The unit being clocked out. Must
|
|
16
|
+
"description": "The unit being clocked out. Must be plan.cursor, or a unit briefed since its own last clock-out: clock_in briefs the cursor unit by default and, when its unit_id names one, any unit shiftwork_plan reports in `ready` — and that brief is what lets clock_out accept a unit the cursor is not on."
|
|
17
17
|
},
|
|
18
18
|
"status": {
|
|
19
19
|
"title": "Status",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shiftwork_plan",
|
|
3
|
-
"description": "Shift-work plan: read-only batch view of a checkpoint — which units its `depends_on` graph permits to run in parallel. Answers `{\"result\": \"plan\", \"batches\", \"ready\", \"sequence\", \"width\", \"cursor\"}`: `ready` is `batches[0]`, the units whose dependencies are all satisfied, and `width` is the largest batch length, the widest fan-out.
|
|
3
|
+
"description": "Shift-work plan: read-only batch view of a checkpoint — which units its `depends_on` graph permits to run in parallel. Answers `{\"result\": \"plan\", \"batches\", \"ready\", \"sequence\", \"width\", \"cursor\"}`: `ready` is `batches[0]`, the units whose dependencies are all satisfied, and `width` is the largest batch length, the widest fan-out. A `ready` member beyond the cursor is clockable, but only in that order: shiftwork_clock_in briefs any unit in `ready` when `unit_id` names it, and shiftwork_clock_out then accepts that unit because it was briefed — an id that is neither the cursor nor briefed since its own last clock-out is still refused. Units with status `done` or `dropped` are satisfied — dropped from the graph, and edges pointing at them treated as already resolved; `todo`, `in_progress` and `blocked` stay in it. Every unit has priority 0, because the checkpoint schema has no priority field, so order inside a batch is `plan.units` order. Same refusal sentences as `work_plan` for a duplicate id, an unknown dependency or a cycle, and the same refusals `shiftwork_status` gives for a checkpoint it cannot read. It reports what the dependency graph permits; it does not move the cursor, which is echoed unchanged so the single-pointer contract and the batch view can be read side by side. An orchestrator stays responsible for what it actually dispatches: `depends_on` encodes logical order, not file contention. Never mutates.",
|
|
4
4
|
"surfaces": [
|
|
5
5
|
"mcp"
|
|
6
6
|
],
|
package/dist/cli.d.ts
CHANGED
|
@@ -6,11 +6,15 @@ export interface Options {
|
|
|
6
6
|
store: string | null;
|
|
7
7
|
start: string | null;
|
|
8
8
|
assetsRoot: boolean;
|
|
9
|
+
hook: boolean;
|
|
9
10
|
mcpReport: boolean;
|
|
10
11
|
statusline: boolean;
|
|
11
12
|
update: boolean;
|
|
12
13
|
install: Host | null;
|
|
13
14
|
force: boolean;
|
|
15
|
+
installHooks: boolean;
|
|
16
|
+
removeHooks: boolean;
|
|
17
|
+
yes: boolean;
|
|
14
18
|
}
|
|
15
19
|
/** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
|
|
16
20
|
export declare function parseArgs(argv: readonly string[]): Options;
|
package/dist/cli.js
CHANGED
|
@@ -68,11 +68,12 @@
|
|
|
68
68
|
* reaches for is answered on the wire instead, by `build_identity`. `runtime-ts/README.md`
|
|
69
69
|
* and `docs/install.md` carry the same correction in their own words.
|
|
70
70
|
*/
|
|
71
|
-
import { readFileSync } from 'node:fs';
|
|
71
|
+
import { readFileSync, readSync } from 'node:fs';
|
|
72
72
|
import { dirname } from 'node:path';
|
|
73
73
|
import { fileURLToPath } from 'node:url';
|
|
74
74
|
import { assetsRoot } from './assets.js';
|
|
75
75
|
import { BantamError } from './errors.js';
|
|
76
|
+
import { logHookFailure, runHook } from './hookadapter.js';
|
|
76
77
|
import { buildReport, resolveEventLogPath } from './mcpreport.js';
|
|
77
78
|
import { Memory } from './memory/component.js';
|
|
78
79
|
import { DEFAULT_INDEX_BUDGET } from './memory/store.js';
|
|
@@ -81,7 +82,7 @@ import { buildServer } from './mcp/server.js';
|
|
|
81
82
|
import { RawStdioTransport } from './mcp/transport.js';
|
|
82
83
|
import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs as parseWithSpec, pyIntStrict, } from './pyargparse.js';
|
|
83
84
|
import { pyJoin, PyOSError, pyRepr, pyStatIsDir } from './memory/pyfs.js';
|
|
84
|
-
import { HOSTS, InstallError, installSelf } from './hostinstall.js';
|
|
85
|
+
import { HOSTS, HookConsentUnavailable, HookDeclined, installHooks, InstallError, installSelf, removeHooks, } from './hostinstall.js';
|
|
85
86
|
import { runUpdate } from './selfupdate.js';
|
|
86
87
|
import { statusLine } from './statusline.js';
|
|
87
88
|
/** `SystemExit("...")`: the message on stderr, exit 1. */
|
|
@@ -137,6 +138,38 @@ const PARSER = {
|
|
|
137
138
|
typeName: 'int',
|
|
138
139
|
defaultValue: DEFAULT_INDEX_BUDGET,
|
|
139
140
|
},
|
|
141
|
+
// THE HOOK ADAPTER, AND IT IS A FLAG FOR THE REASON `--mcp-report` GIVES BELOW, ONLY
|
|
142
|
+
// HARDER. `package.json` declares exactly one bin, so anything hung off a second entry
|
|
143
|
+
// point is unreachable in the pure-npx install that is the shipped product -- and the
|
|
144
|
+
// hook adapter was, literally: it lived in `tools/hooks/bantamkit-hook.mjs`, and MEASURED
|
|
145
|
+
// at 0.35.3 with `npm pack --dry-run` the tarball is 174 files under
|
|
146
|
+
// `files: ["dist","assets"]` with not one of them matching `hook`. An operator who
|
|
147
|
+
// installed bantamkit the only way it is published had no adapter on disk to register.
|
|
148
|
+
//
|
|
149
|
+
// POSITION IS WIRE-VISIBLE AND IT IS MEASURED, the same as every flag below it. At the
|
|
150
|
+
// 80-column fallback argparse breaks the usage after `[--index-budget BYTES]`, and that
|
|
151
|
+
// first line is pinned in `test_mcpserver.py`, in `test/cli-surface.test.mjs` and as a
|
|
152
|
+
// THROWING precondition in `tools/conformance/suites/cli.mjs`. Registered HERE -- the
|
|
153
|
+
// first flag AFTER `--index-budget` -- it grows the SECOND usage line only, from 61
|
|
154
|
+
// columns to 70 against a fold at 78. Registering it any earlier would move the pinned
|
|
155
|
+
// line and turn a differential suite into a re-baselining one.
|
|
156
|
+
//
|
|
157
|
+
// BARE, WITH NO METAVAR, AND THE EVENT COMES FROM STDIN. The host sends one JSON object
|
|
158
|
+
// carrying `hook_event_name`, which is what the adapter has always dispatched on; a
|
|
159
|
+
// second spelling of the event on the command line would be a second thing to keep in
|
|
160
|
+
// step with the host, and the registration in `~/.claude/settings.json` would have to
|
|
161
|
+
// carry seven different commands instead of one.
|
|
162
|
+
//
|
|
163
|
+
// THE CONTRACT IS DELIBERATELY NARROW, WHICH IS WHAT MAKES IT GATEABLE: one JSON object
|
|
164
|
+
// in on stdin, at most one JSON object out on stdout, exit 0 ALWAYS. A conformance case
|
|
165
|
+
// can feed both runtimes the same payload and compare the emitted object byte for byte.
|
|
166
|
+
{
|
|
167
|
+
optionStrings: ['--hook'],
|
|
168
|
+
dest: 'hook',
|
|
169
|
+
kind: 'storeTrue',
|
|
170
|
+
help: 'run as a Claude Code hook: one JSON event on stdin, then exit',
|
|
171
|
+
defaultValue: false,
|
|
172
|
+
},
|
|
140
173
|
// WHY THIS IS A FLAG ON THIS PROCESS AND NOT A NEW ENTRY POINT, and why it sits HERE.
|
|
141
174
|
// The lifecycle argument against printing from this process -- stdout is the JSON-RPC
|
|
142
175
|
// channel -- is true of a RUNNING server and not of a flag that prints and returns before
|
|
@@ -226,6 +259,52 @@ const PARSER = {
|
|
|
226
259
|
help: 'with --install, replace an existing bantamkit entry',
|
|
227
260
|
defaultValue: false,
|
|
228
261
|
},
|
|
262
|
+
// THE HOOK REGISTRATION, AND IT IS THREE FLAGS, NOT A MODIFIER ON `--install`.
|
|
263
|
+
//
|
|
264
|
+
// S1 RULING Q3.1: hooks are NEVER written as a side effect of `--install <host>`. Somebody
|
|
265
|
+
// asking to register an MCP server has not asked to register seven hooks that run on every
|
|
266
|
+
// tool call. RULING Q3.2 makes it its own opt-in, with a paired `--remove-hooks`, and gives
|
|
267
|
+
// the reason a modifier was refused: `--force`'s help above says "with --install", and
|
|
268
|
+
// overloading `--install` with a second, differently-consented write would make that
|
|
269
|
+
// sentence false.
|
|
270
|
+
//
|
|
271
|
+
// `--yes` IS THE THIRD STATE OF THE GATE, WHICH IS WHY IT IS A FLAG AND NOT AN ENV VAR.
|
|
272
|
+
// RULING Q3.3 makes the no-terminal-and-no-`--yes` case a REFUSAL at exit 2, so `--yes` is
|
|
273
|
+
// the only path a CI or scripted install has, and the ruling requires it to appear in `-h`
|
|
274
|
+
// so nobody has to guess it. It carries `--force`'s "with --install…" phrasing for the
|
|
275
|
+
// same reason `--force` does: a flag that is inert on its own says so in its own help.
|
|
276
|
+
//
|
|
277
|
+
// POSITION IS WIRE-VISIBLE, the same as every flag above. These three land AFTER `--force`
|
|
278
|
+
// and before the `--store`/`--start` group, which leaves the FIRST line of the 80-column
|
|
279
|
+
// usage — the line pinned by `test/cli-surface.test.mjs` and by the `cli` conformance
|
|
280
|
+
// suite — byte-identical, and moves the wrap boundary on the later lines only. The group
|
|
281
|
+
// indices below move with them.
|
|
282
|
+
{
|
|
283
|
+
optionStrings: ['--install-hooks'],
|
|
284
|
+
dest: 'install_hooks',
|
|
285
|
+
kind: 'storeTrue',
|
|
286
|
+
help: "add bantamkit's hook entries to ~/.claude/settings.json, then exit",
|
|
287
|
+
defaultValue: false,
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
optionStrings: ['--remove-hooks'],
|
|
291
|
+
dest: 'remove_hooks',
|
|
292
|
+
kind: 'storeTrue',
|
|
293
|
+
help: "take bantamkit's hook entries back out of ~/.claude/settings.json, then exit",
|
|
294
|
+
defaultValue: false,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
optionStrings: ['--yes'],
|
|
298
|
+
dest: 'yes',
|
|
299
|
+
kind: 'storeTrue',
|
|
300
|
+
// NAMES BOTH FLAGS since the user's ruling of 2026-09-20 put the same gate on
|
|
301
|
+
// `--remove-hooks`. RULING Q3.3 requires the non-interactive consent path to be in `-h`
|
|
302
|
+
// so nobody has to guess it; a `--yes` that silently also worked with `--remove-hooks`
|
|
303
|
+
// would leave the only way to script a removal undocumented, which is the defect
|
|
304
|
+
// `--assets-root` shipped once already.
|
|
305
|
+
help: 'with --install-hooks or --remove-hooks, say yes in advance instead of being asked',
|
|
306
|
+
defaultValue: false,
|
|
307
|
+
},
|
|
229
308
|
{
|
|
230
309
|
optionStrings: ['--store'],
|
|
231
310
|
dest: 'store',
|
|
@@ -242,10 +321,11 @@ const PARSER = {
|
|
|
242
321
|
},
|
|
243
322
|
],
|
|
244
323
|
// `--store`/`--start` moved from 6/7 to 8/9 when `--install` and `--force` were added
|
|
245
|
-
// ahead of them,
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
324
|
+
// ahead of them, from 8/9 to 9/10 when `--update` was, from 9/10 to 10/11 when `--hook`
|
|
325
|
+
// was, and from 10/11 to 13/14 when `--install-hooks`, `--remove-hooks` and `--yes` were.
|
|
326
|
+
// These are POSITIONS, not names, so adding an action above the group and leaving this
|
|
327
|
+
// line alone would silently make two unrelated flags mutually exclusive.
|
|
328
|
+
groups: [[13, 14]],
|
|
249
329
|
};
|
|
250
330
|
/** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
|
|
251
331
|
export function parseArgs(argv) {
|
|
@@ -256,11 +336,15 @@ export function parseArgs(argv) {
|
|
|
256
336
|
store: values['store'],
|
|
257
337
|
start: values['start'],
|
|
258
338
|
assetsRoot: values['assets_root'],
|
|
339
|
+
hook: values['hook'],
|
|
259
340
|
mcpReport: values['mcp_report'],
|
|
260
341
|
statusline: values['statusline'],
|
|
261
342
|
update: values['update'],
|
|
262
343
|
install: values['install'],
|
|
263
344
|
force: values['force'],
|
|
345
|
+
installHooks: values['install_hooks'],
|
|
346
|
+
removeHooks: values['remove_hooks'],
|
|
347
|
+
yes: values['yes'],
|
|
264
348
|
};
|
|
265
349
|
}
|
|
266
350
|
/**
|
|
@@ -320,6 +404,86 @@ function typedBareAtATerminal(argv) {
|
|
|
320
404
|
return false;
|
|
321
405
|
return Boolean(stream.isTTY);
|
|
322
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* Is there a terminal to ask a question at? The SAME signal `typedBareAtATerminal` uses.
|
|
409
|
+
*
|
|
410
|
+
* `process.stdin.isTTY` is `true` on a terminal and **`undefined`** — not `false` — on a pipe,
|
|
411
|
+
* a file or `/dev/null`, so this is a truthiness test for the reason spelled out above: a
|
|
412
|
+
* `=== false` test would make every non-terminal invocation look like a terminal, and on THIS
|
|
413
|
+
* path that inverts a consent gate. Neither the getter throwing nor a null `process.stdin` is
|
|
414
|
+
* a terminal, and neither may take the process down: what is at stake is a refusal.
|
|
415
|
+
*/
|
|
416
|
+
function haveATerminal() {
|
|
417
|
+
let stream;
|
|
418
|
+
try {
|
|
419
|
+
stream = process.stdin;
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
if (stream === null || stream === undefined)
|
|
425
|
+
return false;
|
|
426
|
+
return Boolean(stream.isTTY);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* RULING Q3.4's question, on stderr, answered on stdin. Only ever called at a terminal.
|
|
430
|
+
*
|
|
431
|
+
* THE QUESTION IS A PARAMETER AND THE READER IS NOT. `--install-hooks` asks "Write these hook
|
|
432
|
+
* entries?" and `--remove-hooks` asks "Remove these hook entries?"; everything after the
|
|
433
|
+
* question mark — the `[y/N]` default, the one-byte read, the EAGAIN retry, and the rule that
|
|
434
|
+
* only `y`/`Y` is a yes — is one implementation on purpose, because two flags rewriting one
|
|
435
|
+
* file must not answer differently to the same keystroke.
|
|
436
|
+
*
|
|
437
|
+
* ONE LINE, READ SYNCHRONOUSLY, ONE BYTE AT A TIME. `readFileSync(0)` would block until EOF —
|
|
438
|
+
* at a terminal that is Ctrl-D, not Enter — so the answer is read to the first newline and no
|
|
439
|
+
* further, which leaves anything the person typed after it for whoever asks next.
|
|
440
|
+
*
|
|
441
|
+
* ANYTHING OTHER THAN `y` OR `Y` MEANS NO, including empty and including EOF. That is the
|
|
442
|
+
* ruling, and it is why the default in the prompt is spelled `[y/N]`: the safe answer is the
|
|
443
|
+
* one you get by pressing Enter, by piping nothing, or by closing the terminal.
|
|
444
|
+
*
|
|
445
|
+
* EAGAIN IS NOT AN ANSWER, AND THE FIRST VERSION OF THIS FUNCTION THOUGHT IT WAS. Node leaves
|
|
446
|
+
* a terminal's fd 0 in NON-BLOCKING mode, so a synchronous read of one the person has not
|
|
447
|
+
* typed into yet throws `EAGAIN` instead of waiting. Measured on a real pty on 2026-09-20:
|
|
448
|
+
* `isTTY=true`, and the very first `readSync(0, …)` threw
|
|
449
|
+
* `EAGAIN: resource temporarily unavailable, read` before anything had been typed. Treating
|
|
450
|
+
* that as EOF answered "no" to a question nobody had been given a chance to answer — the
|
|
451
|
+
* refusal was safe, but the feature was unreachable at the only place it is meant to work,
|
|
452
|
+
* and NO UNIT TEST COULD SEE IT because every one of them injects the answer through the
|
|
453
|
+
* `ask` seam. A pty found it; a suite could not.
|
|
454
|
+
*
|
|
455
|
+
* `Atomics.wait` IS THE ONLY SYNCHRONOUS SLEEP THERE IS, and one is needed: without it the
|
|
456
|
+
* retry is a busy spin on a terminal waiting for a human.
|
|
457
|
+
*/
|
|
458
|
+
function askAtTheTerminal(question) {
|
|
459
|
+
process.stderr.write(`${question} [y/N] `);
|
|
460
|
+
const byte = Buffer.alloc(1);
|
|
461
|
+
const idle = new Int32Array(new SharedArrayBuffer(4));
|
|
462
|
+
let answer = '';
|
|
463
|
+
for (;;) {
|
|
464
|
+
let read;
|
|
465
|
+
try {
|
|
466
|
+
read = readSync(0, byte, 0, 1, null);
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
469
|
+
if (e.code === 'EAGAIN') {
|
|
470
|
+
Atomics.wait(idle, 0, 0, 20); // nothing typed yet; wait for the person, not for the cpu
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
break; // a closed fd, a stream the platform cannot read this way — not a yes
|
|
474
|
+
}
|
|
475
|
+
if (read === 0)
|
|
476
|
+
break; // EOF: Ctrl-D, or a terminal that went away
|
|
477
|
+
const ch = byte.toString('utf8');
|
|
478
|
+
if (ch === '\n')
|
|
479
|
+
break;
|
|
480
|
+
if (ch !== '\r')
|
|
481
|
+
answer += ch;
|
|
482
|
+
}
|
|
483
|
+
process.stderr.write('\n');
|
|
484
|
+
const said = answer.trim();
|
|
485
|
+
return said === 'y' || said === 'Y';
|
|
486
|
+
}
|
|
323
487
|
/** `_build_memory`, including the two refusals argparse cannot express. */
|
|
324
488
|
function buildMemory(options) {
|
|
325
489
|
if (options.k < 1)
|
|
@@ -429,6 +593,28 @@ async function main(argv) {
|
|
|
429
593
|
process.stdout.write(`${root}\n${packFileCount(root)} files\n`);
|
|
430
594
|
return 0;
|
|
431
595
|
}
|
|
596
|
+
if (options.hook) {
|
|
597
|
+
// DISPATCH ORDER IS REGISTRATION ORDER, which is the rule the four flags below already
|
|
598
|
+
// follow and the reason `--mcp-report --install cursor` prints a report and writes
|
|
599
|
+
// nothing. `--hook` is registered directly after `--index-budget`, so it is checked
|
|
600
|
+
// directly after `--assets-root`.
|
|
601
|
+
//
|
|
602
|
+
// EXIT 0 ALWAYS, AND THAT IS THE CONTRACT, not a convenience. A hook that exits non-zero
|
|
603
|
+
// or lets a stack reach stderr is rendered by the host as an error on the user's screen,
|
|
604
|
+
// so every failure is logged into `~/.bantamkit/hooks/hook-log.jsonl` and swallowed. This
|
|
605
|
+
// is the one arm in this file whose refusal path is a log line rather than a message.
|
|
606
|
+
//
|
|
607
|
+
// It returns before `buildMemory` for the same reason every flag around it does: the
|
|
608
|
+
// adapter binds whatever store the winning MCP registration pins, from the cwd the HOST
|
|
609
|
+
// sent in the payload — not from whatever directory the hook process was spawned in.
|
|
610
|
+
try {
|
|
611
|
+
await runHook();
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
logHookFailure(e);
|
|
615
|
+
}
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
432
618
|
if (options.mcpReport) {
|
|
433
619
|
// Same discipline as `--assets-root`, and the same place in `main`: BEFORE
|
|
434
620
|
// `buildMemory`, which touches the filesystem, and before the transport exists at all.
|
|
@@ -485,10 +671,91 @@ async function main(argv) {
|
|
|
485
671
|
}
|
|
486
672
|
return 0;
|
|
487
673
|
}
|
|
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
|
+
// THREE EXITS, AND THE TWO THAT WRITE NOTHING ARE NOT THE SAME EXIT (S1 RULING Q3.3/Q3.4):
|
|
678
|
+
//
|
|
679
|
+
// 0 written, or already installed and matching
|
|
680
|
+
// 1 the person was asked at a terminal and did not say yes — `no hooks were written`
|
|
681
|
+
// 2 there was no terminal to ask at and no `--yes`
|
|
682
|
+
//
|
|
683
|
+
// Neither refusal carries the `error:` prefix `InstallError` gets. Nothing went wrong: the
|
|
684
|
+
// program asked, or found it could not ask, and then did nothing — which is the feature.
|
|
685
|
+
//
|
|
686
|
+
// THE PLAN AND THE QUESTION GO TO STDERR, the report to stdout. One stream is the operator's
|
|
687
|
+
// answer and the other is the conversation that led to it, and a caller piping stdout into
|
|
688
|
+
// something should get the report and not the prompt.
|
|
689
|
+
if (options.installHooks) {
|
|
690
|
+
try {
|
|
691
|
+
process.stdout.write(`${installHooks({
|
|
692
|
+
version: version(),
|
|
693
|
+
yes: options.yes,
|
|
694
|
+
ask: haveATerminal() ? () => askAtTheTerminal('Write these hook entries?') : null,
|
|
695
|
+
tell: (text) => process.stderr.write(text),
|
|
696
|
+
})}\n`);
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
if (e instanceof HookConsentUnavailable) {
|
|
700
|
+
process.stderr.write(`${e.message}\n`);
|
|
701
|
+
return 2;
|
|
702
|
+
}
|
|
703
|
+
if (e instanceof HookDeclined) {
|
|
704
|
+
process.stderr.write(`${e.message}\n`);
|
|
705
|
+
return 1;
|
|
706
|
+
}
|
|
707
|
+
if (!(e instanceof InstallError))
|
|
708
|
+
throw e;
|
|
709
|
+
process.stderr.write(`error: ${e.message}\n`);
|
|
710
|
+
return 1;
|
|
711
|
+
}
|
|
712
|
+
return 0;
|
|
713
|
+
}
|
|
714
|
+
if (options.removeHooks) {
|
|
715
|
+
// THE SAME THREE EXITS, AND THEY ARE THE SAME THREE NUMBERS. RULING Q3.7 used to exempt
|
|
716
|
+
// this flag from the gate; the user overturned that on 2026-09-20 after `--remove-hooks`
|
|
717
|
+
// rewrote the operator's real `~/.claude/settings.json` with no terminal and no `--yes`.
|
|
718
|
+
//
|
|
719
|
+
// 0 removed, or there was nothing of ours to remove
|
|
720
|
+
// 1 the person was asked at a terminal and did not say yes — `no hooks were removed`
|
|
721
|
+
// 2 there was no terminal to ask at and no `--yes`
|
|
722
|
+
//
|
|
723
|
+
// The arms below are `--install-hooks`' arms, in the same order, for the same reasons.
|
|
724
|
+
try {
|
|
725
|
+
process.stdout.write(`${removeHooks({
|
|
726
|
+
yes: options.yes,
|
|
727
|
+
ask: haveATerminal() ? () => askAtTheTerminal('Remove these hook entries?') : null,
|
|
728
|
+
tell: (text) => process.stderr.write(text),
|
|
729
|
+
})}\n`);
|
|
730
|
+
}
|
|
731
|
+
catch (e) {
|
|
732
|
+
if (e instanceof HookConsentUnavailable) {
|
|
733
|
+
process.stderr.write(`${e.message}\n`);
|
|
734
|
+
return 2;
|
|
735
|
+
}
|
|
736
|
+
if (e instanceof HookDeclined) {
|
|
737
|
+
process.stderr.write(`${e.message}\n`);
|
|
738
|
+
return 1;
|
|
739
|
+
}
|
|
740
|
+
if (!(e instanceof InstallError))
|
|
741
|
+
throw e;
|
|
742
|
+
process.stderr.write(`error: ${e.message}\n`);
|
|
743
|
+
return 1;
|
|
744
|
+
}
|
|
745
|
+
return 0;
|
|
746
|
+
}
|
|
488
747
|
// `--force` alone is a typo with a plausible reading — somebody meant to install and
|
|
489
748
|
// dropped the flag that says where. Refusing names the missing half.
|
|
490
749
|
if (options.force)
|
|
491
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');
|
|
492
759
|
// A PERSON TYPED IT. `typedBareAtATerminal` carries the whole argument; what belongs here is
|
|
493
760
|
// only that this sits BEFORE `buildMemory`, which is what creates a store. Somebody who typed
|
|
494
761
|
// a command to see what it does has not asked for a `.bantamkit/memory` directory in whatever
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's hook payload. Every field is optional because the host sends a DIFFERENT shape
|
|
3
|
+
* per event and this module is handed whatever arrives — including, on a malformed line,
|
|
4
|
+
* nothing at all. Narrowing happens in the arm that needs the field, never here.
|
|
5
|
+
*/
|
|
6
|
+
export interface HookInput {
|
|
7
|
+
hook_event_name?: string;
|
|
8
|
+
session_id?: string;
|
|
9
|
+
transcript_path?: string;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
prompt?: string;
|
|
13
|
+
tool_name?: string;
|
|
14
|
+
tool_input?: Record<string, unknown>;
|
|
15
|
+
tool_use_id?: string;
|
|
16
|
+
trigger?: string;
|
|
17
|
+
stop_hook_active?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `bantamkit-mcp --hook`: ONE JSON object in, AT MOST ONE JSON object out, and the caller
|
|
21
|
+
* exits 0 whatever happens.
|
|
22
|
+
*
|
|
23
|
+
* NOTHING HERE THROWS PAST THE CALLER. `cli.ts` wraps this in the same `catch → log → 0` the
|
|
24
|
+
* standalone script's last line was, because a hook that exits non-zero or writes a stack to
|
|
25
|
+
* stderr is rendered by the host as an error on the user's screen.
|
|
26
|
+
*/
|
|
27
|
+
export declare function runHook(): Promise<void>;
|
|
28
|
+
/** The `catch` half of the contract, so `cli.ts` states it in one line. */
|
|
29
|
+
export declare function logHookFailure(e: unknown): void;
|