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 +77 -1
- package/dist/memory/component.d.ts +23 -6
- package/dist/memory/component.js +125 -13
- package/dist/memory/store.d.ts +29 -0
- package/dist/memory/store.js +47 -2
- package/dist/shiftwork.d.ts +4 -1
- package/dist/shiftwork.js +37 -3
- package/package.json +1 -1
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
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
|
144
|
-
*
|
|
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
|
/**
|
package/dist/memory/component.js
CHANGED
|
@@ -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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
98
|
-
*
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
614
|
-
`
|
|
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.`;
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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.
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
-
|
|
510
|
-
|
|
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'));
|
package/dist/shiftwork.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
*
|