bantamkit-mcp 0.35.3 → 0.35.5

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.
@@ -27,7 +27,7 @@
27
27
  * becomes head-25-bytes + `...` + tail-24-bytes, each end floored to a character
28
28
  * boundary. Thirty Thai characters are 34 repr CHARACTERS and are still truncated.
29
29
  */
30
- import { reprValue } from '../pyjson.js';
30
+ import { parseJson, reprValue } from '../pyjson.js';
31
31
  const req = (name, kind) => ({ name, kind, optional: false });
32
32
  const opt = (name, kind, bound = {}) => ({
33
33
  name,
@@ -78,7 +78,15 @@ export const ARG_MODELS = {
78
78
  },
79
79
  memory_recall: { model: 'memory_recallArguments', fields: [req('query', 'str'), opt('k', 'int')] },
80
80
  validate_json: { model: 'validate_jsonArguments', fields: [req('output', 'str'), req('schema', 'dict')] },
81
- shiftwork_clock_in: { model: 'shiftwork_clock_inArguments', fields: [req('checkpoint', 'str')] },
81
+ // `clock_in(checkpoint: str, unit_id: str | None = None)` since job60: the optional field
82
+ // is the same `opt(...)` `shiftwork_clock_out`'s `accounting` already is, and `str` is the
83
+ // strict string `unit_id` is on `shiftwork_clock_out` — one spelling for one argument name
84
+ // across the two tools. An omitted or explicitly null `unit_id` is the default, which is
85
+ // the cursor unit and is byte for byte what the tool did before it had the field.
86
+ shiftwork_clock_in: {
87
+ model: 'shiftwork_clock_inArguments',
88
+ fields: [req('checkpoint', 'str'), opt('unit_id', 'str')],
89
+ },
82
90
  shiftwork_clock_out: {
83
91
  model: 'shiftwork_clock_outArguments',
84
92
  fields: [
@@ -434,6 +442,100 @@ function checkInt(loc, value) {
434
442
  }
435
443
  return { loc, type: 'int_type', msg: 'Input should be a valid integer', input: value };
436
444
  }
445
+ /**
446
+ * CPython's default `sys.int_max_str_digits`, which `json.loads` hits before it returns.
447
+ *
448
+ * `int(<4301 digits>)` is a `ValueError` since CPython 3.11, and the reference's
449
+ * `_ArgMetadata.pre_parse_json` catches exactly that and leaves the key as the string it was
450
+ * — measured in `runtime-py/src/bantamkit/mcpserver.py`'s own docstring, review round 3, on
451
+ * `memory_save(links="[" + "1"*4301 + "]")`. `parseJson` here has no such cap (Python's ints
452
+ * do not overflow and neither does a `bigint`), so the cap is applied to the RESULT instead:
453
+ * a document CPython would have refused mid-decode is one holding an integer literal of more
454
+ * than 4300 digits, and that literal is exactly an `int` node this wide. The observable
455
+ * effect is the same one — the original string survives into validation — which is why this
456
+ * is a check on the tree and not a second decoder. Same number as `docread.ts`'s.
457
+ */
458
+ const INT_MAX_STR_DIGITS = 4300;
459
+ /** Does any integer in this document spell wider than CPython's decoder will convert? */
460
+ function exceedsIntDigitCap(value) {
461
+ switch (value.t) {
462
+ case 'int': {
463
+ const magnitude = value.v < 0n ? -value.v : value.v;
464
+ return magnitude.toString().length > INT_MAX_STR_DIGITS;
465
+ }
466
+ case 'list':
467
+ return value.v.some(exceedsIntDigitCap);
468
+ case 'dict': {
469
+ for (const nested of value.v.values())
470
+ if (exceedsIntDigitCap(nested))
471
+ return true;
472
+ return false;
473
+ }
474
+ default:
475
+ return false;
476
+ }
477
+ }
478
+ /**
479
+ * `FuncMetadata.pre_parse_json` — the SDK step that runs BEFORE `model_validate`.
480
+ *
481
+ * WHY THIS EXISTS AT ALL. The reference does not validate the arguments a client sent; it
482
+ * validates the arguments the SDK handed pydantic, and the SDK unwraps a JSON-encoded string
483
+ * first, because "Claude Desktop is prone to this — in fact it seems incapable of NOT doing
484
+ * it" (the SDK's own words). Until this function the port skipped that step entirely, so the
485
+ * two runtimes read the same `tools/call` differently on every non-`str` argument. Measured
486
+ * over the real stdio servers on both sides, 2026-09-21, sixteen shapes disagreed, including
487
+ * the register's own item: `validate_json(output='{"a": 1}', schema='{"type": "object"}')`
488
+ * answered `{"valid": true}` on Python and `dict_type` on Node. The reference is the
489
+ * reference, so the port gains the step rather than the reference losing it.
490
+ *
491
+ * THE RULE IS THE SDK'S, NOT A PARAPHRASE. A key is unwrapped when all of this holds:
492
+ *
493
+ * * the key names a field of the model — an unknown key is not in `key_to_field_info` and
494
+ * is left alone (it is ignored by validation anyway);
495
+ * * the value is a string;
496
+ * * the field's annotation `is not str`. That is an IDENTITY test on the annotation
497
+ * object, so `str | None` is NOT `str` and DOES get unwrapped: `skill_audit(check=
498
+ * 'null')` reaches the handler as `None` on the reference. Here that is exactly
499
+ * "required `str`" — every other kind, and every OPTIONAL `str`, qualifies;
500
+ * * `json.loads` succeeds. It can fail two ways and both keep the string: a decode error,
501
+ * and the digit cap above;
502
+ * * the result is not a `str`, `int` or `float`. `isinstance(True, int)` is true in
503
+ * Python, so a `bool` is skipped by that same line — `dry_run='true'` stays the string
504
+ * `'true'` and pydantic's lax bool parse is what reads it. What is left, and what this
505
+ * therefore replaces, is exactly a list, a dict, or `null`.
506
+ *
507
+ * It is NOT applied per tool. The reference's one opt-out, `_NO_JSON_UNWRAP`, is dormant —
508
+ * `bantamkit_read` left the roster in job50 — so every served tool unwraps today, and a
509
+ * roster line coming back is the moment to give this function the same set.
510
+ */
511
+ export function preParseJson(model, args) {
512
+ if (args.t !== 'dict')
513
+ return args;
514
+ const byName = new Map(model.fields.map((field) => [field.name, field]));
515
+ let unwrapped = null;
516
+ for (const [key, value] of args.v) {
517
+ const spec = byName.get(key);
518
+ if (spec === undefined || value.t !== 'str')
519
+ continue;
520
+ if (spec.kind === 'str' && !spec.optional)
521
+ continue;
522
+ let parsed;
523
+ try {
524
+ parsed = parseJson(value.v);
525
+ }
526
+ catch {
527
+ continue;
528
+ }
529
+ if (parsed.t === 'str' || parsed.t === 'int' || parsed.t === 'float' || parsed.t === 'bool')
530
+ continue;
531
+ if (exceedsIntDigitCap(parsed))
532
+ continue;
533
+ if (unwrapped === null)
534
+ unwrapped = new Map(args.v);
535
+ unwrapped.set(key, parsed);
536
+ }
537
+ return unwrapped === null ? args : { t: 'dict', v: unwrapped };
538
+ }
437
539
  /**
438
540
  * The pydantic `ValidationError` text, WITHOUT the `Error executing tool <name>: ` prefix.
439
541
  *
@@ -455,9 +557,15 @@ export class PyValidationFailure extends Error {
455
557
  * measured server accepts `{"query": "a", "extra": 1}` without complaint. The absent-key case
456
558
  * and the explicit-`null` case are different: `{}` is `missing`, `{"handoff_patch": null}` is
457
559
  * `dict_type`, and only an OPTIONAL field treats `null` as its default.
560
+ *
561
+ * `preParseJson` runs FIRST, and what it returns is what every error below reports. That
562
+ * ordering is the SDK's: `model_validate` never sees the arguments a client sent, only the
563
+ * unwrapped ones, so a `missing` error on `memory_recall(k='[1]')` carries `{'k': [1]}` as
564
+ * its `input_value` and not the string the wire held.
458
565
  */
459
566
  export function validateArguments(model, args) {
460
- const supplied = args.t === 'dict' ? args.v : new Map();
567
+ const unwrapped = preParseJson(model, args);
568
+ const supplied = unwrapped.t === 'dict' ? unwrapped.v : new Map();
461
569
  const errors = [];
462
570
  const bound = new Map();
463
571
  for (const spec of model.fields) {
@@ -466,7 +574,7 @@ export function validateArguments(model, args) {
466
574
  bound.set(spec.name, { t: 'null' });
467
575
  continue;
468
576
  }
469
- errors.push({ loc: spec.name, type: 'missing', msg: 'Field required', input: args });
577
+ errors.push({ loc: spec.name, type: 'missing', msg: 'Field required', input: unwrapped });
470
578
  continue;
471
579
  }
472
580
  const checked = checkField(spec, supplied.get(spec.name));
@@ -524,11 +524,17 @@ function runTool(name, memory, args, version, log, documents) {
524
524
  log.record('validate_json', error === null ? 'valid' : 'invalid');
525
525
  return { value: notedDict({ t: 'dict', v: out }), wrapped: false };
526
526
  }
527
- case 'shiftwork_clock_in':
527
+ case 'shiftwork_clock_in': {
528
+ // J60: optional `unit_id`. Read the way `bantamkit_read` reads its optional `part` —
529
+ // a value that is not a string is the DEFAULT, which is the cursor unit, so a host
530
+ // sending `null` gets exactly the call it got before the field existed.
531
+ const unitArg = args.get('unit_id');
532
+ const unit = unitArg !== undefined && unitArg.t === 'str' ? unitArg.v : null;
528
533
  return {
529
- value: notedDict(recordResult(log, name, () => shiftwork.clockIn(asText(args.get('checkpoint'))))),
534
+ value: notedDict(recordResult(log, name, () => shiftwork.clockIn(asText(args.get('checkpoint')), unit))),
530
535
  wrapped: false,
531
536
  };
537
+ }
532
538
  case 'shiftwork_clock_out':
533
539
  return {
534
540
  value: notedDict(recordResult(log, name, () => shiftwork.clockOut(asText(args.get('checkpoint')), asText(args.get('unit_id')), asText(args.get('status')), args.get('handoff_patch'), args.get('history_entry'), args.get('accounting') ?? { t: 'null' }))),
@@ -283,7 +283,10 @@ export declare function keptInstall(prefix?: string): KeptInstall | null;
283
283
  * THE PORT OF `selfupdate.record_update`. Its docstring carries the argument and is not
284
284
  * restated: no second network call and no new failure mode (`update` already holds `latest`),
285
285
  * no directory is ever created, the other runtime's key is left exactly as found, and the
286
- * write goes through a temp file and a rename so no reader sees half a record.
286
+ * write goes through a temp file and a rename so no reader sees half a record. That includes
287
+ * its AMENDED 2026-09-21 clause — this writer stamps its OWN entry's `checked_at` and never
288
+ * the record's, because the record's stamp means "a writer refreshed the whole record" and
289
+ * `--update` holds one registry's answer by construction.
287
290
  *
288
291
  * WHY THE WRITER IS HERE AND NOT IN `updatecheck.ts`. That module is the READER, and
289
292
  * `test/updatecheck.test.mjs` gates its source against `writeFileSync`, `renameSync`,
@@ -421,7 +421,10 @@ function stamp() {
421
421
  * THE PORT OF `selfupdate.record_update`. Its docstring carries the argument and is not
422
422
  * restated: no second network call and no new failure mode (`update` already holds `latest`),
423
423
  * no directory is ever created, the other runtime's key is left exactly as found, and the
424
- * write goes through a temp file and a rename so no reader sees half a record.
424
+ * write goes through a temp file and a rename so no reader sees half a record. That includes
425
+ * its AMENDED 2026-09-21 clause — this writer stamps its OWN entry's `checked_at` and never
426
+ * the record's, because the record's stamp means "a writer refreshed the whole record" and
427
+ * `--update` holds one registry's answer by construction.
425
428
  *
426
429
  * WHY THE WRITER IS HERE AND NOT IN `updatecheck.ts`. That module is the READER, and
427
430
  * `test/updatecheck.test.mjs` gates its source against `writeFileSync`, `renameSync`,
@@ -452,8 +455,8 @@ export function recordUpdate(latest, now) {
452
455
  const { source, record } = loadRecord(path);
453
456
  // An UNREADABLE record is replaced rather than merged: there is nothing in it to preserve.
454
457
  const payload = source === SOURCE_RECORD && record !== null ? { ...record } : {};
455
- payload['checked_at'] = now ?? stamp();
456
- payload[RECORD_KEY] = { package: PACKAGE, latest };
458
+ const at = now ?? stamp();
459
+ payload[RECORD_KEY] = { package: PACKAGE, latest, checked_at: at };
457
460
  const temporary = join(directory, `.update-check-${process.pid}-${Date.now()}.json`);
458
461
  try {
459
462
  writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
@@ -32,7 +32,25 @@ export interface ClockOptions {
32
32
  now?: number;
33
33
  }
34
34
  /**
35
- * Validate the checkpoint and return the cursor unit's brief, or a refusal.
35
+ * Validate the checkpoint and return a unit's brief, or a refusal.
36
+ *
37
+ * `unitId` OMITTED IS THE CURSOR UNIT, BYTE FOR BYTE WHAT IT WAS (J60/D1). Given, it is
38
+ * briefed instead — IF the batch view says it is ready. That is the only new judgement, it
39
+ * is taken LAST, and the three above it are untouched, so no existing refusal moves and no
40
+ * caller that never passes a unit can see a difference. A unit that is not ready, including
41
+ * an id naming no unit at all, is ONE refusal carrying the list the caller's next move is to
42
+ * pick from; a batch view that refuses (cycle, unknown dependency) passes through verbatim,
43
+ * in both directions, exactly as `planBatches` already passes `readValid`'s.
44
+ *
45
+ * `ready` cannot be empty here: the all-terminal test above has already answered `success`
46
+ * for a plan with no non-terminal unit, and a non-terminal unit that cannot batch is a
47
+ * refusal rather than an empty list. No sentence is written for a case that cannot be
48
+ * reached.
49
+ *
50
+ * STILL NEVER WRITES `plan.cursor`. A wave of N briefs leaves the pointer where it was; it
51
+ * is `clockOut` that moves it. What each brief DOES write is its own ledger line, naming the
52
+ * unit actually briefed, so `clockOut`'s `briefed` flag keeps working per unit with no
53
+ * change to how it is computed — which is what makes D2 possible.
36
54
  *
37
55
  * `invariants` IS SYNTHESIZED HERE. It is not a key of the checkpoint — it is
38
56
  * `document["job"]["constraints"]` under a different name — which is precisely why a brief
@@ -44,12 +62,18 @@ export interface ClockOptions {
44
62
  * all-terminal test before the cursor lookup — so an empty plan reports success rather than
45
63
  * escalating on its dangling cursor, because `all([])` is true.
46
64
  */
47
- export declare function clockIn(checkpoint: string, options?: ClockOptions): PyValue;
65
+ export declare function clockIn(checkpoint: string, unitId?: string | null, options?: ClockOptions): PyValue;
48
66
  /**
49
67
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
50
68
  *
51
- * `unitId` must name the cursor unit — the contract is execute-the-cursor (driver parity),
52
- * never pick-a-unit. When `job.roles` names the unit's role, `accounting.model` must be one
69
+ * `unitId` must name the cursor unit OR a unit briefed since its own last clock-out
70
+ * (J60/D2) — the `briefed` value this module already computes off `<checkpoint>.log.jsonl`
71
+ * for the accounting line and already refuses to take from the caller, so a wave of briefs
72
+ * can be clocked out in the order the agents actually return. A unit that was never
73
+ * dispatched still cannot be clocked out, and it is refused with the sentence it has always
74
+ * had, unchanged in both runtimes, so every ruled case pinning it stays green;
75
+ * `docs/shiftwork.md` carries the fuller meaning. The gate still runs BEFORE the AS-2
76
+ * role/model check and before the first mutation. When `job.roles` names the unit's role, `accounting.model` must be one
53
77
  * of that role's models, spelled exactly; a wrong or missing model is refused here, before
54
78
  * any mutation and before the accounting line. Extended 2026-09-11 (job47): so is a
55
79
  * `job.roles` value for that role that is not a list of model identifiers — an unreadable
@@ -58,7 +82,9 @@ export declare function clockIn(checkpoint: string, options?: ClockOptions): PyV
58
82
  * that does not fit the shape the `shiftwork_clock_out` asset declares — checked after the
59
83
  * roles gate, before any mutation, same structured return, nothing written; a null
60
84
  * accounting is not validated and stays legal. Mutations: set the unit's status, advance
61
- * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
85
+ * `plan.cursor` to the first unit the GRAPH says is ready (J60/D3 — `ready[0]` of the
86
+ * mutated document, falling back to the first non-terminal unit in `plan.units` order only
87
+ * when the graph cannot batch at all, and to `unitId` when nothing is left),
62
88
  * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
63
89
  *
64
90
  * VALIDATE BEFORE WRITING, AND WRITE NOTHING ON REFUSAL. The mutated document goes through
@@ -85,7 +111,9 @@ export declare function status(checkpoint: string): PyValue;
85
111
  *
86
112
  * The adapter over `workplan.plan`, and it holds no graph logic of its own: a loop over
87
113
  * `depends_on` here would be Layer 1's work done in Layer 5. Its whole content is the three
88
- * decisions below plus the shape it answers in.
114
+ * decisions below plus the shape it answers in — and since J60 even the loop that applies
115
+ * them is `batchView`'s, because `clockIn` and `clockOut` judge by the same three and a
116
+ * second copy is how they would drift apart again.
89
117
  *
90
118
  * **A `done` or `dropped` unit is SATISFIED**, which is two things and not one: it leaves
91
119
  * the graph, AND every edge pointing at it is treated as already resolved. Only the first
@@ -101,10 +129,12 @@ export declare function status(checkpoint: string): PyValue;
101
129
  * insertion order — `plan.units` order, which is the order a reader of the checkpoint
102
130
  * already sees.
103
131
  *
104
- * **The cursor is ECHOED, never written.** `clockOut` remains the only thing that moves it
105
- * and stays v1-linear; this tool reports what the graph PERMITS beside the single pointer
106
- * that says what the driver will actually do next, so an orchestrator can read the two side
107
- * by side and decide. Advisory, in one direction only.
132
+ * **The cursor is ECHOED, never written.** `clockOut` remains the only thing that moves it;
133
+ * this tool reports what the graph PERMITS beside the single pointer that says what the
134
+ * driver will actually do next, so an orchestrator can read the two side by side and
135
+ * decide. Advisory, in one direction only. Since J60 the pointer is moved BY this same view
136
+ * (`ready[0]`), so the two fields can no longer contradict each other on a plannable graph
137
+ * — which was the whole defect — but the echo stays an echo.
108
138
  *
109
139
  * Returns `{result: 'plan', batches, ready, sequence, width, cursor}` — `ready` is
110
140
  * `batches[0]`, or `[]` when the plan is all terminal, which is an ANSWER and not a
package/dist/shiftwork.js CHANGED
@@ -58,6 +58,19 @@
58
58
  * ruling) — it moves to the first non-terminal unit in `plan.units` order, and a non-linear
59
59
  * plan still needs a planner unit to reorder `plan.units` first. The batch view is read-only
60
60
  * and moves nothing.
61
+ *
62
+ * AMENDED 2026-09-20 (job60). The paragraph above stands as the history and is now FALSE as a
63
+ * description of this code: cursor advance no longer ignores `depends_on`. A read-only surface
64
+ * answering from the graph beside a dispatch surface answering from a single pointer is two
65
+ * answers to one question, and they were MEASURED disagreeing two ways — a two-wide `ready`
66
+ * the driver gives no way to spend, and a cursor landing on a unit whose `depends_on` has not
67
+ * run. `docs/superpowers/specs/2026-09-20-clock-in-unit-id-design.md` is the other half of the
68
+ * 2026-09-18 design, not a repair of it. Three changes, all below: `clockIn` takes an optional
69
+ * `unitId` and briefs it IF the graph says it is ready; `clockOut` accepts the cursor unit OR
70
+ * one it briefed, keeping its existing refusal sentence; and cursor advance is `ready[0]`,
71
+ * falling back to `plan.units` order only for a graph that cannot batch at all. There is ONE
72
+ * loop over `depends_on` in this module (`batchView`) and three callers, because a second one
73
+ * is precisely how the two surfaces came to disagree.
61
74
  */
62
75
  import { AssetNotFound, loadSchema, loadToolAsset } from './assets.js';
63
76
  import { schemaError } from './contract.js';
@@ -282,9 +295,81 @@ function briefed(log, unitId) {
282
295
  }
283
296
  return flag;
284
297
  }
298
+ // -------------------------------------------------------------------------- the graph
299
+ /**
300
+ * The batch view of an IN-MEMORY document — the module's ONE loop over `depends_on`.
301
+ *
302
+ * J60. Three callers need the same answer and only one of them has a file to read:
303
+ * `planBatches` answers it for a checkpoint on disk, `clockIn` needs it to judge a
304
+ * requested `unitId`, and `clockOut` needs it on the document it has just MUTATED and has
305
+ * not written yet. A second loop for the in-memory callers is exactly how the advisory
306
+ * surface and the dispatch surface came to disagree, which is the defect this job closes,
307
+ * so the loop lives here and the three callers share its answer.
308
+ *
309
+ * The three decisions it holds are the ones `planBatches` documents — a `done` or
310
+ * `dropped` unit is SATISFIED (it leaves the graph and every edge into it is resolved),
311
+ * every unit has priority 0, and the cursor is not consulted at all. Refusals are the
312
+ * core's own: a `PlanResult` that is a `PlanError` carries the duplicate-id, unknown-
313
+ * dependency or cycle sentence, and every caller passes it on verbatim rather than
314
+ * rewording it.
315
+ */
316
+ function batchView(doc) {
317
+ const units = subList(subDict(doc, 'plan'), 'units').v;
318
+ const terminal = (unit) => TERMINAL_UNIT_STATUS.has(text(unit.v.get('status')));
319
+ const satisfied = new Set();
320
+ for (const unit of units) {
321
+ if (terminal(unit))
322
+ satisfied.add(text(unit.v.get('id')));
323
+ }
324
+ const nodes = [];
325
+ for (const unit of units) {
326
+ const u = unit;
327
+ if (terminal(u))
328
+ continue;
329
+ // `list(unit.get("depends_on") or [])`. `depends_on` is REQUIRED by the schema, so
330
+ // `readValid` has already refused a unit without it and this default cannot fire —
331
+ // it is written anyway because the reference writes it, and a default on one side
332
+ // only is how two runtimes come to disagree about real data.
333
+ const declared = u.v.get('depends_on');
334
+ const deps = declared !== undefined && declared.t === 'list' ? declared.v.map(text) : [];
335
+ nodes.push({ id: text(u.v.get('id')), depends_on: deps.filter((dep) => !satisfied.has(dep)), priority: 0 });
336
+ }
337
+ return plan(nodes);
338
+ }
339
+ /**
340
+ * `batches[0]`, or `[]` — for a plan that is all terminal AND for a graph that refuses.
341
+ *
342
+ * The empty answer is what makes the refusal case fall through to the caller's own
343
+ * fallback instead of acquiring a new way to refuse: `clockOut` RECORDS, and a recording
344
+ * surface does not stop working because the graph it was handed cannot be batched.
345
+ * `clockIn` never reaches this with a refusal — it returns the core's sentence first.
346
+ */
347
+ function readyIds(view) {
348
+ if ('result' in view)
349
+ return [];
350
+ return view.batches.length > 0 ? view.batches[0] : [];
351
+ }
285
352
  // ----------------------------------------------------------------------------- clock_in
286
353
  /**
287
- * Validate the checkpoint and return the cursor unit's brief, or a refusal.
354
+ * Validate the checkpoint and return a unit's brief, or a refusal.
355
+ *
356
+ * `unitId` OMITTED IS THE CURSOR UNIT, BYTE FOR BYTE WHAT IT WAS (J60/D1). Given, it is
357
+ * briefed instead — IF the batch view says it is ready. That is the only new judgement, it
358
+ * is taken LAST, and the three above it are untouched, so no existing refusal moves and no
359
+ * caller that never passes a unit can see a difference. A unit that is not ready, including
360
+ * an id naming no unit at all, is ONE refusal carrying the list the caller's next move is to
361
+ * pick from; a batch view that refuses (cycle, unknown dependency) passes through verbatim,
362
+ * in both directions, exactly as `planBatches` already passes `readValid`'s.
363
+ *
364
+ * `ready` cannot be empty here: the all-terminal test above has already answered `success`
365
+ * for a plan with no non-terminal unit, and a non-terminal unit that cannot batch is a
366
+ * refusal rather than an empty list. No sentence is written for a case that cannot be
367
+ * reached.
368
+ *
369
+ * STILL NEVER WRITES `plan.cursor`. A wave of N briefs leaves the pointer where it was; it
370
+ * is `clockOut` that moves it. What each brief DOES write is its own ledger line, naming the
371
+ * unit actually briefed, so `clockOut`'s `briefed` flag keeps working per unit with no
372
+ * change to how it is computed — which is what makes D2 possible.
288
373
  *
289
374
  * `invariants` IS SYNTHESIZED HERE. It is not a key of the checkpoint — it is
290
375
  * `document["job"]["constraints"]` under a different name — which is precisely why a brief
@@ -296,7 +381,7 @@ function briefed(log, unitId) {
296
381
  * all-terminal test before the cursor lookup — so an empty plan reports success rather than
297
382
  * escalating on its dangling cursor, because `all([])` is true.
298
383
  */
299
- export function clockIn(checkpoint, options = {}) {
384
+ export function clockIn(checkpoint, unitId = null, options = {}) {
300
385
  const { document, refusal } = readValid(pyJoin(checkpoint));
301
386
  if (refusal !== null)
302
387
  return refusal;
@@ -318,7 +403,22 @@ export function clockIn(checkpoint, options = {}) {
318
403
  ]);
319
404
  }
320
405
  const cursor = text(field(subDict(doc, 'plan'), 'cursor'));
321
- const unit = findUnit(doc, cursor);
406
+ // D1: selection, and it is the LAST judgement. `selected` differs from `cursor` only on
407
+ // the new branch, so the dangling-cursor escalation below still says `cursor` truthfully:
408
+ // a `unitId` that reached `selected` came out of `ready`, whose ids are `plan.units`' own,
409
+ // and `findUnit` cannot fail for it.
410
+ let selected = cursor;
411
+ if (unitId !== null && unitId !== undefined) {
412
+ const view = batchView(doc);
413
+ if ('result' in view)
414
+ return errorResult(view.reason);
415
+ const ready = readyIds(view);
416
+ // `", "` in BATCH order, not sorted: the order the caller would dispatch them in.
417
+ if (!ready.includes(unitId))
418
+ return errorResult(`unit ${unitId} is not ready; ready is ${ready.join(', ')}`);
419
+ selected = unitId;
420
+ }
421
+ const unit = findUnit(doc, selected);
322
422
  if (unit === null) {
323
423
  return dict([
324
424
  ['result', str('escalate')],
@@ -328,7 +428,8 @@ export function clockIn(checkpoint, options = {}) {
328
428
  // F6: the brief is issued, so say so in the ledger — best effort, and only on this branch:
329
429
  // a refusal above issued nothing and therefore records nothing. `pyJoin` is the same
330
430
  // normalisation `clockOut` applies before it builds ITS log path, so the two land in one file.
331
- recordBrief(logPath(pyJoin(checkpoint)), cursor, field(unit, 'role'), options.now ?? Date.now() / 1000);
431
+ // The line names the unit ACTUALLY briefed, which is what D2 reads back.
432
+ recordBrief(logPath(pyJoin(checkpoint)), selected, field(unit, 'role'), options.now ?? Date.now() / 1000);
332
433
  return dict([
333
434
  ['result', str('brief')],
334
435
  ['unit', unit],
@@ -564,8 +665,14 @@ function accountingRefusal(unitId, unit, accounting) {
564
665
  /**
565
666
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
566
667
  *
567
- * `unitId` must name the cursor unit — the contract is execute-the-cursor (driver parity),
568
- * never pick-a-unit. When `job.roles` names the unit's role, `accounting.model` must be one
668
+ * `unitId` must name the cursor unit OR a unit briefed since its own last clock-out
669
+ * (J60/D2) — the `briefed` value this module already computes off `<checkpoint>.log.jsonl`
670
+ * for the accounting line and already refuses to take from the caller, so a wave of briefs
671
+ * can be clocked out in the order the agents actually return. A unit that was never
672
+ * dispatched still cannot be clocked out, and it is refused with the sentence it has always
673
+ * had, unchanged in both runtimes, so every ruled case pinning it stays green;
674
+ * `docs/shiftwork.md` carries the fuller meaning. The gate still runs BEFORE the AS-2
675
+ * role/model check and before the first mutation. When `job.roles` names the unit's role, `accounting.model` must be one
569
676
  * of that role's models, spelled exactly; a wrong or missing model is refused here, before
570
677
  * any mutation and before the accounting line. Extended 2026-09-11 (job47): so is a
571
678
  * `job.roles` value for that role that is not a list of model identifiers — an unreadable
@@ -574,7 +681,9 @@ function accountingRefusal(unitId, unit, accounting) {
574
681
  * that does not fit the shape the `shiftwork_clock_out` asset declares — checked after the
575
682
  * roles gate, before any mutation, same structured return, nothing written; a null
576
683
  * accounting is not validated and stays legal. Mutations: set the unit's status, advance
577
- * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
684
+ * `plan.cursor` to the first unit the GRAPH says is ready (J60/D3 — `ready[0]` of the
685
+ * mutated document, falling back to the first non-terminal unit in `plan.units` order only
686
+ * when the graph cannot batch at all, and to `unitId` when nothing is left),
578
687
  * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
579
688
  *
580
689
  * VALIDATE BEFORE WRITING, AND WRITE NOTHING ON REFUSAL. The mutated document goes through
@@ -598,8 +707,15 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
598
707
  return errorResult(`unit ${unitId} is not in the plan`);
599
708
  const plan = subDict(doc, 'plan');
600
709
  const cursor = text(field(plan, 'cursor'));
601
- if (unitId !== cursor)
710
+ const log = logPath(path);
711
+ // D2: the cursor unit, or one this checkpoint's ledger says was briefed and not yet
712
+ // clocked out. `briefed` is the SAME helper the accounting line's own field is measured
713
+ // with, called here rather than reimplemented — two readers of one ledger is the shape
714
+ // this job exists to remove. The sentence is unchanged, deliberately: it is pinned by
715
+ // ruled conformance cases, and widening the wording would move every one of them.
716
+ if (unitId !== cursor && !briefed(log, unitId)) {
602
717
  return errorResult(`unit ${unitId} is not the cursor unit ${cursor}`);
718
+ }
603
719
  const wrongModel = modelRefusal(doc, unitId, unit, accounting);
604
720
  if (wrongModel !== null)
605
721
  return errorResult(wrongModel);
@@ -607,8 +723,19 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
607
723
  if (badLine !== null)
608
724
  return errorResult(badLine);
609
725
  unit.v.set('status', str(status));
726
+ // D3: the cursor follows the GRAPH, on the MUTATED document — the unit just finished is
727
+ // already out of it. Three steps, and the middle one is only reachable for a graph that
728
+ // cannot batch at all (a cycle, an unknown dependency), because `ready` is computed over
729
+ // exactly the units `remaining` holds: a recording surface does not acquire a new way to
730
+ // refuse, so such a checkpoint can still be driven to its end in plan order. For a linear
731
+ // chain `ready[0] === remaining[0]`, which is why every case written before this holds its
732
+ // value.
610
733
  const remaining = subList(plan, 'units').v.filter((u) => !TERMINAL_UNIT_STATUS.has(text(u.v.get('status'))));
611
- plan.v.set('cursor', remaining.length > 0 ? field(remaining[0], 'id') : str(unitId));
734
+ const ready = readyIds(batchView(doc));
735
+ if (ready.length > 0)
736
+ plan.v.set('cursor', str(ready[0]));
737
+ else
738
+ plan.v.set('cursor', remaining.length > 0 ? field(remaining[0], 'id') : str(unitId));
612
739
  const handoff = subDict(doc, 'handoff');
613
740
  for (const [key, value] of asPatch(handoffPatch))
614
741
  handoff.v.set(key, value);
@@ -631,7 +758,6 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
631
758
  ]);
632
759
  for (const [key, value] of asPatch(accounting))
633
760
  record.set(key, value);
634
- const log = logPath(path);
635
761
  // F6: `briefed` is MEASURED off the ledger, AFTER the orchestrator's keys are merged, so the
636
762
  // runtime's answer wins over a self-reported one — the order is load-bearing. It is written
637
763
  // on every line, `accounting: null` included: the base shape (ts/unit/role/status) is the
@@ -705,7 +831,9 @@ export function status(checkpoint) {
705
831
  *
706
832
  * The adapter over `workplan.plan`, and it holds no graph logic of its own: a loop over
707
833
  * `depends_on` here would be Layer 1's work done in Layer 5. Its whole content is the three
708
- * decisions below plus the shape it answers in.
834
+ * decisions below plus the shape it answers in — and since J60 even the loop that applies
835
+ * them is `batchView`'s, because `clockIn` and `clockOut` judge by the same three and a
836
+ * second copy is how they would drift apart again.
709
837
  *
710
838
  * **A `done` or `dropped` unit is SATISFIED**, which is two things and not one: it leaves
711
839
  * the graph, AND every edge pointing at it is treated as already resolved. Only the first
@@ -721,10 +849,12 @@ export function status(checkpoint) {
721
849
  * insertion order — `plan.units` order, which is the order a reader of the checkpoint
722
850
  * already sees.
723
851
  *
724
- * **The cursor is ECHOED, never written.** `clockOut` remains the only thing that moves it
725
- * and stays v1-linear; this tool reports what the graph PERMITS beside the single pointer
726
- * that says what the driver will actually do next, so an orchestrator can read the two side
727
- * by side and decide. Advisory, in one direction only.
852
+ * **The cursor is ECHOED, never written.** `clockOut` remains the only thing that moves it;
853
+ * this tool reports what the graph PERMITS beside the single pointer that says what the
854
+ * driver will actually do next, so an orchestrator can read the two side by side and
855
+ * decide. Advisory, in one direction only. Since J60 the pointer is moved BY this same view
856
+ * (`ready[0]`), so the two fields can no longer contradict each other on a plannable graph
857
+ * — which was the whole defect — but the echo stays an echo.
728
858
  *
729
859
  * Returns `{result: 'plan', batches, ready, sequence, width, cursor}` — `ready` is
730
860
  * `batches[0]`, or `[]` when the plan is all terminal, which is an ANSWER and not a
@@ -737,27 +867,7 @@ export function planBatches(checkpoint) {
737
867
  if (refusal !== null)
738
868
  return refusal;
739
869
  const doc = document;
740
- const units = subList(subDict(doc, 'plan'), 'units').v;
741
- const terminal = (unit) => TERMINAL_UNIT_STATUS.has(text(unit.v.get('status')));
742
- const satisfied = new Set();
743
- for (const unit of units) {
744
- if (terminal(unit))
745
- satisfied.add(text(unit.v.get('id')));
746
- }
747
- const nodes = [];
748
- for (const unit of units) {
749
- const u = unit;
750
- if (terminal(u))
751
- continue;
752
- // `list(unit.get("depends_on") or [])`. `depends_on` is REQUIRED by the schema, so
753
- // `readValid` has already refused a unit without it and this default cannot fire —
754
- // it is written anyway because the reference writes it, and a default on one side
755
- // only is how two runtimes come to disagree about real data.
756
- const declared = u.v.get('depends_on');
757
- const deps = declared !== undefined && declared.t === 'list' ? declared.v.map(text) : [];
758
- nodes.push({ id: text(u.v.get('id')), depends_on: deps.filter((dep) => !satisfied.has(dep)), priority: 0 });
759
- }
760
- const answer = plan(nodes);
870
+ const answer = batchView(doc);
761
871
  if ('result' in answer)
762
872
  return errorResult(answer.reason);
763
873
  const batches = answer.batches.map((batch) => ({ t: 'list', v: batch.map(str) }));
@@ -107,9 +107,9 @@ export declare function readRecord(path?: string): Record<string, unknown> | nul
107
107
  * Split from the reading so the conformance harness and the tests can construct a state
108
108
  * directly and so the only thing that touches a disk is `loadRecord`.
109
109
  *
110
- * `checked_at` is validated in EVERY arm that reached a record and not only on the `current`
111
- * path: a record that cannot say when it was written is not a record, and one rule is one thing
112
- * for the two runtimes to reproduce instead of two.
110
+ * The SELECTED `checked_at` (see `checkedDate`) is validated in EVERY arm that reached a record
111
+ * and not only on the `current` path: a record that cannot say when it was written is not a
112
+ * record, and one rule is one thing for the two runtimes to reproduce instead of two.
113
113
  */
114
114
  export declare function decide(installed: string, source: Source, record: Record<string, unknown> | null, key?: string): UpdateStatus;
115
115
  /** Read the record and decide. The whole reader, in one call, for `bantamkit_status`. */