bantamkit-mcp 0.35.0 → 0.35.1

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/eventlog.js CHANGED
@@ -32,11 +32,14 @@
32
32
  * one stray write breaks the wire suite. Nothing in this module names `process.stdout`,
33
33
  * `process.stderr` or `console`.
34
34
  * * **Metadata only.** Never a tool argument's value, never a memory body, never a
35
- * validated output, never a query, never a document row. Most of the twelve tools take
36
- * unbounded free text and two take absolute paths (`skill_audit`'s `root`, and
37
- * `token_ledger`'s `root` and `prices`; `bantamkit_read`'s `path` and `repo_map`'s `root`
38
- * and `focus` were two more until both tools left the roster — job50 I5, 2026-09-12 — and
39
- * their DORMANT handlers still record the same way); each record carries counts and
35
+ * validated output, never a query, never a document row. Nine of the fourteen tools take
36
+ * an unbounded string and among those `skill_audit`'s `root`, `token_ledger`'s `root` and
37
+ * `prices` and the four shift-work tools' `checkpoint` are absolute paths
38
+ * (`bantamkit_read`'s `path` and `repo_map`'s `root` and `focus` were two more until both
39
+ * tools left the roster — job50 I5, 2026-09-12 — and their DORMANT handlers still record
40
+ * the same way; `work_plan` is NOT one of the nine: its `nodes` is an array of objects,
41
+ * not an array of strings, so the rule does not reach the ids inside it); each record
42
+ * carries counts and
40
43
  * tokens from a closed set — never the path, never a part name, never a skill id, never a
41
44
  * mapped file, never a session id, never a model name).
42
45
  * Every value written here is an ASCII token from a closed set, a number, or a boolean.
@@ -31,7 +31,7 @@ import { type PyValue } from '../pyjson.js';
31
31
  /** What a parameter accepts. `optional` is `| None = None` in the signature. */
32
32
  export interface FieldSpec {
33
33
  readonly name: string;
34
- readonly kind: 'str' | 'int' | 'bool' | 'dict' | 'dictInt' | 'dictStr' | 'listStr';
34
+ readonly kind: 'str' | 'int' | 'bool' | 'dict' | 'dictInt' | 'dictStr' | 'listDict' | 'listStr';
35
35
  readonly optional: boolean;
36
36
  /** `Field(le=...)`: an inclusive ceiling, checked AFTER the lax int parse succeeds. */
37
37
  readonly le?: bigint;
@@ -174,6 +174,16 @@ export const ARG_MODELS = {
174
174
  opt('versions', 'dictStr'),
175
175
  ],
176
176
  },
177
+ // `work_plan(nodes: list[dict[str, Any]])` on the reference (W4). The only
178
+ // `list[dict[...]]` on the surface and the reason `listDict` exists: pydantic validates
179
+ // the list's ITEMS, one error per bad element and in the list's own order, with the
180
+ // INDEX in the location — `nodes.0`, the way `listStr` reports it. `dict[str, Any]` puts
181
+ // no constraint on what is INSIDE a node, so `{}` and `{'id': 5}` both validate here and
182
+ // reach `workplan.plan`, exactly as they do on the reference.
183
+ work_plan: { model: 'work_planArguments', fields: [req('nodes', 'listDict')] },
184
+ // `shiftwork_plan(checkpoint: str)` — the same one strict `str` the other four
185
+ // shift-work tools take.
186
+ shiftwork_plan: { model: 'shiftwork_planArguments', fields: [req('checkpoint', 'str')] },
177
187
  };
178
188
  /** `type(value).__name__`, for the `input_type=` half of the sentence. */
179
189
  function pyTypeName(value) {
@@ -287,6 +297,28 @@ function checkField(spec, value) {
287
297
  }
288
298
  return bad.length > 0 ? bad : { value: { t: 'dict', v: coerced } };
289
299
  }
300
+ case 'listDict': {
301
+ // `list[dict[str, Any]]`. The same shape as `listStr` below with the `dict`
302
+ // validator on the items: measured against pydantic 2.13, `{'nodes': 'x'}` is one
303
+ // `list_type` at `nodes` and `{'nodes': [1, {...}, 'z']}` is TWO `dict_type` errors,
304
+ // `nodes.0` and `nodes.2` — every bad item, not the first.
305
+ if (value.t !== 'list') {
306
+ return [{ loc: spec.name, type: 'list_type', msg: 'Input should be a valid list', input: value }];
307
+ }
308
+ const bad = [];
309
+ for (let i = 0; i < value.v.length; i += 1) {
310
+ const item = value.v[i];
311
+ if (item.t !== 'dict') {
312
+ bad.push({
313
+ loc: `${spec.name}.${i}`,
314
+ type: 'dict_type',
315
+ msg: 'Input should be a valid dictionary',
316
+ input: item,
317
+ });
318
+ }
319
+ }
320
+ return bad.length > 0 ? bad : { value };
321
+ }
290
322
  case 'listStr': {
291
323
  if (value.t !== 'list') {
292
324
  return [{ loc: spec.name, type: 'list_type', msg: 'Input should be a valid list', input: value }];
@@ -4,13 +4,20 @@ import { EventLog } from '../eventlog.js';
4
4
  import type { Memory } from '../memory/component.js';
5
5
  import type { RawStdioTransport } from './transport.js';
6
6
  /**
7
- * The twelve, in the order `build_server` lists them — which is the order `tools/list` emits.
7
+ * The fourteen, in the order `build_server` lists them — which is the order `tools/list` emits.
8
8
  *
9
9
  * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
10
10
  * beside `memory_save` where a reader would look for it, `skill_audit` after that,
11
- * `memory_dream` after that and `token_ledger` after that, for the same reason the reference
12
- * appends: registration order IS the served order, and appending is the only edit that
13
- * leaves the others where every existing declaration says they are.
11
+ * `memory_dream` after that, `token_ledger` after that and `work_plan` then `shiftwork_plan`
12
+ * after that, for the same reason the reference appends: registration order IS the served
13
+ * order, and appending is the only edit that leaves the others where every existing
14
+ * declaration says they are.
15
+ *
16
+ * `work_plan` BEFORE `shiftwork_plan` is the reference's order (`mcpserver.py`'s `tools`
17
+ * list), and it is load-bearing rather than alphabetical: `runtime-py/tests/data/
18
+ * served-tool-surface.json` pins the served order as a golden and `wire.mjs` compares the
19
+ * two `tools/list` answers frame by frame, so registering them the other way round is a
20
+ * red in three places and a wire change nobody asked for.
14
21
  *
15
22
  * `bantamkit_read` (tenth) and `repo_map` (thirteenth) LEFT this list on the user's ruling
16
23
  * of 2026-09-12 (job50, I5), the same ruling that took them out of the reference's
@@ -22,7 +29,7 @@ import type { RawStdioTransport } from './transport.js';
22
29
  * its argument model. The handlers below are DORMANT, not gone: a roster decision, not a
23
30
  * deletion of working code.
24
31
  */
25
- export declare const MCP_TOOLS: readonly ["memory_save", "memory_recall", "validate_json", "shiftwork_clock_in", "shiftwork_clock_out", "shiftwork_status", "build_identity", "bantamkit_status", "memory_compact", "skill_audit", "memory_dream", "token_ledger"];
32
+ export declare const MCP_TOOLS: readonly ["memory_save", "memory_recall", "validate_json", "shiftwork_clock_in", "shiftwork_clock_out", "shiftwork_status", "build_identity", "bantamkit_status", "memory_compact", "skill_audit", "memory_dream", "token_ledger", "work_plan", "shiftwork_plan"];
26
33
  /**
27
34
  * The advertisement for one tool, out of its manifest, with the surface checked.
28
35
  *
@@ -43,18 +43,26 @@ import { EventLog } from '../eventlog.js';
43
43
  import { PyOSError, asPyOSError, pyReadText } from '../memory/pyfs.js';
44
44
  import { fromJs, parseJson, reprValue, toJs } from '../pyjson.js';
45
45
  import * as shiftwork from '../shiftwork.js';
46
+ import { plan } from '../workplan.js';
46
47
  import { buildIdentity, SERVER_NAME } from './identity.js';
47
48
  import { ARG_MODELS, PyValidationFailure, validateArguments } from './pyargs.js';
48
49
  import { sdkJson } from './sdkjson.js';
49
50
  import { degradedConditions, degradedNotice, SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES, statusReport, STATUS_NAME, STATUS_PROMPT_DESCRIPTION, STATUS_PROMPT_TAIL, STATUS_PROMPT_TITLE, } from './status.js';
50
51
  /**
51
- * The twelve, in the order `build_server` lists them — which is the order `tools/list` emits.
52
+ * The fourteen, in the order `build_server` lists them — which is the order `tools/list` emits.
52
53
  *
53
54
  * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
54
55
  * beside `memory_save` where a reader would look for it, `skill_audit` after that,
55
- * `memory_dream` after that and `token_ledger` after that, for the same reason the reference
56
- * appends: registration order IS the served order, and appending is the only edit that
57
- * leaves the others where every existing declaration says they are.
56
+ * `memory_dream` after that, `token_ledger` after that and `work_plan` then `shiftwork_plan`
57
+ * after that, for the same reason the reference appends: registration order IS the served
58
+ * order, and appending is the only edit that leaves the others where every existing
59
+ * declaration says they are.
60
+ *
61
+ * `work_plan` BEFORE `shiftwork_plan` is the reference's order (`mcpserver.py`'s `tools`
62
+ * list), and it is load-bearing rather than alphabetical: `runtime-py/tests/data/
63
+ * served-tool-surface.json` pins the served order as a golden and `wire.mjs` compares the
64
+ * two `tools/list` answers frame by frame, so registering them the other way round is a
65
+ * red in three places and a wire change nobody asked for.
58
66
  *
59
67
  * `bantamkit_read` (tenth) and `repo_map` (thirteenth) LEFT this list on the user's ruling
60
68
  * of 2026-09-12 (job50, I5), the same ruling that took them out of the reference's
@@ -79,6 +87,8 @@ export const MCP_TOOLS = [
79
87
  'skill_audit',
80
88
  'memory_dream',
81
89
  'token_ledger',
90
+ 'work_plan',
91
+ 'shiftwork_plan',
82
92
  ];
83
93
  /** The served set, for the call path: a name outside it is unknown, whatever `ARG_MODELS` says. */
84
94
  const SERVED = new Set(MCP_TOOLS);
@@ -163,6 +173,44 @@ function rpcError(code, message, data) {
163
173
  }
164
174
  const asDict = (value) => (value.t === 'dict' ? value.v : new Map());
165
175
  const asText = (value) => (value && value.t === 'str' ? value.v : '');
176
+ /**
177
+ * `work_plan`'s `nodes` as `workplan.plan` takes them.
178
+ *
179
+ * The reference's model is `list[dict[str, Any]]`, so pydantic guarantees a list of dicts
180
+ * and NOTHING about what is inside one; `workplan.plan` then reads `node["id"]`,
181
+ * `node.get("depends_on") or []` and `node.get("priority", 0)` off raw Python objects. This
182
+ * reproduces those three reads and their defaults.
183
+ *
184
+ * A NODE WITH NO `id` RAISES, and raising is the port: `node["id"]` is a `KeyError` on the
185
+ * reference, whose `str()` is the key in single quotes, so the SDK's envelope reads
186
+ * `Error executing tool work_plan: 'id'`. Defaulting it here would answer a plan over a
187
+ * node the reference refused to look at.
188
+ *
189
+ * WHERE THE DYNAMIC HALF STOPS, said rather than hidden. Python keeps an id's own type and
190
+ * this side stringifies it with `str()`'s spelling (`reprValue` for every non-`str`), so a
191
+ * document mixing `5` and `"5"` is two nodes there and one here, and a non-numeric
192
+ * `priority` is a `TypeError` there and a 0 here. Both need a node whose types contradict
193
+ * `assets/tools/work_plan.json`, which nothing in the shift-work corpus produces; they are
194
+ * named here because an unwritten difference is the one that is found later.
195
+ */
196
+ function planNodes(value) {
197
+ const pyText = (v) => (v.t === 'str' ? v.v : reprValue(v));
198
+ const items = value !== undefined && value.t === 'list' ? value.v : [];
199
+ return items.map((item) => {
200
+ const node = item.t === 'dict' ? item.v : new Map();
201
+ const id = node.get('id');
202
+ if (id === undefined)
203
+ throw new BantamError("'id'");
204
+ const declared = node.get('depends_on');
205
+ const priority = node.get('priority');
206
+ return {
207
+ id: pyText(id),
208
+ // `list(node.get("depends_on") or [])`: missing, `null` and `[]` are one case.
209
+ depends_on: declared !== undefined && declared.t === 'list' ? declared.v.map(pyText) : [],
210
+ priority: priority !== undefined && (priority.t === 'int' || priority.t === 'float') ? Number(priority.v) : 0,
211
+ };
212
+ });
213
+ }
166
214
  /** `int | None` after validation: the tagged int, or `null` for the default. */
167
215
  function asInt(value) {
168
216
  return value && value.t === 'int' ? Number(value.v) : null;
@@ -879,6 +927,47 @@ function runTool(name, memory, args, version, log, documents) {
879
927
  return { value: { t: 'str', v: noted(tokenledger.asJson(result)) }, wrapped: true };
880
928
  });
881
929
  }
930
+ case 'work_plan':
931
+ // `depends_on` turned into the batches that may run in parallel, over any task list
932
+ // the caller hands over. No path, no file, no clock: `workplan.plan` is Layer 1 and
933
+ // this is the seam that serves it.
934
+ //
935
+ // `result` is added HERE and not in `workplan.plan`, which answers the COMPUTATION
936
+ // (`batches`, `sequence`, `width`) rather than a wire shape. The wire shape is the
937
+ // tool asset's, so the verdict key goes on at the seam that serves it — the same
938
+ // division `shiftwork.planBatches` uses one layer down. A refusal already carries its
939
+ // own `result` and passes through untouched, because `recordResult` reads that key to
940
+ // write the register's verdict to the log.
941
+ //
942
+ // THE MAPPING IS INSIDE THE CLOSURE, not above it, so that a node the core cannot
943
+ // read raises where the reference raises: within `_record_result`, which records the
944
+ // exception and re-raises it into the `Error executing tool work_plan:` envelope.
945
+ return {
946
+ value: notedDict(recordResult(log, name, () => {
947
+ const answer = plan(planNodes(args.get('nodes')));
948
+ if ('result' in answer)
949
+ return { t: 'dict', v: new Map([['result', { t: 'str', v: 'error' }], ['reason', { t: 'str', v: answer.reason }]]) };
950
+ return {
951
+ t: 'dict',
952
+ v: new Map([
953
+ ['result', { t: 'str', v: 'plan' }],
954
+ ['batches', { t: 'list', v: answer.batches.map((batch) => ({ t: 'list', v: batch.map((id) => ({ t: 'str', v: id })) })) }],
955
+ ['sequence', { t: 'list', v: answer.sequence.map((id) => ({ t: 'str', v: id })) }],
956
+ ['width', { t: 'int', v: BigInt(answer.width) }],
957
+ ]),
958
+ };
959
+ })),
960
+ wrapped: false,
961
+ };
962
+ case 'shiftwork_plan':
963
+ // The read-only batch view of a checkpoint. Registered beside `shiftwork_status`
964
+ // because it is the same kind of thing: a path in, an answer out, and not one byte
965
+ // written — `test/shiftwork.test.mjs` hashes the checkpoint AND its `.log.jsonl`
966
+ // before and after to hold that, rather than commenting it.
967
+ return {
968
+ value: notedDict(recordResult(log, name, () => shiftwork.planBatches(asText(args.get('checkpoint'))))),
969
+ wrapped: false,
970
+ };
882
971
  default:
883
972
  // `tool_manager.call_tool` raises `ToolError(f"Unknown tool: {name}")`, which the
884
973
  // handler turns into an isError result rather than a JSON-RPC error.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * `docs/memory.md` states, as a design decision, that `lint`, `archived` and `restore`
6
6
  * are **not** agent tools — "lifecycle is an operator decision, not a model decision" —
7
- * and the twelve-tool surface holds to it; `compact` is the one exception since job42
7
+ * and the fourteen-tool surface holds to it; `compact` is the one exception since job42
8
8
  * (`memory_compact`, the on-refusal path the model reaches itself). `runtime-py` made that
9
9
  * position coherent in August 2026 by giving the operator `python -m bantamkit.memory`.
10
10
  * `runtime-ts` did not. MEASURED 2026-08-24 before this file existed: `node dist/cli.js -h`
@@ -80,3 +80,36 @@ export declare function clockOut(checkpoint: string, unitId: string, status: str
80
80
  * The result is dumped without `sort_keys` on the wire, so that order is observable.
81
81
  */
82
82
  export declare function status(checkpoint: string): PyValue;
83
+ /**
84
+ * Read-only batch view of a checkpoint. Never mutates.
85
+ *
86
+ * The adapter over `workplan.plan`, and it holds no graph logic of its own: a loop over
87
+ * `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.
89
+ *
90
+ * **A `done` or `dropped` unit is SATISFIED**, which is two things and not one: it leaves
91
+ * the graph, AND every edge pointing at it is treated as already resolved. Only the first
92
+ * would be a defect rather than a simplification — `workplan.plan` refuses an edge into an
93
+ * id no node declares, so dropping the unit while keeping the edge would make every
94
+ * checkpoint with one finished unit unplannable, which is every checkpoint after its first
95
+ * clock-out. `todo`, `in_progress` and `blocked` are all still work and all stay in. The
96
+ * terminal pair is `TERMINAL_UNIT_STATUS`, the same constant the driver's success test
97
+ * uses, so "finished" means one thing in this module.
98
+ *
99
+ * **Every unit gets priority 0.** The checkpoint schema has no priority field and this
100
+ * design does not add one, so the tie-break inside a batch falls through to the core's
101
+ * insertion order — `plan.units` order, which is the order a reader of the checkpoint
102
+ * already sees.
103
+ *
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.
108
+ *
109
+ * Returns `{result: 'plan', batches, ready, sequence, width, cursor}` — `ready` is
110
+ * `batches[0]`, or `[]` when the plan is all terminal, which is an ANSWER and not a
111
+ * refusal. Refusals pass through verbatim in both directions: `readValid`'s for a
112
+ * checkpoint that cannot be read or does not validate, and the core's own duplicate-id /
113
+ * unknown-dependency / cycle sentences for a graph that cannot batch.
114
+ */
115
+ export declare function planBatches(checkpoint: string): PyValue;
package/dist/shiftwork.js CHANGED
@@ -47,17 +47,24 @@
47
47
  * same place. That makes three refusal strings, not two, and the third is likewise a
48
48
  * per-side literal.
49
49
  *
50
- * WHAT IS NOT PORTED, DELIBERATELY. The `depends_on` field is ignored on cursor advance
51
- * (v1-linear, the Python module's own ruling), there is no lock (the MCP topology has one
52
- * orchestrator by construction), and log-then-commit ordering is preserved exactly: the
53
- * accounting line is appended BEFORE the atomic rename, so a partial failure can lose the
54
- * commit but never the accounting.
50
+ * WHAT IS NOT PORTED, DELIBERATELY. There is no lock (the MCP topology has one orchestrator
51
+ * by construction), and log-then-commit ordering is preserved exactly: the accounting line is
52
+ * appended BEFORE the atomic rename, so a partial failure can lose the commit but never the
53
+ * accounting.
54
+ *
55
+ * `depends_on` USED TO BE LISTED ABOVE, AND NO LONGER BELONGS THERE. `planBatches` below
56
+ * READS `depends_on` and answers the batch view, so the module no longer ignores the field;
57
+ * what still ignores it is CURSOR ADVANCE, which stays v1-linear (the Python module's own
58
+ * ruling) — it moves to the first non-terminal unit in `plan.units` order, and a non-linear
59
+ * plan still needs a planner unit to reorder `plan.units` first. The batch view is read-only
60
+ * and moves nothing.
55
61
  */
56
62
  import { AssetNotFound, loadSchema, loadToolAsset } from './assets.js';
57
63
  import { schemaError } from './contract.js';
58
64
  import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, PyUnicodeDecodeError, } from './memory/pyfs.js';
59
65
  import { dumpJson, fromJs, parseJson, PyJSONDecodeError, reprValue } from './pyjson.js';
60
66
  import { checkSchema, PyJsonSchemaUnsupported } from './pyjsonschema.js';
67
+ import { plan } from './workplan.js';
61
68
  export const SCHEMA_NAME = 'shiftwork-checkpoint';
62
69
  /** The accounting line's shape lives on the TOOL asset, not in a second copy here (F5). */
63
70
  const TOOL_ASSET = 'shiftwork_clock_out';
@@ -692,3 +699,74 @@ export function status(checkpoint) {
692
699
  ['last_history', history.v.length > 0 ? history.v[history.v.length - 1] : { t: 'null' }],
693
700
  ]);
694
701
  }
702
+ // ------------------------------------------------------------------------- plan_batches
703
+ /**
704
+ * Read-only batch view of a checkpoint. Never mutates.
705
+ *
706
+ * The adapter over `workplan.plan`, and it holds no graph logic of its own: a loop over
707
+ * `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.
709
+ *
710
+ * **A `done` or `dropped` unit is SATISFIED**, which is two things and not one: it leaves
711
+ * the graph, AND every edge pointing at it is treated as already resolved. Only the first
712
+ * would be a defect rather than a simplification — `workplan.plan` refuses an edge into an
713
+ * id no node declares, so dropping the unit while keeping the edge would make every
714
+ * checkpoint with one finished unit unplannable, which is every checkpoint after its first
715
+ * clock-out. `todo`, `in_progress` and `blocked` are all still work and all stay in. The
716
+ * terminal pair is `TERMINAL_UNIT_STATUS`, the same constant the driver's success test
717
+ * uses, so "finished" means one thing in this module.
718
+ *
719
+ * **Every unit gets priority 0.** The checkpoint schema has no priority field and this
720
+ * design does not add one, so the tie-break inside a batch falls through to the core's
721
+ * insertion order — `plan.units` order, which is the order a reader of the checkpoint
722
+ * already sees.
723
+ *
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.
728
+ *
729
+ * Returns `{result: 'plan', batches, ready, sequence, width, cursor}` — `ready` is
730
+ * `batches[0]`, or `[]` when the plan is all terminal, which is an ANSWER and not a
731
+ * refusal. Refusals pass through verbatim in both directions: `readValid`'s for a
732
+ * checkpoint that cannot be read or does not validate, and the core's own duplicate-id /
733
+ * unknown-dependency / cycle sentences for a graph that cannot batch.
734
+ */
735
+ export function planBatches(checkpoint) {
736
+ const { document, refusal } = readValid(pyJoin(checkpoint));
737
+ if (refusal !== null)
738
+ return refusal;
739
+ 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);
761
+ if ('result' in answer)
762
+ return errorResult(answer.reason);
763
+ const batches = answer.batches.map((batch) => ({ t: 'list', v: batch.map(str) }));
764
+ return dict([
765
+ ['result', str('plan')],
766
+ ['batches', { t: 'list', v: batches }],
767
+ ['ready', batches.length > 0 ? batches[0] : { t: 'list', v: [] }],
768
+ ['sequence', { t: 'list', v: answer.sequence.map(str) }],
769
+ ['width', int(answer.width)],
770
+ ['cursor', field(subDict(doc, 'plan'), 'cursor')],
771
+ ]);
772
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Layer 1 — the work-plan planner: `depends_on` turned into batches that may run in parallel.
3
+ *
4
+ * A pure function. No filesystem, no clock, no `mcp` import, no dependency: `docs/architecture.md`
5
+ * puts a deterministic graph planner in Core, and `shiftwork.ts` — which is Layer 5 — is the thing
6
+ * that will call it, not the thing it lives in.
7
+ *
8
+ * WHERE THE SEMANTICS COME FROM. `docs/superpowers/specs/2026-09-18-workplan-dag-design.md`. The
9
+ * user's own `ex-flow@1.1.0` measured the numbers in that spec, and it is deliberately NOT an
10
+ * install: `runtime-ts/package.json` declares exactly one runtime dependency,
11
+ * `@modelcontextprotocol/sdk`, and `docs/porting.md` uses "no runtime dependency is allowed into
12
+ * `runtime-ts`" as the live justification for three divergence rows. So `ex-flow` is the
13
+ * SPECIFICATION and this is one of two hand-written implementations held to it — the other being
14
+ * `runtime-py/src/bantamkit/workplan.py`, written independently so that
15
+ * `tools/conformance/suites/workplan.mjs` proves an agreement rather than a copy.
16
+ *
17
+ * WHAT IS DELIBERATELY LEFT OUT of `ex-flow`'s surface: `deadlineStrategy`, `weightStrategy`,
18
+ * `fairnessPolicy`/`maxDeferralRounds` aging, `schedulerMode: "throughput"`, `concurrencyCap`,
19
+ * `resourceCaps`, the clone modes and the config builder. None of the 19 measured checkpoints
20
+ * supplies a deadline, a weight or a resource class, so every one of them would be untested
21
+ * surface. `sequence` is `ex-flow`'s `fullSequence` under a shorter name.
22
+ *
23
+ * WHAT THIS DOES NOT CLAIM. `depends_on` encodes logical order, not file contention. Measured on
24
+ * `.shiftwork/checkpoint-job44.json`, whose first batch is twelve units wide, seven of those
25
+ * twelve name `runtime-ts/src/docread.ts`. The graph says they may run in parallel; the filesystem
26
+ * does not. This function reports what the graph permits and the orchestrator stays responsible
27
+ * for what it actually dispatches.
28
+ */
29
+ /** One task. `priority` is optional and absent means `0` — the checkpoint schema has no such field. */
30
+ export interface PlanNode {
31
+ id: string;
32
+ depends_on: string[];
33
+ priority?: number;
34
+ }
35
+ /** A plan: the batches, the same ids read end to end, and the widest fan-out in the plan. */
36
+ export interface PlanOk {
37
+ batches: string[][];
38
+ sequence: string[];
39
+ width: number;
40
+ }
41
+ /**
42
+ * A refusal, in the `{result, reason}` shape `shiftwork` already returns, so nothing new is
43
+ * invented for it. The three sentences are pinned per-side in `test/workplan.test.mjs` because a
44
+ * sentence both runtimes copied is invisible to the differential.
45
+ */
46
+ export interface PlanError {
47
+ result: 'error';
48
+ reason: string;
49
+ }
50
+ export type PlanResult = PlanOk | PlanError;
51
+ /**
52
+ * Level-synchronous Kahn: batch *k* holds every node whose dependencies all appear in batches < *k*.
53
+ *
54
+ * **Empty input is not an error.** `plan([])` is `{batches: [], sequence: [], width: 0}` — a plan
55
+ * with nothing left to do is the answer, the same reasoning `clock_in` applies when an all-terminal
56
+ * checkpoint reports success rather than escalating on a dangling cursor.
57
+ *
58
+ * **The three refusals are checked in a fixed order** — duplicate id, then unknown dependency, then
59
+ * cycle — because a graph can carry two faults at once and both runtimes have to name the same one.
60
+ */
61
+ export declare function plan(nodes: readonly PlanNode[]): PlanResult;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Layer 1 — the work-plan planner: `depends_on` turned into batches that may run in parallel.
3
+ *
4
+ * A pure function. No filesystem, no clock, no `mcp` import, no dependency: `docs/architecture.md`
5
+ * puts a deterministic graph planner in Core, and `shiftwork.ts` — which is Layer 5 — is the thing
6
+ * that will call it, not the thing it lives in.
7
+ *
8
+ * WHERE THE SEMANTICS COME FROM. `docs/superpowers/specs/2026-09-18-workplan-dag-design.md`. The
9
+ * user's own `ex-flow@1.1.0` measured the numbers in that spec, and it is deliberately NOT an
10
+ * install: `runtime-ts/package.json` declares exactly one runtime dependency,
11
+ * `@modelcontextprotocol/sdk`, and `docs/porting.md` uses "no runtime dependency is allowed into
12
+ * `runtime-ts`" as the live justification for three divergence rows. So `ex-flow` is the
13
+ * SPECIFICATION and this is one of two hand-written implementations held to it — the other being
14
+ * `runtime-py/src/bantamkit/workplan.py`, written independently so that
15
+ * `tools/conformance/suites/workplan.mjs` proves an agreement rather than a copy.
16
+ *
17
+ * WHAT IS DELIBERATELY LEFT OUT of `ex-flow`'s surface: `deadlineStrategy`, `weightStrategy`,
18
+ * `fairnessPolicy`/`maxDeferralRounds` aging, `schedulerMode: "throughput"`, `concurrencyCap`,
19
+ * `resourceCaps`, the clone modes and the config builder. None of the 19 measured checkpoints
20
+ * supplies a deadline, a weight or a resource class, so every one of them would be untested
21
+ * surface. `sequence` is `ex-flow`'s `fullSequence` under a shorter name.
22
+ *
23
+ * WHAT THIS DOES NOT CLAIM. `depends_on` encodes logical order, not file contention. Measured on
24
+ * `.shiftwork/checkpoint-job44.json`, whose first batch is twelve units wide, seven of those
25
+ * twelve name `runtime-ts/src/docread.ts`. The graph says they may run in parallel; the filesystem
26
+ * does not. This function reports what the graph permits and the orchestrator stays responsible
27
+ * for what it actually dispatches.
28
+ */
29
+ const refuse = (reason) => ({ result: 'error', reason });
30
+ /**
31
+ * Name the cycle that stopped Kahn, deterministically.
32
+ *
33
+ * Two runtimes left to their own devices will name two different — equally correct — cycles in the
34
+ * same graph, and the conformance suite goes red for a reason that is not a defect. The rule, and
35
+ * the three rulings that disambiguate it:
36
+ *
37
+ * - Scan the nodes Kahn could not emit **in input order** and start at the first.
38
+ * - Walk `depends_on` **in declared order**, following only dependencies that are themselves
39
+ * UN-EMITTED. A dependency already emitted in an earlier batch can never close a cycle, so
40
+ * following it would dead-end on a graph that does have one. (Every un-emitted node has at least
41
+ * one un-emitted dependency — otherwise it was ready — so the walk can never run out of edges.)
42
+ * - The first repeated id closes the cycle, and the reported path is the CYCLE, not the whole walk:
43
+ * the scan may have started merely downstream of the loop, and a sentence that named a node
44
+ * outside the loop would be describing something other than the cycle it claims to name.
45
+ */
46
+ function cyclePath(nodes, emitted) {
47
+ const byId = new Map(nodes.map((node) => [node.id, node]));
48
+ const path = [];
49
+ /** id -> where it sits in `path`, which is what turns "seen before" into "trim the tail here". */
50
+ const at = new Map();
51
+ let current = nodes.find((node) => !emitted.has(node.id));
52
+ while (current !== undefined) {
53
+ const repeat = at.get(current.id);
54
+ if (repeat !== undefined)
55
+ return [...path.slice(repeat), current.id];
56
+ at.set(current.id, path.length);
57
+ path.push(current.id);
58
+ const next = current.depends_on.find((dep) => !emitted.has(dep));
59
+ current = next === undefined ? undefined : byId.get(next);
60
+ }
61
+ // Unreachable on a graph that reached here: the walk only ends by repeating an id.
62
+ return path;
63
+ }
64
+ /**
65
+ * Level-synchronous Kahn: batch *k* holds every node whose dependencies all appear in batches < *k*.
66
+ *
67
+ * **Empty input is not an error.** `plan([])` is `{batches: [], sequence: [], width: 0}` — a plan
68
+ * with nothing left to do is the answer, the same reasoning `clock_in` applies when an all-terminal
69
+ * checkpoint reports success rather than escalating on a dangling cursor.
70
+ *
71
+ * **The three refusals are checked in a fixed order** — duplicate id, then unknown dependency, then
72
+ * cycle — because a graph can carry two faults at once and both runtimes have to name the same one.
73
+ */
74
+ export function plan(nodes) {
75
+ // 1 — duplicate id. First in input order wins.
76
+ const declared = new Set();
77
+ for (const node of nodes) {
78
+ if (declared.has(node.id))
79
+ return refuse(`duplicate node id ${node.id}`);
80
+ declared.add(node.id);
81
+ }
82
+ // 2 — a dependency nothing declares. First node in input order, first dep in declared order.
83
+ for (const node of nodes) {
84
+ for (const dep of node.depends_on) {
85
+ if (!declared.has(dep)) {
86
+ return refuse(`node ${node.id} depends on ${dep}, which no node declares`);
87
+ }
88
+ }
89
+ }
90
+ // 3 — the batches.
91
+ const emitted = new Set();
92
+ const batches = [];
93
+ const sequence = [];
94
+ let width = 0;
95
+ while (emitted.size < nodes.length) {
96
+ /**
97
+ * Carry the input index explicitly rather than leaning on `Array.prototype.sort` being stable.
98
+ * It is, in every Node this package supports — but the tie-break is the thing the conformance
99
+ * gate compares against Python, and a reader has to be able to SEE that it is insertion order
100
+ * and not a property of the sort.
101
+ */
102
+ const ready = [];
103
+ for (const [index, node] of nodes.entries()) {
104
+ if (emitted.has(node.id))
105
+ continue;
106
+ if (node.depends_on.every((dep) => emitted.has(dep))) {
107
+ ready.push({ id: node.id, priority: node.priority ?? 0, index });
108
+ }
109
+ }
110
+ // Nothing is ready and something is left: the remainder contains a cycle.
111
+ if (ready.length === 0) {
112
+ return refuse(`the graph has a cycle: ${cyclePath(nodes, emitted).join(' -> ')}`);
113
+ }
114
+ // Priority descending, then insertion order.
115
+ ready.sort((a, b) => b.priority - a.priority || a.index - b.index);
116
+ const batch = ready.map((entry) => entry.id);
117
+ batches.push(batch);
118
+ for (const id of batch) {
119
+ emitted.add(id);
120
+ sequence.push(id);
121
+ }
122
+ if (batch.length > width)
123
+ width = batch.length;
124
+ }
125
+ return { batches, sequence, width };
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bantamkit-mcp",
3
- "version": "0.35.0",
3
+ "version": "0.35.1",
4
4
  "description": "Memory MCP server for Claude Code, Cursor, VS Code Copilot and Claude Desktop: per-person memory, JSON Schema validation, shift-work tools. Pure Node; install once with npx, run offline.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -44,7 +44,8 @@
44
44
  "scripts": {
45
45
  "build": "tsc -p tsconfig.json",
46
46
  "sync-assets": "node scripts/sync-assets.mjs",
47
- "prepack": "node scripts/sync-assets.mjs",
47
+ "prepack": "node scripts/build-for-pack.mjs && npm run sync-assets",
48
+ "prepublishOnly": "node scripts/gate-before-publish.mjs",
48
49
  "pretest": "npm run build",
49
50
  "test": "node scripts/run-tests.mjs"
50
51
  },