bantamkit-mcp 0.32.1 → 0.34.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/dist/shiftwork.js CHANGED
@@ -10,7 +10,9 @@
10
10
  * * the rewritten checkpoint — `json.dumps(document, indent=2) + "\n"`, `.tmp` then
11
11
  * `replace()`;
12
12
  * * the `.log.jsonl` line — `json.dumps(record, sort_keys=True) + "\n"`, append mode,
13
- * **never read back**, so a wrong line is never noticed by the code that wrote it;
13
+ * read back by exactly ONE thing since job50/F6 — `briefed()`, which looks at the `unit`
14
+ * and `event` keys and nothing else — so a wrong line is still never noticed by the
15
+ * code that wrote it;
14
16
  * * the returned dict, which the MCP surface hands straight to the model.
15
17
  *
16
18
  * THE ensure_ascii RULING. `json.dumps` defaults to `ensure_ascii=True` and
@@ -51,15 +53,33 @@
51
53
  * accounting line is appended BEFORE the atomic rename, so a partial failure can lose the
52
54
  * commit but never the accounting.
53
55
  */
54
- import { loadSchema } from './assets.js';
56
+ import { AssetNotFound, loadSchema, loadToolAsset } from './assets.js';
55
57
  import { schemaError } from './contract.js';
56
- import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, } from './memory/pyfs.js';
58
+ import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, PyUnicodeDecodeError, } from './memory/pyfs.js';
57
59
  import { dumpJson, fromJs, parseJson, PyJSONDecodeError, reprValue } from './pyjson.js';
60
+ import { checkSchema, PyJsonSchemaUnsupported } from './pyjsonschema.js';
58
61
  export const SCHEMA_NAME = 'shiftwork-checkpoint';
62
+ /** The accounting line's shape lives on the TOOL asset, not in a second copy here (F5). */
63
+ const TOOL_ASSET = 'shiftwork_clock_out';
64
+ /**
65
+ * J50-9A/9B: the one sentence for a pack that cannot supply the accounting shape. FIXED —
66
+ * no path, no exception text, no unit — read out of `ACCOUNTING_SHAPE_UNREADABLE` in the
67
+ * Python module byte for byte, so a differential case can compare the two; which of the
68
+ * shapes failed is not said, because the property is the same for all of them (see
69
+ * `accountingSchema`).
70
+ */
71
+ export const ACCOUNTING_SHAPE_UNREADABLE = "cannot clock out: the shiftwork_clock_out tool asset cannot be read as the " +
72
+ "accounting line's shape, so it allows no accounting line";
59
73
  /** The driver's SUCCESS test. */
60
74
  export const TERMINAL_UNIT_STATUS = new Set(['done', 'dropped']);
61
75
  /** The schema's `maxItems` — older entries fall off the ring. */
62
76
  export const HISTORY_RING_SIZE = 5;
77
+ /**
78
+ * F6: the `event` value of the ledger line `clockIn` appends when it issues a brief. The word
79
+ * is the register's own — `clock_in` answers `result: "brief"` — so the ledger names the
80
+ * thing it recorded with the same word the wire used. `BRIEF_EVENT` in `shiftwork.py`.
81
+ */
82
+ export const BRIEF_EVENT = 'brief';
63
83
  // ------------------------------------------------------------------- tagged-value sugar
64
84
  const str = (v) => ({ t: 'str', v });
65
85
  const int = (v) => ({ t: 'int', v: BigInt(v) });
@@ -161,6 +181,100 @@ export function timestamp(now) {
161
181
  return (`${pad(when.getUTCFullYear(), 4)}-${pad(when.getUTCMonth() + 1)}-${pad(when.getUTCDate())}` +
162
182
  `T${pad(when.getUTCHours())}:${pad(when.getUTCMinutes())}:${pad(when.getUTCSeconds())}Z`);
163
183
  }
184
+ // ------------------------------------------------------------------------ F6: the ledger
185
+ /**
186
+ * `Path(str(path) + ".log.jsonl")` — string concatenation on the NORMALIZED path, not
187
+ * `with_suffix`, so `cp.json` gets `cp.json.log.jsonl` and not `cp.log.jsonl`.
188
+ */
189
+ function logPath(path) {
190
+ return `${path}.log.jsonl`;
191
+ }
192
+ /**
193
+ * `_record_brief`: append the brief line — best effort, NEVER throws for a store that
194
+ * refuses writes, never changes the brief.
195
+ *
196
+ * One line per call. A unit clocked in twice before it clocks out (a relaunch after a
197
+ * crashed subagent) leaves two brief lines, and a reader should conclude exactly that. The
198
+ * ledger records events, not state.
199
+ *
200
+ * THE NARROWNESS IS THE PORT OF `except OSError`. `pyAppendText` converts every filesystem
201
+ * failure into a `PyOSError` — a read-only directory, a directory where the file belongs, a
202
+ * full disk — and that is the ONLY class swallowed here. Anything else out of this block (a
203
+ * `TypeError` from a record that could not be dumped, a bug in this function) is not an
204
+ * OSError in Python and is rethrown here, because hiding it would leave every unit
205
+ * `briefed: false` forever with no red anywhere. The same `instanceof PyOSError` test the
206
+ * two write legs of `clockOut` already use, for the same reason.
207
+ */
208
+ function recordBrief(log, unitId, role, now) {
209
+ const record = new Map([
210
+ ['event', str(BRIEF_EVENT)],
211
+ ['ts', str(timestamp(now))],
212
+ ['unit', str(unitId)],
213
+ ['role', role],
214
+ ]);
215
+ try {
216
+ pyAppendText(log, `${dumpJson({ t: 'dict', v: record }, { sortKeys: true })}\n`);
217
+ }
218
+ catch (e) {
219
+ if (!(e instanceof PyOSError))
220
+ throw e;
221
+ }
222
+ }
223
+ /**
224
+ * `str.splitlines()` — the boundaries CPython splits on, which are more than `\n`. The
225
+ * CRLF and bare CR forms are already folded by `pyReadText` (universal newlines, as
226
+ * `Path.read_text` folds them), so they do not need to be here, but nothing is lost by it.
227
+ */
228
+ const SPLITLINES = /\r\n|[\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029]/;
229
+ /**
230
+ * `_briefed`: was a brief issued for `unitId` since its last clock-out? Read off the ledger.
231
+ *
232
+ * Walk the ledger in order, tracking only lines that name this unit: a brief line sets the
233
+ * flag, an accounting line (any line that is not a brief event) clears it. The answer is the
234
+ * flag at the end — so a clock-out CONSUMES the brief: brief, blocked, re-run without a
235
+ * clock_in reads `false`; brief, blocked, clock_in, done reads `true`. Detectable false
236
+ * negatives (a retried clock-out's duplicate line) beat undetectable false positives (an
237
+ * inline re-run marked briefed because a brief was issued once, ever).
238
+ *
239
+ * Reads the ledger the way a reader must: a missing or unreadable log is `false`, a line
240
+ * that is not JSON or not an object is skipped, never fatal. `record.get("unit") != unit_id`
241
+ * is a Python `!=`, so a `unit` that is not a string never equals the id; and the read is
242
+ * `except OSError` — a ledger that does not DECODE as UTF-8 raises out of Python's
243
+ * `clock_out` (`UnicodeDecodeError` is a `ValueError`), so `PyUnicodeDecodeError` is
244
+ * rethrown here for the same reason `recordBrief` rethrows: the port does not swallow what
245
+ * the reference does not.
246
+ */
247
+ function briefed(log, unitId) {
248
+ let content;
249
+ try {
250
+ content = pyReadText(log);
251
+ }
252
+ catch (e) {
253
+ if (!(e instanceof PyOSError))
254
+ throw e;
255
+ return false;
256
+ }
257
+ let flag = false;
258
+ for (const line of content.split(SPLITLINES)) {
259
+ let record;
260
+ try {
261
+ record = parseJson(line);
262
+ }
263
+ catch (e) {
264
+ if (!(e instanceof PyJSONDecodeError))
265
+ throw e;
266
+ continue;
267
+ }
268
+ if (record.t !== 'dict')
269
+ continue;
270
+ const unit = record.v.get('unit');
271
+ if (unit === undefined || unit.t !== 'str' || unit.v !== unitId)
272
+ continue;
273
+ const event = record.v.get('event');
274
+ flag = event !== undefined && event.t === 'str' && event.v === BRIEF_EVENT;
275
+ }
276
+ return flag;
277
+ }
164
278
  // ----------------------------------------------------------------------------- clock_in
165
279
  /**
166
280
  * Validate the checkpoint and return the cursor unit's brief, or a refusal.
@@ -175,7 +289,7 @@ export function timestamp(now) {
175
289
  * all-terminal test before the cursor lookup — so an empty plan reports success rather than
176
290
  * escalating on its dangling cursor, because `all([])` is true.
177
291
  */
178
- export function clockIn(checkpoint) {
292
+ export function clockIn(checkpoint, options = {}) {
179
293
  const { document, refusal } = readValid(pyJoin(checkpoint));
180
294
  if (refusal !== null)
181
295
  return refusal;
@@ -204,6 +318,10 @@ export function clockIn(checkpoint) {
204
318
  ['reason', str(`cursor ${cursor} names no unit`)],
205
319
  ]);
206
320
  }
321
+ // F6: the brief is issued, so say so in the ledger — best effort, and only on this branch:
322
+ // a refusal above issued nothing and therefore records nothing. `pyJoin` is the same
323
+ // normalisation `clockOut` applies before it builds ITS log path, so the two land in one file.
324
+ recordBrief(logPath(pyJoin(checkpoint)), cursor, field(unit, 'role'), options.now ?? Date.now() / 1000);
207
325
  return dict([
208
326
  ['result', str('brief')],
209
327
  ['unit', unit],
@@ -305,6 +423,137 @@ function modelRefusal(document, unitId, unit, accounting) {
305
423
  }
306
424
  return null;
307
425
  }
426
+ /**
427
+ * The accounting line's shape, read off the clock_out TOOL asset — never a second copy.
428
+ *
429
+ * J50-7 put the definition on `parameters.properties.accounting` of the tool manifest,
430
+ * which is what the wire advertises; but the MCP call path enforces only the argument
431
+ * kind (`dict`), so a host is held to what it was promised only if this function reads
432
+ * the same file. The declared value is `anyOf: [object, null]`. The null arm is the
433
+ * caller's (a null line is legal and is not an audit record), so only the object arm is
434
+ * validated here, wrapped under the key `accounting` so the pointed error names
435
+ * `accounting` / `accounting/<key>` exactly the way `checkpoint invalid:` names a path —
436
+ * one renderer for every schema refusal this module makes, not a second one.
437
+ *
438
+ * Returns null when the pack CANNOT SUPPLY the shape (J50-9A, ported J50-9B). Reading the
439
+ * asset gave this module a dependency the roles gate never had, and a `BANTAMKIT_ASSETS`
440
+ * pack trimmed to `schemas/` — the very pack both runtimes' roles tests build — made
441
+ * `clockOut` die on `AssetNotFound` where the module promises a structured refusal. The
442
+ * caller turns null into `ACCOUNTING_SHAPE_UNREADABLE`, fail CLOSED, per J47-4: a
443
+ * declaration this code cannot read is not a licence. MISSING AND MALFORMED ARE ONE CASE,
444
+ * not two, because the property is one — the line cannot be checked, so it is not allowed —
445
+ * and a second sentence would either render an exception message the two runtimes spell
446
+ * differently, or split one property into per-shape cases that can each be missed. The
447
+ * shapes folded in, each MEASURED to throw out of `clockOut` before this existed: no
448
+ * `tools/` dir or no file (`AssetNotFound`); bytes that are not UTF-8
449
+ * (`PyUnicodeDecodeError`) or not JSON (`SyntaxError`); a manifest without
450
+ * `parameters.properties.accounting.anyOf` at each step of that path (`TypeError`, five
451
+ * ways); an `anyOf` with no object arm (a hand-thrown `Error`); and an object arm the
452
+ * validator refuses as a schema (`PyJsonSchemaUnsupported`, checked here so
453
+ * `accountingRefusal` only ever validates a schema).
454
+ */
455
+ function accountingSchema() {
456
+ let manifest;
457
+ try {
458
+ manifest = loadToolAsset(TOOL_ASSET);
459
+ }
460
+ catch (e) {
461
+ // `except (AssetNotFound, OSError, ValueError)`: no `tools/` dir or no file; a file the
462
+ // OS will not hand over; bytes that are not UTF-8 (`UnicodeDecodeError`, a `ValueError`
463
+ // there and `PyUnicodeDecodeError` here); text that is not JSON (`JSONDecodeError` there,
464
+ // `SyntaxError` out of `JSON.parse` here). Anything else is a defect and still escapes.
465
+ if (e instanceof AssetNotFound ||
466
+ e instanceof PyOSError ||
467
+ e instanceof PyUnicodeDecodeError ||
468
+ e instanceof SyntaxError) {
469
+ return null;
470
+ }
471
+ throw e;
472
+ }
473
+ // `isinstance(declared, dict) and key in declared`, at each step of the path. A JSON
474
+ // array is not a dict, and neither is `null` — `typeof null === 'object'` is not a policy.
475
+ let declared = manifest;
476
+ for (const key of ['parameters', 'properties', 'accounting', 'anyOf']) {
477
+ if (!isDict(declared) || !(key in declared))
478
+ return null;
479
+ declared = declared[key];
480
+ }
481
+ if (!Array.isArray(declared))
482
+ return null;
483
+ // `next((a for a in declared if isinstance(a, dict) and a.get("type") == "object"), None)`
484
+ const arm = declared.find((a) => isDict(a) && a.type === 'object');
485
+ if (arm === undefined)
486
+ return null;
487
+ const schema = { type: 'object', properties: { accounting: arm } };
488
+ // `check_schema`, taken HERE so `accountingRefusal` only ever validates a schema. The
489
+ // port's `validate` runs the same check first and would throw the same class out of
490
+ // `schemaError`; catching it there would make one property two sites.
491
+ try {
492
+ checkSchema(fromJs(schema));
493
+ }
494
+ catch (e) {
495
+ if (e instanceof PyJsonSchemaUnsupported)
496
+ return null;
497
+ throw e;
498
+ }
499
+ return schema;
500
+ }
501
+ /** `isinstance(x, dict)` for a value that came out of `JSON.parse`. */
502
+ function isDict(value) {
503
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
504
+ }
505
+ /**
506
+ * F5 (job50): an accounting line that does not fit its declared shape is refused.
507
+ *
508
+ * Returns the refusal sentence, or null when the clock-out may proceed. The sentence is
509
+ * a fixed frame around the SAME `schemaError` rendering the checkpoint refusals use
510
+ * (`checkpoint invalid: …`, `refused to write: …`), over the same `{"accounting": …}`
511
+ * wrapper the reference builds, so the differential compares like with like. The frame
512
+ * is J50-8's, read out of `_accounting_refusal` and reproduced to the character:
513
+ * `unit {unit_id} in role {role} reported an accounting line the schema refuses: {problem}`.
514
+ *
515
+ * Checked BEFORE anything is written: no accounting line, no cursor advance, the
516
+ * checkpoint byte-unchanged. Same posture as `modelRefusal`, same place in `clockOut`.
517
+ *
518
+ * THREE RULINGS, decided in Python and binding here:
519
+ *
520
+ * * ORDER. The roles gate runs first and keeps its sentence; this check runs second. A
521
+ * non-string `model` under a role `job.roles` names gets the ROLES sentence (`reported
522
+ * model 5, which job.roles.implementer does not allow…`); the schema's `model: string`
523
+ * fires only when the roles gate is silent (no map, or a role the map omits).
524
+ * * NULL STAYS LEGAL. `accounting: null` is not validated — the container is not
525
+ * required. In Node that is three spellings of nothing: the JS `null`, an omitted
526
+ * argument (`undefined`), and the tagged `{t: 'null'}` the MCP server hands over.
527
+ * * ODD KEYS PASS. `additionalProperties: true` — a key the schema does not name is
528
+ * written verbatim. Only the NAMED keys have a type, and only `tokens` and
529
+ * `duration_ms` are required.
530
+ * * NO SHAPE, NO LINE (J50-9A). When the pack cannot supply the shape the line is
531
+ * refused with `ACCOUNTING_SHAPE_UNREADABLE`, in this same place, writing nothing —
532
+ * not skipped. Skipping is the fail-open this file has already had once (job47's
533
+ * `modelRefusal` returning unconstrained on an unreadable declaration). A null line
534
+ * is still not validated, so a pack with no `tools/` can clock out a unit that
535
+ * reports no accounting.
536
+ *
537
+ * One measured fact reproduced rather than reasoned about: under 2020-12 semantics a float
538
+ * that is a whole number (`1234.0`) IS an `integer`; `12.5`, `"1234"`, `true` and `-1` are
539
+ * not. `pyjsonschema` already carries that rule (`instance.t === 'float' &&
540
+ * Number.isInteger(instance.v)`), and the float only survives to reach it on the
541
+ * parsed-from-bytes route — a JS-object `1234.0` is `1234` before this code sees it.
542
+ */
543
+ function accountingRefusal(unitId, unit, accounting) {
544
+ if (accounting === null || accounting === undefined)
545
+ return null;
546
+ const line = asPyValue(accounting);
547
+ if (line.t === 'null')
548
+ return null;
549
+ const schema = accountingSchema();
550
+ if (schema === null)
551
+ return ACCOUNTING_SHAPE_UNREADABLE;
552
+ const problem = schemaError(dumpJson(dict([['accounting', line]])), schema);
553
+ if (problem === null)
554
+ return null;
555
+ return `unit ${unitId} in role ${text(field(unit, 'role'))} reported an accounting line the schema refuses: ${problem}`;
556
+ }
308
557
  /**
309
558
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
310
559
  *
@@ -314,7 +563,10 @@ function modelRefusal(document, unitId, unit, accounting) {
314
563
  * any mutation and before the accounting line. Extended 2026-09-11 (job47): so is a
315
564
  * `job.roles` value for that role that is not a list of model identifiers — an unreadable
316
565
  * declaration allows no model, and it is refused in the same place, by the same structured
317
- * return, writing nothing. Mutations: set the unit's status, advance
566
+ * return, writing nothing. Extended 2026-09-13 (job50/F5): and so is an accounting line
567
+ * that does not fit the shape the `shiftwork_clock_out` asset declares — checked after the
568
+ * roles gate, before any mutation, same structured return, nothing written; a null
569
+ * accounting is not validated and stays legal. Mutations: set the unit's status, advance
318
570
  * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
319
571
  * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
320
572
  *
@@ -344,6 +596,9 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
344
596
  const wrongModel = modelRefusal(doc, unitId, unit, accounting);
345
597
  if (wrongModel !== null)
346
598
  return errorResult(wrongModel);
599
+ const badLine = accountingRefusal(unitId, unit, accounting);
600
+ if (badLine !== null)
601
+ return errorResult(badLine);
347
602
  unit.v.set('status', str(status));
348
603
  const remaining = subList(plan, 'units').v.filter((u) => !TERMINAL_UNIT_STATUS.has(text(u.v.get('status'))));
349
604
  plan.v.set('cursor', remaining.length > 0 ? field(remaining[0], 'id') : str(unitId));
@@ -369,11 +624,14 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
369
624
  ]);
370
625
  for (const [key, value] of asPatch(accounting))
371
626
  record.set(key, value);
372
- // `Path(str(path) + ".log.jsonl")` — string concatenation on the NORMALIZED path, not
373
- // `with_suffix`, so `cp.json` gets `cp.json.log.jsonl` and not `cp.log.jsonl`.
374
- const logPath = `${path}.log.jsonl`;
627
+ const log = logPath(path);
628
+ // F6: `briefed` is MEASURED off the ledger, AFTER the orchestrator's keys are merged, so the
629
+ // runtime's answer wins over a self-reported one — the order is load-bearing. It is written
630
+ // on every line, `accounting: null` included: the base shape (ts/unit/role/status) is the
631
+ // runtime's, and so is this field. Never a refusal.
632
+ record.set('briefed', { t: 'bool', v: briefed(log, unitId) });
375
633
  try {
376
- pyAppendText(logPath, `${dumpJson({ t: 'dict', v: record }, { sortKeys: true })}\n`);
634
+ pyAppendText(log, `${dumpJson({ t: 'dict', v: record }, { sortKeys: true })}\n`);
377
635
  }
378
636
  catch (e) {
379
637
  if (!(e instanceof PyOSError))
@@ -403,7 +661,7 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
403
661
  ['unit', str(unitId)],
404
662
  ['status', str(status)],
405
663
  ['cursor', field(plan, 'cursor')],
406
- ['log', str(logPath)],
664
+ ['log', str(log)],
407
665
  ]);
408
666
  }
409
667
  // ------------------------------------------------------------------------------- status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bantamkit-mcp",
3
- "version": "0.32.1",
3
+ "version": "0.34.0",
4
4
  "description": "bantamkit MCP server — pure Node, runnable with npx",
5
5
  "type": "module",
6
6
  "bin": {