bantamkit-mcp 0.35.6 → 0.35.8

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 (44) hide show
  1. package/README.md +11 -7
  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/cli.js +28 -0
  9. package/dist/contract.d.ts +0 -9
  10. package/dist/contract.js +51 -12
  11. package/dist/eventlog.d.ts +14 -1
  12. package/dist/eventlog.js +23 -3
  13. package/dist/hookadapter.js +260 -45
  14. package/dist/hostinstall.js +250 -79
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/mcp/pyargs.d.ts +22 -6
  18. package/dist/mcp/pyargs.js +113 -39
  19. package/dist/mcp/server.js +18 -27
  20. package/dist/mcp/status.js +11 -4
  21. package/dist/mcpreport.d.ts +12 -0
  22. package/dist/mcpreport.js +67 -15
  23. package/dist/memory/cli.js +18 -3
  24. package/dist/memory/component.d.ts +17 -0
  25. package/dist/memory/component.js +51 -7
  26. package/dist/memory/factfile.d.ts +21 -3
  27. package/dist/memory/factfile.js +351 -12
  28. package/dist/memory/layers.d.ts +19 -0
  29. package/dist/memory/layers.js +102 -2
  30. package/dist/memory/pyyaml.d.ts +6 -4
  31. package/dist/memory/pyyaml.js +6 -4
  32. package/dist/memory/store.d.ts +82 -7
  33. package/dist/memory/store.js +277 -29
  34. package/dist/npminstall.d.ts +18 -0
  35. package/dist/npminstall.js +26 -5
  36. package/dist/pyjsonschema.d.ts +85 -4
  37. package/dist/pyjsonschema.js +1812 -181
  38. package/dist/selfupdate.d.ts +112 -32
  39. package/dist/selfupdate.js +221 -79
  40. package/dist/shiftwork.d.ts +46 -7
  41. package/dist/shiftwork.js +246 -42
  42. package/dist/workplan.d.ts +8 -3
  43. package/dist/workplan.js +62 -3
  44. package/package.json +1 -1
package/README.md CHANGED
@@ -400,13 +400,17 @@ feature lands in both in one change, and a conformance case compares their answe
400
400
  are deliberate and each is ruled in the
401
401
  [divergence table](https://github.com/Ink01101011/bantamkit/blob/main/docs/porting.md#where-the-two-runtimes-deliberately-differ):
402
402
 
403
- - **Concurrent writes are safe for the memory store and lossy for the checkpoint, in both
404
- runtimes.** `index.md` is derived from `facts/`, so the last writer re-enumerates everything
405
- and the index self-heals: 8 trials of two processes × 15 saves, zero disagreements. A
406
- checkpoint is not: `shiftwork_clock_out` is read-modify-write, so two concurrent clock-outs on
407
- the *same cursor unit* both answer `ok` and one history entry is lost — 10/10 trials on Node
408
- **and** 10/10 on the Python reference. The cursor check serialises the normal case; only the
409
- same-unit collision loses.
403
+ - **Concurrent writes are safe for the memory store and, since job65, for the checkpoint, in
404
+ both runtimes.** `index.md` is derived from `facts/`, so the last writer re-enumerates
405
+ everything and the index self-heals: 8 trials of two processes × 15 saves, zero
406
+ disagreements. A checkpoint is read-modify-write, and until job65 (#116) every writer shared
407
+ one temp file, so two concurrent clock-outs — on the same unit OR on two different units of
408
+ one wave — lost `ok` answers: 10 of 20 in 10 trials, on Node, on Python and across the two.
409
+ `shiftwork_clock_out` now holds `<checkpoint>.lock` (O_EXCL, the same protocol in both
410
+ runtimes) around the whole call and writes through a per-writer temp name: two clock-outs of
411
+ different units both land, and a second clock-out of the same unit is refused as not the
412
+ cursor unit. A writer that cannot take the lock within 5 s answers `result: error` and writes
413
+ nothing.
410
414
  - **Do not hand-edit a fact file's `name`, `description` or `type` to a bare non-string.**
411
415
  `description: 2026` is a YAML integer. Python interpolates it and carries on; this port refuses
412
416
  the whole store with `malformed fact file …`. Quote it: `description: '2026'`. Every fact the
@@ -10,8 +10,7 @@
10
10
  "properties": {
11
11
  "reserve": {
12
12
  "type": "integer",
13
- "minimum": 0,
14
- "description": "Bytes of index headroom to free below the budget. Omit for the default: the size of the largest index line the store holds, capped at half the budget."
13
+ "description": "Bytes of index headroom to free below the budget: the target is `budget - reserve`, a negative value counts as 0, any value is capped at half the budget, and 0 compacts to the budget itself. Omit for the default: the room the degraded-index warning demands (the budget minus `(90 * budget - 1) // 100`) plus the size of the largest index line the store holds -- at the default 24000-byte budget with a 39-byte largest line that is 2401 + 39 = 2440, a 21560-byte target."
15
14
  }
16
15
  }
17
16
  },
@@ -17,9 +17,7 @@
17
17
  },
18
18
  "k": {
19
19
  "type": "integer",
20
- "minimum": 1,
21
- "maximum": 5,
22
- "description": "Facts wanted. Raised to the store default of 3 when smaller; never lowered below it."
20
+ "description": "Facts wanted, clamped to 1..5. Raised to the store default of 3 when smaller; never lowered below it."
23
21
  }
24
22
  }
25
23
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiftwork_clock_in",
3
- "description": "Shift-work clock-in: schema-validate the checkpoint file and return the brief for the unit at plan.cursor — {unit, role, invariants, handoff, do_not, files} — to hand to the spawned agent verbatim. The unit briefed is the one at plan.cursor, or, when unit_id is given, that unit — which must appear in shiftwork_plan's `ready`, the batch the dependency graph says may run now. A unit_id that is not ready, including one naming no unit, is refused with {\"result\": \"error\", \"reason\": \"unit <id> is not ready; ready is <a, b>\"}, and the refusals of the batch view itself (cycle, unknown dependency, unreadable checkpoint) pass through verbatim. Clock-in still never moves plan.cursor; clock_out does. Structured refusals, never exceptions: result=escalate when handoff.open_questions is non-empty, result=success when every unit is done or dropped, result=error when the checkpoint fails validation. Clock-in also WRITES: on result=brief, and only then, it appends one line {event: brief, ts, unit, role} to <checkpoint>.log.jsonl — best-effort: a log it cannot write costs the brief nothing (the brief still returns, no error, only the record is lost). clock_out reads those lines back to write `briefed` on every accounting line, so the ledger is no longer one line per clock-out; a brief line carries `event` and no `status`. See docs/shiftwork.md.",
3
+ "description": "Shift-work clock-in: schema-validate the checkpoint file and return the brief for the unit at plan.cursor — {unit, role, invariants, handoff, do_not, files} — to hand to the spawned agent verbatim. The unit briefed is the one at plan.cursor, or, when unit_id is given, that unit — either way it must appear in shiftwork_plan's `ready`, the batch the dependency graph says may run now: the cursor unit is held to the same ready test as unit_id. A unit that is not ready (a done or dropped cursor, a cursor whose dependencies have not run, a unit_id naming no unit) is refused with {\"result\": \"error\", \"reason\": \"unit <id> is not ready; ready is <a, b>\"}, and on both paths the refusals of the batch view itself (cycle, unknown dependency, unreadable checkpoint) pass through verbatim; a cursor naming no unit still escalates. Clock-in still never moves plan.cursor; clock_out does. Structured refusals, never exceptions: result=escalate when handoff.open_questions is non-empty, result=success when every unit is done or dropped, result=error when the checkpoint fails validation. Clock-in also WRITES: on result=brief, and only then, it appends one line {event: brief, ts, unit, role} to <checkpoint>.log.jsonl — best-effort: a log it cannot write costs the brief nothing (the brief still returns, no error, only the record is lost). clock_out reads those lines back to write `briefed` on every accounting line, so the ledger is no longer one line per clock-out; a brief line carries `event` and no `status`. See docs/shiftwork.md.",
4
4
  "surfaces": [
5
5
  "mcp"
6
6
  ],
@@ -87,7 +87,7 @@
87
87
  "anyOf": [
88
88
  {
89
89
  "type": "object",
90
- "description": "Per-unit cost. Written verbatim, beside ts/unit/role/status, as one line of <checkpoint>.log.jsonl - the ledger the N-sessions experiment reads. The keys named here have fixed meanings so a reader holding only the ledger knows what each number counts, in which unit, and which model produced it; any other key passes through unchanged (a refused key is friction, not safety). Lines written before this schema existed may spell these differently; the schema governs new writes only.",
90
+ "description": "Per-unit cost. Written verbatim, beside ts/unit/role/status, as one line of <checkpoint>.log.jsonl - the ledger the N-sessions experiment reads. The keys named here have fixed meanings so a reader holding only the ledger knows what each number counts, in which unit, and which model produced it; any other key passes through unchanged, except the five the runtime writes itself — unit, role, status, ts, event — which are refused (result=error) before anything is written; a self-reported briefed is overwritten, not refused. Lines written before this schema existed may spell these differently; the schema governs new writes only.",
91
91
  "properties": {
92
92
  "tokens": {
93
93
  "type": "integer",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "validate_json",
3
- "description": "Validate candidate output text against a JSON Schema. Returns {valid, feedback}; when invalid, feed the feedback back to the model and retry.",
3
+ "description": "Validate candidate output text against a JSON Schema. Returns {valid, feedback}; when invalid, feed the feedback back to the model and retry. The output may be any JSON value; otherwise the first fenced block, or the first object or array in the text, is used. $schema selects draft-04, draft-06, draft-07, 2019-09 or 2020-12 (default); $ref resolves only inside the one schema (\"#\", \"#/...\", \"#name\"), so a remote reference or an embedded $id is refused. pattern is Python re syntax, without inline flags, look-behind, backreferences, atomic or possessive groups, conditional groups or \\N{...}; a document's patterns get 500 ms in total.",
4
4
  "surfaces": [
5
5
  "mcp"
6
6
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work_plan",
3
- "description": "Work plan: turn a dependency graph into the batches that may run in parallel. Takes `nodes`, each `{id, depends_on, priority}` — `priority` defaults to 0 — and answers `{\"result\": \"plan\", \"batches\", \"sequence\", \"width\"}`: batch k holds every node whose dependencies all appear in batches below k, `sequence` is those batches flattened in order, and `width` is the largest batch length — the widest fan-out, which is the number an orchestrator needs to decide whether it can afford the batch. Order inside a batch is priority descending, then the order the nodes were given; both halves are contract, because determinism inside a batch is what two implementations are held to. Empty `nodes` is an ANSWER, not a refusal: no batches, no sequence, width 0. Three refusals, checked in this order and one sentence each: `duplicate node id <id>`, `node <id> depends on <dep>, which no node declares`, and `the graph has a cycle: <a> -> <b> -> <a>`. It reports what the graph permits and decides nothing about what may actually be dispatched together — `depends_on` encodes logical order, not file contention, so a batch this tool calls parallel may still hold work that writes one file. Pure computation over the nodes it was handed: it opens no file and takes no path. Never mutates.",
3
+ "description": "Work plan: turn a dependency graph into the batches that may run in parallel. Takes `nodes`, each `{id, depends_on, priority}` — `depends_on` absent or null means none, `priority` defaults to 0 — and answers `{\"result\": \"plan\", \"batches\", \"sequence\", \"width\"}`: batch k holds every node whose dependencies all appear in batches below k, `sequence` is those batches flattened in order, and `width` is the largest batch length — the widest fan-out, which is the number an orchestrator needs to decide whether it can afford the batch. Order inside a batch is priority descending, then the order the nodes were given; both halves are contract, because determinism inside a batch is what two implementations are held to. Empty `nodes` is an ANSWER, not a refusal: no batches, no sequence, width 0. Four refusals, checked in this order and one sentence each: `nodes[<i>].<field> must be ...` for a node whose `id` is not a string, whose `depends_on` is not a list of strings or whose `priority` is not an integer (named by position, never coerced), `duplicate node id <id>`, `node <id> depends on <dep>, which no node declares`, and `the graph has a cycle: <a> -> <b> -> <a>`. It reports what the graph permits and decides nothing about what may actually be dispatched together — `depends_on` encodes logical order, not file contention, so a batch this tool calls parallel may still hold work that writes one file. Pure computation over the nodes it was handed: it opens no file and takes no path. Never mutates.",
4
4
  "surfaces": [
5
5
  "mcp"
6
6
  ],
@@ -17,11 +17,18 @@
17
17
  "description": "The node's identifier, unique across `nodes`"
18
18
  },
19
19
  "depends_on": {
20
- "type": "array",
21
- "items": {
22
- "type": "string"
23
- },
24
- "description": "Ids this node waits for; every one of them must be declared by some node"
20
+ "anyOf": [
21
+ {
22
+ "type": "array",
23
+ "items": {
24
+ "type": "string"
25
+ }
26
+ },
27
+ {
28
+ "type": "null"
29
+ }
30
+ ],
31
+ "description": "Ids this node waits for; every one of them must be declared by some node. Optional: absent or null means none"
25
32
  },
26
33
  "priority": {
27
34
  "type": "integer",
@@ -29,8 +36,7 @@
29
36
  }
30
37
  },
31
38
  "required": [
32
- "id",
33
- "depends_on"
39
+ "id"
34
40
  ]
35
41
  }
36
42
  }
package/dist/cli.js CHANGED
@@ -497,8 +497,36 @@ function buildMemory(options) {
497
497
  checkStoreFlag(options.store);
498
498
  return new Memory(options.store, shared);
499
499
  }
500
+ if (options.start !== null)
501
+ checkStartFlag(options.start);
500
502
  return Memory.layered(options.start, shared);
501
503
  }
504
+ /**
505
+ * job65/#142: `--start` must name a directory that is there.
506
+ *
507
+ * WAS: unchecked. `pyResolve` of a path that does not exist answers the path anyway, the walk
508
+ * then climbed its nonexistent parents to the first real one and bound whatever store sat
509
+ * above it — a typo'd `--start` silently bound an ancestor's store as the WRITABLE project
510
+ * layer. Unlike `--store`, a missing `--start` is not a designation: nothing is ever created
511
+ * at a start directory, so a missing one can only be a mistake, and it is refused by name
512
+ * before a transport exists. Same `stat` and the same sentence family as `checkStoreFlag`.
513
+ */
514
+ function checkStartFlag(raw) {
515
+ const start = pyJoin(raw);
516
+ let isDir;
517
+ try {
518
+ isDir = pyStatIsDir(start);
519
+ }
520
+ catch (e) {
521
+ if (!(e instanceof PyOSError))
522
+ throw e;
523
+ if (e.name === 'FileNotFoundError')
524
+ throw new Refusal(`--start does not exist: ${start}`);
525
+ throw new Refusal(`--start is unreachable: ${start}: ${e.strerror}`);
526
+ }
527
+ if (!isDir)
528
+ throw new Refusal(`--start is not a directory: ${start}`);
529
+ }
502
530
  /**
503
531
  * `--store` may name a store that is missing, never one that is not a store.
504
532
  *
@@ -120,15 +120,6 @@ export declare function documentError(detail: unknown): string;
120
120
  * `Error`'s `message` is that string here, which is what `SkillAuditError` carries.
121
121
  */
122
122
  export declare function toolFailed(tool: string, detail: unknown): string;
123
- /**
124
- * `extract_json`: a fenced block if there is one, otherwise the first `{`/`[` onward.
125
- *
126
- * Both arms are here because both reach the model. The fence is `re.search` with `DOTALL`
127
- * and a non-greedy body, so it takes the FIRST fence and prose on either side is discarded;
128
- * with no fence, `find` takes the earliest brace or bracket — including one inside a string
129
- * of prose, which is faithful and occasionally wrong in exactly Python's way. `raw_decode`
130
- * then stops at the end of that one value, which is why `{"a": 1} — anything else?` parses.
131
- */
132
123
  export declare function extractJson(text: string): PyValue;
133
124
  /**
134
125
  * `schema_error`: a pointed validation error for `output`, or `null` if it satisfies
package/dist/contract.js CHANGED
@@ -35,8 +35,8 @@ import { BantamError } from './errors.js';
35
35
  import { pyStrip } from './memory/factfile.js';
36
36
  import { pyReadText } from './memory/pyfs.js';
37
37
  import { resolveImplicitTag } from './memory/pyyaml.js';
38
- import { fromJs, PyValueError, rawDecode } from './pyjson.js';
39
- import { absolutePath, validate } from './pyjsonschema.js';
38
+ import { fromJs, parseJson, PyValueError, rawDecode, reprValue } from './pyjson.js';
39
+ import { absolutePath, clipText, PyJsonSchemaRefusal, validate } from './pyjsonschema.js';
40
40
  /**
41
41
  * `contract.REQUIRED_KEYS`, in Python's order — the order the error message lists them.
42
42
  *
@@ -541,7 +541,8 @@ export function toolFailed(tool, detail) {
541
541
  * of prose, which is faithful and occasionally wrong in exactly Python's way. `raw_decode`
542
542
  * then stops at the end of that one value, which is why `{"a": 1} — anything else?` parses.
543
543
  */
544
- export function extractJson(text) {
544
+ /** `_unfenced`: the text `extract_json` searches — stripped, and the first fence's body if any. */
545
+ function unfenced(text) {
545
546
  let body = pyStrip(text);
546
547
  // `re.DOTALL` — JS needs `[\s\S]`, and `\s*` after the opening fence is Python's `\s*`.
547
548
  const fence = /```(?:json)?[\s\S]*?```/.exec(body);
@@ -549,6 +550,10 @@ export function extractJson(text) {
549
550
  const inner = /```(?:json)?\s*([\s\S]*?)```/.exec(body);
550
551
  body = pyStrip(inner[1]);
551
552
  }
553
+ return body;
554
+ }
555
+ export function extractJson(text) {
556
+ const body = unfenced(text);
552
557
  const startBrace = body.indexOf('{');
553
558
  const startBracket = body.indexOf('[');
554
559
  let start;
@@ -580,24 +585,58 @@ export function extractJson(text) {
580
585
  * loss.
581
586
  */
582
587
  export function schemaError(output, schema) {
588
+ const isValueError = (e) => e instanceof Error && (e.name === 'PyValueError' || e.name === 'PyJSONDecodeError');
583
589
  let data;
584
590
  try {
585
- data = extractJson(output);
591
+ // #152: a whole-text JSON document of any type first — `"x"`, `1.0` and `null` are JSON
592
+ // texts — and only then the object/array search `extract_json` does (`json.loads` then
593
+ // `extract_json` on the reference).
594
+ data = parseJson(unfenced(output));
586
595
  }
587
- catch (e) {
588
- // `except ValueError` — and `json.JSONDecodeError` IS a `ValueError`, which is how
589
- // CPython's decoder text reaches the model.
590
- if (e instanceof Error && (e.name === 'PyValueError' || e.name === 'PyJSONDecodeError')) {
591
- return parseErrorMessage(e);
596
+ catch (whole) {
597
+ if (!isValueError(whole))
598
+ throw whole;
599
+ try {
600
+ data = extractJson(output);
601
+ }
602
+ catch (e) {
603
+ // `except ValueError` — and `json.JSONDecodeError` IS a `ValueError`, which is how
604
+ // CPython's decoder text reaches the model.
605
+ if (isValueError(e))
606
+ return parseErrorMessage(e);
607
+ throw e;
592
608
  }
593
- throw e;
594
609
  }
595
610
  const asPy = isPyValue(schema) ? schema : fromJs(schema);
596
- const error = validate(data, asPy);
611
+ let error;
612
+ try {
613
+ error = validate(data, asPy);
614
+ }
615
+ catch (e) {
616
+ // A schema the validator will not resolve — a reference outside the one resource, a
617
+ // draft-3 `$schema` — is ANSWERED with its sentence, not raised (#125). The Python side
618
+ // returns the same sentence from `_unsupported_schema` and from the `referencing`
619
+ // exceptions, so `validate_json` says `{"valid": false, "feedback": …}` on both.
620
+ if (e instanceof PyJsonSchemaRefusal)
621
+ return e.message;
622
+ throw e;
623
+ }
597
624
  if (error === null)
598
625
  return null;
599
626
  const where = absolutePath(error).map(String).join('/') || 'root';
600
- return validationErrorMessage(where, error.message);
627
+ return validationErrorMessage(clipText(where), clipDetail(error.message, error.instance));
628
+ }
629
+ /**
630
+ * #153: the offending value quoted at most 200 codepoints, the sentence at most 1000 —
631
+ * `contract._clip_detail`. Every `repr` of the instance is clipped first, so a `maxLength`
632
+ * failure on a 1 MiB string still says `is too long`; the whole-sentence cap then bounds
633
+ * what a message quotes from PARTS of the instance (an unexpected property's name).
634
+ */
635
+ function clipDetail(message, instance) {
636
+ const quoted = reprValue(instance);
637
+ if ([...quoted].length > 200)
638
+ message = message.split(quoted).join(clipText(quoted));
639
+ return clipText(message, 1000);
601
640
  }
602
641
  function isPyValue(value) {
603
642
  return (typeof value === 'object' &&
@@ -98,7 +98,20 @@ export declare class EventLog {
98
98
  * two runtimes.
99
99
  */
100
100
  writeFailed: boolean;
101
- constructor(path: string | null, capBytes?: number, clock?: () => number);
101
+ /**
102
+ * job65/#154: the directory that must already exist before a record is written, or `null`.
103
+ *
104
+ * `fromEnv` sets it to the store root when the switch is `on` (the default file lives
105
+ * INSIDE the store). WAS: the `mkdir` in `append` brought a never-saved store into
106
+ * existence — `--store <absent>` plus one `work_plan` call left `<store>/events/mcp.jsonl`
107
+ * behind, and the next layered session walking up through it bound that directory as its
108
+ * store. The log does not create the store: while the store is not there, a record has
109
+ * nowhere to go and is DROPPED — not redirected, and not counted as `writeFailed`, because
110
+ * nothing was offered to the filesystem. The first save creates the store; records after it
111
+ * land. A literal path is the operator's own choice and keeps `requiresDir === null`.
112
+ */
113
+ readonly requiresDir: string | null;
114
+ constructor(path: string | null, capBytes?: number, clock?: () => number, requiresDir?: string | null);
102
115
  static fromEnv(storeRoot: string, env?: Record<string, string | undefined>, clock?: () => number): EventLog;
103
116
  get enabled(): boolean;
104
117
  /**
package/dist/eventlog.js CHANGED
@@ -58,7 +58,7 @@
58
58
  * notice — the failure mode a diagnostic can least afford.
59
59
  */
60
60
  import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
61
- import { cmpCodepoint, osErrorClassName, pyExists, PyOSError, pyJoin, pyName, pyParent } from './memory/pyfs.js';
61
+ import { cmpCodepoint, osErrorClassName, pyExists, pyIsDir, PyOSError, pyJoin, pyName, pyParent } from './memory/pyfs.js';
62
62
  import { bantamkitDirFor, ensureBantamkitGitignore } from './memory/store.js';
63
63
  /**
64
64
  * Environment switch. Unset or `off`/`0`/`false`/`no`/empty -> disabled. `on`/`1`/`true`/
@@ -185,13 +185,29 @@ export class EventLog {
185
185
  * two runtimes.
186
186
  */
187
187
  writeFailed = false;
188
- constructor(path, capBytes = CAP_BYTES, clock = defaultClock) {
188
+ /**
189
+ * job65/#154: the directory that must already exist before a record is written, or `null`.
190
+ *
191
+ * `fromEnv` sets it to the store root when the switch is `on` (the default file lives
192
+ * INSIDE the store). WAS: the `mkdir` in `append` brought a never-saved store into
193
+ * existence — `--store <absent>` plus one `work_plan` call left `<store>/events/mcp.jsonl`
194
+ * behind, and the next layered session walking up through it bound that directory as its
195
+ * store. The log does not create the store: while the store is not there, a record has
196
+ * nowhere to go and is DROPPED — not redirected, and not counted as `writeFailed`, because
197
+ * nothing was offered to the filesystem. The first save creates the store; records after it
198
+ * land. A literal path is the operator's own choice and keeps `requiresDir === null`.
199
+ */
200
+ requiresDir;
201
+ constructor(path, capBytes = CAP_BYTES, clock = defaultClock, requiresDir = null) {
189
202
  this.path = path;
190
203
  this.capBytes = capBytes;
191
204
  this.clock = clock;
205
+ this.requiresDir = requiresDir;
192
206
  }
193
207
  static fromEnv(storeRoot, env = process.env, clock = defaultClock) {
194
- return new EventLog(resolvePath(storeRoot, env[EVENT_LOG_ENV]), CAP_BYTES, clock);
208
+ const raw = env[EVENT_LOG_ENV];
209
+ const inStore = raw !== undefined && raw !== null && ON.has(raw.trim().toLowerCase());
210
+ return new EventLog(resolvePath(storeRoot, raw), CAP_BYTES, clock, inStore ? storeRoot : null);
195
211
  }
196
212
  get enabled() {
197
213
  return this.path !== null;
@@ -246,6 +262,10 @@ export class EventLog {
246
262
  * by construction, so that branch is a statement of intent, not a live path.
247
263
  */
248
264
  append(path, payload) {
265
+ // job65/#154: `pyIsDir` raises for EACCES exactly as `Path.is_dir()` does, and that lands
266
+ // in `record`'s catch as a lost record — the same verdict the reference reaches.
267
+ if (this.requiresDir !== null && !pyIsDir(this.requiresDir))
268
+ return;
249
269
  // AUDIT FINDING (J51-1, mirrored from `runtime-py/src/bantamkit/eventlog.py`): the
250
270
  // `mkdirSync` below can bring a whole `.bantamkit` directory into existence on its own
251
271
  // -- `BANTAMKIT_EVENT_LOG=on` with a project store that was never saved to reaches here