bantamkit-mcp 0.35.7 → 0.35.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +24 -9
  2. package/assets/tools/memory_compact.json +1 -2
  3. package/assets/tools/memory_recall.json +1 -3
  4. package/assets/tools/shiftwork_clock_in.json +1 -1
  5. package/assets/tools/shiftwork_clock_out.json +1 -1
  6. package/assets/tools/validate_json.json +1 -1
  7. package/assets/tools/work_plan.json +14 -8
  8. package/dist/assets.js +28 -4
  9. package/dist/cli.js +83 -24
  10. package/dist/contract.d.ts +0 -9
  11. package/dist/contract.js +51 -12
  12. package/dist/eventlog.d.ts +22 -2
  13. package/dist/eventlog.js +30 -3
  14. package/dist/hookadapter.js +263 -43
  15. package/dist/hostinstall.d.ts +2 -2
  16. package/dist/hostinstall.js +318 -84
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +1 -1
  19. package/dist/mcp/identity.js +27 -1
  20. package/dist/mcp/pyargs.d.ts +22 -6
  21. package/dist/mcp/pyargs.js +113 -39
  22. package/dist/mcp/server.js +177 -41
  23. package/dist/mcp/status.js +11 -4
  24. package/dist/mcp/transport.d.ts +51 -6
  25. package/dist/mcp/transport.js +217 -26
  26. package/dist/mcpreport.d.ts +12 -0
  27. package/dist/mcpreport.js +67 -15
  28. package/dist/memory/cli.js +18 -3
  29. package/dist/memory/component.d.ts +17 -0
  30. package/dist/memory/component.js +51 -7
  31. package/dist/memory/factfile.d.ts +21 -3
  32. package/dist/memory/factfile.js +351 -12
  33. package/dist/memory/layers.d.ts +19 -0
  34. package/dist/memory/layers.js +102 -2
  35. package/dist/memory/pyyaml.d.ts +6 -4
  36. package/dist/memory/pyyaml.js +6 -4
  37. package/dist/memory/store.d.ts +82 -7
  38. package/dist/memory/store.js +277 -29
  39. package/dist/npminstall.d.ts +18 -0
  40. package/dist/npminstall.js +26 -5
  41. package/dist/pyargparse.js +48 -2
  42. package/dist/pyjsonschema.d.ts +85 -4
  43. package/dist/pyjsonschema.js +1812 -181
  44. package/dist/selfupdate.d.ts +112 -32
  45. package/dist/selfupdate.js +221 -79
  46. package/dist/shiftwork.d.ts +46 -7
  47. package/dist/shiftwork.js +246 -42
  48. package/dist/tokenledger.js +20 -4
  49. package/dist/updatecheck.d.ts +15 -3
  50. package/dist/updatecheck.js +23 -8
  51. package/dist/workplan.d.ts +8 -3
  52. package/dist/workplan.js +62 -3
  53. package/package.json +2 -2
@@ -12,9 +12,10 @@
12
12
  * never `JSON.stringify`. Python writes `{"args": [], "command": "OLD"}` and
13
13
  * `JSON.stringify` writes `{"args":[],"command":"OLD"}` — same object, different
14
14
  * bytes, and the separators are the whole difference.
15
- * 2. The file is written with the same helper at `indent: 2`, which also reproduces
16
- * `ensure_ascii`. A home directory with a non-ASCII name would otherwise make the two
17
- * runtimes write different bytes for the same install.
15
+ * 2. The file is written through `hostJsonText`, which reproduces the reference's
16
+ * `json.dumps(data, indent=<the file's own unit>, ensure_ascii=False)` (#118): the user's
17
+ * Thai text stays Thai, their 4-space or tab indent stays theirs, and the two runtimes
18
+ * still write identical bytes — `install-host-text` in the install suite compares them.
18
19
  *
19
20
  * WHAT IS RULED DIFFERENT, and it is one field: the COMMAND. This side records
20
21
  * `<absolute node> <absolute .../dist/cli.js>`; the reference records its own console script.
@@ -32,14 +33,14 @@
32
33
  * launches it without the network. See `thisCommand`.
33
34
  */
34
35
  import { spawnSync } from 'node:child_process';
35
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
36
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
36
37
  import { homedir } from 'node:os';
37
- import { dirname, join } from 'node:path';
38
+ import { dirname, join, resolve } from 'node:path';
38
39
  import { fileURLToPath } from 'node:url';
39
40
  import { currentInstall, Undetermined } from './mcp/identity.js';
40
41
  import { bantamkitDirFor, ensureBantamkitGitignore } from './memory/store.js';
41
42
  import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin } from './npminstall.js';
42
- import { dumpJson, fromJs } from './pyjson.js';
43
+ import { dumpJson, fromJs, parseJson, PyJSONDecodeError } from './pyjson.js';
43
44
  export { keptCli, keptPrefix };
44
45
  /** The four hosts, in the order they print in `--help`. */
45
46
  export const HOSTS = ['claude', 'claude-desktop', 'copilot', 'cursor'];
@@ -49,6 +50,7 @@ export const ENTRY = 'bantamkit';
49
50
  export class InstallError extends Error {
50
51
  }
51
52
  const dumps = (value, indent = null, sortKeys = false) => dumpJson(fromJs(value), { indent, sortKeys });
53
+ const pyDict = (entries) => ({ t: 'dict', v: new Map(entries) });
52
54
  /**
53
55
  * The file `--install <host>` would write, on THIS platform.
54
56
  *
@@ -100,25 +102,64 @@ export function entryFor(host, command, args) {
100
102
  * `[1, 2, 3]`, the ONE input where the two vocabularies happen to agree, so neither suite
101
103
  * could see it. Review measured `"hello"` and `5`.
102
104
  *
103
- * `int` vs `float` follows `json`'s own split: a JSON number with no fraction and no exponent
104
- * is decoded by `int`, everything else by `float`.
105
+ * Since #118 the value is tagged by the same split `json` makes — a JSON number with no
106
+ * fraction and no exponent is `int`, everything else `float` — so the tag IS the name.
105
107
  */
106
108
  function pyTypeName(value) {
107
- if (value === null)
108
- return 'NoneType';
109
- if (Array.isArray(value))
110
- return 'list';
111
- if (typeof value === 'string')
112
- return 'str';
113
- if (typeof value === 'boolean')
114
- return 'bool';
115
- if (typeof value === 'number')
116
- return Number.isInteger(value) ? 'int' : 'float';
117
- return typeof value;
109
+ switch (value.t) {
110
+ case 'null':
111
+ return 'NoneType';
112
+ case 'bool':
113
+ return 'bool';
114
+ case 'int':
115
+ return 'int';
116
+ case 'float':
117
+ return 'float';
118
+ case 'str':
119
+ return 'str';
120
+ case 'list':
121
+ return 'list';
122
+ case 'dict':
123
+ return 'dict';
124
+ }
125
+ }
126
+ /**
127
+ * The first non-finite float in `value`, with where it sits, or `null`. Depth-first, in
128
+ * document order, so both runtimes name the same number when a file holds two.
129
+ */
130
+ function nonFiniteIn(value, trail) {
131
+ if (value.t === 'float' && !Number.isFinite(value.v))
132
+ return { where: whereIs(trail), value: value.v };
133
+ if (value.t === 'dict') {
134
+ for (const [key, item] of value.v) {
135
+ const found = nonFiniteIn(item, [...trail, key]);
136
+ if (found !== null)
137
+ return found;
138
+ }
139
+ }
140
+ else if (value.t === 'list') {
141
+ for (const [index, item] of value.v.entries()) {
142
+ const found = nonFiniteIn(item, [...trail, index]);
143
+ if (found !== null)
144
+ return found;
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+ /** `mcpServers.other.huge`, `servers.list[2]`: keys joined by `.`, indexes in brackets. */
150
+ function whereIs(trail) {
151
+ let out = '';
152
+ for (const segment of trail) {
153
+ if (typeof segment === 'number')
154
+ out += `[${segment}]`;
155
+ else
156
+ out += out === '' ? segment : `.${segment}`;
157
+ }
158
+ return out === '' ? 'the top level' : out;
118
159
  }
119
160
  function readConfig(path) {
120
161
  if (!existsSync(path))
121
- return {};
162
+ return pyDict();
122
163
  let text;
123
164
  try {
124
165
  text = readFileSync(path, 'utf8');
@@ -127,51 +168,202 @@ function readConfig(path) {
127
168
  throw new InstallError(`cannot read ${path}: ${e.message}`);
128
169
  }
129
170
  if (text.trim() === '')
130
- return {};
171
+ return pyDict();
131
172
  let loaded;
132
173
  try {
133
- loaded = JSON.parse(text);
174
+ loaded = parseJson(text);
134
175
  }
135
176
  catch (e) {
136
- // The reference reports CPython's message, line and column. `JSON.parse` gives neither
137
- // the same wording nor a position, so this is a RULED difference rather than a silent
138
- // one: both refuse, both name the file, and the reason after the colon differs.
139
- throw new InstallError(`${path} is not valid JSON, so this refuses to touch it: ${e.message}`);
177
+ if (!(e instanceof PyJSONDecodeError))
178
+ throw e;
179
+ // The reference reports CPython's message, line and column, and so does this parser —
180
+ // the ruling that once covered V8's `Unexpected token …` wording retired with #118.
181
+ throw new InstallError(`${path} is not valid JSON, so this refuses to touch it: ${e.msg} (line ${e.lineno}, column ${e.colno})`);
140
182
  }
141
- if (loaded === null || typeof loaded !== 'object' || Array.isArray(loaded)) {
183
+ if (loaded.t !== 'dict') {
142
184
  throw new InstallError(`${path} holds ${pyTypeName(loaded)}, not an object; refusing to touch it`);
143
185
  }
186
+ // A NUMBER JSON CANNOT CARRY IS REFUSED HERE, before anything is planned (#118). `1e400`
187
+ // decodes to `Infinity` and `dumpJson` — faithful to `json.dumps` — would write it back as
188
+ // that bare word, which no JSON parser accepts: the host then cannot read ANY of its
189
+ // servers, and the next run of this very command refuses its own output. The literals
190
+ // `NaN`/`Infinity` that CPython's decoder tolerates take the same door.
191
+ const found = nonFiniteIn(loaded, []);
192
+ if (found !== null) {
193
+ const spelled = dumpJson({ t: 'float', v: found.value });
194
+ throw new InstallError(`${path} has a number at ${found.where} that JSON cannot carry (${spelled}); refusing to touch it`);
195
+ }
144
196
  return loaded;
145
197
  }
198
+ /**
199
+ * Where the bytes land: the file ITSELF, through any symlink (#136).
200
+ *
201
+ * A dotfiles user keeps `~/.cursor/mcp.json` as a link into a repository. Writing a temp file
202
+ * beside the LINK and renaming it onto the link's name replaces the link with a detached
203
+ * regular file: the repository copy never gets the entry and the next sync reverts it without
204
+ * a word. `realpathSync` wants the target to exist; a link whose target is not there yet is
205
+ * followed by hand (the reference's `os.path.realpath` is non-strict), so the write creates
206
+ * the target and the link stays a link either way.
207
+ */
208
+ function writeTarget(path) {
209
+ try {
210
+ return realpathSync(path);
211
+ }
212
+ catch {
213
+ // ENOENT: a fresh file, or a link whose target does not exist yet.
214
+ }
215
+ let at = path;
216
+ for (let hops = 0; hops < 40; hops += 1) {
217
+ let link;
218
+ try {
219
+ if (!lstatSync(at).isSymbolicLink())
220
+ return at;
221
+ link = readlinkSync(at);
222
+ }
223
+ catch {
224
+ return at;
225
+ }
226
+ at = resolve(dirname(at), link);
227
+ }
228
+ return at;
229
+ }
230
+ /**
231
+ * Write the whole document, atomically, leaving no half-file behind on a crash — and atomic
232
+ * ONTO THE REAL FILE: the temp file is made beside `writeTarget(path)` and renamed onto it, so
233
+ * a symlinked config is written through rather than swapped for a regular file.
234
+ */
146
235
  function writeConfig(path, data) {
147
236
  mkdirSync(dirname(path), { recursive: true });
148
- const tmp = `${path}.bantamkit-tmp`;
237
+ const target = writeTarget(path);
238
+ if (target !== path)
239
+ mkdirSync(dirname(target), { recursive: true });
240
+ const tmp = `${target}.bantamkit-tmp`;
149
241
  // The mode of the file being replaced, carried onto its replacement. A host config holds
150
242
  // API keys in per-server `env` blocks, and a user who chmod'ed theirs to 0600 had it come
151
243
  // back 0644, because a fresh temp file gets the process umask. Measured on both runtimes
152
244
  // before this line existed.
153
- const mode = existsSync(path) ? statSync(path).mode & 0o7777 : null;
245
+ const mode = existsSync(target) ? statSync(target).mode & 0o7777 : null;
154
246
  try {
155
- writeFileSync(tmp, `${dumps(data, 2)}\n`, 'utf8');
247
+ writeFileSync(tmp, `${hostJsonText(data, detectedIndent(target))}\n`, 'utf8');
156
248
  if (mode !== null)
157
249
  chmodSync(tmp, mode);
158
- renameSync(tmp, path);
250
+ renameSync(tmp, target);
159
251
  }
160
252
  catch (e) {
161
253
  rmSync(tmp, { force: true });
162
254
  throw new InstallError(`cannot write ${path}: ${e.message}`);
163
255
  }
164
256
  }
257
+ /**
258
+ * The indent unit the file already uses: a tab, or N spaces; two for a new file (#118).
259
+ *
260
+ * Read off the FIRST line that starts with whitespace and has something after it. A JSON
261
+ * string cannot hold a raw newline, so every line start is structural and that line is a
262
+ * depth-one member. A file with no such line (absent, empty, all on one line) gets two
263
+ * spaces, which is what this writer always used. The reference's `_detected_indent` applies
264
+ * the same rule to the same text.
265
+ *
266
+ * Lines break where Python's universal newlines break them — `\r\n`, a bare `\r`, or `\n` —
267
+ * because the reference reads through `Path.read_text`, which translates all three. Splitting
268
+ * on `\n` alone saw a classic-Mac (CR-only) file as one line, fell back to two spaces, and so
269
+ * wrote different bytes from the reference and lost the file's indent (#118, review J66-12 B1).
270
+ */
271
+ function detectedIndent(path) {
272
+ let text;
273
+ try {
274
+ text = readFileSync(path, 'utf8');
275
+ }
276
+ catch {
277
+ return ' ';
278
+ }
279
+ for (const line of text.split(/\r\n|\r|\n/)) {
280
+ if ((line[0] !== ' ' && line[0] !== '\t') || !/[^ \t\r]/.test(line))
281
+ continue;
282
+ if (line[0] === '\t')
283
+ return '\t';
284
+ return ' '.repeat(line.length - line.replace(/^ +/, '').length);
285
+ }
286
+ return ' ';
287
+ }
288
+ /**
289
+ * The host file's text: the user's indent unit, non-ASCII as literal UTF-8 (#118) — the
290
+ * bytes the reference's `json.dumps(data, indent=<unit>, ensure_ascii=False)` writes.
291
+ *
292
+ * Built from `dumpJson`'s `ensure_ascii` output at indent 2, which is already CPython's in
293
+ * every other respect, by two rewrites that cannot touch a string's meaning:
294
+ * - indentation: every line start in that output is structural (a string holds no raw
295
+ * newline), and its leading spaces are exactly 2 × depth, so they become unit × depth;
296
+ * - escapes: each `\uXXXX` at 0x7f or above goes back to the character itself — a valid
297
+ * surrogate PAIR as one astral character — which is what `ensure_ascii=False` writes
298
+ * (it escapes only `"`, `\` and C0). A LONE surrogate stays escaped: UTF-8 cannot carry
299
+ * it, and the reference writes it back the same way. A `\\` pair is consumed first, so
300
+ * the text `ก` inside a string (an escaped backslash) is never mistaken for one.
301
+ */
302
+ function hostJsonText(data, indent) {
303
+ const ascii = dumpJson(data, { indent: 2 });
304
+ const unescaped = ascii.replace(/\\u(d[89ab][0-9a-f]{2})\\u(d[c-f][0-9a-f]{2})|\\u([0-9a-f]{4})|\\[^u]/g, (whole, high, low, single) => {
305
+ if (high !== undefined && low !== undefined) {
306
+ return String.fromCharCode(parseInt(high, 16), parseInt(low, 16));
307
+ }
308
+ if (single === undefined)
309
+ return whole;
310
+ const unit = parseInt(single, 16);
311
+ if (unit < 0x7f || (unit >= 0xd800 && unit <= 0xdfff))
312
+ return whole;
313
+ return String.fromCharCode(unit);
314
+ });
315
+ if (indent === ' ')
316
+ return unescaped;
317
+ return unescaped.replace(/^((?: {2})+)/gm, (lead) => indent.repeat(lead.length / 2));
318
+ }
165
319
  /** `date.today().isoformat()` — the LOCAL date, which is what the reference takes. */
166
320
  function today() {
167
321
  const now = new Date();
168
322
  const pad = (n) => String(n).padStart(2, '0');
169
323
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
170
324
  }
325
+ /** `os.path.lexists`: there is SOMETHING at this name, a dangling link included. */
326
+ function lexists(path) {
327
+ try {
328
+ lstatSync(path);
329
+ return true;
330
+ }
331
+ catch {
332
+ return false;
333
+ }
334
+ }
335
+ /**
336
+ * The name the next backup of `path` gets: the first one that is not taken.
337
+ *
338
+ * `<name>.backup-<date>` when nothing holds that name, then `<name>.backup-<date>.1`, `.2`,
339
+ * ... on the same day. The plan lines print this same name before the write, so what the
340
+ * plan says and what the write does are one computation.
341
+ */
342
+ function backupTarget(path) {
343
+ const stamp = today();
344
+ let candidate = `${path}.backup-${stamp}`;
345
+ for (let n = 1; lexists(candidate); n += 1)
346
+ candidate = `${path}.backup-${stamp}.${n}`;
347
+ return candidate;
348
+ }
349
+ /**
350
+ * Copy the file aside before changing it. Returns the copy, or `null` if there was none.
351
+ *
352
+ * Dated, so a person looking at their own config directory a week later can tell when it
353
+ * happened — and NEVER OVERWRITTEN (#138). Until job65 a second run on the same day wrote the
354
+ * same `<name>.backup-<date>` on purpose, "rather than growing a pile nobody prunes"; the
355
+ * README's undo promise is that the backup restores the file as it was BEFORE bantamkit
356
+ * touched it, and the second run was overwriting exactly that copy with the first run's
357
+ * output. A pile of dated copies is the price of an undo that works; the counter suffix is
358
+ * the price of keeping the documented name for the first one.
359
+ *
360
+ * `copyFileSync` follows a symlink on the source, so the copy holds the REAL file's bytes and
361
+ * sits beside the path the report names.
362
+ */
171
363
  function backup(path) {
172
364
  if (!existsSync(path))
173
365
  return null;
174
- const target = `${path}.backup-${today()}`;
366
+ const target = backupTarget(path);
175
367
  try {
176
368
  copyFileSync(path, target);
177
369
  }
@@ -218,25 +410,26 @@ export function install(host, command, args, force = false) {
218
410
  const key = configKey(host);
219
411
  const wanted = entryFor(host, command, args);
220
412
  const data = readConfig(path);
221
- const raw = data[key];
222
- if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
413
+ const raw = data.v.get(key);
414
+ if (raw !== undefined && raw.t !== 'dict') {
223
415
  throw new InstallError(`${path} has a '${key}' that is not an object; refusing to touch it`);
224
416
  }
225
- const servers = (raw ?? {});
226
- const existing = servers[ENTRY];
227
- if (existing !== undefined && dumps(existing, null, true) === dumps(wanted, null, true)) {
417
+ const servers = raw ?? pyDict();
418
+ // MEMBERSHIP, not a null test: `"bantamkit": null` is an entry (`PY_NONE`, not `undefined`).
419
+ const existing = servers.v.get(ENTRY);
420
+ if (existing !== undefined && dumpJson(existing, { sortKeys: true }) === dumps(wanted, null, true)) {
228
421
  return `bantamkit is already installed in ${host} and matches\n file : ${path}`;
229
422
  }
230
423
  if (existing !== undefined && !force) {
231
424
  throw new InstallError(`${host} already has a bantamkit entry with different settings\n` +
232
425
  ` file : ${path}\n` +
233
- ` current : ${dumps(existing, null, true)}\n` +
426
+ ` current : ${dumpJson(existing, { sortKeys: true })}\n` +
234
427
  ` proposed: ${dumps(wanted, null, true)}\n` +
235
428
  ' re-run with --force to replace it');
236
429
  }
237
430
  const copied = backup(path);
238
- servers[ENTRY] = wanted;
239
- data[key] = servers;
431
+ servers.v.set(ENTRY, fromJs(wanted));
432
+ data.v.set(key, servers);
240
433
  writeConfig(path, data);
241
434
  const lines = [
242
435
  `installed bantamkit into ${host}`,
@@ -494,10 +687,13 @@ function hookEntry(matcher, command) {
494
687
  ];
495
688
  return entry;
496
689
  }
497
- /** A plain object — not null, not an array. The shape every test below needs first. */
498
- function isObject(value) {
499
- return typeof value === 'object' && value !== null && !Array.isArray(value);
500
- }
690
+ /** `{"event": [...]}` as a `PyValue`, for the one comparison and the one write that need it. */
691
+ const asPyValue = (table) => ({ t: 'dict', v: new Map([...table].map(([event, entries]) => [event, { t: 'list', v: entries }])) });
692
+ /** The `str` behind a key, or `undefined` when the key is absent or holds something else. */
693
+ const strAt = (dict, key) => {
694
+ const value = dict.v.get(key);
695
+ return value !== undefined && value.t === 'str' ? value.v : undefined;
696
+ };
501
697
  /**
502
698
  * The two command spellings bantamkit ever wrote, for entries that predate the marker.
503
699
  *
@@ -516,13 +712,17 @@ function isObject(value) {
516
712
  * third-party script with the word in its filename was claimed as ours and deleted.
517
713
  */
518
714
  function isLegacyOurs(hook) {
519
- if (hook['type'] !== 'command')
715
+ if (strAt(hook, 'type') !== 'command')
520
716
  return false;
521
- const command = hook['command'];
522
- if (typeof command !== 'string' || !command.includes('bantamkit'))
717
+ const command = strAt(hook, 'command');
718
+ if (command === undefined || !command.includes('bantamkit'))
523
719
  return false;
524
720
  return command.includes('bantamkit-hook') || command === '--hook' || command.endsWith(' --hook');
525
721
  }
722
+ /** ONE inner hook object: ours by marker, or by one of the two legacy spellings. */
723
+ function isOurHook(hook) {
724
+ return hook.t === 'dict' && (hook.v.has(HOOK_MARKER_KEY) || isLegacyOurs(hook));
725
+ }
526
726
  /**
527
727
  * RULING Q3.6's idempotence rule, RE-DECIDED ON THE ENTRY ALONE (J62-22).
528
728
  *
@@ -539,60 +739,96 @@ function isLegacyOurs(hook) {
539
739
  * shapes we wrote before the marker existed. Where any binary lives is not consulted.
540
740
  */
541
741
  function isOurs(entry) {
542
- if (!isObject(entry))
742
+ if (entry.t !== 'dict')
543
743
  return false;
544
- const hooks = entry['hooks'];
545
- if (!Array.isArray(hooks))
744
+ const hooks = entry.v.get('hooks');
745
+ if (hooks === undefined || hooks.t !== 'list')
546
746
  return false;
547
- return hooks.some((hook) => isObject(hook) && (HOOK_MARKER_KEY in hook || isLegacyOurs(hook)));
747
+ return hooks.v.some(isOurHook);
748
+ }
749
+ /**
750
+ * `entry` with bantamkit's own command objects taken out, and how many came out.
751
+ *
752
+ * THE UNIT OF OWNERSHIP IS THE COMMAND OBJECT, NOT THE MATCHER BLOCK (#129). `isOurs` answers
753
+ * "does this block hold one of ours", and dropping the whole block on that answer deleted a
754
+ * user's `notify-send` that shared the block with ours — after a plan that had just promised
755
+ * their hooks were left byte for byte. So: a block that is not ours comes back untouched (the
756
+ * same object, nothing removed); a block that holds ours loses ONLY those objects and keeps
757
+ * every other key it had; and a block left with no hooks at all is `null`, because
758
+ * `{"matcher": ..., "hooks": []}` is an artefact nobody wrote.
759
+ */
760
+ function withoutOurs(entry) {
761
+ if (!isOurs(entry) || entry.t !== 'dict')
762
+ return [entry, 0];
763
+ const hooks = entry.v.get('hooks');
764
+ if (hooks === undefined || hooks.t !== 'list')
765
+ return [entry, 0];
766
+ const kept = hooks.v.filter((hook) => !isOurHook(hook));
767
+ const removed = hooks.v.length - kept.length;
768
+ if (kept.length === 0)
769
+ return [null, removed];
770
+ const trimmed = pyDict(entry.v);
771
+ trimmed.v.set('hooks', { t: 'list', v: kept });
772
+ return [trimmed, removed];
548
773
  }
549
774
  /** The `hooks` object, validated. Refuses by name rather than crashing on somebody's file. */
550
775
  function readHooks(path, data) {
551
- const raw = data['hooks'];
776
+ const raw = data.v.get('hooks');
552
777
  if (raw === undefined)
553
- return {};
554
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
778
+ return new Map();
779
+ if (raw.t !== 'dict') {
555
780
  throw new InstallError(`${path} has a 'hooks' that is not an object; refusing to touch it`);
556
781
  }
557
- const hooks = {};
558
- for (const [event, entries] of Object.entries(raw)) {
559
- if (!Array.isArray(entries)) {
782
+ const hooks = new Map();
783
+ for (const [event, entries] of raw.v) {
784
+ if (entries.t !== 'list') {
560
785
  throw new InstallError(`${path} has a 'hooks.${event}' that is not a list; refusing to touch it`);
561
786
  }
562
- hooks[event] = [...entries];
787
+ hooks.set(event, [...entries.v]);
563
788
  }
564
789
  return hooks;
565
790
  }
566
791
  /**
567
- * The `hooks` object this run wants, built from whatever is there now.
792
+ * The `hooks` object this run wants, built from whatever is there now, the events that change,
793
+ * and how many of OUR command objects came out.
568
794
  *
569
- * ONE PASS OVER THE SEVEN EVENTS AND NOTHING ELSE: every event keeps its non-bantamkit entries
570
- * in their existing order, ours is appended after them, and an event left with none loses its
571
- * key rather than holding an empty list — `install.mjs`'s rule, kept because a settings file
572
- * full of empty arrays is a worse artefact than one with nothing in it.
795
+ * ONE PASS OVER THE SEVEN EVENTS AND NOTHING ELSE: every event keeps its non-bantamkit hooks in
796
+ * their existing order and their existing blocks, ours is appended after them, and an event
797
+ * left with none loses its key rather than holding an empty list — `install.mjs`'s rule, kept
798
+ * because a settings file full of empty arrays is a worse artefact than one with nothing in
799
+ * it. A block is dropped only when taking ours out of it leaves it empty (`withoutOurs`).
573
800
  *
574
801
  * Events outside the seven are not read, not reordered and not removed.
575
802
  */
576
803
  function plannedHooks(current, command, remove) {
577
- const hooks = { ...current };
804
+ const hooks = new Map(current);
578
805
  const touched = [];
806
+ let removed = 0;
579
807
  for (const [event, matcher] of HOOK_EVENTS) {
580
- const before = hooks[event] ?? [];
581
- const kept = before.filter((entry) => !isOurs(entry));
808
+ const before = hooks.get(event) ?? [];
809
+ const kept = [];
810
+ let removedHere = 0;
811
+ for (const entry of before) {
812
+ const [trimmed, count] = withoutOurs(entry);
813
+ removedHere += count;
814
+ if (trimmed !== null)
815
+ kept.push(trimmed);
816
+ }
817
+ removed += removedHere;
582
818
  if (remove) {
583
- if (kept.length !== before.length)
819
+ if (removedHere > 0)
584
820
  touched.push(event);
585
821
  }
586
822
  else {
587
- kept.push(hookEntry(matcher, command));
823
+ kept.push(fromJs(hookEntry(matcher, command)));
588
824
  touched.push(event);
589
825
  }
590
826
  if (kept.length > 0)
591
- hooks[event] = kept;
827
+ hooks.set(event, kept);
592
828
  else
593
- delete hooks[event];
829
+ hooks.delete(event);
594
830
  }
595
- return { hooks, touched };
831
+ return { hooks, touched, removed };
596
832
  }
597
833
  const EVENT_NAMES = HOOK_EVENTS.map(([event]) => event).join(' ');
598
834
  /**
@@ -610,7 +846,7 @@ function hookPlan(path, command) {
610
846
  ` command: ${command}`,
611
847
  ];
612
848
  if (existsSync(path))
613
- lines.push(` backup : ${path}.backup-${today()}`);
849
+ lines.push(` backup : ${backupTarget(path)}`);
614
850
  lines.push("Existing hooks are left byte-for-byte; only bantamkit's own entries are replaced.");
615
851
  return `${lines.join('\n')}\n`;
616
852
  }
@@ -631,7 +867,7 @@ function removalPlan(path, events, entries) {
631
867
  return ([
632
868
  `bantamkit would remove ${entries} hook entries from ${path}`,
633
869
  ` events : ${events.join(' ')}`,
634
- ` backup : ${path}.backup-${today()}`,
870
+ ` backup : ${backupTarget(path)}`,
635
871
  "Existing hooks are left byte-for-byte; only bantamkit's own entries are removed.",
636
872
  ].join('\n') + '\n');
637
873
  }
@@ -651,8 +887,8 @@ function removalPlan(path, events, entries) {
651
887
  * 5. dated backup, then one atomic write.
652
888
  *
653
889
  * The write itself is `writeConfig` — the same temp-file replace, the same carried mode, the
654
- * same `indent: 2` through `dumps` — so the bytes this leaves and the bytes `--install` leaves
655
- * are produced by one function (RULING Q3.5).
890
+ * same detected indent and literal non-ASCII through `hostJsonText` (#118) — so the bytes this
891
+ * leaves and the bytes `--install` leaves are produced by one function (RULING Q3.5).
656
892
  */
657
893
  export function installHooks(options = {}) {
658
894
  const { command, args } = thisCommand(options);
@@ -661,7 +897,7 @@ export function installHooks(options = {}) {
661
897
  const data = readConfig(path);
662
898
  const current = readHooks(path, data);
663
899
  const { hooks } = plannedHooks(current, rendered, false);
664
- if (dumps(hooks, null, true) === dumps(current, null, true)) {
900
+ if (dumpJson(asPyValue(hooks), { sortKeys: true }) === dumpJson(asPyValue(current), { sortKeys: true })) {
665
901
  return `bantamkit hooks are already installed in ${path} and match`;
666
902
  }
667
903
  const tell = options.tell ?? (() => { });
@@ -681,7 +917,7 @@ export function installHooks(options = {}) {
681
917
  tell(hookPlan(path, rendered));
682
918
  }
683
919
  const copied = backup(path);
684
- data['hooks'] = hooks;
920
+ data.v.set('hooks', asPyValue(hooks));
685
921
  writeConfig(path, data);
686
922
  const lines = [
687
923
  `installed bantamkit hooks into ${path}`,
@@ -721,14 +957,12 @@ export function removeHooks(options = {}) {
721
957
  const path = claudeSettingsPath();
722
958
  const data = readConfig(path);
723
959
  const current = readHooks(path, data);
724
- const { hooks, touched } = plannedHooks(current, '', true);
960
+ const { hooks, touched, removed: entries } = plannedHooks(current, '', true);
725
961
  if (touched.length === 0)
726
962
  return `no bantamkit hooks are installed in ${path}`;
727
- // Entries, not events: an event can hold more than one of ours if somebody hand-edited the
728
- // file, and the plan has to say what is actually going.
729
- let entries = 0;
730
- for (const event of touched)
731
- entries += (current[event] ?? []).length - (hooks[event] ?? []).length;
963
+ // `entries` counts OUR COMMAND OBJECTS, not events and not matcher blocks: an event can hold
964
+ // more than one of ours if somebody hand-edited the file, and a block that also holds a
965
+ // user's hook stays while ours leaves it (#129) — the plan has to say what is actually going.
732
966
  const tell = options.tell ?? (() => { });
733
967
  const yes = options.yes ?? false;
734
968
  const ask = options.ask ?? null;
@@ -746,7 +980,7 @@ export function removeHooks(options = {}) {
746
980
  tell(removalPlan(path, touched, entries));
747
981
  }
748
982
  const copied = backup(path);
749
- data['hooks'] = hooks;
983
+ data.v.set('hooks', asPyValue(hooks));
750
984
  writeConfig(path, data);
751
985
  const lines = [`removed bantamkit hooks from ${path}`, ` events : ${touched.join(' ')}`];
752
986
  if (copied !== null)
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { BantamError } from './errors.js';
2
2
  export { ContractParseError, extractJson, loadContract, parseContractDocument, parseErrorMessage, REQUIRED_KEYS, schemaError, schemaRetryFeedback, validationErrorMessage, } from './contract.js';
3
3
  export { dumpJson, fromJs, PY_NONE, parseJson, pyFloatRepr, PyJSONDecodeError, PyValueError, rawDecode, reprValue, toJs, } from './pyjson.js';
4
4
  export type { DumpOptions, PyValue } from './pyjson.js';
5
- export { absolutePath, bestMatch, iterErrors, PyJsonSchemaUnsupported, pyEqual, validate, } from './pyjsonschema.js';
6
- export type { PyValidationError } from './pyjsonschema.js';
5
+ export { absolutePath, bestMatch, iterErrors, PyJsonSchemaRefusal, PyJsonSchemaUnsupported, pyUrlNormalize, pyEqual, validate, } from './pyjsonschema.js';
6
+ export type { Draft, PyValidationError } from './pyjsonschema.js';
7
7
  export { AssetNotFound, assetsRoot, loadSchema, loadSkill, loadTool, loadToolAsset } from './assets.js';
8
8
  export type { Tool } from './assets.js';
9
9
  export { decodeFactBytes, FactParseError, formatFact, parseFactText, parseFrontmatter, pySplit, pyStrip, todayLocal, } from './memory/factfile.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { BantamError } from './errors.js';
2
2
  export { ContractParseError, extractJson, loadContract, parseContractDocument, parseErrorMessage, REQUIRED_KEYS, schemaError, schemaRetryFeedback, validationErrorMessage, } from './contract.js';
3
3
  export { dumpJson, fromJs, PY_NONE, parseJson, pyFloatRepr, PyJSONDecodeError, PyValueError, rawDecode, reprValue, toJs, } from './pyjson.js';
4
- export { absolutePath, bestMatch, iterErrors, PyJsonSchemaUnsupported, pyEqual, validate, } from './pyjsonschema.js';
4
+ export { absolutePath, bestMatch, iterErrors, PyJsonSchemaRefusal, PyJsonSchemaUnsupported, pyUrlNormalize, pyEqual, validate, } from './pyjsonschema.js';
5
5
  export { AssetNotFound, assetsRoot, loadSchema, loadSkill, loadTool, loadToolAsset } from './assets.js';
6
6
  export { decodeFactBytes, FactParseError, formatFact, parseFactText, parseFrontmatter, pySplit, pyStrip, todayLocal, } from './memory/factfile.js';
7
7
  export { constructPlain, PyScalar, resolveImplicitTag, safeDumpMapping, YamlConstructError, YamlEmitError, } from './memory/pyyaml.js';
@@ -75,9 +75,35 @@ const unavailable = (reason) => ({ unavailable: reason });
75
75
  */
76
76
  function walkFiles(root) {
77
77
  const found = [];
78
+ // job66/#140: `rglob` over a root that is not a directory yields NOTHING — missing, a
79
+ // regular file, or a dangling link alike (`select_from` checks `is_dir()` first) — and it
80
+ // swallows `PermissionError` on any directory it cannot list. WAS: `readdirSync` threw, so
81
+ // `BANTAMKIT_ASSETS=<missing> --assets-root` died with a raw ENOENT stack where the
82
+ // reference prints `<path>` / `0 files` at exit 0. Measured on CPython 3.12:
83
+ // `list(Path("<missing>").rglob("*"))`, `<file>` and a mode-000 directory are all `[]`.
84
+ let rootIsDir = false;
85
+ try {
86
+ rootIsDir = statSync(root).isDirectory();
87
+ }
88
+ catch {
89
+ rootIsDir = false;
90
+ }
91
+ if (!rootIsDir)
92
+ return [];
93
+ const listing = (here) => {
94
+ try {
95
+ return readdirSync(here, { withFileTypes: true });
96
+ }
97
+ catch (e) {
98
+ const code = e.code;
99
+ if (code === 'EACCES' || code === 'EPERM')
100
+ return [];
101
+ throw e;
102
+ }
103
+ };
78
104
  const descend = (parts) => {
79
105
  const here = parts.length === 0 ? root : join(root, ...parts);
80
- for (const entry of readdirSync(here, { withFileTypes: true })) {
106
+ for (const entry of listing(here)) {
81
107
  const next = [...parts, entry.name];
82
108
  if (entry.isDirectory()) {
83
109
  descend(next);