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/dist/mcp/pyargs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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));
|
package/dist/mcp/server.js
CHANGED
|
@@ -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' }))),
|
package/dist/selfupdate.d.ts
CHANGED
|
@@ -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`,
|
package/dist/selfupdate.js
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/dist/shiftwork.d.ts
CHANGED
|
@@ -32,7 +32,25 @@ export interface ClockOptions {
|
|
|
32
32
|
now?: number;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
* Validate the checkpoint and return
|
|
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
|
|
52
|
-
*
|
|
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
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
568
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
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
|
|
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) }));
|
package/dist/updatecheck.d.ts
CHANGED
|
@@ -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
|
|
111
|
-
* path: a record that cannot say when it was written is not a
|
|
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`. */
|