bantamkit-mcp 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/assets/contracts/default.yaml +90 -0
  4. package/assets/evals/devteam/manifest.yaml +351 -0
  5. package/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. package/assets/evals/devteam/repo/README.md +12 -0
  7. package/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. package/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. package/assets/evals/fixtures/.gitkeep +0 -0
  40. package/assets/evals/fixtures/catalog.json +6 -0
  41. package/assets/evals/perturbations/task-completion.yaml +576 -0
  42. package/assets/evals/tasks/.gitkeep +0 -0
  43. package/assets/evals/tasks/extract-contact.yaml +14 -0
  44. package/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. package/assets/evals/tasks/extract-order.yaml +15 -0
  46. package/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. package/assets/evals/tasks/extract-versions.yaml +17 -0
  48. package/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. package/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. package/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. package/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. package/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. package/assets/evals/tasks/recall-owner.yaml +13 -0
  59. package/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. package/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. package/assets/evals/tasks/shop-compare.yaml +9 -0
  62. package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. package/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. package/assets/evals/tasks/shop-total.yaml +9 -0
  65. package/assets/profiles/default.yaml +31 -0
  66. package/assets/profiles/patient.yaml +31 -0
  67. package/assets/rubrics/.gitkeep +0 -0
  68. package/assets/rubrics/code-quality.yaml +20 -0
  69. package/assets/rubrics/grounded-completion.yaml +37 -0
  70. package/assets/rubrics/task-completion.yaml +28 -0
  71. package/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. package/assets/skills/.gitkeep +0 -0
  73. package/assets/skills/file-graph.md +7 -0
  74. package/assets/skills/memory.md +35 -0
  75. package/assets/tools/.gitkeep +0 -0
  76. package/assets/tools/bantamkit_read.json +48 -0
  77. package/assets/tools/bantamkit_status.json +25 -0
  78. package/assets/tools/build_identity.json +17 -0
  79. package/assets/tools/document_list.json +12 -0
  80. package/assets/tools/document_read.json +31 -0
  81. package/assets/tools/file_graph.json +12 -0
  82. package/assets/tools/memory_compact.json +31 -0
  83. package/assets/tools/memory_recall.json +38 -0
  84. package/assets/tools/memory_save.json +61 -0
  85. package/assets/tools/shiftwork_clock_in.json +25 -0
  86. package/assets/tools/shiftwork_clock_out.json +60 -0
  87. package/assets/tools/shiftwork_status.json +25 -0
  88. package/assets/tools/skill_audit.json +70 -0
  89. package/assets/tools/validate_json.json +31 -0
  90. package/dist/assets.d.ts +48 -0
  91. package/dist/assets.js +109 -0
  92. package/dist/charsets.d.ts +17 -0
  93. package/dist/charsets.js +580 -0
  94. package/dist/cli.d.ts +12 -0
  95. package/dist/cli.js +280 -0
  96. package/dist/contract.d.ts +145 -0
  97. package/dist/contract.js +608 -0
  98. package/dist/docread.d.ts +357 -0
  99. package/dist/docread.js +3315 -0
  100. package/dist/errors.d.ts +11 -0
  101. package/dist/errors.js +14 -0
  102. package/dist/eventlog.d.ts +137 -0
  103. package/dist/eventlog.js +294 -0
  104. package/dist/htmlentities.d.ts +2 -0
  105. package/dist/htmlentities.js +16 -0
  106. package/dist/index.d.ts +23 -0
  107. package/dist/index.js +13 -0
  108. package/dist/mcp/identity.d.ts +25 -0
  109. package/dist/mcp/identity.js +275 -0
  110. package/dist/mcp/pyargs.d.ts +80 -0
  111. package/dist/mcp/pyargs.js +362 -0
  112. package/dist/mcp/sdkjson.d.ts +56 -0
  113. package/dist/mcp/sdkjson.js +91 -0
  114. package/dist/mcp/server.d.ts +45 -0
  115. package/dist/mcp/server.js +740 -0
  116. package/dist/mcp/status.d.ts +210 -0
  117. package/dist/mcp/status.js +357 -0
  118. package/dist/mcp/transport.d.ts +68 -0
  119. package/dist/mcp/transport.js +111 -0
  120. package/dist/mcpreport.d.ts +214 -0
  121. package/dist/mcpreport.js +758 -0
  122. package/dist/memory/cli.d.ts +2 -0
  123. package/dist/memory/cli.js +396 -0
  124. package/dist/memory/component.d.ts +256 -0
  125. package/dist/memory/component.js +497 -0
  126. package/dist/memory/factfile.d.ts +120 -0
  127. package/dist/memory/factfile.js +406 -0
  128. package/dist/memory/layers.d.ts +92 -0
  129. package/dist/memory/layers.js +410 -0
  130. package/dist/memory/pyfs.d.ts +521 -0
  131. package/dist/memory/pyfs.js +1608 -0
  132. package/dist/memory/pyyaml.d.ts +155 -0
  133. package/dist/memory/pyyaml.js +868 -0
  134. package/dist/memory/store.d.ts +478 -0
  135. package/dist/memory/store.js +1034 -0
  136. package/dist/pyargparse.d.ts +141 -0
  137. package/dist/pyargparse.js +813 -0
  138. package/dist/pyjson.d.ts +110 -0
  139. package/dist/pyjson.js +524 -0
  140. package/dist/pyjsonschema.d.ts +50 -0
  141. package/dist/pyjsonschema.js +951 -0
  142. package/dist/shiftwork.d.ts +60 -0
  143. package/dist/shiftwork.js +324 -0
  144. package/dist/skillaudit.d.ts +172 -0
  145. package/dist/skillaudit.js +914 -0
  146. package/dist/statusline.d.ts +106 -0
  147. package/dist/statusline.js +281 -0
  148. package/package.json +45 -0
@@ -0,0 +1,60 @@
1
+ import { type PyValue } from './pyjson.js';
2
+ export declare const SCHEMA_NAME = "shiftwork-checkpoint";
3
+ /** The driver's SUCCESS test. */
4
+ export declare const TERMINAL_UNIT_STATUS: ReadonlySet<string>;
5
+ /** The schema's `maxItems` — older entries fall off the ring. */
6
+ export declare const HISTORY_RING_SIZE = 5;
7
+ /**
8
+ * `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))` — UTC, SECOND precision.
9
+ *
10
+ * `Date.prototype.toISOString` would write `2025-08-23T06:20:00.000Z`: three digits and a
11
+ * dot that no line Python ever appended to a `.log.jsonl` contains. `gmtime` also FLOORS
12
+ * its float argument rather than rounding it, which is visible for every `.999`.
13
+ */
14
+ export declare function timestamp(now: number): string;
15
+ /** The injectable clock. Python reads `time.time()`; a test and the differential pin it. */
16
+ export interface ClockOptions {
17
+ /** Seconds since the epoch, as `time.time()` returns them. */
18
+ now?: number;
19
+ }
20
+ /**
21
+ * Validate the checkpoint and return the cursor unit's brief, or a refusal.
22
+ *
23
+ * `invariants` IS SYNTHESIZED HERE. It is not a key of the checkpoint — it is
24
+ * `document["job"]["constraints"]` under a different name — which is precisely why a brief
25
+ * has to be handed to a subagent verbatim and not as a path: the file at that path does not
26
+ * contain the array. Same for `do_not`, which is `handoff.do_not`, and `files`, which is
27
+ * `state.artifacts`.
28
+ *
29
+ * Order is load-bearing: open questions are checked BEFORE the all-terminal test, and the
30
+ * all-terminal test before the cursor lookup — so an empty plan reports success rather than
31
+ * escalating on its dangling cursor, because `all([])` is true.
32
+ */
33
+ export declare function clockIn(checkpoint: string): PyValue;
34
+ /**
35
+ * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
36
+ *
37
+ * `unitId` must name the cursor unit — the contract is execute-the-cursor (driver parity),
38
+ * never pick-a-unit. Mutations: set the unit's status, advance `plan.cursor` to the first
39
+ * non-terminal unit in PLAN order (`depends_on` is ignored), shallow-merge `handoffPatch`
40
+ * into `handoff`, push `historyEntry` onto the 5-entry ring.
41
+ *
42
+ * VALIDATE BEFORE WRITING, AND WRITE NOTHING ON REFUSAL. The mutated document goes through
43
+ * the full schema before the first byte leaves; a failure returns `refused to write: …` and
44
+ * has touched neither the checkpoint nor the log — not even in the case where the log
45
+ * append would have succeeded, which is the one a reader assumes is safe because the log is
46
+ * append-only.
47
+ *
48
+ * Key ORDER survives the mutation because a `Map.set` on an existing key keeps its slot,
49
+ * exactly as CPython's dict does, and `json.dumps` here runs with `sort_keys=False`. A port
50
+ * that rebuilt the document from an object literal would reorder `handoff` on every merge.
51
+ */
52
+ export declare function clockOut(checkpoint: string, unitId: string, status: string, handoffPatch: unknown, historyEntry: unknown, accounting?: unknown, options?: ClockOptions): PyValue;
53
+ /**
54
+ * Read-only progress summary. Never mutates.
55
+ *
56
+ * `units` is a count per status in FIRST-SEEN order — a plain `dict` built by iteration, so
57
+ * its key order is the order the statuses appear in the plan and not an alphabetical one.
58
+ * The result is dumped without `sort_keys` on the wire, so that order is observable.
59
+ */
60
+ export declare function status(checkpoint: string): PyValue;
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Shift-work checkpoint operations — a port of `runtime-py/src/bantamkit/shiftwork.py`,
3
+ * statement for statement.
4
+ *
5
+ * The control flow is 78 executable statements and none of it is hard. The risk is
6
+ * entirely in how the two runtimes SERIALIZE, and the checkpoint bantamkit's own jobs are
7
+ * run from is one of the files at stake. Three artefacts leave this module and only one of
8
+ * them is ever read back:
9
+ *
10
+ * * the rewritten checkpoint — `json.dumps(document, indent=2) + "\n"`, `.tmp` then
11
+ * `replace()`;
12
+ * * the `.log.jsonl` line — `json.dumps(record, sort_keys=True) + "\n"`, append mode,
13
+ * **never read back**, so a wrong line is never noticed by the code that wrote it;
14
+ * * the returned dict, which the MCP surface hands straight to the model.
15
+ *
16
+ * THE ensure_ascii RULING. `json.dumps` defaults to `ensure_ascii=True` and
17
+ * `JSON.stringify` has no such mode. Node must reproduce Python's escaping, not the other
18
+ * way round: the reference implementation is the one that has already written every
19
+ * checkpoint on disk (measured — `.shiftwork/job38-npx-public-install/checkpoint.json`
20
+ * carries 1 `เ` run and 16 `—`, and not one raw non-ASCII byte), and a Node
21
+ * clock_out that emitted raw UTF-8 would rewrite all 29 KB of it on its first successful
22
+ * call. Nothing would error: the file still parses to the same strings. It would just stop
23
+ * being diffable against the Python server's output, silently, from then on. So the escape
24
+ * is the contract, and `pyjson.dumpJson` is where it lives — with the other three rules
25
+ * (separators, codepoint `sort_keys`, `5.0` stays a float) that a `JSON.stringify` would
26
+ * also have got wrong.
27
+ *
28
+ * WHY THE DOCUMENT IS A `PyValue` AND NOT A PLAIN OBJECT. `1` and `1.0` are one type in JS.
29
+ * A checkpoint or an accounting record that came off the wire holding `5.0` has to leave
30
+ * holding `5.0`, and an integer past 2^53 has to survive; both need the tagged model
31
+ * `pyjson.ts` already carries. The three entry points therefore return `PyValue`, and the
32
+ * MCP layer converts with `pyjson.toJs` at the last moment.
33
+ *
34
+ * WHAT IS NOT PORTED, DELIBERATELY. The `depends_on` field is ignored on cursor advance
35
+ * (v1-linear, the Python module's own ruling), there is no lock (the MCP topology has one
36
+ * orchestrator by construction), and log-then-commit ordering is preserved exactly: the
37
+ * accounting line is appended BEFORE the atomic rename, so a partial failure can lose the
38
+ * commit but never the accounting.
39
+ */
40
+ import { loadSchema } from './assets.js';
41
+ import { schemaError } from './contract.js';
42
+ import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, } from './memory/pyfs.js';
43
+ import { dumpJson, fromJs, parseJson, PyJSONDecodeError } from './pyjson.js';
44
+ export const SCHEMA_NAME = 'shiftwork-checkpoint';
45
+ /** The driver's SUCCESS test. */
46
+ export const TERMINAL_UNIT_STATUS = new Set(['done', 'dropped']);
47
+ /** The schema's `maxItems` — older entries fall off the ring. */
48
+ export const HISTORY_RING_SIZE = 5;
49
+ // ------------------------------------------------------------------- tagged-value sugar
50
+ const str = (v) => ({ t: 'str', v });
51
+ const int = (v) => ({ t: 'int', v: BigInt(v) });
52
+ const dict = (entries) => ({ t: 'dict', v: new Map(entries) });
53
+ /** The document is schema-valid before any of these run, so the shapes are guaranteed. */
54
+ const field = (d, key) => d.v.get(key);
55
+ const subDict = (d, key) => field(d, key);
56
+ const subList = (d, key) => field(d, key);
57
+ const text = (v) => v.v;
58
+ /**
59
+ * Whatever the caller handed over, as a `PyValue`.
60
+ *
61
+ * A value that is already tagged passes through EXACTLY — that is the route that keeps
62
+ * `5.0` a float, and the MCP layer should take it wherever it still holds the wire bytes
63
+ * (`pyjson.parseJson` over the raw arguments). A plain JS value goes through `fromJs` and
64
+ * takes its one documented loss: an integral JS number cannot remember that its JSON text
65
+ * said `5.0`, so a `{"tokens": 5.0}` that has already been through an SDK's `JSON.parse`
66
+ * logs as `5` where Python logs `5.0`. Ruled and pinned in
67
+ * `tools/conformance/suites/shiftwork.mjs`; the loss is upstream of this module, not in it.
68
+ */
69
+ function asPyValue(value) {
70
+ return isPyValue(value) ? value : fromJs(value);
71
+ }
72
+ function isPyValue(value) {
73
+ return (typeof value === 'object' &&
74
+ value !== null &&
75
+ 't' in value &&
76
+ typeof value.t === 'string' &&
77
+ ['null', 'bool', 'int', 'float', 'str', 'list', 'dict'].includes(value.t));
78
+ }
79
+ /** `dict(x) if x else {}` — `None` and `{}` are both the empty patch. */
80
+ function asPatch(value) {
81
+ if (value === null || value === undefined)
82
+ return new Map();
83
+ const lifted = asPyValue(value);
84
+ return lifted.t === 'dict' ? lifted.v : new Map();
85
+ }
86
+ // ------------------------------------------------------------------------------- shared
87
+ function errorResult(reason) {
88
+ return dict([
89
+ ['result', str('error')],
90
+ ['reason', str(reason)],
91
+ ]);
92
+ }
93
+ /**
94
+ * Read and full-schema-validate. Returns `{document}` or `{refusal}`.
95
+ *
96
+ * Three refusals in Python's order, and the exceptions that are NOT caught matter as much
97
+ * as the ones that are: `read_text` raises `UnicodeDecodeError` on invalid UTF-8 and that
98
+ * is a `ValueError`, not an `OSError`, so it escapes to the caller in both runtimes.
99
+ *
100
+ * `schema_error` is handed the raw TEXT rather than the parsed document, exactly as Python
101
+ * does, so the checkpoint goes through `extract_json` + the decoder a second time and any
102
+ * disagreement between the two parses would show up here rather than at write time.
103
+ */
104
+ function readValid(path) {
105
+ let raw;
106
+ try {
107
+ raw = pyReadText(path);
108
+ }
109
+ catch (e) {
110
+ if (e instanceof PyOSError)
111
+ return { document: null, refusal: errorResult(`checkpoint unreadable: ${e.message}`) };
112
+ throw e;
113
+ }
114
+ let document;
115
+ try {
116
+ document = parseJson(raw);
117
+ }
118
+ catch (e) {
119
+ if (e instanceof PyJSONDecodeError) {
120
+ return { document: null, refusal: errorResult(`checkpoint is not parseable as JSON: ${e.message}`) };
121
+ }
122
+ throw e;
123
+ }
124
+ const problem = schemaError(raw, loadSchema(SCHEMA_NAME));
125
+ if (problem !== null)
126
+ return { document: null, refusal: errorResult(`checkpoint invalid: ${problem}`) };
127
+ return { document: document, refusal: null };
128
+ }
129
+ function findUnit(document, unitId) {
130
+ for (const unit of subList(subDict(document, 'plan'), 'units').v) {
131
+ if (unit.v.get('id') !== undefined && text(unit.v.get('id')) === unitId) {
132
+ return unit;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))` — UTC, SECOND precision.
139
+ *
140
+ * `Date.prototype.toISOString` would write `2025-08-23T06:20:00.000Z`: three digits and a
141
+ * dot that no line Python ever appended to a `.log.jsonl` contains. `gmtime` also FLOORS
142
+ * its float argument rather than rounding it, which is visible for every `.999`.
143
+ */
144
+ export function timestamp(now) {
145
+ const when = new Date(Math.floor(now) * 1000);
146
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
147
+ return (`${pad(when.getUTCFullYear(), 4)}-${pad(when.getUTCMonth() + 1)}-${pad(when.getUTCDate())}` +
148
+ `T${pad(when.getUTCHours())}:${pad(when.getUTCMinutes())}:${pad(when.getUTCSeconds())}Z`);
149
+ }
150
+ // ----------------------------------------------------------------------------- clock_in
151
+ /**
152
+ * Validate the checkpoint and return the cursor unit's brief, or a refusal.
153
+ *
154
+ * `invariants` IS SYNTHESIZED HERE. It is not a key of the checkpoint — it is
155
+ * `document["job"]["constraints"]` under a different name — which is precisely why a brief
156
+ * has to be handed to a subagent verbatim and not as a path: the file at that path does not
157
+ * contain the array. Same for `do_not`, which is `handoff.do_not`, and `files`, which is
158
+ * `state.artifacts`.
159
+ *
160
+ * Order is load-bearing: open questions are checked BEFORE the all-terminal test, and the
161
+ * all-terminal test before the cursor lookup — so an empty plan reports success rather than
162
+ * escalating on its dangling cursor, because `all([])` is true.
163
+ */
164
+ export function clockIn(checkpoint) {
165
+ const { document, refusal } = readValid(pyJoin(checkpoint));
166
+ if (refusal !== null)
167
+ return refusal;
168
+ const doc = document;
169
+ const handoff = subDict(doc, 'handoff');
170
+ const openQuestions = subList(handoff, 'open_questions');
171
+ if (openQuestions.v.length > 0) {
172
+ return dict([
173
+ ['result', str('escalate')],
174
+ ['reason', str(`open question: ${text(openQuestions.v[0])}`)],
175
+ ['open_questions', openQuestions],
176
+ ]);
177
+ }
178
+ const units = subList(subDict(doc, 'plan'), 'units');
179
+ if (units.v.every((u) => TERMINAL_UNIT_STATUS.has(text(u.v.get('status'))))) {
180
+ return dict([
181
+ ['result', str('success')],
182
+ ['reason', str('all units done or dropped')],
183
+ ]);
184
+ }
185
+ const cursor = text(field(subDict(doc, 'plan'), 'cursor'));
186
+ const unit = findUnit(doc, cursor);
187
+ if (unit === null) {
188
+ return dict([
189
+ ['result', str('escalate')],
190
+ ['reason', str(`cursor ${cursor} names no unit`)],
191
+ ]);
192
+ }
193
+ return dict([
194
+ ['result', str('brief')],
195
+ ['unit', unit],
196
+ ['role', field(unit, 'role')],
197
+ ['invariants', field(subDict(doc, 'job'), 'constraints')],
198
+ ['handoff', handoff],
199
+ ['do_not', field(handoff, 'do_not')],
200
+ ['files', field(subDict(doc, 'state'), 'artifacts')],
201
+ ]);
202
+ }
203
+ // ---------------------------------------------------------------------------- clock_out
204
+ /**
205
+ * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
206
+ *
207
+ * `unitId` must name the cursor unit — the contract is execute-the-cursor (driver parity),
208
+ * never pick-a-unit. Mutations: set the unit's status, advance `plan.cursor` to the first
209
+ * non-terminal unit in PLAN order (`depends_on` is ignored), shallow-merge `handoffPatch`
210
+ * into `handoff`, push `historyEntry` onto the 5-entry ring.
211
+ *
212
+ * VALIDATE BEFORE WRITING, AND WRITE NOTHING ON REFUSAL. The mutated document goes through
213
+ * the full schema before the first byte leaves; a failure returns `refused to write: …` and
214
+ * has touched neither the checkpoint nor the log — not even in the case where the log
215
+ * append would have succeeded, which is the one a reader assumes is safe because the log is
216
+ * append-only.
217
+ *
218
+ * Key ORDER survives the mutation because a `Map.set` on an existing key keeps its slot,
219
+ * exactly as CPython's dict does, and `json.dumps` here runs with `sort_keys=False`. A port
220
+ * that rebuilt the document from an object literal would reorder `handoff` on every merge.
221
+ */
222
+ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry, accounting = null, options = {}) {
223
+ const path = pyJoin(checkpoint);
224
+ const { document, refusal } = readValid(path);
225
+ if (refusal !== null)
226
+ return refusal;
227
+ const doc = document;
228
+ const unit = findUnit(doc, unitId);
229
+ if (unit === null)
230
+ return errorResult(`unit ${unitId} is not in the plan`);
231
+ const plan = subDict(doc, 'plan');
232
+ const cursor = text(field(plan, 'cursor'));
233
+ if (unitId !== cursor)
234
+ return errorResult(`unit ${unitId} is not the cursor unit ${cursor}`);
235
+ unit.v.set('status', str(status));
236
+ const remaining = subList(plan, 'units').v.filter((u) => !TERMINAL_UNIT_STATUS.has(text(u.v.get('status'))));
237
+ plan.v.set('cursor', remaining.length > 0 ? field(remaining[0], 'id') : str(unitId));
238
+ const handoff = subDict(doc, 'handoff');
239
+ for (const [key, value] of asPatch(handoffPatch))
240
+ handoff.v.set(key, value);
241
+ const history = subList(doc, 'history');
242
+ doc.v.set('history', {
243
+ t: 'list',
244
+ v: [...history.v, asPyValue(historyEntry)].slice(-HISTORY_RING_SIZE),
245
+ });
246
+ const problem = schemaError(dumpJson(doc), loadSchema(SCHEMA_NAME));
247
+ if (problem !== null)
248
+ return errorResult(`refused to write: ${problem}`);
249
+ // `record.update(accounting or {})`: the four keys the record starts with are OVERRIDABLE
250
+ // by the orchestrator's accounting, and `sort_keys=True` means the merge order never
251
+ // reaches the file anyway.
252
+ const record = new Map([
253
+ ['ts', str(timestamp(options.now ?? Date.now() / 1000))],
254
+ ['unit', str(unitId)],
255
+ ['role', field(unit, 'role')],
256
+ ['status', str(status)],
257
+ ]);
258
+ for (const [key, value] of asPatch(accounting))
259
+ record.set(key, value);
260
+ // `Path(str(path) + ".log.jsonl")` — string concatenation on the NORMALIZED path, not
261
+ // `with_suffix`, so `cp.json` gets `cp.json.log.jsonl` and not `cp.log.jsonl`.
262
+ const logPath = `${path}.log.jsonl`;
263
+ try {
264
+ pyAppendText(logPath, `${dumpJson({ t: 'dict', v: record }, { sortKeys: true })}\n`);
265
+ }
266
+ catch (e) {
267
+ if (!(e instanceof PyOSError))
268
+ throw e;
269
+ return errorResult(`accounting log unwritable, checkpoint untouched: ${e.message}`);
270
+ }
271
+ const tmp = pyWithSuffix(path, `${pySuffix(path)}.tmp`);
272
+ try {
273
+ pyWriteText(tmp, `${dumpJson(doc, { indent: 2 })}\n`);
274
+ pyReplace(tmp, path);
275
+ }
276
+ catch (e) {
277
+ if (!(e instanceof PyOSError))
278
+ throw e;
279
+ // `contextlib.suppress(OSError)` — the cleanup's own failure is not the story.
280
+ try {
281
+ pyUnlink(tmp);
282
+ }
283
+ catch (cleanup) {
284
+ if (!(cleanup instanceof PyOSError))
285
+ throw cleanup;
286
+ }
287
+ return errorResult(`checkpoint unwritable, last log line uncommitted: ${e.message}`);
288
+ }
289
+ return dict([
290
+ ['result', str('ok')],
291
+ ['unit', str(unitId)],
292
+ ['status', str(status)],
293
+ ['cursor', field(plan, 'cursor')],
294
+ ['log', str(logPath)],
295
+ ]);
296
+ }
297
+ // ------------------------------------------------------------------------------- status
298
+ /**
299
+ * Read-only progress summary. Never mutates.
300
+ *
301
+ * `units` is a count per status in FIRST-SEEN order — a plain `dict` built by iteration, so
302
+ * its key order is the order the statuses appear in the plan and not an alphabetical one.
303
+ * The result is dumped without `sort_keys` on the wire, so that order is observable.
304
+ */
305
+ export function status(checkpoint) {
306
+ const { document, refusal } = readValid(pyJoin(checkpoint));
307
+ if (refusal !== null)
308
+ return refusal;
309
+ const doc = document;
310
+ const counts = new Map();
311
+ for (const unit of subList(subDict(doc, 'plan'), 'units').v) {
312
+ const key = text(unit.v.get('status'));
313
+ const seen = counts.get(key);
314
+ counts.set(key, int(seen === undefined ? 1 : Number(seen.v) + 1));
315
+ }
316
+ const history = subList(doc, 'history');
317
+ return dict([
318
+ ['result', str('status')],
319
+ ['cursor', field(subDict(doc, 'plan'), 'cursor')],
320
+ ['units', { t: 'dict', v: counts }],
321
+ ['open_questions', int(subList(subDict(doc, 'handoff'), 'open_questions').v.length)],
322
+ ['last_history', history.v.length > 0 ? history.v[history.v.length - 1] : { t: 'null' }],
323
+ ]);
324
+ }
@@ -0,0 +1,172 @@
1
+ import { BantamError } from './errors.js';
2
+ export declare const SKILL_FILE = "SKILL.md";
3
+ export declare const OMIT_NOT_ENABLED = "plugin-not-enabled";
4
+ export declare const OMIT_DUPLICATE = "duplicate-skill";
5
+ export declare const OMIT_STALE_VERSION = "stale-version";
6
+ export declare const OMIT_UNREADABLE = "unreadable-file";
7
+ export declare const OMIT_UNPARSED = "unparsed-frontmatter";
8
+ /**
9
+ * Reporting order for omissions. Fixed, so two runtimes emit the same document. The two
10
+ * version subjects are adjacent because they are the two halves of one rule.
11
+ */
12
+ export declare const OMISSION_ORDER: readonly ["plugin-not-enabled", "duplicate-skill", "stale-version", "unreadable-file", "unparsed-frontmatter"];
13
+ export declare const KIND_SHARED_PHRASE = "shared-trigger-phrase";
14
+ export declare const KIND_OVER_BUDGET = "catalogue-over-budget";
15
+ export declare const KIND_NEVER_INVOKED = "never-invoked";
16
+ export declare const KIND_FRONTMATTER = "frontmatter-malformed";
17
+ export declare const KIND_NAME_MISMATCH = "name-mismatch";
18
+ export declare const SEVERITY: Readonly<Record<string, string>>;
19
+ /** Reporting order for findings, and the order the contract lists them in. */
20
+ export declare const FINDING_ORDER: readonly ["shared-trigger-phrase", "catalogue-over-budget", "never-invoked", "frontmatter-malformed", "name-mismatch"];
21
+ /**
22
+ * `check` selects a FAMILY, not a kind: an operator asking about the bill wants both the
23
+ * overage and the skills nobody ever called, and one asking about frontmatter wants both the
24
+ * block that will not parse and the name that does not match its directory.
25
+ */
26
+ export declare const CHECK_FAMILIES: Readonly<Record<string, readonly string[]>>;
27
+ export declare const BAD_NO_BLOCK = "no-frontmatter";
28
+ export declare const BAD_UNTERMINATED = "unterminated-frontmatter";
29
+ export declare const BAD_NO_DESCRIPTION = "no-description";
30
+ /**
31
+ * The audit could not be run at all: the message names what was seen at `root`.
32
+ *
33
+ * Reserved for a failure of the SCAN. A file that will not decode, a block that will not
34
+ * parse and a plugin that is switched off are all recorded as omissions and counted, never
35
+ * raised: an audit that refuses because one of twenty-six files is malformed has told the
36
+ * operator nothing about the other twenty-five.
37
+ */
38
+ export declare class SkillAuditError extends BantamError {
39
+ }
40
+ export interface OmissionDict {
41
+ readonly subject: string;
42
+ readonly count: number;
43
+ readonly size: number;
44
+ readonly what: string;
45
+ }
46
+ /**
47
+ * A `SKILL.md` that was found and not counted, as a COUNT and the fact behind it.
48
+ *
49
+ * `size` is the UTF-8 description bytes those files would have added to the catalogue, `0`
50
+ * where that is not knowable — exactly the two cases where the description could not be read
51
+ * at all. `what` is the paths, relative to the root, in scan order.
52
+ */
53
+ export declare class Omission {
54
+ readonly subject: string;
55
+ readonly count: number;
56
+ readonly size: number;
57
+ readonly what: string;
58
+ constructor(subject: string, count: number, size?: number, what?: string);
59
+ asDict(): OmissionDict;
60
+ }
61
+ export interface FindingDict {
62
+ readonly kind: string;
63
+ readonly severity: string;
64
+ readonly skills: string[];
65
+ readonly detail: string;
66
+ }
67
+ /**
68
+ * One thing wrong with the catalogue.
69
+ *
70
+ * `severity` is not stored independently — it is `SEVERITY[kind]`, fixed per kind. `detail`
71
+ * is a machine fact and not a sentence. Wording for a person is Layer 2's job.
72
+ */
73
+ export declare class Finding {
74
+ readonly kind: string;
75
+ readonly skills: readonly string[];
76
+ readonly detail: string;
77
+ constructor(kind: string, skills: readonly string[], detail: string);
78
+ get severity(): string;
79
+ asDict(): FindingDict;
80
+ }
81
+ export interface AuditDict {
82
+ readonly roots: string[];
83
+ readonly skills: number;
84
+ readonly catalogue_bytes: number;
85
+ readonly findings: FindingDict[];
86
+ readonly omissions: OmissionDict[];
87
+ }
88
+ /** The whole answer. `skills` plus every omission's `count` is every `SKILL.md` found. */
89
+ export declare class Audit {
90
+ readonly roots: readonly string[];
91
+ readonly skills: number;
92
+ readonly catalogueBytes: number;
93
+ readonly findings: readonly Finding[];
94
+ readonly omissions: readonly Omission[];
95
+ constructor(roots: readonly string[], skills: number, catalogueBytes: number, findings?: readonly Finding[], omissions?: readonly Omission[]);
96
+ asDict(): AuditDict;
97
+ /**
98
+ * Two-space indent and no ASCII escaping, so the reference's
99
+ * `json.dumps(..., indent=2, ensure_ascii=False)` produces the same bytes.
100
+ */
101
+ asJson(): string;
102
+ }
103
+ /**
104
+ * A whole-value quoted YAML scalar without its quotes; every other value unchanged.
105
+ *
106
+ * `"Use when a test is \"flaky in prod\""` is one scalar and unwraps. `"a" and "b"` opens and
107
+ * ends with `"` and is NOT one — its first quote closes at index 2 — so it is left alone and
108
+ * both of its phrases survive. `"never closed` never closes and is left alone too.
109
+ */
110
+ export declare function unwrapScalar(value: string): string;
111
+ /**
112
+ * Every literal quoted phrase in `text`, in order, deduplicated.
113
+ *
114
+ * Delimiters are paired sequentially — first with second, third with fourth — per quote
115
+ * character, and an unpaired trailing delimiter opens nothing.
116
+ */
117
+ export declare function phrases(text: string): string[];
118
+ export interface AuditOptions {
119
+ /** Plugin ids that are switched on, `<plugin>@<marketplace>`. `null` counts everything. */
120
+ readonly enabled?: readonly string[] | null;
121
+ /** The caller's own call counts, keyed by skill id. `null` is NO measurement. */
122
+ readonly usage?: ReadonlyMap<string, number | bigint> | null;
123
+ /** Which family of findings to report; `all` by default. */
124
+ readonly check?: string;
125
+ /** The catalogue byte budget. `null` means the budget finding cannot fire. */
126
+ readonly budget?: number | bigint | null;
127
+ /**
128
+ * The version directory the host actually serves, per `<plugin>@<marketplace>`. Host truth
129
+ * the caller supplies, the same class of argument `enabled` is; a plugin absent from it
130
+ * falls back to the byte order. `null` is every plugin absent.
131
+ */
132
+ readonly versions?: ReadonlyMap<string, string> | null;
133
+ }
134
+ /**
135
+ * Walk `root` and answer the whole audit.
136
+ *
137
+ * `usage` omitted is not the same as `usage` empty. An empty map is a measurement saying
138
+ * nothing was called; an absent map is no measurement at all, and reporting every skill as
139
+ * `never-invoked` on the strength of it would be an assertion about data this tool was never
140
+ * given. So `never-invoked` fires only when `usage` is supplied — the same discipline
141
+ * `catalogue-over-budget` follows for `budget`.
142
+ *
143
+ * An empty `root` is refused rather than resolved: `Path("")` is `Path(".")` on the reference
144
+ * and `statSync('')` throws here, so the two answered a CWD-relative audit and a refusal for
145
+ * the same input, and the reference's half contradicts the determinism the contract claims.
146
+ *
147
+ * `versions` is the same kind of argument `enabled` is: host truth the caller supplies rather
148
+ * than a fact this tool can read off `root`. It names, per `<plugin>@<marketplace>`, the
149
+ * version directory the host actually serves; a plugin absent from it falls back to the byte
150
+ * order, which is deterministic and — among names that are not versions — arbitrary.
151
+ *
152
+ * `skills`, `catalogueBytes` and `omissions` are reported whatever `check` says: they are the
153
+ * measurement, and `check` selects which family of FINDINGS is worth reporting on top of it.
154
+ */
155
+ /** One `SKILL.md` as `scan` hands it back: the record `audit` then counts or omits. */
156
+ export interface ScannedSkill {
157
+ readonly id: string;
158
+ readonly relpath: string;
159
+ readonly directory: string;
160
+ readonly marketplace: string;
161
+ readonly plugin: string;
162
+ readonly version: string;
163
+ readonly description: string;
164
+ readonly name: string;
165
+ readonly router: boolean;
166
+ readonly omitted: string | null;
167
+ readonly malformed: string | null;
168
+ readonly phrases: readonly string[];
169
+ readonly bytes: number;
170
+ }
171
+ export declare function scan(root: string, enabled?: readonly string[] | null, versions?: ReadonlyMap<string, string> | null): ScannedSkill[];
172
+ export declare function audit(root: string, options?: AuditOptions): Audit;