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
@@ -7,13 +7,32 @@ export { compareVersions, PACKAGE, runInstaller, shlexJoin };
7
7
  */
8
8
  export declare const PROGRAM = "bantamkit-mcp";
9
9
  /**
10
- * The one URL this toolbox ever fetches. Divergent by construction (`INDEX_URL`).
11
- *
12
- * `/latest` and not the full packument: the packument carries every version ever published and
13
- * grows without bound, and the question here is one string. Measured 2026-09-11 — this document
14
- * is 26 keys and carries `version` at the top level.
10
+ * The command that asks the index what `latest` is — npm itself, never a URL of this module's
11
+ * own. Divergent by construction (the reference asks pip).
12
+ *
13
+ * WHY npm AND NOT `fetch` (J65-19, #135). The version check and the `npm install` that follows
14
+ * it must agree about WHICH registry they mean, and only npm knows: `npm_config_registry`, the
15
+ * project and user `.npmrc`, `proxy`/`https-proxy` and the `HTTPS_PROXY` environment, auth
16
+ * tokens for a private mirror, `strict-ssl`, `cafile`. Measured 2026-10-13 (#135): with
17
+ * `npm_config_registry=http://127.0.0.1:4873/` and `~/.npmrc registry=` set, the old `fetch`
18
+ * still asked `https://registry.npmjs.org/bantamkit-mcp/latest`. Node's own `fetch` cannot
19
+ * even honour `HTTPS_PROXY` without a dependency this toolbox refuses to add, so reading the
20
+ * registry out of npm's config and fetching it here would have fixed half the report.
21
+ *
22
+ * `@latest` resolved by npm, `--json` so the answer is the manifest of that dist-tag with
23
+ * `version` at the top level — the same document shape the old URL answered, measured against
24
+ * a loopback registry in `test/selfupdate.test.mjs`, so `latestFromIndexPayload` is unchanged.
25
+ * `--fetch-retries=0` because a registry that refuses the connection takes npm 70 seconds
26
+ * under its default retry schedule (measured, npm 11.6.2), which is seven times this flag's
27
+ * deadline: the operator would read "did not answer within 10 seconds" where the truth is
28
+ * `ECONNREFUSED`, and that truth is what `UNREACHABLE` is for.
29
+ *
30
+ * `--prefix` / `--global` MIRROR `upgradeCommand` EXACTLY, because npm reads the project
31
+ * `.npmrc` relative to the prefix it is given: a `view` asked from the operator's cwd and an
32
+ * `install` pointed at the kept tree could read two different `registry=` lines, which is the
33
+ * disagreement #135 is about.
15
34
  */
16
- export declare const INDEX_URL = "https://registry.npmjs.org/bantamkit-mcp/latest";
35
+ export declare function indexCommand(environment: Environment): string[];
17
36
  /**
18
37
  * Explicit, and short enough that a person who typed the flag does not think it hung.
19
38
  * AS-7(3): the network is not on any other path here, so nothing else inherits this.
@@ -43,6 +62,11 @@ export declare const PRINTED = "the command printed:";
43
62
  export declare const UPDATED = "updated {program} from {installed} to {latest}.";
44
63
  /** AS-7(1), measured. This is the line that makes the success true rather than plausible. */
45
64
  export declare const RESTART: string;
65
+ /**
66
+ * `{article}` is `a` or `an` by the shape word's first letter (`article`): "a ephemeral
67
+ * install" shipped in every release up to 0.35.7 (#177), because the template carried the
68
+ * article and the shape was substituted after it.
69
+ */
46
70
  export declare const NO_ROUTE: string;
47
71
  export declare const TIMED_OUT: string;
48
72
  export declare const UNREACHABLE: string;
@@ -52,6 +76,18 @@ export declare const NOT_A_VERSION: string;
52
76
  * and a refusal that swallowed it would send the operator to re-run the command by hand.
53
77
  */
54
78
  export declare const COMMAND_FAILED: string;
79
+ /**
80
+ * The installer exited 0 and the install does not report the version it was told to install
81
+ * (J65-19, #128). The same shape as `COMMAND_FAILED` — the command on line 1, the installer's
82
+ * own words last — because it is the same kind of failure read off the disk instead of off
83
+ * the exit code. Measured 2026-10-13 (#128): with the index stubbed to 9.9.9 and an `npm`
84
+ * that recorded its argv and changed nothing, 0.35.7 printed `updated bantamkit-mcp from 0.35.7
85
+ * to 9.9.9.` and exited 0 with 0.35.7 still on disk. `{found}` is the version the manifest
86
+ * reports now, or `NO_INSTALLED_VERSION` when there is no readable manifest at all.
87
+ */
88
+ export declare const NOT_INSTALLED: string;
89
+ /** What `{found}` reads when no manifest could be read back after the installer ran. */
90
+ export declare const NO_INSTALLED_VERSION = "no readable version";
55
91
  /**
56
92
  * The install shape itself could not be derived — `identity.Undetermined`, which is what an
57
93
  * origin that is neither an index nor a path on this machine raises (`git+https://…`). That
@@ -70,7 +106,13 @@ export declare const NO_VERSION_FIELD = "the response carries no version string"
70
106
  * The fallthrough for a shape word `ROUTES` has never heard of — `INSTALL_SHAPES` growing a
71
107
  * sixth word. Not divergent: it names no package manager, so it is the reference's sentence.
72
108
  */
73
- export declare const NO_RECORDED_ROUTE = "There is no recorded update route for a {shape} install, so --update will not guess one.";
109
+ export declare const NO_RECORDED_ROUTE = "There is no recorded update route for {article} {shape} install, so --update will not guess one.";
110
+ /**
111
+ * `a` or `an` for the word that follows, by its first letter — the rule English applies to
112
+ * `ephemeral` and the reference's `_article` applies byte for byte. A letter test and not a
113
+ * table, because `INSTALL_SHAPES` can grow a word this file has never seen.
114
+ */
115
+ export declare function article(word: string): string;
74
116
  /**
75
117
  * Per shape, what the real update route is — the answer to "then how DO I update this?".
76
118
  * DIVERGENT BY CONSTRUCTION and registered in `docs/porting.md` by J46-31: the reference's
@@ -167,8 +209,28 @@ export interface Origin {
167
209
  export interface Environment {
168
210
  readonly root: string;
169
211
  readonly global: boolean;
212
+ /**
213
+ * Whether the prefix tree's own `package.json` pins this package EXACTLY (`"0.35.7"`, not
214
+ * `"^0.35.7"`). npm rewrites an exact pin to a caret range on `npm install <pkg>@<v>` unless
215
+ * told `--save-exact` (observed in #128), so `upgradeCommand` carries the flag when the pin
216
+ * was exact and the operator's record keeps its shape. Absent or `false` otherwise.
217
+ */
218
+ readonly exact?: boolean;
170
219
  }
171
220
  export declare function installEnvironment(runningFile?: string): Environment;
221
+ /**
222
+ * The version the tree `--update` just pointed npm at reports NOW, read off its manifest, or
223
+ * `null` when there is no readable one. The default `installedVersion` seam (J65-19, #128):
224
+ * what the success line is allowed to say is what this returns, never what the index said.
225
+ */
226
+ export declare function readInstalledVersion(environment: Environment): string | null;
227
+ /**
228
+ * The package root above a running `dist/` — the directory whose `package.json` is the one this
229
+ * build reads its version from — or `packageDirectory` itself when no manifest sits above it.
230
+ * The `checkout` route names "a source tree", and `<tree>/dist` is that tree's build output, not
231
+ * the tree (#177): `cli.ts` hands in `dirname(import.meta.url)`, which is `dist/`.
232
+ */
233
+ export declare function packageRoot(packageDirectory: string): string;
172
234
  /**
173
235
  * `str.format` over the one substitution shape these sentences use — `{name}` and nothing else.
174
236
  *
@@ -190,25 +252,29 @@ export declare function fill(template: string, values: Readonly<Record<string, s
190
252
  */
191
253
  export declare function latestFromIndexPayload(text: string): string;
192
254
  /**
193
- * The ONLY network call in this runtime. One request, one timeout, two failure words.
194
- *
195
- * THE PLATFORM'S `fetch`, NOT A DEPENDENCY. `package.json` declares exactly one runtime
196
- * dependency and the pure-node `npx` install is a standing ruling, so the HTTP client is the
197
- * one Node ships (>= 18, and `engines` already requires >= 20) and the deadline is
198
- * `AbortSignal.timeout`, which is the same object the abort arrives as.
199
- *
200
- * THE CONTRACT WITH `update` IS THE EXCEPTION TYPE — see `IndexTimeout`. Measured on Node
201
- * v25.2.1 against a local socket that accepts and never answers: the rejection is a
202
- * `DOMException` named `TimeoutError`. Everything else is a `TypeError: fetch failed` whose
203
- * `cause` carries the sentence worth printing — `connect ECONNREFUSED 127.0.0.1:49999`,
204
- * `getaddrinfo ENOTFOUND …` — so the cause's message is what `UNREACHABLE` interpolates and
205
- * the bare "fetch failed" never reaches an operator.
206
- *
207
- * A non-2xx is an `Error` and not a timeout, the way the reference turns `HTTPError` into a
208
- * plain `OSError`; and the body is decoded leniently, because a body that is not UTF-8 is not
209
- * a version either and should be refused BY NAME in `latestFromIndexPayload`.
255
+ * The ONLY path to the network in this runtime, and it goes through npm. One command, one
256
+ * deadline, two failure words.
257
+ *
258
+ * NOT THE PLATFORM'S `fetch` ANY MORE (J65-19, #135): see `indexCommand` for why the question
259
+ * is asked of npm. No new dependency either — `spawnCommand` is the spawner `--install` and the
260
+ * installer below already use. The deadline is `spawnSync`'s own `timeout`: npm is killed when
261
+ * it passes and the result carries `ETIMEDOUT`, which is the one fact that becomes
262
+ * `IndexTimeout`. Measured in `test/selfupdate.test.mjs` with a command that sleeps.
263
+ *
264
+ * THE CONTRACT WITH `update` IS THE EXCEPTION TYPE — see `IndexTimeout`. Everything that is
265
+ * not the deadline is a plain `Error` whose message is the sentence worth printing: with
266
+ * `--json`, npm writes its failure to STDOUT as `{"error": {"code", "summary", "detail"}}`
267
+ * (measured, npm 11.6.2: `E404` for a package the mirror does not carry, `ECONNREFUSED` with
268
+ * `request to http://127.0.0.1:1/bantamkit-mcp failed, reason: connect ECONNREFUSED` for a
269
+ * dead one), so `summary` is taken first — it names the URL npm actually asked, which is the
270
+ * configured registry and the thing an operator behind a mirror needs to see — then `code`,
271
+ * then the first line npm wrote to stderr, then the bare exit status. A missing `npm` is the
272
+ * spawn error's own message (`spawnSync npm ENOENT`).
273
+ *
274
+ * Nothing here parses a version: a body that is not the manifest is not a version either and
275
+ * is refused BY NAME in `latestFromIndexPayload`, through the real parser.
210
276
  */
211
- export declare function fetchIndex(url?: string, timeout?: number): Promise<string>;
277
+ export declare function fetchIndex(command: readonly string[], timeout?: number): Promise<string>;
212
278
  /**
213
279
  * What a `registry` install upgrades with, ON THE TREE IT IS ACTUALLY IN.
214
280
  *
@@ -221,12 +287,20 @@ export declare function fetchIndex(url?: string, timeout?: number): Promise<stri
221
287
  * no `package.json` beside `node_modules`, `npm install --prefix` prunes every sibling package.
222
288
  * Both arms are the rows `README.md#updating` already documents.
223
289
  *
224
- * `@latest` IS NOT DECORATION. `npm install <pkg>` in a tree that already declares `<pkg>`
225
- * satisfies the existing range rather than moving it, which is a command that exits 0 having
226
- * changed nothing — the J46-4 defect, arrived at through npm's semantics rather than through
227
- * this code's.
290
+ * `@<latest>` AND NOT `@latest` (J65-19, #128). A bare `npm install <pkg>` in a tree that
291
+ * already declares `<pkg>` satisfies the existing range rather than moving it — a command that
292
+ * exits 0 having changed nothing, the J46-4 defect through npm's semantics. `@latest` fixed that
293
+ * and left a second hole: npm resolves the dist-tag AGAIN, at install time, against whatever
294
+ * registry it is configured for, so a mirror behind the index, or a tag moved between the two
295
+ * calls, installs something other than the version line 1 of the report compared — and the
296
+ * success line was printed from the compared number regardless. The spec now names the version
297
+ * this flag compared, and `update` reads the manifest back before it says `updated`.
298
+ *
299
+ * `--save-exact` WHEN THE PIN WAS EXACT: npm rewrites `"0.35.7"` to `"^0.35.7"` on this
300
+ * command unless told otherwise (observed in #128), and an operator who pinned exactly did so
301
+ * on purpose. The kept install `--install` makes carries npm's own caret, so it keeps it.
228
302
  */
229
- export declare function upgradeCommand(environment: Environment): string[];
303
+ export declare function upgradeCommand(environment: Environment, latest: string): string[];
230
304
  /**
231
305
  * `10` rather than `10.0`, because the sentence is read by a person, not parsed.
232
306
  *
@@ -237,9 +311,15 @@ export declare function upgradeCommand(environment: Environment): string[];
237
311
  */
238
312
  export declare function seconds(timeout: number): string;
239
313
  export interface UpdateOptions {
240
- readonly fetch?: (url: string, timeout: number) => Promise<string> | string;
314
+ /** The index request: handed the command `indexCommand` built and the timeout, answers the body. */
315
+ readonly fetch?: (command: string[], timeout: number) => Promise<string> | string;
241
316
  readonly installer?: (command: string[]) => [number, string];
242
- readonly url?: string;
317
+ /**
318
+ * What the tree reports AFTER the installer exited 0 — `readInstalledVersion` by default. A
319
+ * seam so a test can hand in the manifest npm would have written without an npm, and the
320
+ * `--update` conformance arms can drive the verify failure on both runtimes.
321
+ */
322
+ readonly installedVersion?: (environment: Environment) => string | null;
243
323
  readonly timeout?: number;
244
324
  readonly environment?: Environment;
245
325
  /**
@@ -11,13 +11,20 @@
11
11
  * EXACTLY THREE THINGS DIFFER FROM THE REFERENCE, and each is a genuinely different object
12
12
  * rather than a different spelling of one object:
13
13
  *
14
- * 1. THE URL AND THE JSON PATH. PyPI answers `pypi.org/pypi/bantamkit/json` and the version
15
- * sits at `info.version`; the npm registry answers `registry.npmjs.org/bantamkit-mcp/
16
- * latest` and it sits at the top level as `version`. Measured 2026-09-11 against the real
17
- * registry: the `/latest` document is the manifest of the `latest` dist-tag, 26 keys, and
18
- * `version` is `0.30.0`.
19
- * 2. THE COMMAND. `pip install --upgrade` there; `npm install` here, and WHICH `npm install`
20
- * is a measured question — see `upgradeCommand`.
14
+ * 1. THE QUESTION ASKED OF THE INDEX, AND THE JSON PATH. Until J65-19 (#135) each runtime
15
+ * fetched one hard-coded URL itself — `pypi.org/pypi/bantamkit/json` with the version at
16
+ * `info.version`, `registry.npmjs.org/bantamkit-mcp/latest` with it at the top level —
17
+ * so `npm_config_registry`, `.npmrc` and a mandatory proxy were honoured by the
18
+ * `npm install` this flag then ran and ignored by the version check in front of it.
19
+ * Now each side asks ITS OWN PACKAGE MANAGER what the index holds, under whatever
20
+ * registry, proxy and auth config that manager already reads: `npm view bantamkit-mcp@latest
21
+ * --json` here (the manifest of the `latest` dist-tag, `version` at the top level;
22
+ * measured against a loopback registry, `test/selfupdate.test.mjs`), and a `pip install
23
+ * --dry-run --report -` there (the version at `install[0].metadata.version`). See
24
+ * `indexCommand` and `fetchIndex`.
25
+ * 2. THE COMMAND. `pip install --upgrade bantamkit==<latest>` there; `npm install
26
+ * bantamkit-mcp@<latest>` here, and WHICH `npm install` is a measured question — see
27
+ * `upgradeCommand`. Both name the version they compared (J65-19, #128), never `latest`.
21
28
  * 3. THE FOUR `ROUTES` SENTENCES, which name those commands and the things they update.
22
29
  * `ephemeral`'s is not a translation of the reference's and must not be made into one:
23
30
  * see the note on that row.
@@ -44,7 +51,7 @@ import { readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'n
44
51
  import { basename, dirname, join, resolve } from 'node:path';
45
52
  import { fileURLToPath } from 'node:url';
46
53
  import { currentInstall, Undetermined } from './mcp/identity.js';
47
- import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin } from './npminstall.js';
54
+ import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin, spawnCommand, } from './npminstall.js';
48
55
  import { KEY as RECORD_KEY, loadRecord, recordPath, SOURCE_RECORD } from './updatecheck.js';
49
56
  // `PACKAGE`, `shlexJoin` and `runInstaller` moved to `npminstall.ts` in job51 so `--install` can
50
57
  // share the npm spawner without importing this module (see that file's header). Re-exported so
@@ -59,13 +66,38 @@ export { compareVersions, PACKAGE, runInstaller, shlexJoin };
59
66
  */
60
67
  export const PROGRAM = 'bantamkit-mcp';
61
68
  /**
62
- * The one URL this toolbox ever fetches. Divergent by construction (`INDEX_URL`).
63
- *
64
- * `/latest` and not the full packument: the packument carries every version ever published and
65
- * grows without bound, and the question here is one string. Measured 2026-09-11 — this document
66
- * is 26 keys and carries `version` at the top level.
69
+ * The command that asks the index what `latest` is — npm itself, never a URL of this module's
70
+ * own. Divergent by construction (the reference asks pip).
71
+ *
72
+ * WHY npm AND NOT `fetch` (J65-19, #135). The version check and the `npm install` that follows
73
+ * it must agree about WHICH registry they mean, and only npm knows: `npm_config_registry`, the
74
+ * project and user `.npmrc`, `proxy`/`https-proxy` and the `HTTPS_PROXY` environment, auth
75
+ * tokens for a private mirror, `strict-ssl`, `cafile`. Measured 2026-10-13 (#135): with
76
+ * `npm_config_registry=http://127.0.0.1:4873/` and `~/.npmrc registry=` set, the old `fetch`
77
+ * still asked `https://registry.npmjs.org/bantamkit-mcp/latest`. Node's own `fetch` cannot
78
+ * even honour `HTTPS_PROXY` without a dependency this toolbox refuses to add, so reading the
79
+ * registry out of npm's config and fetching it here would have fixed half the report.
80
+ *
81
+ * `@latest` resolved by npm, `--json` so the answer is the manifest of that dist-tag with
82
+ * `version` at the top level — the same document shape the old URL answered, measured against
83
+ * a loopback registry in `test/selfupdate.test.mjs`, so `latestFromIndexPayload` is unchanged.
84
+ * `--fetch-retries=0` because a registry that refuses the connection takes npm 70 seconds
85
+ * under its default retry schedule (measured, npm 11.6.2), which is seven times this flag's
86
+ * deadline: the operator would read "did not answer within 10 seconds" where the truth is
87
+ * `ECONNREFUSED`, and that truth is what `UNREACHABLE` is for.
88
+ *
89
+ * `--prefix` / `--global` MIRROR `upgradeCommand` EXACTLY, because npm reads the project
90
+ * `.npmrc` relative to the prefix it is given: a `view` asked from the operator's cwd and an
91
+ * `install` pointed at the kept tree could read two different `registry=` lines, which is the
92
+ * disagreement #135 is about.
67
93
  */
68
- export const INDEX_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
94
+ export function indexCommand(environment) {
95
+ return ['npm', 'view', ...npmLocation(environment), `${PACKAGE}@latest`, '--json', '--fetch-retries=0'];
96
+ }
97
+ /** The two words that point npm at the tree `--update` is about: `--global`, or `--prefix <root>`. */
98
+ function npmLocation(environment) {
99
+ return environment.global ? ['--global'] : ['--prefix', environment.root];
100
+ }
69
101
  /**
70
102
  * Explicit, and short enough that a person who typed the flag does not think it hung.
71
103
  * AS-7(3): the network is not on any other path here, so nothing else inherits this.
@@ -106,8 +138,13 @@ export const RESTART = 'restart the server: a running {program} keeps serving th
106
138
  // `--update` asked for an update, and a command that exits 0 having changed nothing is the
107
139
  // J46-4 defect. Exit 1 and not 2, because argparse already owns 2 for a usage error and
108
140
  // `--update` is not one.
109
- export const NO_ROUTE = '{program} {installed} is installed and the package index has {latest}, but this is a ' +
110
- '{shape} install, which --update will not touch. {route}';
141
+ /**
142
+ * `{article}` is `a` or `an` by the shape word's first letter (`article`): "a ephemeral
143
+ * install" shipped in every release up to 0.35.7 (#177), because the template carried the
144
+ * article and the shape was substituted after it.
145
+ */
146
+ export const NO_ROUTE = '{program} {installed} is installed and the package index has {latest}, but this is ' +
147
+ '{article} {shape} install, which --update will not touch. {route}';
111
148
  export const TIMED_OUT = 'the package index did not answer within {timeout} seconds; --update needs the network, ' +
112
149
  'and nothing was changed.';
113
150
  export const UNREACHABLE = 'the package index could not be reached: {reason}; --update needs the network, and ' +
@@ -122,6 +159,21 @@ export const COMMAND_FAILED = 'the update command exited {code}: {command}\n' +
122
159
  '{program} {installed} is still installed; nothing was changed.\n' +
123
160
  '{printed}\n' +
124
161
  '{output}';
162
+ /**
163
+ * The installer exited 0 and the install does not report the version it was told to install
164
+ * (J65-19, #128). The same shape as `COMMAND_FAILED` — the command on line 1, the installer's
165
+ * own words last — because it is the same kind of failure read off the disk instead of off
166
+ * the exit code. Measured 2026-10-13 (#128): with the index stubbed to 9.9.9 and an `npm`
167
+ * that recorded its argv and changed nothing, 0.35.7 printed `updated bantamkit-mcp from 0.35.7
168
+ * to 9.9.9.` and exited 0 with 0.35.7 still on disk. `{found}` is the version the manifest
169
+ * reports now, or `NO_INSTALLED_VERSION` when there is no readable manifest at all.
170
+ */
171
+ export const NOT_INSTALLED = 'the update command exited 0 but did not install {latest}: {command}\n' +
172
+ '{program} now reports {found}, not {latest}; the tree the command names did not take the update.\n' +
173
+ '{printed}\n' +
174
+ '{output}';
175
+ /** What `{found}` reads when no manifest could be read back after the installer ran. */
176
+ export const NO_INSTALLED_VERSION = 'no readable version';
125
177
  /**
126
178
  * The install shape itself could not be derived — `identity.Undetermined`, which is what an
127
179
  * origin that is neither an index nor a path on this machine raises (`git+https://…`). That
@@ -141,7 +193,15 @@ export const NO_VERSION_FIELD = 'the response carries no version string';
141
193
  * The fallthrough for a shape word `ROUTES` has never heard of — `INSTALL_SHAPES` growing a
142
194
  * sixth word. Not divergent: it names no package manager, so it is the reference's sentence.
143
195
  */
144
- export const NO_RECORDED_ROUTE = 'There is no recorded update route for a {shape} install, so --update will not guess one.';
196
+ export const NO_RECORDED_ROUTE = 'There is no recorded update route for {article} {shape} install, so --update will not guess one.';
197
+ /**
198
+ * `a` or `an` for the word that follows, by its first letter — the rule English applies to
199
+ * `ephemeral` and the reference's `_article` applies byte for byte. A letter test and not a
200
+ * table, because `INSTALL_SHAPES` can grow a word this file has never seen.
201
+ */
202
+ export function article(word) {
203
+ return /^[aeiou]/i.test(word) ? 'an' : 'a';
204
+ }
145
205
  /**
146
206
  * Per shape, what the real update route is — the answer to "then how DO I update this?".
147
207
  * DIVERGENT BY CONSTRUCTION and registered in `docs/porting.md` by J46-31: the reference's
@@ -213,18 +273,72 @@ export function installEnvironment(runningFile = fileURLToPath(import.meta.url))
213
273
  if (basename(here) === 'node_modules') {
214
274
  const root = dirname(here);
215
275
  let owned = false;
276
+ let exact = false;
216
277
  try {
217
- readFileSync(join(root, 'package.json'));
278
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
218
279
  owned = true;
280
+ exact = pinsExactly(manifest);
219
281
  }
220
282
  catch {
221
283
  owned = false;
222
284
  }
223
- return { root, global: !owned };
285
+ return { root, global: !owned, exact };
286
+ }
287
+ const parent = dirname(here);
288
+ if (parent === here)
289
+ return { root: '', global: true, exact: false };
290
+ here = parent;
291
+ }
292
+ }
293
+ /** Does this manifest pin `PACKAGE` to one version — a spec starting with a digit — in any dependency table? */
294
+ function pinsExactly(manifest) {
295
+ if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest))
296
+ return false;
297
+ for (const table of ['dependencies', 'devDependencies', 'optionalDependencies']) {
298
+ const deps = manifest[table];
299
+ if (typeof deps !== 'object' || deps === null)
300
+ continue;
301
+ const spec = deps[PACKAGE];
302
+ if (typeof spec === 'string' && /^\d/.test(spec.trim()))
303
+ return true;
304
+ }
305
+ return false;
306
+ }
307
+ /**
308
+ * The version the tree `--update` just pointed npm at reports NOW, read off its manifest, or
309
+ * `null` when there is no readable one. The default `installedVersion` seam (J65-19, #128):
310
+ * what the success line is allowed to say is what this returns, never what the index said.
311
+ */
312
+ export function readInstalledVersion(environment) {
313
+ if (environment.root === '')
314
+ return null;
315
+ try {
316
+ const manifest = JSON.parse(readFileSync(join(environment.root, 'node_modules', PACKAGE, 'package.json'), 'utf8'));
317
+ return typeof manifest.version === 'string' && manifest.version.trim() !== '' ? manifest.version.trim() : null;
318
+ }
319
+ catch {
320
+ return null;
321
+ }
322
+ }
323
+ /**
324
+ * The package root above a running `dist/` — the directory whose `package.json` is the one this
325
+ * build reads its version from — or `packageDirectory` itself when no manifest sits above it.
326
+ * The `checkout` route names "a source tree", and `<tree>/dist` is that tree's build output, not
327
+ * the tree (#177): `cli.ts` hands in `dirname(import.meta.url)`, which is `dist/`.
328
+ */
329
+ export function packageRoot(packageDirectory) {
330
+ let here = resolve(packageDirectory);
331
+ for (;;) {
332
+ try {
333
+ readFileSync(join(here, 'package.json'));
334
+ return here;
335
+ }
336
+ catch {
337
+ /* not here; look one level up */
224
338
  }
225
339
  const parent = dirname(here);
226
340
  if (parent === here)
227
- return { root: '', global: true };
341
+ return packageDirectory;
228
342
  here = parent;
229
343
  }
230
344
  }
@@ -272,57 +386,57 @@ export function latestFromIndexPayload(text) {
272
386
  }
273
387
  return version;
274
388
  }
275
- /** The User-Agent's version half, or nothing. A header, never an output — never a refusal. */
276
- function packageVersion() {
277
- try {
278
- const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
279
- return typeof manifest.version === 'string' ? manifest.version : '0';
280
- }
281
- catch {
282
- return '0';
283
- }
284
- }
285
389
  /**
286
- * The ONLY network call in this runtime. One request, one timeout, two failure words.
287
- *
288
- * THE PLATFORM'S `fetch`, NOT A DEPENDENCY. `package.json` declares exactly one runtime
289
- * dependency and the pure-node `npx` install is a standing ruling, so the HTTP client is the
290
- * one Node ships (>= 18, and `engines` already requires >= 20) and the deadline is
291
- * `AbortSignal.timeout`, which is the same object the abort arrives as.
292
- *
293
- * THE CONTRACT WITH `update` IS THE EXCEPTION TYPE — see `IndexTimeout`. Measured on Node
294
- * v25.2.1 against a local socket that accepts and never answers: the rejection is a
295
- * `DOMException` named `TimeoutError`. Everything else is a `TypeError: fetch failed` whose
296
- * `cause` carries the sentence worth printing — `connect ECONNREFUSED 127.0.0.1:49999`,
297
- * `getaddrinfo ENOTFOUND …` — so the cause's message is what `UNREACHABLE` interpolates and
298
- * the bare "fetch failed" never reaches an operator.
299
- *
300
- * A non-2xx is an `Error` and not a timeout, the way the reference turns `HTTPError` into a
301
- * plain `OSError`; and the body is decoded leniently, because a body that is not UTF-8 is not
302
- * a version either and should be refused BY NAME in `latestFromIndexPayload`.
390
+ * The ONLY path to the network in this runtime, and it goes through npm. One command, one
391
+ * deadline, two failure words.
392
+ *
393
+ * NOT THE PLATFORM'S `fetch` ANY MORE (J65-19, #135): see `indexCommand` for why the question
394
+ * is asked of npm. No new dependency either — `spawnCommand` is the spawner `--install` and the
395
+ * installer below already use. The deadline is `spawnSync`'s own `timeout`: npm is killed when
396
+ * it passes and the result carries `ETIMEDOUT`, which is the one fact that becomes
397
+ * `IndexTimeout`. Measured in `test/selfupdate.test.mjs` with a command that sleeps.
398
+ *
399
+ * THE CONTRACT WITH `update` IS THE EXCEPTION TYPE — see `IndexTimeout`. Everything that is
400
+ * not the deadline is a plain `Error` whose message is the sentence worth printing: with
401
+ * `--json`, npm writes its failure to STDOUT as `{"error": {"code", "summary", "detail"}}`
402
+ * (measured, npm 11.6.2: `E404` for a package the mirror does not carry, `ECONNREFUSED` with
403
+ * `request to http://127.0.0.1:1/bantamkit-mcp failed, reason: connect ECONNREFUSED` for a
404
+ * dead one), so `summary` is taken first — it names the URL npm actually asked, which is the
405
+ * configured registry and the thing an operator behind a mirror needs to see — then `code`,
406
+ * then the first line npm wrote to stderr, then the bare exit status. A missing `npm` is the
407
+ * spawn error's own message (`spawnSync npm ENOENT`).
408
+ *
409
+ * Nothing here parses a version: a body that is not the manifest is not a version either and
410
+ * is refused BY NAME in `latestFromIndexPayload`, through the real parser.
303
411
  */
304
- export async function fetchIndex(url = INDEX_URL, timeout = DEFAULT_TIMEOUT_SECONDS) {
305
- const deadline = AbortSignal.timeout(Math.max(1, Math.round(timeout * 1000)));
412
+ export async function fetchIndex(command, timeout = DEFAULT_TIMEOUT_SECONDS) {
413
+ const done = spawnCommand(command, Math.max(1, Math.round(timeout * 1000)));
414
+ if (done.error !== null) {
415
+ if (done.error.code === 'ETIMEDOUT')
416
+ throw new IndexTimeout(done.error.message);
417
+ throw new Error(done.error.message);
418
+ }
419
+ if (done.status !== 0)
420
+ throw new Error(npmFailureReason(done));
421
+ return done.stdout;
422
+ }
423
+ /** The one line of an npm failure worth printing, in the order `fetchIndex`'s docstring gives. */
424
+ function npmFailureReason(done) {
306
425
  try {
307
- const response = await fetch(url, {
308
- headers: { Accept: 'application/json', 'User-Agent': `${PROGRAM}/${packageVersion()}` },
309
- signal: deadline,
310
- });
311
- if (!response.ok) {
312
- throw new Error(`HTTP ${response.status} ${response.statusText}`.trimEnd());
426
+ const parsed = JSON.parse(done.stdout);
427
+ const error = parsed?.error;
428
+ if (typeof error === 'object' && error !== null) {
429
+ if (typeof error.summary === 'string' && error.summary.trim() !== '')
430
+ return error.summary.trim();
431
+ if (typeof error.code === 'string' && error.code.trim() !== '')
432
+ return error.code.trim();
313
433
  }
314
- const raw = await response.arrayBuffer();
315
- return new TextDecoder('utf-8').decode(raw);
316
434
  }
317
- catch (e) {
318
- if (e instanceof IndexTimeout)
319
- throw e;
320
- if (e instanceof Error && e.name === 'TimeoutError')
321
- throw new IndexTimeout(e.message);
322
- const cause = e.cause;
323
- const detail = cause instanceof Error && cause.message !== '' ? cause.message : null;
324
- throw new Error(detail ?? (e instanceof Error ? e.message : String(e)));
435
+ catch {
436
+ /* not npm's JSON error shape; fall through to stderr */
325
437
  }
438
+ const line = done.stderr.split('\n').map((l) => l.trim()).find((l) => l !== '');
439
+ return line ?? `exit ${done.status ?? 'unknown'}`;
326
440
  }
327
441
  /**
328
442
  * What a `registry` install upgrades with, ON THE TREE IT IS ACTUALLY IN.
@@ -336,15 +450,22 @@ export async function fetchIndex(url = INDEX_URL, timeout = DEFAULT_TIMEOUT_SECO
336
450
  * no `package.json` beside `node_modules`, `npm install --prefix` prunes every sibling package.
337
451
  * Both arms are the rows `README.md#updating` already documents.
338
452
  *
339
- * `@latest` IS NOT DECORATION. `npm install <pkg>` in a tree that already declares `<pkg>`
340
- * satisfies the existing range rather than moving it, which is a command that exits 0 having
341
- * changed nothing — the J46-4 defect, arrived at through npm's semantics rather than through
342
- * this code's.
453
+ * `@<latest>` AND NOT `@latest` (J65-19, #128). A bare `npm install <pkg>` in a tree that
454
+ * already declares `<pkg>` satisfies the existing range rather than moving it — a command that
455
+ * exits 0 having changed nothing, the J46-4 defect through npm's semantics. `@latest` fixed that
456
+ * and left a second hole: npm resolves the dist-tag AGAIN, at install time, against whatever
457
+ * registry it is configured for, so a mirror behind the index, or a tag moved between the two
458
+ * calls, installs something other than the version line 1 of the report compared — and the
459
+ * success line was printed from the compared number regardless. The spec now names the version
460
+ * this flag compared, and `update` reads the manifest back before it says `updated`.
461
+ *
462
+ * `--save-exact` WHEN THE PIN WAS EXACT: npm rewrites `"0.35.7"` to `"^0.35.7"` on this
463
+ * command unless told otherwise (observed in #128), and an operator who pinned exactly did so
464
+ * on purpose. The kept install `--install` makes carries npm's own caret, so it keeps it.
343
465
  */
344
- export function upgradeCommand(environment) {
345
- if (environment.global)
346
- return ['npm', 'install', '--global', `${PACKAGE}@latest`];
347
- return ['npm', 'install', '--prefix', environment.root, `${PACKAGE}@latest`];
466
+ export function upgradeCommand(environment, latest) {
467
+ const exact = environment.exact === true && !environment.global ? ['--save-exact'] : [];
468
+ return ['npm', 'install', ...npmLocation(environment), ...exact, `${PACKAGE}@${latest}`];
348
469
  }
349
470
  /**
350
471
  * `10` rather than `10.0`, because the sentence is read by a person, not parsed.
@@ -476,12 +597,12 @@ export function recordUpdate(latest, now) {
476
597
  export async function update(installed, origin, options = {}) {
477
598
  const ask = options.fetch ?? fetchIndex;
478
599
  const installer = options.installer ?? runInstaller;
479
- const url = options.url ?? INDEX_URL;
600
+ const installedVersion = options.installedVersion ?? readInstalledVersion;
480
601
  const timeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS;
481
602
  const environment = options.environment ?? installEnvironment();
482
603
  let body;
483
604
  try {
484
- body = await ask(url, timeout);
605
+ body = await ask(indexCommand(environment), timeout);
485
606
  }
486
607
  catch (e) {
487
608
  // ORDER IS LOAD-BEARING: `IndexTimeout` IS an `Error`, exactly as `TimeoutError` IS an
@@ -508,7 +629,7 @@ export async function update(installed, origin, options = {}) {
508
629
  return `${header}\n${UP_TO_DATE}`;
509
630
  if (order > 0)
510
631
  return `${header}\n${AHEAD}`;
511
- const command = upgradeCommand(environment);
632
+ const command = upgradeCommand(environment, latest);
512
633
  const rendered = shlexJoin(command);
513
634
  if (origin.shape !== 'registry') {
514
635
  // An unknown shape word is a fact about THIS code being behind `INSTALL_SHAPES`, not about
@@ -519,6 +640,7 @@ export async function update(installed, origin, options = {}) {
519
640
  program: PROGRAM,
520
641
  installed,
521
642
  latest,
643
+ article: article(origin.shape),
522
644
  shape: origin.shape,
523
645
  route: fill(route, {
524
646
  source: origin.source,
@@ -526,6 +648,7 @@ export async function update(installed, origin, options = {}) {
526
648
  command: rendered,
527
649
  installed,
528
650
  latest,
651
+ article: article(origin.shape),
529
652
  shape: origin.shape,
530
653
  }),
531
654
  }));
@@ -542,6 +665,23 @@ export async function update(installed, origin, options = {}) {
542
665
  output: printed,
543
666
  }));
544
667
  }
668
+ // THE SUCCESS LINE IS READ OFF THE DISK, NOT OFF THE INDEX (J65-19, #128). An installer that
669
+ // exited 0 has said nothing about which version it left behind: npm resolves its own spec,
670
+ // against its own registry, and `npm install` in a tree that already satisfies the range is
671
+ // a no-op that exits 0. So the tree's manifest is read back and compared with the version
672
+ // this flag compared, by the same comparator — `0.30` and `0.30.0` are one release — and a
673
+ // mismatch, or no readable manifest at all, is a refusal carrying the installer's words.
674
+ const found = installedVersion(environment);
675
+ if (found === null || compareVersions(found, latest) !== 0) {
676
+ throw new UpdateRefused(fill(NOT_INSTALLED, {
677
+ latest,
678
+ command: rendered,
679
+ program: PROGRAM,
680
+ found: found ?? NO_INSTALLED_VERSION,
681
+ printed: PRINTED,
682
+ output: printed,
683
+ }));
684
+ }
545
685
  return [
546
686
  header,
547
687
  fill(UPDATING, { command: rendered }),
@@ -593,9 +733,11 @@ export async function runUpdate(out, err, installed, packageDirectory, options =
593
733
  }
594
734
  // `source` is `null` for the shapes that HAVE no recorded origin rather than for one whose
595
735
  // path could not be read — `registry`, where the route is this flag itself, and `checkout`,
596
- // whose tree IS the thing to update. The running package directory is not a guess: it is
597
- // where the code being executed lives, and it is the tree the route says to `git pull`.
598
- let origin = { shape, source: recorded ?? packageDirectory };
736
+ // whose tree IS the thing to update. The running package's ROOT is not a guess: it is the
737
+ // directory whose manifest this build reads its version from, and it is the tree the route
738
+ // says to `git pull`. `packageDirectory` itself is `dist/` — the build output the route says
739
+ // a pull alone does not change — which is why it is walked up from (#177).
740
+ let origin = { shape, source: recorded ?? packageRoot(packageDirectory) };
599
741
  let current = installed;
600
742
  let chosen = options;
601
743
  // J51-5: an `npx` cache WITH a kept install updates the kept install — its version is the one