bantamkit-mcp 0.35.7 → 0.35.9

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 (53) hide show
  1. package/README.md +24 -9
  2. package/assets/tools/memory_compact.json +1 -2
  3. package/assets/tools/memory_recall.json +1 -3
  4. package/assets/tools/shiftwork_clock_in.json +1 -1
  5. package/assets/tools/shiftwork_clock_out.json +1 -1
  6. package/assets/tools/validate_json.json +1 -1
  7. package/assets/tools/work_plan.json +14 -8
  8. package/dist/assets.js +28 -4
  9. package/dist/cli.js +83 -24
  10. package/dist/contract.d.ts +0 -9
  11. package/dist/contract.js +51 -12
  12. package/dist/eventlog.d.ts +22 -2
  13. package/dist/eventlog.js +30 -3
  14. package/dist/hookadapter.js +263 -43
  15. package/dist/hostinstall.d.ts +2 -2
  16. package/dist/hostinstall.js +318 -84
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +1 -1
  19. package/dist/mcp/identity.js +27 -1
  20. package/dist/mcp/pyargs.d.ts +22 -6
  21. package/dist/mcp/pyargs.js +113 -39
  22. package/dist/mcp/server.js +177 -41
  23. package/dist/mcp/status.js +11 -4
  24. package/dist/mcp/transport.d.ts +51 -6
  25. package/dist/mcp/transport.js +217 -26
  26. package/dist/mcpreport.d.ts +12 -0
  27. package/dist/mcpreport.js +67 -15
  28. package/dist/memory/cli.js +18 -3
  29. package/dist/memory/component.d.ts +17 -0
  30. package/dist/memory/component.js +51 -7
  31. package/dist/memory/factfile.d.ts +21 -3
  32. package/dist/memory/factfile.js +351 -12
  33. package/dist/memory/layers.d.ts +19 -0
  34. package/dist/memory/layers.js +102 -2
  35. package/dist/memory/pyyaml.d.ts +6 -4
  36. package/dist/memory/pyyaml.js +6 -4
  37. package/dist/memory/store.d.ts +82 -7
  38. package/dist/memory/store.js +277 -29
  39. package/dist/npminstall.d.ts +18 -0
  40. package/dist/npminstall.js +26 -5
  41. package/dist/pyargparse.js +48 -2
  42. package/dist/pyjsonschema.d.ts +85 -4
  43. package/dist/pyjsonschema.js +1812 -181
  44. package/dist/selfupdate.d.ts +112 -32
  45. package/dist/selfupdate.js +221 -79
  46. package/dist/shiftwork.d.ts +46 -7
  47. package/dist/shiftwork.js +246 -42
  48. package/dist/tokenledger.js +20 -4
  49. package/dist/updatecheck.d.ts +15 -3
  50. package/dist/updatecheck.js +23 -8
  51. package/dist/workplan.d.ts +8 -3
  52. package/dist/workplan.js +62 -3
  53. package/package.json +2 -2
@@ -18,6 +18,22 @@ export declare const HISTORY_RING_SIZE = 5;
18
18
  * thing it recorded with the same word the wire used. `BRIEF_EVENT` in `shiftwork.py`.
19
19
  */
20
20
  export declare const BRIEF_EVENT = "brief";
21
+ /**
22
+ * job65/#162: the `event` value of the line `clockOut` appends right after an accounting line
23
+ * whose COMMIT failed. Log-then-commit keeps the accounting; this marker says the line above
24
+ * it did not count, so `briefed` hands the brief back and the documented retry is accepted.
25
+ * `UNCOMMITTED_EVENT` in `shiftwork.py`.
26
+ */
27
+ export declare const UNCOMMITTED_EVENT = "uncommitted";
28
+ /**
29
+ * job65/#117: the keys the runtime writes on every accounting line. A caller line that sets
30
+ * one is refused before anything is written — `record.set` over the base keys used to let
31
+ * `{"unit": "C", "event": "brief"}` turn A's clock-out into a forged brief of C that `briefed`
32
+ * then trusted. `briefed` is NOT here on purpose: F6 ruled a self-reported `briefed` is
33
+ * OVERWRITTEN by the measured value, pinned on both sides. `RESERVED_LEDGER_KEYS` in
34
+ * `shiftwork.py`, same order, because the sentence names them in this order.
35
+ */
36
+ export declare const RESERVED_LEDGER_KEYS: readonly string[];
21
37
  /**
22
38
  * `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))` — UTC, SECOND precision.
23
39
  *
@@ -30,17 +46,40 @@ export declare function timestamp(now: number): string;
30
46
  export interface ClockOptions {
31
47
  /** Seconds since the epoch, as `time.time()` returns them. */
32
48
  now?: number;
49
+ /**
50
+ * A TEST SEAM, like `now`: how long `clockOut` waits for `<checkpoint>.lock` before it
51
+ * refuses. The product value is `LOCK_WAIT_MS`; the Python side has no such option and its
52
+ * tests monkeypatch the module constant instead. Nothing on the wire reads it.
53
+ */
54
+ lockWaitMs?: number;
33
55
  }
56
+ /**
57
+ * job65/#116: the checkpoint's read-modify-write runs under `<checkpoint>.lock`, taken with
58
+ * O_EXCL — the one primitive both platforms and both runtimes share without a dependency,
59
+ * and the shape `driver.lock` and the hook ledger's lock (J65-4, #167) already use, so a
60
+ * Python writer and a Node writer serialise against each other on the one file they share.
61
+ * One policy differs from the ledger's: a writer that cannot take the lock within
62
+ * `LOCK_WAIT_MS` is REFUSED, nothing written — never let through unlocked, because through
63
+ * is exactly the race #116 measured. A lock older than `LOCK_STALE_MS` is a dead holder's.
64
+ * The three numbers are `shiftwork.py`'s, verbatim.
65
+ */
66
+ export declare const LOCK_WAIT_MS = 5000;
67
+ export declare const LOCK_STALE_MS = 30000;
68
+ export declare const LOCK_POLL_MS = 5;
34
69
  /**
35
70
  * Validate the checkpoint and return a unit's brief, or a refusal.
36
71
  *
37
- * `unitId` OMITTED IS THE CURSOR UNIT, BYTE FOR BYTE WHAT IT WAS (J60/D1). Given, it is
38
- * briefed instead — IF the batch view says it is ready. That is the only new judgement, it
39
- * is taken LAST, and the three above it are untouched, so no existing refusal moves and no
40
- * caller that never passes a unit can see a difference. A unit that is not ready, including
41
- * an id naming no unit at all, is ONE refusal carrying the list the caller's next move is to
42
- * pick from; a batch view that refuses (cycle, unknown dependency) passes through verbatim,
43
- * in both directions, exactly as `planBatches` already passes `readValid`'s.
72
+ * `unitId` OMITTED SELECTS THE CURSOR UNIT (J60/D1), given, that unit — and EITHER WAY the
73
+ * selected unit is briefed only IF the batch view says it is ready (job65/#164). The readiness
74
+ * judgement is taken LAST, after the three above it, so no existing refusal moves. Until job65
75
+ * the default path skipped it — "byte for byte what it was" — and #164 measured what that
76
+ * exemption cost: a cursor left on a `done` unit by a hand edit, or on a unit whose
77
+ * dependency had not run by a re-plan, was briefed with no refusal and no warning, the exact
78
+ * hazard job60 closed for the named path. A unit that is not ready, including an id naming no
79
+ * unit at all and a terminal cursor unit (the batch view drops done/dropped units), is ONE
80
+ * refusal carrying the list the caller's next move is to pick from; a batch view that refuses
81
+ * (cycle, unknown dependency) passes through verbatim on both paths, exactly as `planBatches`
82
+ * already passes `readValid`'s.
44
83
  *
45
84
  * `ready` cannot be empty here: the all-terminal test above has already answered `success`
46
85
  * for a plan with no non-terminal unit, and a non-terminal unit that cannot batch is a
package/dist/shiftwork.js CHANGED
@@ -47,10 +47,18 @@
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. 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.
50
+ * WHAT IS NOT PORTED, DELIBERATELY. Log-then-commit ordering is preserved exactly: the
51
+ * accounting line is appended BEFORE the atomic rename, so a partial failure can lose the
52
+ * commit but never the accounting — and since job65 (#162) the orphan line is followed by an
53
+ * `{"event": "uncommitted"}` marker so `briefed` hands the brief back and the retry lands;
54
+ * since job65 (#117) a caller line that sets one of `RESERVED_LEDGER_KEYS` is refused before
55
+ * anything is written; and since job65 (#164) `clockIn()` with no `unitId` applies the same
56
+ * readiness test as `clockIn(unitId)`. "There is no lock" used to open this paragraph, with the
57
+ * reason that the MCP topology has one orchestrator by construction; job60's waves made that
58
+ * false and job65 (#116) measured it — two clock-outs of DIFFERENT units at one instant lost
59
+ * 10 of 20 `ok` answers in 10 trials, on each runtime and across them. Both runtimes now take
60
+ * `<checkpoint>.lock` around the whole read-modify-write (see `acquireLock`), the same
61
+ * O_EXCL protocol on both sides because the two share one checkpoint on disk.
54
62
  *
55
63
  * `depends_on` USED TO BE LISTED ABOVE, AND NO LONGER BELONGS THERE. `planBatches` below
56
64
  * READS `depends_on` and answers the batch view, so the module no longer ignores the field;
@@ -74,7 +82,9 @@
74
82
  */
75
83
  import { AssetNotFound, loadSchema, loadToolAsset } from './assets.js';
76
84
  import { schemaError } from './contract.js';
77
- import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, PyUnicodeDecodeError, } from './memory/pyfs.js';
85
+ import { randomBytes } from 'node:crypto';
86
+ import { closeSync, openSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
87
+ import { asPyOSError, pyAppendText, pyJoin, pyNewlineOut, PyOSError, pyReplace, pyResolve, pyUnlink, pyReadText, PyUnicodeDecodeError, } from './memory/pyfs.js';
78
88
  import { dumpJson, fromJs, parseJson, PyJSONDecodeError, reprValue } from './pyjson.js';
79
89
  import { checkSchema, PyJsonSchemaUnsupported } from './pyjsonschema.js';
80
90
  import { plan } from './workplan.js';
@@ -100,6 +110,22 @@ export const HISTORY_RING_SIZE = 5;
100
110
  * thing it recorded with the same word the wire used. `BRIEF_EVENT` in `shiftwork.py`.
101
111
  */
102
112
  export const BRIEF_EVENT = 'brief';
113
+ /**
114
+ * job65/#162: the `event` value of the line `clockOut` appends right after an accounting line
115
+ * whose COMMIT failed. Log-then-commit keeps the accounting; this marker says the line above
116
+ * it did not count, so `briefed` hands the brief back and the documented retry is accepted.
117
+ * `UNCOMMITTED_EVENT` in `shiftwork.py`.
118
+ */
119
+ export const UNCOMMITTED_EVENT = 'uncommitted';
120
+ /**
121
+ * job65/#117: the keys the runtime writes on every accounting line. A caller line that sets
122
+ * one is refused before anything is written — `record.set` over the base keys used to let
123
+ * `{"unit": "C", "event": "brief"}` turn A's clock-out into a forged brief of C that `briefed`
124
+ * then trusted. `briefed` is NOT here on purpose: F6 ruled a self-reported `briefed` is
125
+ * OVERWRITTEN by the measured value, pinned on both sides. `RESERVED_LEDGER_KEYS` in
126
+ * `shiftwork.py`, same order, because the sentence names them in this order.
127
+ */
128
+ export const RESERVED_LEDGER_KEYS = ['unit', 'role', 'status', 'ts', 'event'];
103
129
  // ------------------------------------------------------------------- tagged-value sugar
104
130
  const str = (v) => ({ t: 'str', v });
105
131
  const int = (v) => ({ t: 'int', v: BigInt(v) });
@@ -161,8 +187,13 @@ function readValid(path) {
161
187
  raw = pyReadText(path);
162
188
  }
163
189
  catch (e) {
164
- if (e instanceof PyOSError)
190
+ // #163: `UnicodeDecodeError` is a ValueError, not an OSError, and it escaped every tool as
191
+ // a raised error while a missing file answered `result: error`. Same sentence head as the
192
+ // missing file: the bytes could not be read. The paragraph above that says it escapes in
193
+ // both runtimes is the history; it no longer does in either.
194
+ if (e instanceof PyOSError || e instanceof PyUnicodeDecodeError) {
165
195
  return { document: null, refusal: errorResult(`checkpoint unreadable: ${e.message}`) };
196
+ }
166
197
  throw e;
167
198
  }
168
199
  let document;
@@ -178,6 +209,18 @@ function readValid(path) {
178
209
  const problem = schemaError(raw, loadSchema(SCHEMA_NAME));
179
210
  if (problem !== null)
180
211
  return { document: null, refusal: errorResult(`checkpoint invalid: ${problem}`) };
212
+ // #165: the schema cannot say that unit ids are unique, so this is the only check, and it
213
+ // sits on the read every tool starts with: status, clock_in and clock_out now answer what
214
+ // `shiftwork_plan` already answered through the core. Before it, `findUnit` took the first
215
+ // match and a clock-out of the first `A` of two left a document no job could finish. The
216
+ // sentence is `workplan`'s, verbatim, so the plan view's answer does not move.
217
+ const seen = new Set();
218
+ for (const unit of subList(subDict(document, 'plan'), 'units').v) {
219
+ const id = text(field(unit, 'id'));
220
+ if (seen.has(id))
221
+ return { document: null, refusal: errorResult(`duplicate node id ${id}`) };
222
+ seen.add(id);
223
+ }
181
224
  return { document: document, refusal: null };
182
225
  }
183
226
  function findUnit(document, unitId) {
@@ -201,6 +244,19 @@ export function timestamp(now) {
201
244
  return (`${pad(when.getUTCFullYear(), 4)}-${pad(when.getUTCMonth() + 1)}-${pad(when.getUTCDate())}` +
202
245
  `T${pad(when.getUTCHours())}:${pad(when.getUTCMinutes())}:${pad(when.getUTCSeconds())}Z`);
203
246
  }
247
+ /**
248
+ * job65/#116: the checkpoint's read-modify-write runs under `<checkpoint>.lock`, taken with
249
+ * O_EXCL — the one primitive both platforms and both runtimes share without a dependency,
250
+ * and the shape `driver.lock` and the hook ledger's lock (J65-4, #167) already use, so a
251
+ * Python writer and a Node writer serialise against each other on the one file they share.
252
+ * One policy differs from the ledger's: a writer that cannot take the lock within
253
+ * `LOCK_WAIT_MS` is REFUSED, nothing written — never let through unlocked, because through
254
+ * is exactly the race #116 measured. A lock older than `LOCK_STALE_MS` is a dead holder's.
255
+ * The three numbers are `shiftwork.py`'s, verbatim.
256
+ */
257
+ export const LOCK_WAIT_MS = 5000;
258
+ export const LOCK_STALE_MS = 30_000;
259
+ export const LOCK_POLL_MS = 5;
204
260
  // ------------------------------------------------------------------------ F6: the ledger
205
261
  /**
206
262
  * `Path(str(path) + ".log.jsonl")` — string concatenation on the NORMALIZED path, not
@@ -226,8 +282,16 @@ function logPath(path) {
226
282
  * two write legs of `clockOut` already use, for the same reason.
227
283
  */
228
284
  function recordBrief(log, unitId, role, now) {
285
+ recordEvent(log, BRIEF_EVENT, unitId, role, now);
286
+ }
287
+ /**
288
+ * `_record_event`: one `{event, ts, unit, role}` line, best effort — the brief line (F6) and,
289
+ * since job65/#162, the `uncommitted` marker share this shape, so a reader tells every event
290
+ * line from an accounting line by the one key, `event`, never by `status`.
291
+ */
292
+ function recordEvent(log, event, unitId, role, now) {
229
293
  const record = new Map([
230
- ['event', str(BRIEF_EVENT)],
294
+ ['event', str(event)],
231
295
  ['ts', str(timestamp(now))],
232
296
  ['unit', str(unitId)],
233
297
  ['role', role],
@@ -250,11 +314,16 @@ const SPLITLINES = /\r\n|[\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029]/;
250
314
  * `_briefed`: was a brief issued for `unitId` since its last clock-out? Read off the ledger.
251
315
  *
252
316
  * Walk the ledger in order, tracking only lines that name this unit: a brief line sets the
253
- * flag, an accounting line (any line that is not a brief event) clears it. The answer is the
317
+ * flag, an accounting line (any line that is not a brief event) clears it, and — since
318
+ * job65/#162 — an `uncommitted` line restores whatever the flag was before the accounting
319
+ * line it follows, because that line's commit failed and it did not count. The answer is the
254
320
  * flag at the end — so a clock-out CONSUMES the brief: brief, blocked, re-run without a
255
- * clock_in reads `false`; brief, blocked, clock_in, done reads `true`. Detectable false
256
- * negatives (a retried clock-out's duplicate line) beat undetectable false positives (an
257
- * inline re-run marked briefed because a brief was issued once, ever).
321
+ * clock_in reads `false`; brief, blocked, clock_in, done reads `true`; brief, uncommitted
322
+ * clock-out, retry reads `true`. Until job65 the retry read `false` by choice ("detectable
323
+ * false negatives beat undetectable false positives"); #162 measured that the orphan line also
324
+ * SPENT the brief, so a briefed non-cursor unit's retry was refused and the documented
325
+ * recovery did not exist. The inline re-run is still `false`: the marker is what now tells the
326
+ * two apart.
258
327
  *
259
328
  * Reads the ledger the way a reader must: a missing or unreadable log is `false`, a line
260
329
  * that is not JSON or not an object is skipped, never fatal. `record.get("unit") != unit_id`
@@ -275,6 +344,7 @@ function briefed(log, unitId) {
275
344
  return false;
276
345
  }
277
346
  let flag = false;
347
+ let beforeLastAccounting = false;
278
348
  for (const line of content.split(SPLITLINES)) {
279
349
  let record;
280
350
  try {
@@ -291,7 +361,15 @@ function briefed(log, unitId) {
291
361
  if (unit === undefined || unit.t !== 'str' || unit.v !== unitId)
292
362
  continue;
293
363
  const event = record.v.get('event');
294
- flag = event !== undefined && event.t === 'str' && event.v === BRIEF_EVENT;
364
+ const name = event !== undefined && event.t === 'str' ? event.v : null;
365
+ if (name === BRIEF_EVENT)
366
+ flag = true;
367
+ else if (name === UNCOMMITTED_EVENT)
368
+ flag = beforeLastAccounting;
369
+ else {
370
+ beforeLastAccounting = flag;
371
+ flag = false;
372
+ }
295
373
  }
296
374
  return flag;
297
375
  }
@@ -353,13 +431,17 @@ function readyIds(view) {
353
431
  /**
354
432
  * Validate the checkpoint and return a unit's brief, or a refusal.
355
433
  *
356
- * `unitId` OMITTED IS THE CURSOR UNIT, BYTE FOR BYTE WHAT IT WAS (J60/D1). Given, it is
357
- * briefed instead — IF the batch view says it is ready. That is the only new judgement, it
358
- * is taken LAST, and the three above it are untouched, so no existing refusal moves and no
359
- * caller that never passes a unit can see a difference. A unit that is not ready, including
360
- * an id naming no unit at all, is ONE refusal carrying the list the caller's next move is to
361
- * pick from; a batch view that refuses (cycle, unknown dependency) passes through verbatim,
362
- * in both directions, exactly as `planBatches` already passes `readValid`'s.
434
+ * `unitId` OMITTED SELECTS THE CURSOR UNIT (J60/D1), given, that unit — and EITHER WAY the
435
+ * selected unit is briefed only IF the batch view says it is ready (job65/#164). The readiness
436
+ * judgement is taken LAST, after the three above it, so no existing refusal moves. Until job65
437
+ * the default path skipped it — "byte for byte what it was" — and #164 measured what that
438
+ * exemption cost: a cursor left on a `done` unit by a hand edit, or on a unit whose
439
+ * dependency had not run by a re-plan, was briefed with no refusal and no warning, the exact
440
+ * hazard job60 closed for the named path. A unit that is not ready, including an id naming no
441
+ * unit at all and a terminal cursor unit (the batch view drops done/dropped units), is ONE
442
+ * refusal carrying the list the caller's next move is to pick from; a batch view that refuses
443
+ * (cycle, unknown dependency) passes through verbatim on both paths, exactly as `planBatches`
444
+ * already passes `readValid`'s.
363
445
  *
364
446
  * `ready` cannot be empty here: the all-terminal test above has already answered `success`
365
447
  * for a plan with no non-terminal unit, and a non-terminal unit that cannot batch is a
@@ -403,28 +485,27 @@ export function clockIn(checkpoint, unitId = null, options = {}) {
403
485
  ]);
404
486
  }
405
487
  const cursor = text(field(subDict(doc, 'plan'), 'cursor'));
406
- // D1: selection, and it is the LAST judgement. `selected` differs from `cursor` only on
407
- // the new branch, so the dangling-cursor escalation below still says `cursor` truthfully:
408
- // a `unitId` that reached `selected` came out of `ready`, whose ids are `plan.units`' own,
409
- // and `findUnit` cannot fail for it.
410
- let selected = cursor;
411
- if (unitId !== null && unitId !== undefined) {
412
- const view = batchView(doc);
413
- if ('result' in view)
414
- return errorResult(view.reason);
415
- const ready = readyIds(view);
416
- // `", "` in BATCH order, not sorted: the order the caller would dispatch them in.
417
- if (!ready.includes(unitId))
418
- return errorResult(`unit ${unitId} is not ready; ready is ${ready.join(', ')}`);
419
- selected = unitId;
420
- }
488
+ // D1: selection, then ONE readiness test for both paths (#164). The dangling-cursor
489
+ // escalation stays above the graph and keeps its sentence; on the named path a `unitId`
490
+ // naming no unit falls through to the not-ready sentence, as before.
491
+ const named = unitId !== null && unitId !== undefined;
492
+ const selected = named ? unitId : cursor;
421
493
  const unit = findUnit(doc, selected);
422
- if (unit === null) {
494
+ if (!named && unit === null) {
423
495
  return dict([
424
496
  ['result', str('escalate')],
425
497
  ['reason', str(`cursor ${cursor} names no unit`)],
426
498
  ]);
427
499
  }
500
+ const view = batchView(doc);
501
+ if ('result' in view)
502
+ return errorResult(view.reason);
503
+ const ready = readyIds(view);
504
+ // `", "` in BATCH order, not sorted: the order the caller would dispatch them in. A
505
+ // terminal cursor unit is not in `ready`, so it meets this same sentence.
506
+ if (unit === null || !ready.includes(selected)) {
507
+ return errorResult(`unit ${selected} is not ready; ready is ${ready.join(', ')}`);
508
+ }
428
509
  // F6: the brief is issued, so say so in the ledger — best effort, and only on this branch:
429
510
  // a refusal above issued nothing and therefore records nothing. `pyJoin` is the same
430
511
  // normalisation `clockOut` applies before it builds ITS log path, so the two land in one file.
@@ -662,6 +743,25 @@ function accountingRefusal(unitId, unit, accounting) {
662
743
  return null;
663
744
  return `unit ${unitId} in role ${text(field(unit, 'role'))} reported an accounting line the schema refuses: ${problem}`;
664
745
  }
746
+ /**
747
+ * `_reserved_refusal` (#117): an accounting line that sets a key the runtime writes is refused,
748
+ * nothing written. `record.set` over the base keys let `{"unit": "C", "event": "brief"}` on A's
749
+ * clock-out write a line `briefed` read as a brief of C, and the next `clockOut("C")` was
750
+ * accepted although C was never briefed, was not the cursor, and had a dependency still `todo`
751
+ * (measured: A=done B=todo C=done). A refusal rather than a merge-under, because a line whose
752
+ * keys are dropped is a line the orchestrator believes it wrote. Runs after the shape check
753
+ * (`additionalProperties: true` lets these keys through it) and before any mutation. The keys
754
+ * are named in `RESERVED_LEDGER_KEYS` order so both runtimes spell one sentence for one line.
755
+ */
756
+ function reservedRefusal(unitId, unit, accounting) {
757
+ if (accounting === null || accounting === undefined)
758
+ return null;
759
+ const line = asPatch(accounting);
760
+ const present = RESERVED_LEDGER_KEYS.filter((key) => line.has(key));
761
+ if (present.length === 0)
762
+ return null;
763
+ return `unit ${unitId} in role ${text(field(unit, 'role'))} reported an accounting line that sets the runtime's own keys: ${present.join(', ')}`;
764
+ }
665
765
  /**
666
766
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
667
767
  *
@@ -698,6 +798,96 @@ function accountingRefusal(unitId, unit, accounting) {
698
798
  */
699
799
  export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry, accounting = null, options = {}) {
700
800
  const path = pyJoin(checkpoint);
801
+ const real = realTarget(path);
802
+ const lock = `${real}.lock`;
803
+ const { held, refusal: locked } = acquireLock(lock, options.lockWaitMs ?? LOCK_WAIT_MS);
804
+ if (locked !== null)
805
+ return locked;
806
+ try {
807
+ return clockOutLocked(path, real, unitId, status, handoffPatch, historyEntry, accounting, options);
808
+ }
809
+ finally {
810
+ if (held) {
811
+ try {
812
+ unlinkSync(lock);
813
+ }
814
+ catch {
815
+ /* `contextlib.suppress(OSError)`: the release's own failure is not the story */
816
+ }
817
+ }
818
+ }
819
+ }
820
+ /**
821
+ * #137: the file the rename lands on — a symlinked checkpoint's TARGET, so the link stays a
822
+ * link and every other path to the same file sees the clock-out. Only the write side
823
+ * resolves: the read, the ledger and every sentence use the path as given.
824
+ */
825
+ function realTarget(path) {
826
+ try {
827
+ return pyResolve(path);
828
+ }
829
+ catch {
830
+ return path; // a symlink loop, or a path the OS will not resolve — `Path.resolve`'s own arm
831
+ }
832
+ }
833
+ /**
834
+ * `_acquire_lock`: take `<checkpoint>.lock` with O_EXCL. Answers `{held, refusal}`.
835
+ *
836
+ * `held` is false without a refusal in exactly one case: the directory will not hold a lock
837
+ * file for a reason other than one existing (read-only, gone). The clock-out then goes on
838
+ * UNLOCKED, because the temp file that follows cannot be created there either and ITS
839
+ * refusal is the one that names the cause — the `readonly-directory-*` arms keep their
840
+ * sentences. A lock that exists is waited on; one older than `LOCK_STALE_MS` is a dead
841
+ * holder's and is removed; past the wait the clock-out is refused with nothing written, so
842
+ * the caller can retry — never let through, which is the race.
843
+ */
844
+ function acquireLock(lock, waitMs) {
845
+ const deadline = Date.now() + waitMs;
846
+ for (;;) {
847
+ let fd;
848
+ try {
849
+ fd = openSync(lock, 'wx', 0o644);
850
+ }
851
+ catch (e) {
852
+ if (e?.code !== 'EEXIST')
853
+ return { held: false, refusal: null };
854
+ try {
855
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS)
856
+ unlinkSync(lock);
857
+ }
858
+ catch {
859
+ /* released between the open and the stat: go round again */
860
+ }
861
+ if (Date.now() >= deadline) {
862
+ return { held: false, refusal: errorResult(`checkpoint locked by another clock-out, nothing written: ${lock}`) };
863
+ }
864
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_POLL_MS);
865
+ continue;
866
+ }
867
+ try {
868
+ writeSync(fd, `${process.pid}\n`);
869
+ }
870
+ finally {
871
+ closeSync(fd);
872
+ }
873
+ return { held: true, refusal: null };
874
+ }
875
+ }
876
+ /** `tmp.open("x", encoding="utf-8").write(text)` — O_EXCL, newline translation included. */
877
+ function pyWriteTextExclusive(path, text) {
878
+ try {
879
+ writeFileSync(path, pyNewlineOut(text), { encoding: 'utf8', flag: 'wx' });
880
+ }
881
+ catch (e) {
882
+ throw asPyOSError(e, path, undefined, 'crt');
883
+ }
884
+ }
885
+ /** `str(OSError(e.errno, e.strerror, str(path)))`: the sentence with the checkpoint in the filename slot. */
886
+ function atCheckpoint(e, path) {
887
+ return new PyOSError(e.errno, e.code, e.strerror, path, null, e.winerror).message;
888
+ }
889
+ /** `clock_out`'s body, run under the lock its caller holds: mutate, validate, write. */
890
+ function clockOutLocked(path, real, unitId, status, handoffPatch, historyEntry, accounting, options) {
701
891
  const { document, refusal } = readValid(path);
702
892
  if (refusal !== null)
703
893
  return refusal;
@@ -722,6 +912,9 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
722
912
  const badLine = accountingRefusal(unitId, unit, accounting);
723
913
  if (badLine !== null)
724
914
  return errorResult(badLine);
915
+ const forged = reservedRefusal(unitId, unit, accounting);
916
+ if (forged !== null)
917
+ return errorResult(forged);
725
918
  unit.v.set('status', str(status));
726
919
  // D3: the cursor follows the GRAPH, on the MUTATED document — the unit just finished is
727
920
  // already out of it. Three steps, and the middle one is only reachable for a graph that
@@ -747,9 +940,9 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
747
940
  const problem = schemaError(dumpJson(doc), loadSchema(SCHEMA_NAME));
748
941
  if (problem !== null)
749
942
  return errorResult(`refused to write: ${problem}`);
750
- // `record.update(accounting or {})`: the four keys the record starts with are OVERRIDABLE
751
- // by the orchestrator's accounting, and `sort_keys=True` means the merge order never
752
- // reaches the file anyway.
943
+ // `record.update(accounting or {})`: since #117 none of the four base keys (nor `event`)
944
+ // can be in the accounting — `reservedRefusal` above — and `sort_keys=True` means the
945
+ // merge order never reaches the file anyway.
753
946
  const record = new Map([
754
947
  ['ts', str(timestamp(options.now ?? Date.now() / 1000))],
755
948
  ['unit', str(unitId)],
@@ -771,10 +964,17 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
771
964
  throw e;
772
965
  return errorResult(`accounting log unwritable, checkpoint untouched: ${e.message}`);
773
966
  }
774
- const tmp = pyWithSuffix(path, `${pySuffix(path)}.tmp`);
967
+ // #116: a PER-WRITER temp name, created exclusively — never `with_suffix(".tmp")`, which
968
+ // gave every writer of one checkpoint the same temp file (two renames of one file, so one
969
+ // `ok` answer with nothing of it on disk) and gave a suffix-less `checkpoint` the name of
970
+ // an unrelated sibling, `checkpoint.tmp`, which it overwrote and renamed away. #137: the
971
+ // rename lands on `real`, the link's target. The refusal names the checkpoint AS GIVEN and
972
+ // not the temp name: a random name in a sentence is not reproducible, and the file the
973
+ // caller could not write is, to the caller, the checkpoint.
974
+ const tmp = `${real}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
775
975
  try {
776
- pyWriteText(tmp, `${dumpJson(doc, { indent: 2 })}\n`);
777
- pyReplace(tmp, path);
976
+ pyWriteTextExclusive(tmp, `${dumpJson(doc, { indent: 2 })}\n`);
977
+ pyReplace(tmp, real);
778
978
  }
779
979
  catch (e) {
780
980
  if (!(e instanceof PyOSError))
@@ -787,7 +987,11 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
787
987
  if (!(cleanup instanceof PyOSError))
788
988
  throw cleanup;
789
989
  }
790
- return errorResult(`checkpoint unwritable, last log line uncommitted: ${e.message}`);
990
+ // #162: the accounting line above is kept (log-then-commit: never lose the accounting)
991
+ // and MARKED: this line says it did not count, so `briefed` hands the brief back and the
992
+ // retry the sentence invites is accepted. Best effort, like the brief line.
993
+ recordEvent(log, UNCOMMITTED_EVENT, unitId, field(unit, 'role'), options.now ?? Date.now() / 1000);
994
+ return errorResult(`checkpoint unwritable, last log line uncommitted: ${atCheckpoint(e, path)}`);
791
995
  }
792
996
  return dict([
793
997
  ['result', str('ok')],
@@ -31,7 +31,7 @@
31
31
  * total`), never as a comparison afterwards: a `number` past 2**53 is already rounded, so the
32
32
  * check would be reading a value this side had got wrong.
33
33
  */
34
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
34
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
35
35
  import { join } from 'node:path';
36
36
  import { BantamError } from './errors.js';
37
37
  import { MAX_SAFE_INT, TOKEN_CLASSES, loadPriceTable, priceTokens, } from './pricing.js';
@@ -125,8 +125,21 @@ export function walk(base) {
125
125
  // exactly as it is there.
126
126
  isDirectory = false;
127
127
  }
128
- if (isDirectory)
129
- visit(child, `${rel}/`);
128
+ if (isDirectory) {
129
+ // #151: a symlinked directory is not a transcript tree. `statSync` follows the link,
130
+ // so a link to a directory lands in this arm and is skipped here: neither walked nor
131
+ // read. Following it walked a link to an ancestor until ELOOP and counted the one
132
+ // transcript beneath it once per level. `skill_audit`'s rule, and the reference's.
133
+ let link = false;
134
+ try {
135
+ link = lstatSync(child).isSymbolicLink();
136
+ }
137
+ catch {
138
+ link = false;
139
+ }
140
+ if (!link)
141
+ visit(child, `${rel}/`);
142
+ }
130
143
  else if (name.endsWith(TRANSCRIPT_SUFFIX))
131
144
  found.push([child, rel]);
132
145
  }
@@ -139,7 +152,10 @@ export function walk(base) {
139
152
  * raises, which would make an unreadable transcript a counted one on this side alone.
140
153
  */
141
154
  function readUtf8Strict(path) {
142
- const decoder = new TextDecoder('utf-8', { fatal: true });
155
+ // `ignoreBOM: true` KEEPS a leading U+FEFF (#150). The default strips it from the first line
156
+ // only, where `read_text(encoding="utf-8")` keeps it and `json.loads` refuses it; kept, the
157
+ // line is `unparsed-line` on both sides, as a BOM on any later line already was.
158
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
143
159
  return decoder.decode(readFileSync(path));
144
160
  }
145
161
  function usageOf(record) {
@@ -30,6 +30,18 @@ export declare const UPDATE_NEVER = "update: never checked.";
30
30
  * falsify the claim, the same reason `selfupdate.COMPARISON` carries both.
31
31
  */
32
32
  export declare const UPDATE_AVAILABLE: string;
33
+ /**
34
+ * The command `UPDATE_AVAILABLE` tells the operator to run: THIS node, absolute (job66/#171).
35
+ *
36
+ * WAS: `` `{program} --update` ``, the bare command, which `--install` puts only in
37
+ * `<prefix>/node_modules/.bin` — on nobody's PATH — so the sentence answered `command not
38
+ * found` from a GUI-launched host's shell. `process.execPath` and this build's own `cli.js`
39
+ * (the sibling of this module in `dist/`), each double-quoted, need no PATH and hold a home
40
+ * directory with a space in it. The reference fills in `sys.executable -m bantamkit.mcpserver`
41
+ * instead; the TEMPLATE stays byte-identical and the filling is the `docs/porting.md` row
42
+ * *the update command names each runtime's own interpreter*.
43
+ */
44
+ export declare function updateCommand(): string;
33
45
  /** Names the date because the record may be months old. */
34
46
  export declare const UPDATE_CURRENT = "update: {program} {installed} is current as of {date}.";
35
47
  /**
@@ -111,8 +123,8 @@ export declare function readRecord(path?: string): Record<string, unknown> | nul
111
123
  * and not only on the `current` path: a record that cannot say when it was written is not a
112
124
  * record, and one rule is one thing for the two runtimes to reproduce instead of two.
113
125
  */
114
- export declare function decide(installed: string, source: Source, record: Record<string, unknown> | null, key?: string): UpdateStatus;
126
+ export declare function decide(installed: string, source: Source, record: Record<string, unknown> | null, key?: string, command?: string): UpdateStatus;
115
127
  /** 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;
128
+ export declare function updateStatus(installed: string, key?: string, path?: string, command?: string): UpdateStatus;
117
129
  /** 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;
130
+ export declare function updateLine(installed: string, key?: string, path?: string, command?: string): string;
@@ -35,7 +35,7 @@
35
35
  * `0.10.0` is the pair that says so, and `test/updatecheck.test.mjs` both runs it and pins
36
36
  * structurally that nothing comparator-shaped is defined here.
37
37
  *
38
- * NO NEW DEPENDENCY: `node:fs`, `node:os` and `node:path`. The `str.format` substitution the
38
+ * NO NEW DEPENDENCY: `node:fs`, `node:os`, `node:path` and `node:url`. The `str.format` substitution the
39
39
  * reference gets from CPython is the four lines of `fill` below — `selfupdate.ts` has the same
40
40
  * four and cannot be imported for them, for the reason two paragraphs up.
41
41
  *
@@ -45,6 +45,7 @@
45
45
  import { readFileSync } from 'node:fs';
46
46
  import { homedir } from 'node:os';
47
47
  import { join } from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
48
49
  import { compareVersions } from './npminstall.js';
49
50
  export { compareVersions };
50
51
  /**
@@ -80,7 +81,21 @@ export const UPDATE_NEVER = 'update: never checked.';
80
81
  * falsify the claim, the same reason `selfupdate.COMPARISON` carries both.
81
82
  */
82
83
  export const UPDATE_AVAILABLE = 'update: {program} {installed} is running; the package index has {latest} — run ' +
83
- '`{program} --update`, then reconnect the host.';
84
+ '`{command}`, then reconnect the host.';
85
+ /**
86
+ * The command `UPDATE_AVAILABLE` tells the operator to run: THIS node, absolute (job66/#171).
87
+ *
88
+ * WAS: `` `{program} --update` ``, the bare command, which `--install` puts only in
89
+ * `<prefix>/node_modules/.bin` — on nobody's PATH — so the sentence answered `command not
90
+ * found` from a GUI-launched host's shell. `process.execPath` and this build's own `cli.js`
91
+ * (the sibling of this module in `dist/`), each double-quoted, need no PATH and hold a home
92
+ * directory with a space in it. The reference fills in `sys.executable -m bantamkit.mcpserver`
93
+ * instead; the TEMPLATE stays byte-identical and the filling is the `docs/porting.md` row
94
+ * *the update command names each runtime's own interpreter*.
95
+ */
96
+ export function updateCommand() {
97
+ return `"${process.execPath}" "${fileURLToPath(new URL('./cli.js', import.meta.url))}" --update`;
98
+ }
84
99
  /** Names the date because the record may be months old. */
85
100
  export const UPDATE_CURRENT = 'update: {program} {installed} is current as of {date}.';
86
101
  /**
@@ -268,7 +283,7 @@ function checkedDate(record, key) {
268
283
  * and not only on the `current` path: a record that cannot say when it was written is not a
269
284
  * record, and one rule is one thing for the two runtimes to reproduce instead of two.
270
285
  */
271
- export function decide(installed, source, record, key = KEY) {
286
+ export function decide(installed, source, record, key = KEY, command = updateCommand()) {
272
287
  if (source === SOURCE_ABSENT)
273
288
  return { state: STATE_NEVER, line: UPDATE_NEVER };
274
289
  if (source !== SOURCE_RECORD || record === null) {
@@ -283,7 +298,7 @@ export function decide(installed, source, record, key = KEY) {
283
298
  if (order < 0) {
284
299
  return {
285
300
  state: STATE_AVAILABLE,
286
- line: fill(UPDATE_AVAILABLE, { program: PROGRAM, installed, latest }),
301
+ line: fill(UPDATE_AVAILABLE, { program: PROGRAM, installed, latest, command }),
287
302
  };
288
303
  }
289
304
  if (order === 0) {
@@ -298,11 +313,11 @@ export function decide(installed, source, record, key = KEY) {
298
313
  };
299
314
  }
300
315
  /** Read the record and decide. The whole reader, in one call, for `bantamkit_status`. */
301
- export function updateStatus(installed, key = KEY, path = recordPath()) {
316
+ export function updateStatus(installed, key = KEY, path = recordPath(), command = updateCommand()) {
302
317
  const { source, record } = loadRecord(path);
303
- return decide(installed, source, record, key);
318
+ return decide(installed, source, record, key, command);
304
319
  }
305
320
  /** Exactly one of the five sentences. Always a line: there is no silent state here. */
306
- export function updateLine(installed, key = KEY, path = recordPath()) {
307
- return updateStatus(installed, key, path).line;
321
+ export function updateLine(installed, key = KEY, path = recordPath(), command = updateCommand()) {
322
+ return updateStatus(installed, key, path, command).line;
308
323
  }