bantamkit-mcp 0.35.0 → 0.35.2
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 +8 -5
- package/dist/mcp/pyargs.d.ts +1 -1
- package/dist/mcp/pyargs.js +32 -0
- package/dist/mcp/server.d.ts +12 -5
- package/dist/mcp/server.js +93 -4
- package/dist/mcp/status.js +14 -0
- package/dist/memory/cli.js +1 -1
- package/dist/selfupdate.d.ts +21 -12
- package/dist/selfupdate.js +74 -1
- package/dist/shiftwork.d.ts +33 -0
- package/dist/shiftwork.js +83 -5
- package/dist/updatecheck.d.ts +118 -0
- package/dist/updatecheck.js +278 -0
- package/dist/workplan.d.ts +61 -0
- package/dist/workplan.js +126 -0
- package/package.json +3 -2
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.
|
|
36
|
-
* unbounded
|
|
37
|
-
* `
|
|
38
|
-
* and `focus` were two more until both
|
|
39
|
-
*
|
|
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.
|
package/dist/mcp/pyargs.d.ts
CHANGED
|
@@ -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;
|
package/dist/mcp/pyargs.js
CHANGED
|
@@ -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 }];
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
12
|
-
*
|
|
13
|
-
* leaves the others where every existing
|
|
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
|
*
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
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
|
|
56
|
-
*
|
|
57
|
-
* leaves the others where every existing
|
|
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.
|
package/dist/mcp/status.js
CHANGED
|
@@ -34,6 +34,7 @@ import { join } from 'node:path';
|
|
|
34
34
|
import { AssetNotFound, assetsRoot } from '../assets.js';
|
|
35
35
|
import { Memory } from '../memory/component.js';
|
|
36
36
|
import { INDEX_PRESSURE_PERCENT } from '../memory/store.js';
|
|
37
|
+
import { updateLine } from '../updatecheck.js';
|
|
37
38
|
import { Undetermined, currentInstall, originStat } from './identity.js';
|
|
38
39
|
/**
|
|
39
40
|
* The tool AND the prompt answer to this one name. Deliberately the same word: the operator
|
|
@@ -423,6 +424,19 @@ export function statusReport(memory, log, conditions, identity, tools, prompts,
|
|
|
423
424
|
(size === null ? 'unreadable' : String(size)) +
|
|
424
425
|
` of ${budget} bytes`,
|
|
425
426
|
`event log: ${log.enabled ? 'on' : 'off'}`,
|
|
427
|
+
// THE UPDATE LINE IS NOT A CONDITION, and that is the ruling of 2026-09-19 rather than an
|
|
428
|
+
// omission. A newer version existing is not a fault: the server is serving correctly, and
|
|
429
|
+
// this file's own header carries the operator's reason — a footer on every result is
|
|
430
|
+
// noise, noise trains the reader to stop reading. So this never flips line 1, never enters
|
|
431
|
+
// `degradedConditions`, and never rides another tool's reply. It also means the record is
|
|
432
|
+
// opened ONLY here, when someone asked for a report, and not on the per-call path whose
|
|
433
|
+
// cost `docs/status.md` documents.
|
|
434
|
+
//
|
|
435
|
+
// `updatecheck` opens one file and asks nobody anything. It is imported instead of
|
|
436
|
+
// `selfupdate.js` deliberately: `test/selfupdate.test.mjs` pins that `cli.ts` is the ONLY
|
|
437
|
+
// module in `src/` reaching the one `fetch` in this runtime, and this file is on a path a
|
|
438
|
+
// host reaches. The version is the one line 2 already printed.
|
|
439
|
+
updateLine(identity.get('version')),
|
|
426
440
|
];
|
|
427
441
|
if (conditions.length > 0) {
|
|
428
442
|
lines.push(`${plural(conditions.length, 'problem')}:`);
|
package/dist/memory/cli.js
CHANGED
|
@@ -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
|
|
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`
|
package/dist/selfupdate.d.ts
CHANGED
|
@@ -278,19 +278,28 @@ export interface KeptInstall {
|
|
|
278
278
|
*/
|
|
279
279
|
export declare function keptInstall(prefix?: string): KeptInstall | null;
|
|
280
280
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* THE
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
281
|
+
* Put the version this flag just fetched into the record `bantamkit_status` reads.
|
|
282
|
+
*
|
|
283
|
+
* THE PORT OF `selfupdate.record_update`. Its docstring carries the argument and is not
|
|
284
|
+
* restated: no second network call and no new failure mode (`update` already holds `latest`),
|
|
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.
|
|
287
|
+
*
|
|
288
|
+
* WHY THE WRITER IS HERE AND NOT IN `updatecheck.ts`. That module is the READER, and
|
|
289
|
+
* `test/updatecheck.test.mjs` gates its source against `writeFileSync`, `renameSync`,
|
|
290
|
+
* `mkdirSync` and the rest by name — the same claim `test_updatecheck.py` makes over the
|
|
291
|
+
* reference's AST. The split is structural on both sides, so a future reader cannot grow a
|
|
292
|
+
* write by accident. `updatecheck.js` is imported here for the PATH and the KEY, which is the
|
|
293
|
+
* direction that keeps one spelling of each.
|
|
294
|
+
*
|
|
295
|
+
* `renameSync` is atomic on POSIX and on Windows, and the temp file is made in the SAME
|
|
296
|
+
* directory so the rename is never across a filesystem.
|
|
297
|
+
*
|
|
298
|
+
* RETURNS whether it wrote, and THROWS FOR NOTHING. A failed write must not turn a successful
|
|
299
|
+
* `--update` into a failure: the operator's install was updated either way, and the worst case
|
|
300
|
+
* is a status line that still says `never checked`.
|
|
293
301
|
*/
|
|
302
|
+
export declare function recordUpdate(latest: string, now?: string): boolean;
|
|
294
303
|
export declare function update(installed: string, origin: Origin, options?: UpdateOptions): Promise<string>;
|
|
295
304
|
/**
|
|
296
305
|
* `--update` on the CLI: ask the package index, act on the answer, print what happened, exit.
|
package/dist/selfupdate.js
CHANGED
|
@@ -40,11 +40,12 @@
|
|
|
40
40
|
* `test/selfupdate.test.mjs` — and it calls `runUpdate` from the flag and returns before a
|
|
41
41
|
* memory store or a transport exists, the same shape `--assets-root` and `--install` use.
|
|
42
42
|
*/
|
|
43
|
-
import { readFileSync } from 'node:fs';
|
|
43
|
+
import { readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
44
44
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
45
45
|
import { fileURLToPath } from 'node:url';
|
|
46
46
|
import { currentInstall, Undetermined } from './mcp/identity.js';
|
|
47
47
|
import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin } from './npminstall.js';
|
|
48
|
+
import { KEY as RECORD_KEY, loadRecord, recordPath, SOURCE_RECORD } from './updatecheck.js';
|
|
48
49
|
// `PACKAGE`, `shlexJoin` and `runInstaller` moved to `npminstall.ts` in job51 so `--install` can
|
|
49
50
|
// share the npm spawner without importing this module (see that file's header). Re-exported so
|
|
50
51
|
// this module's surface — and every test importing these from `dist/selfupdate.js` — is unchanged.
|
|
@@ -404,6 +405,71 @@ export function keptInstall(prefix = keptPrefix()) {
|
|
|
404
405
|
* checkout five releases behind is owed that number even though this flag will not be the thing
|
|
405
406
|
* that installs it. The shape decides the ACTION, never whether the question is asked.
|
|
406
407
|
*/
|
|
408
|
+
/**
|
|
409
|
+
* The `checked_at` stamp's shape, spelled rather than defaulted.
|
|
410
|
+
*
|
|
411
|
+
* `toISOString()` renders `.000Z` and CPython's `isoformat()` renders `+00:00`, so a record
|
|
412
|
+
* written by the two runtimes would differ in bytes neither reader cares about. The reference
|
|
413
|
+
* formats `%Y-%m-%dT%H:%M:%SZ`; this strips the milliseconds to reach the same shape.
|
|
414
|
+
*/
|
|
415
|
+
function stamp() {
|
|
416
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Put the version this flag just fetched into the record `bantamkit_status` reads.
|
|
420
|
+
*
|
|
421
|
+
* THE PORT OF `selfupdate.record_update`. Its docstring carries the argument and is not
|
|
422
|
+
* restated: no second network call and no new failure mode (`update` already holds `latest`),
|
|
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.
|
|
425
|
+
*
|
|
426
|
+
* WHY THE WRITER IS HERE AND NOT IN `updatecheck.ts`. That module is the READER, and
|
|
427
|
+
* `test/updatecheck.test.mjs` gates its source against `writeFileSync`, `renameSync`,
|
|
428
|
+
* `mkdirSync` and the rest by name — the same claim `test_updatecheck.py` makes over the
|
|
429
|
+
* reference's AST. The split is structural on both sides, so a future reader cannot grow a
|
|
430
|
+
* write by accident. `updatecheck.js` is imported here for the PATH and the KEY, which is the
|
|
431
|
+
* direction that keeps one spelling of each.
|
|
432
|
+
*
|
|
433
|
+
* `renameSync` is atomic on POSIX and on Windows, and the temp file is made in the SAME
|
|
434
|
+
* directory so the rename is never across a filesystem.
|
|
435
|
+
*
|
|
436
|
+
* RETURNS whether it wrote, and THROWS FOR NOTHING. A failed write must not turn a successful
|
|
437
|
+
* `--update` into a failure: the operator's install was updated either way, and the worst case
|
|
438
|
+
* is a status line that still says `never checked`.
|
|
439
|
+
*/
|
|
440
|
+
export function recordUpdate(latest, now) {
|
|
441
|
+
const path = recordPath();
|
|
442
|
+
const directory = dirname(path);
|
|
443
|
+
try {
|
|
444
|
+
if (!statSync(directory).isDirectory())
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
// No `<homedir>/.bantamkit` at all: an install makes that directory, and `--update` does
|
|
449
|
+
// not get to decide where this toolbox's home lives. Writing nothing is the answer.
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
const { source, record } = loadRecord(path);
|
|
453
|
+
// An UNREADABLE record is replaced rather than merged: there is nothing in it to preserve.
|
|
454
|
+
const payload = source === SOURCE_RECORD && record !== null ? { ...record } : {};
|
|
455
|
+
payload['checked_at'] = now ?? stamp();
|
|
456
|
+
payload[RECORD_KEY] = { package: PACKAGE, latest };
|
|
457
|
+
const temporary = join(directory, `.update-check-${process.pid}-${Date.now()}.json`);
|
|
458
|
+
try {
|
|
459
|
+
writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
460
|
+
renameSync(temporary, path);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
try {
|
|
464
|
+
unlinkSync(temporary);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
/* the temp file never existed, or is already gone: either way there is nothing to do */
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
407
473
|
export async function update(installed, origin, options = {}) {
|
|
408
474
|
const ask = options.fetch ?? fetchIndex;
|
|
409
475
|
const installer = options.installer ?? runInstaller;
|
|
@@ -426,6 +492,13 @@ export async function update(installed, origin, options = {}) {
|
|
|
426
492
|
throw e;
|
|
427
493
|
}
|
|
428
494
|
const latest = latestFromIndexPayload(body);
|
|
495
|
+
// THE RECORD IS WRITTEN HERE AND NOT IN ONE OF THE ARMS BELOW, because what it records is
|
|
496
|
+
// "the index said X on this date" — which is true the moment the fetch returned, whatever
|
|
497
|
+
// this flag then decides to do about it. An operator whose shape has no route gets the
|
|
498
|
+
// refusal AND a `bantamkit_status` that now knows the number; one who is already current
|
|
499
|
+
// gets a line that says so with a date. The return value is deliberately dropped: a record
|
|
500
|
+
// that could not be written is not a reason to fail an update that worked.
|
|
501
|
+
recordUpdate(latest);
|
|
429
502
|
const header = fill(COMPARISON, { program: PROGRAM, installed, latest });
|
|
430
503
|
const order = compareVersions(installed, latest);
|
|
431
504
|
if (order === 0)
|
package/dist/shiftwork.d.ts
CHANGED
|
@@ -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.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* accounting
|
|
54
|
-
*
|
|
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,118 @@
|
|
|
1
|
+
import { compareVersions } from './npminstall.js';
|
|
2
|
+
export { compareVersions };
|
|
3
|
+
/**
|
|
4
|
+
* The command the operator types — the one word that is true on npm and on PyPI both.
|
|
5
|
+
*
|
|
6
|
+
* Equal to `selfupdate.PROGRAM` by construction and by a test, never by an import: see the
|
|
7
|
+
* module docstring. A sentence naming a PACKAGE could not be byte-identical across runtimes,
|
|
8
|
+
* because the PyPI distribution is `bantamkit` and the npm package is `bantamkit-mcp`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const PROGRAM = "bantamkit-mcp";
|
|
11
|
+
/**
|
|
12
|
+
* The key of the record THIS runtime reads. Divergent by construction: the reference reads
|
|
13
|
+
* `pypi`. Registered as a `docs/porting.md` divergence row by J57-5.
|
|
14
|
+
*/
|
|
15
|
+
export declare const KEY = "npm";
|
|
16
|
+
/**
|
|
17
|
+
* The two path components, spelled once. `.bantamkit` under a HOME is this toolbox's own
|
|
18
|
+
* directory; `.bantamkit` under a cwd is a memory store, and this module never builds one.
|
|
19
|
+
*/
|
|
20
|
+
export declare const RECORD_DIR = ".bantamkit";
|
|
21
|
+
export declare const RECORD_NAME = "update-check.json";
|
|
22
|
+
/**
|
|
23
|
+
* No record at all. The state of a machine that has never been checked and of one that has
|
|
24
|
+
* been offline since it was installed — deliberately the same state, because they are the same
|
|
25
|
+
* fact about what is known.
|
|
26
|
+
*/
|
|
27
|
+
export declare const UPDATE_NEVER = "update: never checked.";
|
|
28
|
+
/**
|
|
29
|
+
* The whole point of the feature. `{latest}` and `{installed}` are BOTH here so the reader can
|
|
30
|
+
* falsify the claim, the same reason `selfupdate.COMPARISON` carries both.
|
|
31
|
+
*/
|
|
32
|
+
export declare const UPDATE_AVAILABLE: string;
|
|
33
|
+
/** Names the date because the record may be months old. */
|
|
34
|
+
export declare const UPDATE_CURRENT = "update: {program} {installed} is current as of {date}.";
|
|
35
|
+
/**
|
|
36
|
+
* A real state, not a curiosity: a checkout build, or a release not yet published, lands here —
|
|
37
|
+
* the same pair `selfupdate.AHEAD` exists for.
|
|
38
|
+
*/
|
|
39
|
+
export declare const UPDATE_AHEAD = "update: {program} {installed} is ahead of the package index, which has {latest}.";
|
|
40
|
+
/**
|
|
41
|
+
* One sentence for every malformed shape. It does not name WHICH shape: the operator cannot act
|
|
42
|
+
* differently on any of them, and a per-shape sentence would be N more strings to port.
|
|
43
|
+
*/
|
|
44
|
+
export declare const UPDATE_UNREADABLE = "update: the update record could not be read.";
|
|
45
|
+
export declare const STATE_NEVER = "never";
|
|
46
|
+
export declare const STATE_AVAILABLE = "available";
|
|
47
|
+
export declare const STATE_CURRENT = "current";
|
|
48
|
+
export declare const STATE_AHEAD = "ahead";
|
|
49
|
+
export declare const STATE_UNREADABLE = "unreadable";
|
|
50
|
+
export declare const STATES: readonly ["never", "available", "current", "ahead", "unreadable"];
|
|
51
|
+
/** One of the five above. */
|
|
52
|
+
export type State = (typeof STATES)[number];
|
|
53
|
+
export declare const SOURCE_ABSENT = "absent";
|
|
54
|
+
export declare const SOURCE_UNREADABLE = "unreadable";
|
|
55
|
+
export declare const SOURCE_RECORD = "record";
|
|
56
|
+
/** One of the three above. */
|
|
57
|
+
export type Source = typeof SOURCE_ABSENT | typeof SOURCE_UNREADABLE | typeof SOURCE_RECORD;
|
|
58
|
+
/**
|
|
59
|
+
* Which of the five states, and the one line that says so.
|
|
60
|
+
*
|
|
61
|
+
* Both halves are returned because the conformance gate needs the STATE (the bit that must not
|
|
62
|
+
* differ between runtimes) while `bantamkit_status` needs the LINE (the bit an operator reads).
|
|
63
|
+
* Deriving either from the other would be a second decision somewhere.
|
|
64
|
+
*/
|
|
65
|
+
export interface UpdateStatus {
|
|
66
|
+
state: State;
|
|
67
|
+
line: string;
|
|
68
|
+
}
|
|
69
|
+
/** What `loadRecord` found, and the record if it found one. */
|
|
70
|
+
export interface LoadedRecord {
|
|
71
|
+
source: Source;
|
|
72
|
+
record: Record<string, unknown> | null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* `<homedir>/.bantamkit/update-check.json`. Never relative to a cwd.
|
|
76
|
+
*
|
|
77
|
+
* `homedir()` reads the environment on every call on every platform this ships to — `HOME` on
|
|
78
|
+
* POSIX, `USERPROFILE` on Windows — which is both the seam the tests use and the reason the
|
|
79
|
+
* path cannot follow a cwd. `.bantamkit` relative to a cwd is a MEMORY STORE, and creating one
|
|
80
|
+
* by accident is a defect class this repo has already paid for (J54-3, `hostinstall.ts:362`).
|
|
81
|
+
*
|
|
82
|
+
* Returns a path whether or not anything is at it. Nothing here creates either component.
|
|
83
|
+
*/
|
|
84
|
+
export declare function recordPath(home?: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* What is at the path, and what it holds. Opens one file, creates nothing, and never throws.
|
|
87
|
+
*
|
|
88
|
+
* `ENOENT` and `ENOTDIR` are ABSENT — in both, nothing is at the path, and a `.bantamkit` that
|
|
89
|
+
* is a regular file leaves the operator in exactly the position of one who has never been
|
|
90
|
+
* checked. Every other error (a directory where the record should be, a mode that cannot be
|
|
91
|
+
* read) is UNREADABLE, because something IS there and this reader cannot use it. Undecodable
|
|
92
|
+
* bytes, text that is not JSON, and JSON that is not an object are UNREADABLE for the same
|
|
93
|
+
* reason.
|
|
94
|
+
*/
|
|
95
|
+
export declare function loadRecord(path?: string): LoadedRecord;
|
|
96
|
+
/**
|
|
97
|
+
* The parsed record, or `null` when there is not one. Creates nothing, never throws.
|
|
98
|
+
*
|
|
99
|
+
* `null` deliberately does NOT say which of absent-or-unreadable it was — a caller that needs
|
|
100
|
+
* the difference calls `loadRecord`, which is the one this module's own decision uses. This is
|
|
101
|
+
* the convenience surface for a caller that only wants the object.
|
|
102
|
+
*/
|
|
103
|
+
export declare function readRecord(path?: string): Record<string, unknown> | null;
|
|
104
|
+
/**
|
|
105
|
+
* The five-state decision, pure: no filesystem, no clock, no network, no TTL.
|
|
106
|
+
*
|
|
107
|
+
* Split from the reading so the conformance harness and the tests can construct a state
|
|
108
|
+
* directly and so the only thing that touches a disk is `loadRecord`.
|
|
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.
|
|
113
|
+
*/
|
|
114
|
+
export declare function decide(installed: string, source: Source, record: Record<string, unknown> | null, key?: string): UpdateStatus;
|
|
115
|
+
/** Read the record and decide. The whole reader, in one call, for `bantamkit_status`. */
|
|
116
|
+
export declare function updateStatus(installed: string, key?: string, path?: string): UpdateStatus;
|
|
117
|
+
/** Exactly one of the five sentences. Always a line: there is no silent state here. */
|
|
118
|
+
export declare function updateLine(installed: string, key?: string, path?: string): string;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The update record on disk, and the five-state line `bantamkit_status` prints from it.
|
|
3
|
+
*
|
|
4
|
+
* THE PORT OF `runtime-py/src/bantamkit/updatecheck.py`, AND THE SENTENCES ARE COPIED FROM IT
|
|
5
|
+
* BYTE FOR BYTE. That module's docstring carries the whole argument — why no reader here may
|
|
6
|
+
* reach the network (`selfupdate.py:29-31`, measured: one registry GET is 1.5x to 100x an
|
|
7
|
+
* entire 0.09 s cold stdio boot), why a newer version existing is NOT a degraded condition,
|
|
8
|
+
* why there is NO TTL in a reader, and why the date is sliced rather than rendered. It is not
|
|
9
|
+
* restated here; read it there. What belongs HERE is the part that could not be copied.
|
|
10
|
+
*
|
|
11
|
+
* EXACTLY ONE THING DIFFERS FROM THE REFERENCE, and it is a genuinely different object rather
|
|
12
|
+
* than a different spelling of one: `KEY` is `npm` here and `pypi` there, because npm and
|
|
13
|
+
* PyPI are two registries that can disagree at one version number — job56 shipped a record
|
|
14
|
+
* that day where they did. The record carries both, each runtime reads its own, and the split
|
|
15
|
+
* is the `docs/porting.md` divergence row J57-5 registers, the same genuinely-different-object
|
|
16
|
+
* as `--update`'s URL row.
|
|
17
|
+
*
|
|
18
|
+
* `PROGRAM` IS DECLARED HERE RATHER THAN IMPORTED, AND THAT IS NOT A SECOND SOURCE OF TRUTH.
|
|
19
|
+
* `test/selfupdate.test.mjs` pins that `cli.ts` is the ONLY module in `src/` importing
|
|
20
|
+
* `selfupdate.ts` — the structural half of AS-7(b), which says a dependency-free toolbox must
|
|
21
|
+
* not grow a network call on a path a host reaches. This module is reached by
|
|
22
|
+
* `bantamkit_status`, which is exactly such a path, so importing the module that owns the one
|
|
23
|
+
* `fetch` in this runtime would break that gate for a five-word constant. The equality is
|
|
24
|
+
* pinned in `test/updatecheck.test.mjs` instead, where importing both is free, the way the
|
|
25
|
+
* reference pins `updatecheck.PROGRAM is selfupdate.PROGRAM`.
|
|
26
|
+
*
|
|
27
|
+
* `compareVersions` IS IMPORTED, because ordering is the part that can be WRONG. It comes from
|
|
28
|
+
* `npminstall.ts`, which is where it lives and which `selfupdate.ts` re-exports rather than
|
|
29
|
+
* owns — so this is the same function the flag uses, not a copy that agrees today. There is no
|
|
30
|
+
* second comparator in this codebase and this module does not add one; `0.9.0` against
|
|
31
|
+
* `0.10.0` is the pair that says so, and `test/updatecheck.test.mjs` both runs it and pins
|
|
32
|
+
* structurally that nothing comparator-shaped is defined here.
|
|
33
|
+
*
|
|
34
|
+
* NO NEW DEPENDENCY: `node:fs`, `node:os` and `node:path`. The `str.format` substitution the
|
|
35
|
+
* reference gets from CPython is the four lines of `fill` below — `selfupdate.ts` has the same
|
|
36
|
+
* four and cannot be imported for them, for the reason two paragraphs up.
|
|
37
|
+
*
|
|
38
|
+
* Layer 5 (Composition): this module reads one file off the operator's home directory and
|
|
39
|
+
* exists to be called from `mcp/status.ts`. It imports no MCP surface and reaches no network.
|
|
40
|
+
*/
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
42
|
+
import { homedir } from 'node:os';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
import { compareVersions } from './npminstall.js';
|
|
45
|
+
export { compareVersions };
|
|
46
|
+
/**
|
|
47
|
+
* The command the operator types — the one word that is true on npm and on PyPI both.
|
|
48
|
+
*
|
|
49
|
+
* Equal to `selfupdate.PROGRAM` by construction and by a test, never by an import: see the
|
|
50
|
+
* module docstring. A sentence naming a PACKAGE could not be byte-identical across runtimes,
|
|
51
|
+
* because the PyPI distribution is `bantamkit` and the npm package is `bantamkit-mcp`.
|
|
52
|
+
*/
|
|
53
|
+
export const PROGRAM = 'bantamkit-mcp';
|
|
54
|
+
/**
|
|
55
|
+
* The key of the record THIS runtime reads. Divergent by construction: the reference reads
|
|
56
|
+
* `pypi`. Registered as a `docs/porting.md` divergence row by J57-5.
|
|
57
|
+
*/
|
|
58
|
+
export const KEY = 'npm';
|
|
59
|
+
/**
|
|
60
|
+
* The two path components, spelled once. `.bantamkit` under a HOME is this toolbox's own
|
|
61
|
+
* directory; `.bantamkit` under a cwd is a memory store, and this module never builds one.
|
|
62
|
+
*/
|
|
63
|
+
export const RECORD_DIR = '.bantamkit';
|
|
64
|
+
export const RECORD_NAME = 'update-check.json';
|
|
65
|
+
// --- the sentences ------------------------------------------------------------------------
|
|
66
|
+
// Copied from `runtime-py/src/bantamkit/updatecheck.py` byte for byte. Changing one is a
|
|
67
|
+
// change to both runtimes and to the conformance suite, never to this file alone.
|
|
68
|
+
/**
|
|
69
|
+
* No record at all. The state of a machine that has never been checked and of one that has
|
|
70
|
+
* been offline since it was installed — deliberately the same state, because they are the same
|
|
71
|
+
* fact about what is known.
|
|
72
|
+
*/
|
|
73
|
+
export const UPDATE_NEVER = 'update: never checked.';
|
|
74
|
+
/**
|
|
75
|
+
* The whole point of the feature. `{latest}` and `{installed}` are BOTH here so the reader can
|
|
76
|
+
* falsify the claim, the same reason `selfupdate.COMPARISON` carries both.
|
|
77
|
+
*/
|
|
78
|
+
export const UPDATE_AVAILABLE = 'update: {program} {installed} is running; the package index has {latest} — run ' +
|
|
79
|
+
'`{program} --update`, then reconnect the host.';
|
|
80
|
+
/** Names the date because the record may be months old. */
|
|
81
|
+
export const UPDATE_CURRENT = 'update: {program} {installed} is current as of {date}.';
|
|
82
|
+
/**
|
|
83
|
+
* A real state, not a curiosity: a checkout build, or a release not yet published, lands here —
|
|
84
|
+
* the same pair `selfupdate.AHEAD` exists for.
|
|
85
|
+
*/
|
|
86
|
+
export const UPDATE_AHEAD = 'update: {program} {installed} is ahead of the package index, which has {latest}.';
|
|
87
|
+
/**
|
|
88
|
+
* One sentence for every malformed shape. It does not name WHICH shape: the operator cannot act
|
|
89
|
+
* differently on any of them, and a per-shape sentence would be N more strings to port.
|
|
90
|
+
*/
|
|
91
|
+
export const UPDATE_UNREADABLE = 'update: the update record could not be read.';
|
|
92
|
+
// --- the states ----------------------------------------------------------------------------
|
|
93
|
+
// The names are what the non-ruled conformance companion compares: given one record and one
|
|
94
|
+
// running version, both runtimes must land in the SAME state, whichever key each one read.
|
|
95
|
+
export const STATE_NEVER = 'never';
|
|
96
|
+
export const STATE_AVAILABLE = 'available';
|
|
97
|
+
export const STATE_CURRENT = 'current';
|
|
98
|
+
export const STATE_AHEAD = 'ahead';
|
|
99
|
+
export const STATE_UNREADABLE = 'unreadable';
|
|
100
|
+
export const STATES = [
|
|
101
|
+
STATE_NEVER,
|
|
102
|
+
STATE_AVAILABLE,
|
|
103
|
+
STATE_CURRENT,
|
|
104
|
+
STATE_AHEAD,
|
|
105
|
+
STATE_UNREADABLE,
|
|
106
|
+
];
|
|
107
|
+
// --- what the loader found -------------------------------------------------------------------
|
|
108
|
+
// Three outcomes and not two, because "there is no file" and "there is a file I cannot read" are
|
|
109
|
+
// different sentences. Collapsing them would print `never checked` over a corrupted record,
|
|
110
|
+
// which is the one wrong thing a reader of this file can do.
|
|
111
|
+
export const SOURCE_ABSENT = 'absent';
|
|
112
|
+
export const SOURCE_UNREADABLE = 'unreadable';
|
|
113
|
+
export const SOURCE_RECORD = 'record';
|
|
114
|
+
/**
|
|
115
|
+
* `0.36.0`, and `0.31.0rc1` too — a first component of digits and dotted parts after it.
|
|
116
|
+
* NOT a PEP 440 or semver parse: a parser is a second, larger thing for two runtimes to
|
|
117
|
+
* reproduce exactly, in service of strings neither writer can produce. `v1.2.3` and `nightly`
|
|
118
|
+
* are not versions here, and neither registry serves either.
|
|
119
|
+
*/
|
|
120
|
+
const VERSION = /^[0-9]+(?:\.[0-9A-Za-z_+-]+)*$/;
|
|
121
|
+
/**
|
|
122
|
+
* The `YYYY-MM-DD` prefix, and only the prefix. Whatever follows it is the writer's business:
|
|
123
|
+
* this module never renders a date, it slices one. A rendered date would make the two runtimes
|
|
124
|
+
* disagree on a machine set to another locale.
|
|
125
|
+
*/
|
|
126
|
+
const DATE_PREFIX = /^([0-9]{4}-[0-9]{2}-[0-9]{2})/;
|
|
127
|
+
/**
|
|
128
|
+
* UTF-8, STRICTLY. `readFileSync(path, 'utf8')` would substitute U+FFFD for an undecodable
|
|
129
|
+
* byte and hand this module a string the reference raises on, so a half-written record would be
|
|
130
|
+
* a different state on the two sides. Decoding the bytes with `fatal` makes it the same one.
|
|
131
|
+
*
|
|
132
|
+
* `ignoreBOM` IS LEFT AT ITS DEFAULT OF `false`, WHICH STRIPS A LEADING BOM, AND THAT IS LOAD
|
|
133
|
+
* BEARING. The reference reads with `utf-8-sig` for exactly this reason: PowerShell's
|
|
134
|
+
* `Set-Content` and `Out-File` write UTF-8 WITH a BOM by default, and a record a reader can
|
|
135
|
+
* plainly act on is not "a shape this reader cannot act on". Setting `ignoreBOM: true` here
|
|
136
|
+
* would leave the BOM in the string, `JSON.parse` would refuse it, and this side would call a
|
|
137
|
+
* Windows operator's perfectly good record broken. The BOM arms in
|
|
138
|
+
* `tools/conformance/suites/updatecheck.mjs` are what hold the two sides together on it; it is
|
|
139
|
+
* NOT a `docs/porting.md` divergence, because there is no difference left to register.
|
|
140
|
+
*/
|
|
141
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
142
|
+
/**
|
|
143
|
+
* `{name}` substitution, the four lines of `str.format` these sentences use.
|
|
144
|
+
*
|
|
145
|
+
* `selfupdate.ts` exports the same function and this module may not import it (see the module
|
|
146
|
+
* docstring). Kept to exactly the braces the five sentences carry: a missing key is left alone
|
|
147
|
+
* rather than rendered as `undefined`, which is what makes a typo visible instead of silent.
|
|
148
|
+
*/
|
|
149
|
+
function fill(template, values) {
|
|
150
|
+
return template.replace(/\{([a-z]+)\}/g, (whole, name) => values[name] ?? whole);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* `<homedir>/.bantamkit/update-check.json`. Never relative to a cwd.
|
|
154
|
+
*
|
|
155
|
+
* `homedir()` reads the environment on every call on every platform this ships to — `HOME` on
|
|
156
|
+
* POSIX, `USERPROFILE` on Windows — which is both the seam the tests use and the reason the
|
|
157
|
+
* path cannot follow a cwd. `.bantamkit` relative to a cwd is a MEMORY STORE, and creating one
|
|
158
|
+
* by accident is a defect class this repo has already paid for (J54-3, `hostinstall.ts:362`).
|
|
159
|
+
*
|
|
160
|
+
* Returns a path whether or not anything is at it. Nothing here creates either component.
|
|
161
|
+
*/
|
|
162
|
+
export function recordPath(home = homedir()) {
|
|
163
|
+
return join(home, RECORD_DIR, RECORD_NAME);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* What is at the path, and what it holds. Opens one file, creates nothing, and never throws.
|
|
167
|
+
*
|
|
168
|
+
* `ENOENT` and `ENOTDIR` are ABSENT — in both, nothing is at the path, and a `.bantamkit` that
|
|
169
|
+
* is a regular file leaves the operator in exactly the position of one who has never been
|
|
170
|
+
* checked. Every other error (a directory where the record should be, a mode that cannot be
|
|
171
|
+
* read) is UNREADABLE, because something IS there and this reader cannot use it. Undecodable
|
|
172
|
+
* bytes, text that is not JSON, and JSON that is not an object are UNREADABLE for the same
|
|
173
|
+
* reason.
|
|
174
|
+
*/
|
|
175
|
+
export function loadRecord(path = recordPath()) {
|
|
176
|
+
let text;
|
|
177
|
+
try {
|
|
178
|
+
text = UTF8.decode(readFileSync(path));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
const code = error.code;
|
|
182
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
183
|
+
return { source: SOURCE_ABSENT, record: null };
|
|
184
|
+
// Everything else is a state, not an exception: `EISDIR`, `EACCES`, and the `TypeError`
|
|
185
|
+
// a fatal `TextDecoder` throws on bytes that are not UTF-8.
|
|
186
|
+
return { source: SOURCE_UNREADABLE, record: null };
|
|
187
|
+
}
|
|
188
|
+
let payload;
|
|
189
|
+
try {
|
|
190
|
+
payload = JSON.parse(text);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return { source: SOURCE_UNREADABLE, record: null };
|
|
194
|
+
}
|
|
195
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
196
|
+
// A list or a bare number is well-formed JSON and is not a record. `typeof null` is
|
|
197
|
+
// `'object'` in JavaScript and `null` is not a record either.
|
|
198
|
+
return { source: SOURCE_UNREADABLE, record: null };
|
|
199
|
+
}
|
|
200
|
+
return { source: SOURCE_RECORD, record: payload };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The parsed record, or `null` when there is not one. Creates nothing, never throws.
|
|
204
|
+
*
|
|
205
|
+
* `null` deliberately does NOT say which of absent-or-unreadable it was — a caller that needs
|
|
206
|
+
* the difference calls `loadRecord`, which is the one this module's own decision uses. This is
|
|
207
|
+
* the convenience surface for a caller that only wants the object.
|
|
208
|
+
*/
|
|
209
|
+
export function readRecord(path = recordPath()) {
|
|
210
|
+
return loadRecord(path).record;
|
|
211
|
+
}
|
|
212
|
+
/** `record[key].latest` when it is a version string, else `null`. Throws for nothing. */
|
|
213
|
+
function latestIn(record, key) {
|
|
214
|
+
const entry = record[key];
|
|
215
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
|
|
216
|
+
return null;
|
|
217
|
+
const latest = entry['latest'];
|
|
218
|
+
if (typeof latest !== 'string')
|
|
219
|
+
return null;
|
|
220
|
+
const trimmed = latest.trim();
|
|
221
|
+
return VERSION.test(trimmed) ? trimmed : null;
|
|
222
|
+
}
|
|
223
|
+
/** The `YYYY-MM-DD` prefix of `checked_at`, or `null`. Sliced, never rendered. */
|
|
224
|
+
function checkedDate(record) {
|
|
225
|
+
const checkedAt = record['checked_at'];
|
|
226
|
+
if (typeof checkedAt !== 'string')
|
|
227
|
+
return null;
|
|
228
|
+
const found = DATE_PREFIX.exec(checkedAt.trim());
|
|
229
|
+
return found?.[1] ?? null;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The five-state decision, pure: no filesystem, no clock, no network, no TTL.
|
|
233
|
+
*
|
|
234
|
+
* Split from the reading so the conformance harness and the tests can construct a state
|
|
235
|
+
* directly and so the only thing that touches a disk is `loadRecord`.
|
|
236
|
+
*
|
|
237
|
+
* `checked_at` is validated in EVERY arm that reached a record and not only on the `current`
|
|
238
|
+
* path: a record that cannot say when it was written is not a record, and one rule is one thing
|
|
239
|
+
* for the two runtimes to reproduce instead of two.
|
|
240
|
+
*/
|
|
241
|
+
export function decide(installed, source, record, key = KEY) {
|
|
242
|
+
if (source === SOURCE_ABSENT)
|
|
243
|
+
return { state: STATE_NEVER, line: UPDATE_NEVER };
|
|
244
|
+
if (source !== SOURCE_RECORD || record === null) {
|
|
245
|
+
return { state: STATE_UNREADABLE, line: UPDATE_UNREADABLE };
|
|
246
|
+
}
|
|
247
|
+
const date = checkedDate(record);
|
|
248
|
+
const latest = latestIn(record, key);
|
|
249
|
+
if (date === null || latest === null) {
|
|
250
|
+
return { state: STATE_UNREADABLE, line: UPDATE_UNREADABLE };
|
|
251
|
+
}
|
|
252
|
+
const order = compareVersions(installed, latest);
|
|
253
|
+
if (order < 0) {
|
|
254
|
+
return {
|
|
255
|
+
state: STATE_AVAILABLE,
|
|
256
|
+
line: fill(UPDATE_AVAILABLE, { program: PROGRAM, installed, latest }),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (order === 0) {
|
|
260
|
+
return {
|
|
261
|
+
state: STATE_CURRENT,
|
|
262
|
+
line: fill(UPDATE_CURRENT, { program: PROGRAM, installed, date }),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
state: STATE_AHEAD,
|
|
267
|
+
line: fill(UPDATE_AHEAD, { program: PROGRAM, installed, latest }),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/** Read the record and decide. The whole reader, in one call, for `bantamkit_status`. */
|
|
271
|
+
export function updateStatus(installed, key = KEY, path = recordPath()) {
|
|
272
|
+
const { source, record } = loadRecord(path);
|
|
273
|
+
return decide(installed, source, record, key);
|
|
274
|
+
}
|
|
275
|
+
/** Exactly one of the five sentences. Always a line: there is no silent state here. */
|
|
276
|
+
export function updateLine(installed, key = KEY, path = recordPath()) {
|
|
277
|
+
return updateStatus(installed, key, path).line;
|
|
278
|
+
}
|
|
@@ -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;
|
package/dist/workplan.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.35.2",
|
|
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
|
|
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
|
},
|