bantamkit-mcp 0.35.6 → 0.35.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +11 -7
  2. package/assets/tools/memory_compact.json +1 -2
  3. package/assets/tools/memory_recall.json +1 -3
  4. package/assets/tools/shiftwork_clock_in.json +1 -1
  5. package/assets/tools/shiftwork_clock_out.json +1 -1
  6. package/assets/tools/validate_json.json +1 -1
  7. package/assets/tools/work_plan.json +14 -8
  8. package/dist/cli.js +28 -0
  9. package/dist/contract.d.ts +0 -9
  10. package/dist/contract.js +51 -12
  11. package/dist/eventlog.d.ts +14 -1
  12. package/dist/eventlog.js +23 -3
  13. package/dist/hookadapter.js +260 -45
  14. package/dist/hostinstall.js +250 -79
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/mcp/pyargs.d.ts +22 -6
  18. package/dist/mcp/pyargs.js +113 -39
  19. package/dist/mcp/server.js +18 -27
  20. package/dist/mcp/status.js +11 -4
  21. package/dist/mcpreport.d.ts +12 -0
  22. package/dist/mcpreport.js +67 -15
  23. package/dist/memory/cli.js +18 -3
  24. package/dist/memory/component.d.ts +17 -0
  25. package/dist/memory/component.js +51 -7
  26. package/dist/memory/factfile.d.ts +21 -3
  27. package/dist/memory/factfile.js +351 -12
  28. package/dist/memory/layers.d.ts +19 -0
  29. package/dist/memory/layers.js +102 -2
  30. package/dist/memory/pyyaml.d.ts +6 -4
  31. package/dist/memory/pyyaml.js +6 -4
  32. package/dist/memory/store.d.ts +82 -7
  33. package/dist/memory/store.js +277 -29
  34. package/dist/npminstall.d.ts +18 -0
  35. package/dist/npminstall.js +26 -5
  36. package/dist/pyjsonschema.d.ts +85 -4
  37. package/dist/pyjsonschema.js +1812 -181
  38. package/dist/selfupdate.d.ts +112 -32
  39. package/dist/selfupdate.js +221 -79
  40. package/dist/shiftwork.d.ts +46 -7
  41. package/dist/shiftwork.js +246 -42
  42. package/dist/workplan.d.ts +8 -3
  43. package/dist/workplan.js +62 -3
  44. package/package.json +1 -1
@@ -32,14 +32,14 @@
32
32
  * launches it without the network. See `thisCommand`.
33
33
  */
34
34
  import { spawnSync } from 'node:child_process';
35
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
35
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
36
36
  import { homedir } from 'node:os';
37
- import { dirname, join } from 'node:path';
37
+ import { dirname, join, resolve } from 'node:path';
38
38
  import { fileURLToPath } from 'node:url';
39
39
  import { currentInstall, Undetermined } from './mcp/identity.js';
40
40
  import { bantamkitDirFor, ensureBantamkitGitignore } from './memory/store.js';
41
41
  import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin } from './npminstall.js';
42
- import { dumpJson, fromJs } from './pyjson.js';
42
+ import { dumpJson, fromJs, parseJson, PyJSONDecodeError } from './pyjson.js';
43
43
  export { keptCli, keptPrefix };
44
44
  /** The four hosts, in the order they print in `--help`. */
45
45
  export const HOSTS = ['claude', 'claude-desktop', 'copilot', 'cursor'];
@@ -49,6 +49,7 @@ export const ENTRY = 'bantamkit';
49
49
  export class InstallError extends Error {
50
50
  }
51
51
  const dumps = (value, indent = null, sortKeys = false) => dumpJson(fromJs(value), { indent, sortKeys });
52
+ const pyDict = (entries) => ({ t: 'dict', v: new Map(entries) });
52
53
  /**
53
54
  * The file `--install <host>` would write, on THIS platform.
54
55
  *
@@ -100,25 +101,64 @@ export function entryFor(host, command, args) {
100
101
  * `[1, 2, 3]`, the ONE input where the two vocabularies happen to agree, so neither suite
101
102
  * could see it. Review measured `"hello"` and `5`.
102
103
  *
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`.
104
+ * Since #118 the value is tagged by the same split `json` makes — a JSON number with no
105
+ * fraction and no exponent is `int`, everything else `float` — so the tag IS the name.
105
106
  */
106
107
  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;
108
+ switch (value.t) {
109
+ case 'null':
110
+ return 'NoneType';
111
+ case 'bool':
112
+ return 'bool';
113
+ case 'int':
114
+ return 'int';
115
+ case 'float':
116
+ return 'float';
117
+ case 'str':
118
+ return 'str';
119
+ case 'list':
120
+ return 'list';
121
+ case 'dict':
122
+ return 'dict';
123
+ }
124
+ }
125
+ /**
126
+ * The first non-finite float in `value`, with where it sits, or `null`. Depth-first, in
127
+ * document order, so both runtimes name the same number when a file holds two.
128
+ */
129
+ function nonFiniteIn(value, trail) {
130
+ if (value.t === 'float' && !Number.isFinite(value.v))
131
+ return { where: whereIs(trail), value: value.v };
132
+ if (value.t === 'dict') {
133
+ for (const [key, item] of value.v) {
134
+ const found = nonFiniteIn(item, [...trail, key]);
135
+ if (found !== null)
136
+ return found;
137
+ }
138
+ }
139
+ else if (value.t === 'list') {
140
+ for (const [index, item] of value.v.entries()) {
141
+ const found = nonFiniteIn(item, [...trail, index]);
142
+ if (found !== null)
143
+ return found;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ /** `mcpServers.other.huge`, `servers.list[2]`: keys joined by `.`, indexes in brackets. */
149
+ function whereIs(trail) {
150
+ let out = '';
151
+ for (const segment of trail) {
152
+ if (typeof segment === 'number')
153
+ out += `[${segment}]`;
154
+ else
155
+ out += out === '' ? segment : `.${segment}`;
156
+ }
157
+ return out === '' ? 'the top level' : out;
118
158
  }
119
159
  function readConfig(path) {
120
160
  if (!existsSync(path))
121
- return {};
161
+ return pyDict();
122
162
  let text;
123
163
  try {
124
164
  text = readFileSync(path, 'utf8');
@@ -127,35 +167,86 @@ function readConfig(path) {
127
167
  throw new InstallError(`cannot read ${path}: ${e.message}`);
128
168
  }
129
169
  if (text.trim() === '')
130
- return {};
170
+ return pyDict();
131
171
  let loaded;
132
172
  try {
133
- loaded = JSON.parse(text);
173
+ loaded = parseJson(text);
134
174
  }
135
175
  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}`);
176
+ if (!(e instanceof PyJSONDecodeError))
177
+ throw e;
178
+ // The reference reports CPython's message, line and column, and so does this parser —
179
+ // the ruling that once covered V8's `Unexpected token …` wording retired with #118.
180
+ throw new InstallError(`${path} is not valid JSON, so this refuses to touch it: ${e.msg} (line ${e.lineno}, column ${e.colno})`);
140
181
  }
141
- if (loaded === null || typeof loaded !== 'object' || Array.isArray(loaded)) {
182
+ if (loaded.t !== 'dict') {
142
183
  throw new InstallError(`${path} holds ${pyTypeName(loaded)}, not an object; refusing to touch it`);
143
184
  }
185
+ // A NUMBER JSON CANNOT CARRY IS REFUSED HERE, before anything is planned (#118). `1e400`
186
+ // decodes to `Infinity` and `dumpJson` — faithful to `json.dumps` — would write it back as
187
+ // that bare word, which no JSON parser accepts: the host then cannot read ANY of its
188
+ // servers, and the next run of this very command refuses its own output. The literals
189
+ // `NaN`/`Infinity` that CPython's decoder tolerates take the same door.
190
+ const found = nonFiniteIn(loaded, []);
191
+ if (found !== null) {
192
+ const spelled = dumpJson({ t: 'float', v: found.value });
193
+ throw new InstallError(`${path} has a number at ${found.where} that JSON cannot carry (${spelled}); refusing to touch it`);
194
+ }
144
195
  return loaded;
145
196
  }
197
+ /**
198
+ * Where the bytes land: the file ITSELF, through any symlink (#136).
199
+ *
200
+ * A dotfiles user keeps `~/.cursor/mcp.json` as a link into a repository. Writing a temp file
201
+ * beside the LINK and renaming it onto the link's name replaces the link with a detached
202
+ * regular file: the repository copy never gets the entry and the next sync reverts it without
203
+ * a word. `realpathSync` wants the target to exist; a link whose target is not there yet is
204
+ * followed by hand (the reference's `os.path.realpath` is non-strict), so the write creates
205
+ * the target and the link stays a link either way.
206
+ */
207
+ function writeTarget(path) {
208
+ try {
209
+ return realpathSync(path);
210
+ }
211
+ catch {
212
+ // ENOENT: a fresh file, or a link whose target does not exist yet.
213
+ }
214
+ let at = path;
215
+ for (let hops = 0; hops < 40; hops += 1) {
216
+ let link;
217
+ try {
218
+ if (!lstatSync(at).isSymbolicLink())
219
+ return at;
220
+ link = readlinkSync(at);
221
+ }
222
+ catch {
223
+ return at;
224
+ }
225
+ at = resolve(dirname(at), link);
226
+ }
227
+ return at;
228
+ }
229
+ /**
230
+ * Write the whole document, atomically, leaving no half-file behind on a crash — and atomic
231
+ * ONTO THE REAL FILE: the temp file is made beside `writeTarget(path)` and renamed onto it, so
232
+ * a symlinked config is written through rather than swapped for a regular file.
233
+ */
146
234
  function writeConfig(path, data) {
147
235
  mkdirSync(dirname(path), { recursive: true });
148
- const tmp = `${path}.bantamkit-tmp`;
236
+ const target = writeTarget(path);
237
+ if (target !== path)
238
+ mkdirSync(dirname(target), { recursive: true });
239
+ const tmp = `${target}.bantamkit-tmp`;
149
240
  // The mode of the file being replaced, carried onto its replacement. A host config holds
150
241
  // API keys in per-server `env` blocks, and a user who chmod'ed theirs to 0600 had it come
151
242
  // back 0644, because a fresh temp file gets the process umask. Measured on both runtimes
152
243
  // before this line existed.
153
- const mode = existsSync(path) ? statSync(path).mode & 0o7777 : null;
244
+ const mode = existsSync(target) ? statSync(target).mode & 0o7777 : null;
154
245
  try {
155
- writeFileSync(tmp, `${dumps(data, 2)}\n`, 'utf8');
246
+ writeFileSync(tmp, `${dumpJson(data, { indent: 2 })}\n`, 'utf8');
156
247
  if (mode !== null)
157
248
  chmodSync(tmp, mode);
158
- renameSync(tmp, path);
249
+ renameSync(tmp, target);
159
250
  }
160
251
  catch (e) {
161
252
  rmSync(tmp, { force: true });
@@ -168,10 +259,48 @@ function today() {
168
259
  const pad = (n) => String(n).padStart(2, '0');
169
260
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
170
261
  }
262
+ /** `os.path.lexists`: there is SOMETHING at this name, a dangling link included. */
263
+ function lexists(path) {
264
+ try {
265
+ lstatSync(path);
266
+ return true;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
272
+ /**
273
+ * The name the next backup of `path` gets: the first one that is not taken.
274
+ *
275
+ * `<name>.backup-<date>` when nothing holds that name, then `<name>.backup-<date>.1`, `.2`,
276
+ * ... on the same day. The plan lines print this same name before the write, so what the
277
+ * plan says and what the write does are one computation.
278
+ */
279
+ function backupTarget(path) {
280
+ const stamp = today();
281
+ let candidate = `${path}.backup-${stamp}`;
282
+ for (let n = 1; lexists(candidate); n += 1)
283
+ candidate = `${path}.backup-${stamp}.${n}`;
284
+ return candidate;
285
+ }
286
+ /**
287
+ * Copy the file aside before changing it. Returns the copy, or `null` if there was none.
288
+ *
289
+ * Dated, so a person looking at their own config directory a week later can tell when it
290
+ * happened — and NEVER OVERWRITTEN (#138). Until job65 a second run on the same day wrote the
291
+ * same `<name>.backup-<date>` on purpose, "rather than growing a pile nobody prunes"; the
292
+ * README's undo promise is that the backup restores the file as it was BEFORE bantamkit
293
+ * touched it, and the second run was overwriting exactly that copy with the first run's
294
+ * output. A pile of dated copies is the price of an undo that works; the counter suffix is
295
+ * the price of keeping the documented name for the first one.
296
+ *
297
+ * `copyFileSync` follows a symlink on the source, so the copy holds the REAL file's bytes and
298
+ * sits beside the path the report names.
299
+ */
171
300
  function backup(path) {
172
301
  if (!existsSync(path))
173
302
  return null;
174
- const target = `${path}.backup-${today()}`;
303
+ const target = backupTarget(path);
175
304
  try {
176
305
  copyFileSync(path, target);
177
306
  }
@@ -218,25 +347,26 @@ export function install(host, command, args, force = false) {
218
347
  const key = configKey(host);
219
348
  const wanted = entryFor(host, command, args);
220
349
  const data = readConfig(path);
221
- const raw = data[key];
222
- if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
350
+ const raw = data.v.get(key);
351
+ if (raw !== undefined && raw.t !== 'dict') {
223
352
  throw new InstallError(`${path} has a '${key}' that is not an object; refusing to touch it`);
224
353
  }
225
- const servers = (raw ?? {});
226
- const existing = servers[ENTRY];
227
- if (existing !== undefined && dumps(existing, null, true) === dumps(wanted, null, true)) {
354
+ const servers = raw ?? pyDict();
355
+ // MEMBERSHIP, not a null test: `"bantamkit": null` is an entry (`PY_NONE`, not `undefined`).
356
+ const existing = servers.v.get(ENTRY);
357
+ if (existing !== undefined && dumpJson(existing, { sortKeys: true }) === dumps(wanted, null, true)) {
228
358
  return `bantamkit is already installed in ${host} and matches\n file : ${path}`;
229
359
  }
230
360
  if (existing !== undefined && !force) {
231
361
  throw new InstallError(`${host} already has a bantamkit entry with different settings\n` +
232
362
  ` file : ${path}\n` +
233
- ` current : ${dumps(existing, null, true)}\n` +
363
+ ` current : ${dumpJson(existing, { sortKeys: true })}\n` +
234
364
  ` proposed: ${dumps(wanted, null, true)}\n` +
235
365
  ' re-run with --force to replace it');
236
366
  }
237
367
  const copied = backup(path);
238
- servers[ENTRY] = wanted;
239
- data[key] = servers;
368
+ servers.v.set(ENTRY, fromJs(wanted));
369
+ data.v.set(key, servers);
240
370
  writeConfig(path, data);
241
371
  const lines = [
242
372
  `installed bantamkit into ${host}`,
@@ -494,10 +624,13 @@ function hookEntry(matcher, command) {
494
624
  ];
495
625
  return entry;
496
626
  }
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
- }
627
+ /** `{"event": [...]}` as a `PyValue`, for the one comparison and the one write that need it. */
628
+ const asPyValue = (table) => ({ t: 'dict', v: new Map([...table].map(([event, entries]) => [event, { t: 'list', v: entries }])) });
629
+ /** The `str` behind a key, or `undefined` when the key is absent or holds something else. */
630
+ const strAt = (dict, key) => {
631
+ const value = dict.v.get(key);
632
+ return value !== undefined && value.t === 'str' ? value.v : undefined;
633
+ };
501
634
  /**
502
635
  * The two command spellings bantamkit ever wrote, for entries that predate the marker.
503
636
  *
@@ -516,13 +649,17 @@ function isObject(value) {
516
649
  * third-party script with the word in its filename was claimed as ours and deleted.
517
650
  */
518
651
  function isLegacyOurs(hook) {
519
- if (hook['type'] !== 'command')
652
+ if (strAt(hook, 'type') !== 'command')
520
653
  return false;
521
- const command = hook['command'];
522
- if (typeof command !== 'string' || !command.includes('bantamkit'))
654
+ const command = strAt(hook, 'command');
655
+ if (command === undefined || !command.includes('bantamkit'))
523
656
  return false;
524
657
  return command.includes('bantamkit-hook') || command === '--hook' || command.endsWith(' --hook');
525
658
  }
659
+ /** ONE inner hook object: ours by marker, or by one of the two legacy spellings. */
660
+ function isOurHook(hook) {
661
+ return hook.t === 'dict' && (hook.v.has(HOOK_MARKER_KEY) || isLegacyOurs(hook));
662
+ }
526
663
  /**
527
664
  * RULING Q3.6's idempotence rule, RE-DECIDED ON THE ENTRY ALONE (J62-22).
528
665
  *
@@ -539,60 +676,96 @@ function isLegacyOurs(hook) {
539
676
  * shapes we wrote before the marker existed. Where any binary lives is not consulted.
540
677
  */
541
678
  function isOurs(entry) {
542
- if (!isObject(entry))
679
+ if (entry.t !== 'dict')
543
680
  return false;
544
- const hooks = entry['hooks'];
545
- if (!Array.isArray(hooks))
681
+ const hooks = entry.v.get('hooks');
682
+ if (hooks === undefined || hooks.t !== 'list')
546
683
  return false;
547
- return hooks.some((hook) => isObject(hook) && (HOOK_MARKER_KEY in hook || isLegacyOurs(hook)));
684
+ return hooks.v.some(isOurHook);
685
+ }
686
+ /**
687
+ * `entry` with bantamkit's own command objects taken out, and how many came out.
688
+ *
689
+ * THE UNIT OF OWNERSHIP IS THE COMMAND OBJECT, NOT THE MATCHER BLOCK (#129). `isOurs` answers
690
+ * "does this block hold one of ours", and dropping the whole block on that answer deleted a
691
+ * user's `notify-send` that shared the block with ours — after a plan that had just promised
692
+ * their hooks were left byte for byte. So: a block that is not ours comes back untouched (the
693
+ * same object, nothing removed); a block that holds ours loses ONLY those objects and keeps
694
+ * every other key it had; and a block left with no hooks at all is `null`, because
695
+ * `{"matcher": ..., "hooks": []}` is an artefact nobody wrote.
696
+ */
697
+ function withoutOurs(entry) {
698
+ if (!isOurs(entry) || entry.t !== 'dict')
699
+ return [entry, 0];
700
+ const hooks = entry.v.get('hooks');
701
+ if (hooks === undefined || hooks.t !== 'list')
702
+ return [entry, 0];
703
+ const kept = hooks.v.filter((hook) => !isOurHook(hook));
704
+ const removed = hooks.v.length - kept.length;
705
+ if (kept.length === 0)
706
+ return [null, removed];
707
+ const trimmed = pyDict(entry.v);
708
+ trimmed.v.set('hooks', { t: 'list', v: kept });
709
+ return [trimmed, removed];
548
710
  }
549
711
  /** The `hooks` object, validated. Refuses by name rather than crashing on somebody's file. */
550
712
  function readHooks(path, data) {
551
- const raw = data['hooks'];
713
+ const raw = data.v.get('hooks');
552
714
  if (raw === undefined)
553
- return {};
554
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
715
+ return new Map();
716
+ if (raw.t !== 'dict') {
555
717
  throw new InstallError(`${path} has a 'hooks' that is not an object; refusing to touch it`);
556
718
  }
557
- const hooks = {};
558
- for (const [event, entries] of Object.entries(raw)) {
559
- if (!Array.isArray(entries)) {
719
+ const hooks = new Map();
720
+ for (const [event, entries] of raw.v) {
721
+ if (entries.t !== 'list') {
560
722
  throw new InstallError(`${path} has a 'hooks.${event}' that is not a list; refusing to touch it`);
561
723
  }
562
- hooks[event] = [...entries];
724
+ hooks.set(event, [...entries.v]);
563
725
  }
564
726
  return hooks;
565
727
  }
566
728
  /**
567
- * The `hooks` object this run wants, built from whatever is there now.
729
+ * The `hooks` object this run wants, built from whatever is there now, the events that change,
730
+ * and how many of OUR command objects came out.
568
731
  *
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.
732
+ * ONE PASS OVER THE SEVEN EVENTS AND NOTHING ELSE: every event keeps its non-bantamkit hooks in
733
+ * their existing order and their existing blocks, ours is appended after them, and an event
734
+ * left with none loses its key rather than holding an empty list — `install.mjs`'s rule, kept
735
+ * because a settings file full of empty arrays is a worse artefact than one with nothing in
736
+ * it. A block is dropped only when taking ours out of it leaves it empty (`withoutOurs`).
573
737
  *
574
738
  * Events outside the seven are not read, not reordered and not removed.
575
739
  */
576
740
  function plannedHooks(current, command, remove) {
577
- const hooks = { ...current };
741
+ const hooks = new Map(current);
578
742
  const touched = [];
743
+ let removed = 0;
579
744
  for (const [event, matcher] of HOOK_EVENTS) {
580
- const before = hooks[event] ?? [];
581
- const kept = before.filter((entry) => !isOurs(entry));
745
+ const before = hooks.get(event) ?? [];
746
+ const kept = [];
747
+ let removedHere = 0;
748
+ for (const entry of before) {
749
+ const [trimmed, count] = withoutOurs(entry);
750
+ removedHere += count;
751
+ if (trimmed !== null)
752
+ kept.push(trimmed);
753
+ }
754
+ removed += removedHere;
582
755
  if (remove) {
583
- if (kept.length !== before.length)
756
+ if (removedHere > 0)
584
757
  touched.push(event);
585
758
  }
586
759
  else {
587
- kept.push(hookEntry(matcher, command));
760
+ kept.push(fromJs(hookEntry(matcher, command)));
588
761
  touched.push(event);
589
762
  }
590
763
  if (kept.length > 0)
591
- hooks[event] = kept;
764
+ hooks.set(event, kept);
592
765
  else
593
- delete hooks[event];
766
+ hooks.delete(event);
594
767
  }
595
- return { hooks, touched };
768
+ return { hooks, touched, removed };
596
769
  }
597
770
  const EVENT_NAMES = HOOK_EVENTS.map(([event]) => event).join(' ');
598
771
  /**
@@ -610,7 +783,7 @@ function hookPlan(path, command) {
610
783
  ` command: ${command}`,
611
784
  ];
612
785
  if (existsSync(path))
613
- lines.push(` backup : ${path}.backup-${today()}`);
786
+ lines.push(` backup : ${backupTarget(path)}`);
614
787
  lines.push("Existing hooks are left byte-for-byte; only bantamkit's own entries are replaced.");
615
788
  return `${lines.join('\n')}\n`;
616
789
  }
@@ -631,7 +804,7 @@ function removalPlan(path, events, entries) {
631
804
  return ([
632
805
  `bantamkit would remove ${entries} hook entries from ${path}`,
633
806
  ` events : ${events.join(' ')}`,
634
- ` backup : ${path}.backup-${today()}`,
807
+ ` backup : ${backupTarget(path)}`,
635
808
  "Existing hooks are left byte-for-byte; only bantamkit's own entries are removed.",
636
809
  ].join('\n') + '\n');
637
810
  }
@@ -661,7 +834,7 @@ export function installHooks(options = {}) {
661
834
  const data = readConfig(path);
662
835
  const current = readHooks(path, data);
663
836
  const { hooks } = plannedHooks(current, rendered, false);
664
- if (dumps(hooks, null, true) === dumps(current, null, true)) {
837
+ if (dumpJson(asPyValue(hooks), { sortKeys: true }) === dumpJson(asPyValue(current), { sortKeys: true })) {
665
838
  return `bantamkit hooks are already installed in ${path} and match`;
666
839
  }
667
840
  const tell = options.tell ?? (() => { });
@@ -681,7 +854,7 @@ export function installHooks(options = {}) {
681
854
  tell(hookPlan(path, rendered));
682
855
  }
683
856
  const copied = backup(path);
684
- data['hooks'] = hooks;
857
+ data.v.set('hooks', asPyValue(hooks));
685
858
  writeConfig(path, data);
686
859
  const lines = [
687
860
  `installed bantamkit hooks into ${path}`,
@@ -721,14 +894,12 @@ export function removeHooks(options = {}) {
721
894
  const path = claudeSettingsPath();
722
895
  const data = readConfig(path);
723
896
  const current = readHooks(path, data);
724
- const { hooks, touched } = plannedHooks(current, '', true);
897
+ const { hooks, touched, removed: entries } = plannedHooks(current, '', true);
725
898
  if (touched.length === 0)
726
899
  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;
900
+ // `entries` counts OUR COMMAND OBJECTS, not events and not matcher blocks: an event can hold
901
+ // more than one of ours if somebody hand-edited the file, and a block that also holds a
902
+ // user's hook stays while ours leaves it (#129) — the plan has to say what is actually going.
732
903
  const tell = options.tell ?? (() => { });
733
904
  const yes = options.yes ?? false;
734
905
  const ask = options.ask ?? null;
@@ -746,7 +917,7 @@ export function removeHooks(options = {}) {
746
917
  tell(removalPlan(path, touched, entries));
747
918
  }
748
919
  const copied = backup(path);
749
- data['hooks'] = hooks;
920
+ data.v.set('hooks', asPyValue(hooks));
750
921
  writeConfig(path, data);
751
922
  const lines = [`removed bantamkit hooks from ${path}`, ` events : ${touched.join(' ')}`];
752
923
  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';
@@ -7,12 +7,12 @@
7
7
  * ship a server whose recovery behaviour differs from the reference's on every mistyped
8
8
  * argument, and that is a product difference, not a cosmetic one.
9
9
  *
10
- * WHAT THE URL IS. `https://errors.pydantic.dev/2.13/v/<type>` names an entry in pydantic's
11
- * public error catalogue. This runtime has no pydantic; the URL is part of the sentence the
12
- * reference emits, reproduced because the sentence is the contract. `2.13` is therefore a
13
- * PIN on the reference, not a claim about this process — and it is compared against the
14
- * live server by `tools/conformance/suites/wire.mjs`, so the day the Python side bumps to
15
- * 2.14 the suite goes red here instead of the two runtimes drifting apart quietly.
10
+ * WHY THERE IS NO URL (job65/#185). pydantic ends each error with `For further information
11
+ * visit https://errors.pydantic.dev/<major.minor>/v/<type>`, naming whichever pydantic pip
12
+ * resolved. This port used to reproduce it with `2.13` hard-coded, and the reference printed
13
+ * `2.14` the day PyPI resolved 2.14 — the refusal texts differed by install date. The
14
+ * reference now sets `PYDANTIC_ERRORS_INCLUDE_URL=0` at import (`bantamkit.mcpserver`), so
15
+ * neither runtime prints the line; `tools/conformance/suites/wire.mjs` compares the two.
16
16
  *
17
17
  * EVERY RULE BELOW WAS MEASURED, not read off pydantic's documentation. The probes are in
18
18
  * `scratchpad/job38-n7/probe{4,7,8,9}.py` and each drove the real stdio server:
@@ -35,6 +35,12 @@ export interface FieldSpec {
35
35
  readonly optional: boolean;
36
36
  /** `Field(le=...)`: an inclusive ceiling, checked AFTER the lax int parse succeeds. */
37
37
  readonly le?: bigint;
38
+ /**
39
+ * `Field(ge=...)`: an inclusive floor, checked the same way. On a `dictInt` it binds the
40
+ * VALUES — `dict[str, Annotated[int, Field(ge=0)]]` on the reference — so the frame's
41
+ * location is `usage.<key>`, as the type frame's is.
42
+ */
43
+ readonly ge?: bigint;
38
44
  }
39
45
  export interface ArgModel {
40
46
  /** The pydantic model name, which is the FUNCTION name plus `Arguments`. */
@@ -60,6 +66,15 @@ export interface ArgModel {
60
66
  * register a tool.
61
67
  */
62
68
  export declare const OFFSET_MAXIMUM = 9007199254740991n;
69
+ /**
70
+ * `assets/tools/skill_audit.json` `budget.maximum` — the same `Number.MAX_SAFE_INTEGER` as
71
+ * `OFFSET_MAXIMUM`, spelled as its own constant because it is tied to a different asset
72
+ * and would stay where it is if the other ever moved. Bound on the reference as
73
+ * `Annotated[int, Field(le=SKILL_BUDGET_MAXIMUM)]` (#133, job65): until then the manifest
74
+ * advertised a ceiling neither runtime enforced, and `budget: 2**53` was audited as if it
75
+ * were a number a catalogue could be measured against.
76
+ */
77
+ export declare const SKILL_BUDGET_MAXIMUM = 9007199254740991n;
63
78
  /**
64
79
  * One model per handler, mirroring `build_server`'s closures signature for signature.
65
80
  *
@@ -122,6 +137,7 @@ export declare class PyValidationFailure extends Error {
122
137
  readonly detail: string;
123
138
  constructor(detail: string);
124
139
  }
140
+ export declare function loneSurrogateArgument(args: PyValue): string | null;
125
141
  /**
126
142
  * `model.model_validate(arguments)` for one tool, or the exact `ValidationError` text.
127
143
  *