bantamkit-mcp 0.31.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -80,7 +80,7 @@ import { packFileCount } from './mcp/identity.js';
80
80
  import { buildServer } from './mcp/server.js';
81
81
  import { RawStdioTransport } from './mcp/transport.js';
82
82
  import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs as parseWithSpec, pyIntStrict, } from './pyargparse.js';
83
- import { pyRepr } from './memory/pyfs.js';
83
+ import { pyJoin, PyOSError, pyRepr, pyStatIsDir } from './memory/pyfs.js';
84
84
  import { HOSTS, install as installHost, InstallError, thisCommand } from './hostinstall.js';
85
85
  import { runUpdate } from './selfupdate.js';
86
86
  import { statusLine } from './statusline.js';
@@ -330,10 +330,86 @@ function buildMemory(options) {
330
330
  if (options.store !== null) {
331
331
  if (options.store === '')
332
332
  throw new Refusal('--store requires a non-empty path');
333
+ checkStoreFlag(options.store);
333
334
  return new Memory(options.store, shared);
334
335
  }
335
336
  return Memory.layered(options.start, shared);
336
337
  }
338
+ /**
339
+ * `--store` may name a store that is missing, never one that is not a store.
340
+ *
341
+ * THIS CHECK IS RESTORED, NOT INVENTED, and the distinction is the point. Until the project
342
+ * layer was built lazily there WAS a `--store` validation, and it was entirely accidental: the
343
+ * constructor ran `pyMkdirParents`, so a `--store` pointing at a regular file or at a path
344
+ * under a directory that does not exist took the process down with a `PyOSError` stack out of
345
+ * `pyfs`. The reference's `test_statusline.py::
346
+ * test_the_flag_returns_before_anything_a_server_would_touch` depends on it — it arms
347
+ * `--store <a regular file>` as a trap and shows `--statusline` walking past it — and a lazy
348
+ * layer disarms the trap by making that same argv exit 0. The honest way to keep that test
349
+ * true is to mean the refusal on purpose.
350
+ *
351
+ * IT IS `layers.pinnedStore`'s CHECK MINUS ONE ARM, and the missing arm is deliberate and
352
+ * measured. The pin refuses a path that is not there ("nothing was created"); this flag does
353
+ * NOT, and must not, for two reasons that are both already pinned elsewhere in this
354
+ * repository:
355
+ *
356
+ * - The sibling CLI's identical flag is contracted to CREATE one. `tools/conformance/suites/
357
+ * memorycli.mjs` carries `status-creates-a-missing-store`, `--store {BED}/nowhere` over an
358
+ * empty bed, whose whole job is to say the two runtimes create the same two directories
359
+ * there. Refusing a missing `--store` here would put two flags of the same name, in two
360
+ * programs of the same product, in direct contradiction.
361
+ * - "Missing" is no longer a broken state anywhere in this program. The walk designates a
362
+ * project store without creating it, and the first save brings it into existence or refuses
363
+ * by name (`store.ensureDirs`). A `--store` at a path that is not there is that same
364
+ * designated state, reached by being told instead of by searching. MEASURED on the reference
365
+ * side: requiring existence reddened 14 tests that have nothing to do with this defect —
366
+ * `test_tool_manifest.py` ×7, `test_mcpserver.py` ×6, `test_mcp_endpoint.py` — every one of
367
+ * them a fixture naming a store under `tmp_path` it never made, because that is what this
368
+ * flag has always meant.
369
+ *
370
+ * What is left is the arm the accident actually covered and the only one it covered: a
371
+ * `--store` that names something which EXISTS and is NOT A DIRECTORY. That is not a store and
372
+ * never becomes one — `mkdir` under it is ENOTDIR on both runtimes and on every platform, the
373
+ * one row of the errno table where they already agree — so it is refused here, by name, before
374
+ * a transport exists.
375
+ *
376
+ * `FileNotFoundError` and not an errno comparison: the absent case is selected by the exception
377
+ * CPython raises for it, and `pyfs`' `PyOSError` carries that class as its `name`. Every other
378
+ * `stat` failure — a permission wall on the parent, a symlink loop — is a real fault about a
379
+ * path the operator named, and it keeps the pin's sentence.
380
+ *
381
+ * `pyStatIsDir` rather than an `existsSync`/`isDirectory` pair, and that too is `pinnedStore`'s
382
+ * reasoning: a predicate that swallows `PermissionError` and answers false would report an
383
+ * operator's real store as a typo. A single named path gets the accurate reason.
384
+ *
385
+ * `Refusal` and not `MemoryValidationError`, so that both halves of this flag's refusal family
386
+ * read the same way on the terminal: `--store requires a non-empty path` is already bare on
387
+ * stderr at exit 1, and the reference spells both as `SystemExit`.
388
+ */
389
+ function checkStoreFlag(raw) {
390
+ // NOT `pyExpanduser`, unlike the pin. `new Memory(store, ...)` builds the root from this
391
+ // string verbatim, so expanding here would check one path and serve another: `--store ~/x`
392
+ // left unexpanded by the shell would pass a check against the home directory and then build
393
+ // a store in a directory literally named `~`. The check must stat the path the store is
394
+ // going to be. `pyJoin` of the one argument is `Path(raw)`: the same normalisation
395
+ // `MemoryStore` applies to its root, so the path this stats and the path it names in a
396
+ // refusal are the path the store would have used.
397
+ const store = pyJoin(raw);
398
+ let isDir;
399
+ try {
400
+ isDir = pyStatIsDir(store);
401
+ }
402
+ catch (e) {
403
+ if (!(e instanceof PyOSError))
404
+ throw e;
405
+ // designated, not broken: the first save makes it or refuses by name
406
+ if (e.name === 'FileNotFoundError')
407
+ return;
408
+ throw new Refusal(`--store is unreachable: ${store}: ${e.strerror}; nothing was created`);
409
+ }
410
+ if (!isDir)
411
+ throw new Refusal(`--store is not a directory: ${store}`);
412
+ }
337
413
  /**
338
414
  * The version this build declares — the same bytes npm packs the tarball from.
339
415
  *
@@ -128,10 +128,14 @@ export declare class Memory {
128
128
  private readonly layers;
129
129
  private showLayers;
130
130
  /**
131
- * How this store came to be the store, captured once. It cannot be re-derived later: the
132
- * constructor above has already made the directory, so a store that was only DESIGNATED a
133
- * moment ago is indistinguishable on disk from one that was found empty. `null` means the
134
- * caller named the path outright and no resolution happened to report.
131
+ * How this store came to be the store, captured once. It is still captured here and not
132
+ * re-derived later, but the reason has changed with the constructor below and the old one is
133
+ * recorded rather than deleted: it WAS that the constructor had already made the directory,
134
+ * so a store that was only DESIGNATED a moment ago was indistinguishable on disk from one
135
+ * that was found empty. That complaint is now discharged — nothing is created — and the
136
+ * binding is kept because it also carries WHY this path (`origin`, `searchedFrom`), which no
137
+ * amount of looking at the disk recovers. `null` means the caller named the path outright
138
+ * and no resolution happened to report.
135
139
  */
136
140
  private readonly binding;
137
141
  constructor(store: string, options?: MemoryOptions, binding?: StoreBinding | null);
@@ -140,8 +144,15 @@ export declare class Memory {
140
144
  *
141
145
  * `resolveProjectStore` rather than `discoverProjectStore`: the path both return is the
142
146
  * same path, but only the binding carries WHY it is that path and whether it holds
143
- * anything — and `recall` cannot recover either fact afterwards, because constructing the
144
- * store creates the directory.
147
+ * anything — and `recall` cannot recover either fact afterwards.
148
+ *
149
+ * AMENDMENT 2026-09-12 (job48, J48-2), correcting the reason the clause above used to give
150
+ * and not the clause itself. WAS: "because constructing the store creates the directory."
151
+ * NOW that is false — the project layer is built `create: false` (see the constructor) and
152
+ * constructing it creates nothing. The binding is still the only carrier of WHY, because
153
+ * `origin` and `searchedFrom` were never on the disk to begin with; what the disk has
154
+ * stopped being able to answer is merely "was this empty or brand new", which it now answers
155
+ * correctly for the first time.
145
156
  */
146
157
  static layered(start?: string | null, options?: MemoryOptions): Memory;
147
158
  /** The labels, in order. `_layers` is private in the reference too; this is for the tests. */
@@ -280,6 +291,12 @@ export declare class Memory {
280
291
  * at construction and has been saved to since really can answer. The DIAGNOSIS is keyed on
281
292
  * the binding, because "designated" stops being visible on disk the moment `MemoryStore`
282
293
  * creates the directory.
294
+ *
295
+ * AMENDMENT 2026-09-12 (job48, J48-2). The reason in the clause above no longer holds and
296
+ * the keying does: the project layer is built `create: false`, so a designated store IS now
297
+ * distinguishable on disk — it is absent. The DIAGNOSIS still keys on the binding because
298
+ * only the binding carries `origin` and `searchedFrom`, which the disk never held, and
299
+ * because an absent directory and an emptied one must not be told apart by a race.
283
300
  */
284
301
  private nothingToReport;
285
302
  /**
@@ -25,6 +25,8 @@
25
25
  * lines carry a `[project] ` tag that the `--store` form never emits. A port validated
26
26
  * against only one of them ships a string the deployment never produces.
27
27
  */
28
+ import { realpathSync } from 'node:fs';
29
+ import { resolve } from 'node:path';
28
30
  import { BantamError } from '../errors.js';
29
31
  import { pyStrip } from './factfile.js';
30
32
  import { countFacts, loadGrants, MEMORY_DIR_ENV, PROJECT_STORE, resolveProjectStore, } from './layers.js';
@@ -60,6 +62,55 @@ export function normalizeName(name) {
60
62
  export function profileStore() {
61
63
  return pyJoin(pyHome(), ...PROJECT_STORE);
62
64
  }
65
+ /**
66
+ * Whether two paths name ONE directory, symlinks and `/var` vs `/private/var` included.
67
+ *
68
+ * Port of `_same_directory` (`runtime-py/src/bantamkit/memory/component.py`, J47-1,
69
+ * 0844ccb). Realpath, not string equality, and that distinction is measured rather than
70
+ * tidy: on macOS the walk up from a cwd under `/var` returns `/private/var/...` while
71
+ * `pyHome()` returns `/var/...`, so two spellings of one directory compare unequal as
72
+ * strings. The Stop hook's `samePath` (`tools/hooks/bantamkit-hook.mjs`) compares the same
73
+ * two roots the same way and for the same reason — it carried the identical defect, and
74
+ * J47-7 (`a109f99`) brought it to the same `realpathSync.native`, following this function
75
+ * rather than leading it. When the resolution itself
76
+ * fails, an absolute-path comparison is the honest fallback: it can only under-report a
77
+ * match, never invent one.
78
+ *
79
+ * AMENDED 2026-09-11 (J47-3B). The paragraph above stands as the reason this function
80
+ * resolves rather than compares strings, and the fallback's behaviour is unchanged. Two
81
+ * things it says are now known to be wrong about `fs.realpathSync` in particular, and
82
+ * measured: (1) it is NOT the twin of `os.path.realpath`. `realpathSync` hands its argument
83
+ * to `path.resolve` before it resolves anything, and `path.resolve` pops `..` LEXICALLY —
84
+ * before the symlink in front of it is followed. `os.path.realpath` and the kernel pop it
85
+ * AFTER. So a `HOME` spelled `<bed>/link/..`, where `link -> <bed>/deep/real`, is
86
+ * `<bed>/deep` to the reference and `<bed>` to Node: `realpathSync` then threw ENOENT on a
87
+ * directory that is there, the fallback under-reported, and `layered` bound one directory as
88
+ * two layers — the 2026-09-10 self-merge, still live on this side until now. (2) "it can
89
+ * only under-report a match, never invent one" was offered as reassurance, and
90
+ * under-reporting is the DANGEROUS direction here: a missed match IS the duplicate layer.
91
+ *
92
+ * The mechanism is now `realpathSync.native` — libuv's `uv_fs_realpath`, i.e. the platform's
93
+ * own `realpath(3)` / `GetFinalPathNameByHandle` — chosen because it pops `..` in the
94
+ * kernel's order and therefore agrees with `os.path.realpath` byte for byte on that bed,
95
+ * because it keeps the macOS `/var` vs `/private/var` resolution the original was written
96
+ * for, and because it adds no path arithmetic of this port's own. `statSync` identity
97
+ * (`dev` + `ino`) was the other candidate and was not taken: `ino` is not dependable on
98
+ * every Windows filesystem, and this runtime has to answer the same on Windows.
99
+ *
100
+ * The fallback stays `resolve(a) === resolve(b)`, and the under-report it can still produce
101
+ * is provably harmless where `layered` calls this: the native call throws only for a path
102
+ * that is not on disk, and `binding.path` always is — `layered` constructs that store, which
103
+ * creates it, before the guard runs — so a throwing side is a directory that does not exist,
104
+ * and that cannot be the same directory as one that does.
105
+ */
106
+ function sameDirectory(a, b) {
107
+ try {
108
+ return realpathSync.native(a) === realpathSync.native(b);
109
+ }
110
+ catch {
111
+ return resolve(a) === resolve(b);
112
+ }
113
+ }
63
114
  /** The name a layer answers under: the PROJECT directory, not the store directory. */
64
115
  export function layerLabel(root) {
65
116
  if (pyName(pyParent(root)) === '.bantamkit')
@@ -72,17 +123,45 @@ export class Memory {
72
123
  layers;
73
124
  showLayers = false;
74
125
  /**
75
- * How this store came to be the store, captured once. It cannot be re-derived later: the
76
- * constructor above has already made the directory, so a store that was only DESIGNATED a
77
- * moment ago is indistinguishable on disk from one that was found empty. `null` means the
78
- * caller named the path outright and no resolution happened to report.
126
+ * How this store came to be the store, captured once. It is still captured here and not
127
+ * re-derived later, but the reason has changed with the constructor below and the old one is
128
+ * recorded rather than deleted: it WAS that the constructor had already made the directory,
129
+ * so a store that was only DESIGNATED a moment ago was indistinguishable on disk from one
130
+ * that was found empty. That complaint is now discharged — nothing is created — and the
131
+ * binding is kept because it also carries WHY this path (`origin`, `searchedFrom`), which no
132
+ * amount of looking at the disk recovers. `null` means the caller named the path outright
133
+ * and no resolution happened to report.
79
134
  */
80
135
  binding;
81
136
  constructor(store, options = {}, binding = null) {
82
137
  const k = options.k ?? 3;
138
+ // `create: false`, AND IT IS THE WHOLE OF THE cwd-`/` FIX. This was the last eager layer —
139
+ // grants and the profile store have always been lazy — so a cwd that no directory can be
140
+ // created under was a STARTUP CRASH: every GUI MCP host launches its child with cwd `/`,
141
+ // measured live on this machine (`lsof -p <pid> -a -d cwd` on Claude Desktop and all four
142
+ // of its `bantamkit-mcp` children returns `/`), and the client saw only CONNECTION_CLOSED.
143
+ // Refusing at startup with a sentence was the other candidate and it is REFUTED by
144
+ // measurement: it leaves those same four processes dead, which is strictly worse than
145
+ // today. Built lazily, the server starts from `/`, binds `['project', 'profile']`, and
146
+ // answers out of the profile layer. The sentence still exists — it is what
147
+ // `MemoryStore.save`/`compact` say when a write actually needs the directory
148
+ // (`store.ensureDirs`).
149
+ //
150
+ // It is legal because every READ is already defined over an absent directory:
151
+ // `recall() -> []`, `archived() -> []`, `indexText() -> ''`. `facts()`' docstring says so
152
+ // outright, and `create: false` is one of the three callers it names.
153
+ //
154
+ // THE PRICE, stated rather than discovered: a bare server start in a perfectly good cwd no
155
+ // longer scatters `.bantamkit/memory/{facts,archive}` there before anything is saved. That
156
+ // moves TOWARD a stance this file already holds — see the binding comment above, and
157
+ // `cli.ts`'s bare-TTY guard.
158
+ //
159
+ // The reference is `component.py`'s `Memory.__init__` (job48, J48-1); this is the same
160
+ // change in the same place, and the two sentences below are spelled byte for byte.
83
161
  this.store = new MemoryStore(store, {
84
162
  indexBudget: options.indexBudget ?? DEFAULT_INDEX_BUDGET,
85
163
  k,
164
+ create: false,
86
165
  ...(options.today ? { today: options.today } : {}),
87
166
  });
88
167
  this.k = k;
@@ -94,8 +173,15 @@ export class Memory {
94
173
  *
95
174
  * `resolveProjectStore` rather than `discoverProjectStore`: the path both return is the
96
175
  * same path, but only the binding carries WHY it is that path and whether it holds
97
- * anything — and `recall` cannot recover either fact afterwards, because constructing the
98
- * store creates the directory.
176
+ * anything — and `recall` cannot recover either fact afterwards.
177
+ *
178
+ * AMENDMENT 2026-09-12 (job48, J48-2), correcting the reason the clause above used to give
179
+ * and not the clause itself. WAS: "because constructing the store creates the directory."
180
+ * NOW that is false — the project layer is built `create: false` (see the constructor) and
181
+ * constructing it creates nothing. The binding is still the only carrier of WHY, because
182
+ * `origin` and `searchedFrom` were never on the disk to begin with; what the disk has
183
+ * stopped being able to answer is merely "was this empty or brand new", which it now answers
184
+ * correctly for the first time.
99
185
  */
100
186
  static layered(start, options = {}) {
101
187
  const binding = resolveProjectStore(start);
@@ -110,11 +196,21 @@ export class Memory {
110
196
  false,
111
197
  ]);
112
198
  }
113
- mem.layers.push([
114
- 'profile',
115
- new MemoryStore(profileStore(), { k, create: false, ...(today ? { today } : {}) }),
116
- false,
117
- ]);
199
+ // ONE DIRECTORY IS ONE LAYER. The walk above starts at the cwd and climbs, so a
200
+ // session with no `.bantamkit` anywhere above it resolves `~/.bantamkit/memory` —
201
+ // the profile store — as its PROJECT store. Binding that directory a second time
202
+ // gave `dream` the same store twice: every fact collided with itself, was merged
203
+ // into itself, and the "profile copy" that was archived was the same file. It
204
+ // archived 20 of 20 of the user's real facts on 2026-09-10. Such a session has one
205
+ // layer, and `dreamOutcome` already has a true thing to say about that.
206
+ const profileRoot = profileStore();
207
+ if (!sameDirectory(profileRoot, binding.path)) {
208
+ mem.layers.push([
209
+ 'profile',
210
+ new MemoryStore(profileRoot, { k, create: false, ...(today ? { today } : {}) }),
211
+ false,
212
+ ]);
213
+ }
118
214
  return mem;
119
215
  }
120
216
  /** The labels, in order. `_layers` is private in the reference too; this is for the tests. */
@@ -505,6 +601,12 @@ export class Memory {
505
601
  * at construction and has been saved to since really can answer. The DIAGNOSIS is keyed on
506
602
  * the binding, because "designated" stops being visible on disk the moment `MemoryStore`
507
603
  * creates the directory.
604
+ *
605
+ * AMENDMENT 2026-09-12 (job48, J48-2). The reason in the clause above no longer holds and
606
+ * the keying does: the project layer is built `create: false`, so a designated store IS now
607
+ * distinguishable on disk — it is absent. The DIAGNOSIS still keys on the binding because
608
+ * only the binding carries `origin` and `searchedFrom`, which the disk never held, and
609
+ * because an absent directory and an emptied one must not be told apart by a race.
508
610
  */
509
611
  nothingToReport() {
510
612
  let status;
@@ -609,9 +711,19 @@ export class Memory {
609
711
  where = `The project store ${root} is empty.`;
610
712
  }
611
713
  else if (binding.state === 'designated') {
714
+ // AMENDED 2026-09-12 (job48, J48-2) because the sentence became FALSE, not because it
715
+ // read badly. WAS: "No memory store existed at or above <from>, so the empty <root> was
716
+ // created for this session." Under a lazily-built project layer nothing is created — the
717
+ // path is designated and stays absent until a save needs it — so the old sentence told
718
+ // an operator to go look for a directory that is not there. The new one borrows the
719
+ // pin's own closing clause, "nothing was created", because it is the same claim about
720
+ // the same thing, and it reuses the binding's existing `designated` vocabulary rather
721
+ // than minting a word for it. The remedy that follows — "otherwise save a memory to
722
+ // start this one" — is now literally true: the save is what brings the directory into
723
+ // existence. Byte for byte the reference's, `component.py`'s `_binding_diagnosis`.
612
724
  where =
613
- `No memory store existed at or above ${binding.searchedFrom}, so the ` +
614
- `empty ${root} was created for this session.`;
725
+ `No memory store existed at or above ${binding.searchedFrom}, so ${root} ` +
726
+ `was designated for this session; nothing was created there.`;
615
727
  }
616
728
  else if (binding.origin === 'pin') {
617
729
  where = `The project store ${root} is empty; ${MEMORY_DIR_ENV} pinned it.`;
@@ -213,6 +213,35 @@ export declare class MemoryStore {
213
213
  readonly k: number;
214
214
  private readonly today;
215
215
  constructor(root: string, options?: MemoryStoreOptions);
216
+ /**
217
+ * Bring the store's directories into existence, or refuse the write that needs them.
218
+ *
219
+ * THE PROPERTY: a store that cannot be brought into existence cannot be written to, and the
220
+ * refusal names which directory and why. It is a refusal and not a crash because the only
221
+ * callers are the two WRITES (`save`, `compact`); every read is already defined over an
222
+ * absent directory — `facts()`' own docstring rules it, "AN ABSENT DIRECTORY IS `[]`, NOT AN
223
+ * ERROR" — which is what makes a lazily-built project layer legal at all.
224
+ *
225
+ * THE PREDICATE IS THE OUTCOME AND NEVER AN ERRNO, and that is measured rather than tidy.
226
+ * One cwd, `/`, gives CPython EROFS(30) at `/.bantamkit` and Node ENOENT(-2) at
227
+ * `/.bantamkit/memory/facts`, because Node's recursive mkdir does not pass EROFS through and
228
+ * stats the missing path instead — re-measured here at cwd `/`,
229
+ * `mkdirSync('/.bantamkit', {recursive: true})` is ENOENT(-2) where `mkdirSync('/.bantamkit')`
230
+ * is EROFS(-30); a `chmod 555` directory gives EACCES(13) on both; and on Linux `/` is a
231
+ * writable root owned by root, so the same cwd gives EACCES there. Three platforms, three
232
+ * numbers, one fact — the directory could not be made — so the `catch` is the whole of
233
+ * `PyOSError`, which is this runtime's `OSError`, and the sentence carries no errno. A fix
234
+ * keyed on the number would have been green on Linux CI and wrong on the machine the bug was
235
+ * reported from, and on THIS side it would additionally have been keyed on a number libuv
236
+ * invented rather than on the one the filesystem returned.
237
+ *
238
+ * THE SENTENCE NAMES `this.root` AND NOTHING DEEPER, for the same reason. The two runtimes
239
+ * fail at different components of the same path (`/.bantamkit` against
240
+ * `/.bantamkit/memory/facts`) and report different strerrors for it, so a sentence carrying
241
+ * the failing leaf, or that strerror, would be a divergence manufactured by mkdir's
242
+ * internals. `this.root` is the path the caller named and both sides agree on it, which is
243
+ * what lets one conformance case pin this line byte for byte.
244
+ */
216
245
  private ensureDirs;
217
246
  /**
218
247
  * Write a fact, or refuse it as a near-duplicate of one already here.
@@ -505,9 +505,46 @@ export class MemoryStore {
505
505
  if (options.create ?? true)
506
506
  this.ensureDirs();
507
507
  }
508
+ /**
509
+ * Bring the store's directories into existence, or refuse the write that needs them.
510
+ *
511
+ * THE PROPERTY: a store that cannot be brought into existence cannot be written to, and the
512
+ * refusal names which directory and why. It is a refusal and not a crash because the only
513
+ * callers are the two WRITES (`save`, `compact`); every read is already defined over an
514
+ * absent directory — `facts()`' own docstring rules it, "AN ABSENT DIRECTORY IS `[]`, NOT AN
515
+ * ERROR" — which is what makes a lazily-built project layer legal at all.
516
+ *
517
+ * THE PREDICATE IS THE OUTCOME AND NEVER AN ERRNO, and that is measured rather than tidy.
518
+ * One cwd, `/`, gives CPython EROFS(30) at `/.bantamkit` and Node ENOENT(-2) at
519
+ * `/.bantamkit/memory/facts`, because Node's recursive mkdir does not pass EROFS through and
520
+ * stats the missing path instead — re-measured here at cwd `/`,
521
+ * `mkdirSync('/.bantamkit', {recursive: true})` is ENOENT(-2) where `mkdirSync('/.bantamkit')`
522
+ * is EROFS(-30); a `chmod 555` directory gives EACCES(13) on both; and on Linux `/` is a
523
+ * writable root owned by root, so the same cwd gives EACCES there. Three platforms, three
524
+ * numbers, one fact — the directory could not be made — so the `catch` is the whole of
525
+ * `PyOSError`, which is this runtime's `OSError`, and the sentence carries no errno. A fix
526
+ * keyed on the number would have been green on Linux CI and wrong on the machine the bug was
527
+ * reported from, and on THIS side it would additionally have been keyed on a number libuv
528
+ * invented rather than on the one the filesystem returned.
529
+ *
530
+ * THE SENTENCE NAMES `this.root` AND NOTHING DEEPER, for the same reason. The two runtimes
531
+ * fail at different components of the same path (`/.bantamkit` against
532
+ * `/.bantamkit/memory/facts`) and report different strerrors for it, so a sentence carrying
533
+ * the failing leaf, or that strerror, would be a divergence manufactured by mkdir's
534
+ * internals. `this.root` is the path the caller named and both sides agree on it, which is
535
+ * what lets one conformance case pin this line byte for byte.
536
+ */
508
537
  ensureDirs() {
509
- pyMkdirParents(pyJoin(this.root, 'facts'));
510
- pyMkdirParents(pyJoin(this.root, 'archive'));
538
+ try {
539
+ pyMkdirParents(pyJoin(this.root, 'facts'));
540
+ pyMkdirParents(pyJoin(this.root, 'archive'));
541
+ }
542
+ catch (e) {
543
+ if (!(e instanceof PyOSError))
544
+ throw e;
545
+ throw new MemoryValidationError(`memory store could not be created: ${this.root}; the directory is not` +
546
+ ' there and this filesystem would not make it, so nothing was written');
547
+ }
511
548
  }
512
549
  // ---- ops ----
513
550
  /**
@@ -742,6 +779,14 @@ export class MemoryStore {
742
779
  */
743
780
  compact(reserve = null) {
744
781
  const facts = this.facts();
782
+ // SECOND, NOT FIRST, and the order is the reference's own ruling kept intact: the listing
783
+ // still refuses an unreadable store before anything here moves a file. What this line adds
784
+ // is the other half of the same stance — this method WRITES (every `pyReplace` below lands
785
+ // in `archive/`, and `rebuildIndex` writes `index.md` into the root whether or not a single
786
+ // fact was archived), so a root that does not exist and cannot be made is refused here, by
787
+ // name, instead of surfacing as a `FileNotFoundError` out of the final write. `exist_ok`
788
+ // semantics make it free for every store that is already there.
789
+ this.ensureDirs();
745
790
  const sizes = new Map();
746
791
  for (const fact of facts) {
747
792
  sizes.set(pyHashKey(fact.name), Buffer.byteLength(this.indexLine(fact), 'utf8'));
@@ -37,7 +37,10 @@ export declare function clockIn(checkpoint: string): PyValue;
37
37
  * `unitId` must name the cursor unit — the contract is execute-the-cursor (driver parity),
38
38
  * never pick-a-unit. When `job.roles` names the unit's role, `accounting.model` must be one
39
39
  * of that role's models, spelled exactly; a wrong or missing model is refused here, before
40
- * any mutation and before the accounting line. Mutations: set the unit's status, advance
40
+ * any mutation and before the accounting line. Extended 2026-09-11 (job47): so is a
41
+ * `job.roles` value for that role that is not a list of model identifiers — an unreadable
42
+ * declaration allows no model, and it is refused in the same place, by the same structured
43
+ * return, writing nothing. Mutations: set the unit's status, advance
41
44
  * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
42
45
  * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
43
46
  *
package/dist/shiftwork.js CHANGED
@@ -39,7 +39,11 @@
39
39
  * therefore pinned as per-side literals in each runtime's own tests. The check runs at
40
40
  * validation time — after the cursor check, before the first mutation, and so before the
41
41
  * log-then-commit pair below — because a refusal taken after the append would leave an
42
- * orphan accounting line claiming a model that was rejected.
42
+ * orphan accounting line claiming a model that was rejected. J47-5: a role the map DOES
43
+ * name whose value is not a list of model identifiers is refused there too — an unreadable
44
+ * declaration allows no model, and the refusal is the same structured one, taken in the
45
+ * same place. That makes three refusal strings, not two, and the third is likewise a
46
+ * per-side literal.
43
47
  *
44
48
  * WHAT IS NOT PORTED, DELIBERATELY. The `depends_on` field is ignored on cursor advance
45
49
  * (v1-linear, the Python module's own ruling), there is no lock (the MCP topology has one
@@ -252,6 +256,25 @@ function pyFormat(value) {
252
256
  * string and the sentence says so. Reachable, and therefore measured: `BANTAMKIT_ASSETS` is
253
257
  * honoured by both runtimes, so the tests and the conformance corpus drive this branch
254
258
  * through a pack whose schema has lost `minItems`.
259
+ *
260
+ * AND A DECLARATION THIS CODE CANNOT READ IS NOT A LICENCE (J47-4, ported here as J47-5).
261
+ * The same ruling, one step further out: a role the map names is constrained by what it
262
+ * NAMES, and a value that is not a list of model identifiers names nothing, so it allows
263
+ * nothing. This port failed open five ways and mangled the declaration two more. MEASURED
264
+ * end to end under a pack whose `job.roles.additionalProperties` is `true`, with
265
+ * `model: 'haiku'`: a string, a dict, a number, a null and a bool all came back
266
+ * `{"result":"ok"}` with the status set, the cursor advanced and an accounting line
267
+ * written — the clock-out COMPLETED — while `[5]` and `['ok', null]` refused with the
268
+ * unreadable value rendered into the sentence as `5` and `ok, None`. A list holding a
269
+ * non-string is the shape a bare kind test cannot catch, which is why the property is
270
+ * LIST OF STRINGS and not merely list; the reference raised `TypeError: sequence item 0`
271
+ * on exactly that input. Fail CLOSED, through the same structured `_error` return, saying
272
+ * only what is true — the declaration cannot be read, therefore no model is allowed —
273
+ * naming no type (a Python type name would not port: `int` against `number`) and
274
+ * rendering no part of the value. ONE sentence for both accounting shapes: which model
275
+ * was reported cannot matter when the declaration that would judge it is unreadable.
276
+ * `[]` is untouched by this — an empty list IS a list of model identifiers and keeps the
277
+ * sentence J46-10 pinned.
255
278
  */
256
279
  function modelRefusal(document, unitId, unit, accounting) {
257
280
  const role = text(field(unit, 'role'));
@@ -259,8 +282,16 @@ function modelRefusal(document, unitId, unit, accounting) {
259
282
  const allowed = roles === undefined || roles.t !== 'dict' ? undefined : roles.v.get(role);
260
283
  // `role not in roles`. The `t !== 'list'` arm is a TYPE guard and not a policy: the schema
261
284
  // pins the value to an array, so the only way past it is a document no read would accept.
262
- if (allowed === undefined || allowed.t !== 'list')
285
+ // AMENDED 2026-09-11 (J47-5): the two lines above stand as written and are now FALSE as a
286
+ // description of this code. A document no read would accept is exactly what a shared asset
287
+ // drifting produces, the arm answered `unconstrained` for every such document and the
288
+ // clock-out completed, and `t === 'list'` was never the property anyway — `['ok', null]`
289
+ // passed the kind test and mangled `None` into the sentence. The arm below is a POLICY.
290
+ if (allowed === undefined)
263
291
  return null;
292
+ if (allowed.t !== 'list' || !allowed.v.every((model) => model.t === 'str')) {
293
+ return `unit ${unitId} in role ${role} cannot clock out: job.roles.${role} is not a list of model identifiers, so it allows no model`;
294
+ }
264
295
  const names = allowed.v.map((model) => pyFormat(model)).join(', ');
265
296
  const offered = asPatch(accounting).get('model');
266
297
  if (offered === undefined || offered.t === 'null') {
@@ -280,7 +311,10 @@ function modelRefusal(document, unitId, unit, accounting) {
280
311
  * `unitId` must name the cursor unit — the contract is execute-the-cursor (driver parity),
281
312
  * never pick-a-unit. When `job.roles` names the unit's role, `accounting.model` must be one
282
313
  * of that role's models, spelled exactly; a wrong or missing model is refused here, before
283
- * any mutation and before the accounting line. Mutations: set the unit's status, advance
314
+ * any mutation and before the accounting line. Extended 2026-09-11 (job47): so is a
315
+ * `job.roles` value for that role that is not a list of model identifiers — an unreadable
316
+ * declaration allows no model, and it is refused in the same place, by the same structured
317
+ * return, writing nothing. Mutations: set the unit's status, advance
284
318
  * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
285
319
  * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
286
320
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bantamkit-mcp",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
4
4
  "description": "bantamkit MCP server — pure Node, runnable with npx",
5
5
  "type": "module",
6
6
  "bin": {