bantamkit-mcp 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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')
@@ -110,11 +161,21 @@ export class Memory {
110
161
  false,
111
162
  ]);
112
163
  }
113
- mem.layers.push([
114
- 'profile',
115
- new MemoryStore(profileStore(), { k, create: false, ...(today ? { today } : {}) }),
116
- false,
117
- ]);
164
+ // ONE DIRECTORY IS ONE LAYER. The walk above starts at the cwd and climbs, so a
165
+ // session with no `.bantamkit` anywhere above it resolves `~/.bantamkit/memory` —
166
+ // the profile store — as its PROJECT store. Binding that directory a second time
167
+ // gave `dream` the same store twice: every fact collided with itself, was merged
168
+ // into itself, and the "profile copy" that was archived was the same file. It
169
+ // archived 20 of 20 of the user's real facts on 2026-09-10. Such a session has one
170
+ // layer, and `dreamOutcome` already has a true thing to say about that.
171
+ const profileRoot = profileStore();
172
+ if (!sameDirectory(profileRoot, binding.path)) {
173
+ mem.layers.push([
174
+ 'profile',
175
+ new MemoryStore(profileRoot, { k, create: false, ...(today ? { today } : {}) }),
176
+ false,
177
+ ]);
178
+ }
118
179
  return mem;
119
180
  }
120
181
  /** The labels, in order. `_layers` is private in the reference too; this is for the tests. */
@@ -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.0",
4
4
  "description": "bantamkit MCP server — pure Node, runnable with npx",
5
5
  "type": "module",
6
6
  "bin": {