@henols/vice-mcp 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/version.ts CHANGED
@@ -1,248 +1,49 @@
1
- // The ONE authoritative implementation of this project's version-resolution
2
- // algorithm (D-5). Before this file existed the repo carried FOUR
3
- // hand-maintained version strings and none of them were true:
4
- // `src/mcp/vice/package.json` and `installer/package.json` were stale
5
- // placeholders CI never touched between releases, `.claude-plugin/plugin.json`
6
- // was bumped by NO automation at all, and `vice-proxy.ts`'s own
7
- // `PROXY_VERSION` literal -- advertised to every MCP client over
8
- // `initialize` -- sat twelve patches behind npm's actual `latest`. This file
9
- // exists so there is exactly one place that (a) parses the repo-root
10
- // `VERSION` template, (b) resolves it against a published version per D-2's
11
- // four rules, and (c) answers "what version am I, right now, at runtime"
12
- // (D-4) -- every other consumer (the CLI in `scripts/version.mjs`,
13
- // `vice-proxy.ts`'s `PROXY_VERSION`, CI's `release-on-merge` job) calls INTO
14
- // this module rather than re-deriving any part of the algorithm locally.
1
+ // Answers one question: what version is this server, right now, at runtime.
15
2
  //
16
- // Do NOT reimplement the resolution rules (`pinned` / `no-published` /
17
- // `prefix-differs` / `prefix-matches`) anywhere else in this repo -- not in
18
- // `scripts/version.mjs`, not in CI YAML, not in `installer/bin/cli.mjs`.
19
- // `version.test.ts`'s "single-implementation guard" test greps
20
- // `scripts/version.mjs` for the rule literals to catch exactly that
21
- // regression.
3
+ // WHY THIS FILE EXISTS: `vice-proxy.ts` advertises a version to every MCP
4
+ // client over `initialize`. That string used to be a hand-maintained
5
+ // `PROXY_VERSION` literal and it sat twelve patches behind npm's actual
6
+ // `latest`, because nothing bumped it. One function now derives it from the
7
+ // package.json that ships beside the code, so it cannot drift again.
22
8
  //
23
- // Do NOT import `repo-root.ts` from this file. That module's `repoRoot()`
24
- // carries a documented side effect (`ensureResourcesInstalled()`, fired at
25
- // module-load time) this seam must never trigger just by being imported --
26
- // `scripts/version.mjs` and any future test that imports this module for
27
- // its pure functions alone would otherwise deploy host launcher resources as
28
- // a side effect of asking what version something is. Per this repo's own
29
- // convention (see `hostpath.ts`/`containerpath.ts`), the workspace root
30
- // arrives here as an explicit argument (`readTemplate(repoRootDir)`) or a
31
- // caller-supplied lazy thunk (`runtimeVersion({ repoRoot })`), never as an
32
- // import this module resolves itself.
33
- import { existsSync, readFileSync } from "node:fs";
34
- import { join } from "node:path";
35
-
36
- /** The single self-evident placeholder every derived, publishable version
37
- * string (the two npm package.json `.version` fields, the installer's
38
- * `@henols/vice-mcp` dependency pin, and the three plugin-manifest version
39
- * fields) carries in the working tree (R-2). Valid semver -- `npm pack`
40
- * accepts it -- but unmistakably not a release. Overwritten at publish time
41
- * by `npm version` (npm packages) or `scripts/version.mjs stamp` (plugin
42
- * manifests); never hand-edited. */
9
+ // This file once also carried a bespoke version-RESOLUTION algorithm (a
10
+ // `VERSION` template parsed against the published version under four named
11
+ // rules) used only by the auto-patch-on-merge release job. That job and its
12
+ // template are gone: the git tag is the version now, and the publish workflow
13
+ // reads it straight from the ref. Do not reintroduce a second version
14
+ // algorithm here -- if you need to know what a release will be called, look
15
+ // at the tag.
16
+ //
17
+ // Do NOT import `repo-root.ts` from this file, and do not reintroduce a
18
+ // `repoRoot` option. That module's `repoRoot()` fires
19
+ // `ensureResourcesInstalled()` at module-load time, and asking what version
20
+ // something is must never deploy host launcher resources as a side effect.
21
+ // The option existed to locate a `VERSION` template that no longer exists.
22
+ import { readFileSync } from "node:fs";
23
+
24
+ /** The placeholder both publishable `package.json` `.version` fields carry in
25
+ * the working tree. Valid semver -- `npm pack` accepts it -- but unmistakably
26
+ * not a release. `npm version` overwrites it at publish time from the git tag;
27
+ * never hand-edited. */
43
28
  export const DEV_PLACEHOLDER = "0.0.0-dev";
44
29
 
45
- export type ResolveRule = "pinned" | "no-published" | "prefix-differs" | "prefix-matches";
46
-
47
- export interface ResolveResult {
48
- version: string;
49
- rule: ResolveRule;
50
- template: string;
51
- published: string | null;
52
- }
53
-
54
- // Leading zeros are rejected (bare "0" is the only zero-value exception) --
55
- // SemVer 2.0.0 SS2 forbids leading-zero numeric identifiers, and this
56
- // component is echoed VERBATIM into the resolved string for literal (non-`-`)
57
- // slots, so "1.00.0" must be rejected here rather than surfacing later as an
58
- // npm-publish-time failure (MED-2).
59
- const TEMPLATE_COMPONENT = /^(0|[1-9]\d*|-)$/;
60
-
61
- /**
62
- * Parse a version TEMPLATE (not a resolved version) into its three
63
- * dot-separated components. Each component must be either a non-negative
64
- * integer literal or the literal string `-` (an auto-managed slot, D-2).
65
- * Throws on anything else: wrong component count, a non-numeric/non-`-`
66
- * component, a blank/whitespace-only string, OR a literal component
67
- * appearing after a `-` component (MED-1) -- D-2's "literal prefix" wording,
68
- * and every row of CONTEXT.md's worked-example table, only ever describe
69
- * dashes trailing literals, never leading or interleaved. A shape like
70
- * "-.2.3" or "0.-.2" has no defined resolution semantics and must be
71
- * rejected at the one seam that owns validating the hand-edited VERSION
72
- * file, not silently resolved into something CONTEXT.md never specified.
73
- */
74
- export function parseTemplate(raw: string): string[] {
75
- const trimmed = raw.trim();
76
- if (trimmed === "") {
77
- throw new Error(`version.ts: malformed template ${JSON.stringify(raw)} -- empty`);
78
- }
79
- const parts = trimmed.split(".");
80
- if (parts.length !== 3) {
81
- throw new Error(
82
- `version.ts: malformed template ${JSON.stringify(raw)} -- expected exactly 3 dot-separated components, got ${parts.length}`
83
- );
84
- }
85
- let sawDash = false;
86
- for (const part of parts) {
87
- if (part === "-") {
88
- sawDash = true;
89
- continue;
90
- }
91
- if (sawDash) {
92
- throw new Error(
93
- `version.ts: malformed template ${JSON.stringify(raw)} -- literal component ${JSON.stringify(part)} cannot follow a "-" component; dashes only trail literals (D-2)`
94
- );
95
- }
96
- if (!TEMPLATE_COMPONENT.test(part)) {
97
- throw new Error(
98
- `version.ts: malformed template ${JSON.stringify(raw)} -- component ${JSON.stringify(part)} is neither an integer (no leading zeros) nor "-"`
99
- );
100
- }
101
- }
102
- return parts;
103
- }
104
-
105
- /**
106
- * Parse a PUBLISHED version string into a 3-tuple of numbers, stripping any
107
- * `-prerelease` / `+build` suffix first. Returns null (never throws) when
108
- * the input is null, not parseable, or does not have exactly 3 numeric
109
- * dot-separated components -- callers treat null exactly like "nothing is
110
- * published" (D-2 rule 4).
111
- */
112
- function parsePublished(published: string | null): number[] | null {
113
- if (published == null) return null;
114
- const core = published.split(/[-+]/, 1)[0];
115
- const parts = core.split(".");
116
- if (parts.length !== 3) return null;
117
- // Strict plain-decimal-digit validation (MED-4) -- deliberately NOT
118
- // `Number(p)` + `Number.isInteger`, which also accepts hex-like literals
119
- // ("0x2" -> 2), exponential notation ("5e2" -> 500), and whitespace-padded
120
- // numbers. Those would silently coerce malformed `--published` input or an
121
- // unexpected `npm view` response into a number that doesn't reflect the
122
- // original text; this must instead fall back to null (== "no-published",
123
- // D-2 rule 4) exactly like any other unparseable input, matching the
124
- // strict digit validation `parseTemplate`'s `TEMPLATE_COMPONENT` already
125
- // uses for the same kind of input.
126
- if (!parts.every((p) => /^\d+$/.test(p))) return null;
127
- return parts.map((p) => Number(p));
128
- }
129
-
130
- /**
131
- * The D-2 resolution algorithm, in full. `template` must already be
132
- * well-formed (call `parseTemplate` first, or pass through unchanged --
133
- * this function calls it internally so a malformed template always throws
134
- * here too). `published` is the raw published-version string (or null);
135
- * this function does its own stripping/parsing via `parsePublished`.
136
- */
137
- export function resolveVersion(template: string, published: string | null): ResolveResult {
138
- const components = parseTemplate(template);
139
-
140
- if (!components.includes("-")) {
141
- return { version: components.join("."), rule: "pinned", template, published };
142
- }
143
-
144
- const pub = parsePublished(published);
145
-
146
- if (pub === null) {
147
- const resolved = components.map((c) => (c === "-" ? "0" : c));
148
- return { version: resolved.join("."), rule: "no-published", template, published };
149
- }
150
-
151
- const prefixMatches = components.every((c, i) => c === "-" || Number(c) === pub[i]);
152
-
153
- if (!prefixMatches) {
154
- const resolved = components.map((c) => (c === "-" ? "0" : c));
155
- return { version: resolved.join("."), rule: "prefix-differs", template, published };
156
- }
157
-
158
- let firstDashSeen = false;
159
- const resolved = components.map((c, i) => {
160
- if (c !== "-") return c;
161
- if (!firstDashSeen) {
162
- firstDashSeen = true;
163
- // Guard (LOW-1): `pub[i] + 1` on a published component at
164
- // Number.MAX_SAFE_INTEGER would silently lose precision to float
165
- // rounding and could fail to actually increment, violating this
166
- // seam's "always publishes something new" invariant. Purely
167
- // theoretical for real-world semver (patch counts in the billions are
168
- // not realistic) but the arithmetic is load-bearing enough to fail
169
- // loud rather than silently miscompute.
170
- if (pub[i] >= Number.MAX_SAFE_INTEGER) {
171
- throw new Error(
172
- `version.ts: published component ${pub[i]} at index ${i} is at or beyond Number.MAX_SAFE_INTEGER -- refusing to increment (would lose precision)`
173
- );
174
- }
175
- return String(pub[i] + 1);
176
- }
177
- return "0";
178
- });
179
- return { version: resolved.join("."), rule: "prefix-matches", template, published };
180
- }
181
-
182
- /**
183
- * Compare two plain, fully-resolved (no `-`, no prerelease) semver-shaped
184
- * version strings as a numeric 3-tuple. Returns -1/0/1. Throws if either
185
- * side does not parse as 3 numeric components -- this is for comparing
186
- * RESOLVED versions, not templates.
187
- */
188
- export function compareVersions(a: string, b: string): -1 | 0 | 1 {
189
- const pa = parsePublished(a);
190
- const pb = parsePublished(b);
191
- if (pa === null) throw new Error(`version.ts: compareVersions() cannot parse ${JSON.stringify(a)}`);
192
- if (pb === null) throw new Error(`version.ts: compareVersions() cannot parse ${JSON.stringify(b)}`);
193
- for (let i = 0; i < 3; i++) {
194
- if (pa[i] < pb[i]) return -1;
195
- if (pa[i] > pb[i]) return 1;
196
- }
197
- return 0;
198
- }
199
-
200
- /**
201
- * Read `<repoRootDir>/VERSION`, trimmed. Returns the empty string for an
202
- * existing-but-blank (or whitespace-only) file, and null ONLY when the file
203
- * is absent (never throws on either outcome) -- do not treat `=== null` as
204
- * the sole "no template" signal; an empty string is also "no usable
205
- * template", and every current caller checks for it via truthiness (LOW-3).
206
- * Does NOT validate the template shape -- `resolveVersion`/`parseTemplate`
207
- * do that; this function's only job is the filesystem read.
208
- */
209
- export function readTemplate(repoRootDir: string): string | null {
210
- const path = join(repoRootDir, "VERSION");
211
- if (!existsSync(path)) return null;
212
- try {
213
- return readFileSync(path, "utf8").trim();
214
- } catch {
215
- return null;
216
- }
217
- }
218
-
219
30
  export interface RuntimeVersionOptions {
31
+ /** Path to the `package.json` shipped beside the running code. */
220
32
  pkgJsonPath?: string;
221
- /** LAZY -- see this file's header and the precedence note below. Only
222
- * called when `pkgJsonPath`'s own version is absent or is
223
- * DEV_PLACEHOLDER, so a published tarball (which has a real version and
224
- * no repo-root VERSION file) never calls this and never risks the
225
- * stderr note `repoRoot()` implementations may emit. */
226
- repoRoot?: () => string | undefined;
227
33
  }
228
34
 
229
35
  /**
230
- * D-4's runtime precedence, synchronous and never throwing:
36
+ * Runtime precedence, synchronous and never throwing:
231
37
  *
232
38
  * 1. `pkgJsonPath`'s own `.version`, when present and not DEV_PLACEHOLDER
233
- * -- the published-tarball path: `npm version` already stamped it.
234
- * 2. Otherwise call `opts.repoRoot?.()` and, if it yields a directory,
235
- * read that directory's `VERSION` template. A pinned template (no `-`)
236
- * returns verbatim; a template containing `-` resolves every `-` to 0
237
- * and appends a `-dev` prerelease tag, so a dev checkout never claims
238
- * to be a release build.
239
- * 3. Otherwise DEV_PLACEHOLDER.
39
+ * -- the published-tarball path: `npm version` stamped it from the git
40
+ * tag at publish time.
41
+ * 2. Otherwise DEV_PLACEHOLDER, which is what a git checkout reports.
240
42
  *
241
- * The whole body is wrapped in try/catch: any unexpected failure (a
242
- * malformed package.json, a repoRoot() thunk that throws, a malformed
243
- * VERSION template) degrades to DEV_PLACEHOLDER rather than crashing the
244
- * caller -- this is the one runtime-facing entry point in this file, and a
245
- * standalone MCP server must never fail to start over a version string.
43
+ * The body is wrapped in try/catch: a missing or malformed package.json
44
+ * degrades to DEV_PLACEHOLDER rather than crashing the caller. This is the
45
+ * one runtime-facing entry point in this file, and a standalone MCP server
46
+ * must never fail to start over a version string.
246
47
  */
247
48
  export function runtimeVersion(opts: RuntimeVersionOptions = {}): string {
248
49
  try {
@@ -257,19 +58,6 @@ export function runtimeVersion(opts: RuntimeVersionOptions = {}): string {
257
58
  // No readable/parseable package.json at pkgJsonPath -- fall through.
258
59
  }
259
60
  }
260
-
261
- const root = opts.repoRoot?.();
262
- if (root) {
263
- const template = readTemplate(root);
264
- if (template) {
265
- const components = parseTemplate(template);
266
- if (!components.includes("-")) {
267
- return components.join(".");
268
- }
269
- const resolved = components.map((c) => (c === "-" ? "0" : c));
270
- return `${resolved.join(".")}-dev`;
271
- }
272
- }
273
61
  } catch {
274
62
  // Degrade to the placeholder below -- see this function's own doc
275
63
  // comment for why nothing here may throw.
package/vice-proxy.ts CHANGED
@@ -302,21 +302,23 @@ const HERE_DIR = dirname(fileURLToPath(import.meta.url));
302
302
  //
303
303
  // `RESOLVED_BINARY.binPath` is what `vice_ping`'s `resolvedBinaryPath` field
304
304
  // reports (see stock-dispatch.ts's `handlePing()`). It is resolved exactly
305
- // ONCE here, at MCP-server process startup, by a bare `x64sc` `$PATH` probe
306
- // run in THIS process's own environment -- it is NOT re-probed per request
307
- // and has no connection to the broker, a separate, already-running process
308
- // that leases whichever instance it chose to whatever request comes in. On a
309
- // host where bare `x64sc` resolves to one build, `resolvedBinaryPath` reports
310
- // that build's path on every ping, even when the broker actually launched
311
- // (or later recycled to) a different one. The authoritative per-instance
312
- // answer lives in the broker's own launch record (`epoch.json`'s `vice_bin`
313
- // field, written by `broker-epoch.mts`) -- Phase 8.2 plan 04's own
305
+ // ONCE here, at MCP-server process startup -- Phase 60 (LOC-01/LOC-02) routes
306
+ // that resolution through the tool-location seam (a `.c64-re-tools/tools.json`
307
+ // entry for `x64sc`, then a bare `x64sc` `$PATH` probe, in THIS process's own
308
+ // environment) rather than a `$PATH` probe alone -- it is NOT re-probed per
309
+ // request and has no connection to the broker, a separate, already-running
310
+ // process that leases whichever instance it chose to whatever request comes
311
+ // in. On a host where the seam resolves to one build, `resolvedBinaryPath`
312
+ // reports that build's path on every ping, even when the broker actually
313
+ // launched (or later recycled to) a different one. The authoritative
314
+ // per-instance answer lives in the broker's own launch record (`epoch.json`'s
315
+ // `vice_bin` field, written by `broker-epoch.mts`) -- Phase 8.2 plan 04's own
314
316
  // walkthrough had to route around this field entirely and prove backend
315
317
  // identity from that launch record plus a live `ps -o args=` read instead
316
318
  // (2026-08-19 finding, closed as a documentation fix by Phase 15 plan 15-09
317
319
  // rather than a per-request requery, which would be a behavioural change out
318
320
  // of a disposition phase's remit).
319
- const RESOLVED_BINARY = backendDetect.resolvedBackend();
321
+ const RESOLVED_BINARY = backendDetect.resolvedBackend({ toolsDir: toolsDir({ from: HERE_DIR }), projectRoot: repoRoot({ from: HERE_DIR }) });
320
322
 
321
323
  // -------------------------------------------------------------- JSON-RPC
322
324
  //
@@ -395,16 +397,14 @@ process.stdout.on("error", (err) => {
395
397
  // repo-root `VERSION` template -- rendered as `<resolved>-dev` -- only in a
396
398
  // git checkout, degrading to `0.0.0-dev` if neither is available. Reused,
397
399
  // unchanged, as MCPServer's own `version` field below.
398
- const PROXY_VERSION = runtimeVersion({
399
- pkgJsonPath: join(HERE_DIR, "package.json"),
400
- repoRoot: () => repoRoot(),
401
- });
400
+ const PROXY_VERSION = runtimeVersion({ pkgJsonPath: join(HERE_DIR, "package.json") });
402
401
 
403
402
  // --------------------------------------------------------------- tools/list
404
403
  //
405
404
  // A pure, offline read of the committed schema snapshot (decision D-C).
406
- // `refresh-manifest.mjs` is the ONLY writer of that file -- this handler
407
- // never fetches, never awaits a network call, and never throws. Any problem
405
+ // Nothing regenerates that file from a live host -- it is committed and
406
+ // changed by hand. This handler never fetches, never awaits a network
407
+ // call, and never throws. Any problem
408
408
  // with the snapshot (absent, unparseable, wrong shape) degrades to a
409
409
  // well-formed empty `tools` array plus one stderr line naming the path and
410
410
  // the reason, never a fetch and never a hang.
@@ -503,8 +503,8 @@ const RESULT_CONTINUE_TOOL: ToolDefinition = {
503
503
 
504
504
  // The recycle tool (plan 01.3-01, task 1): the only new HOST-SIDE ACTION
505
505
  // this phase adds. Served entirely proxy-local -- like RESULT_CONTINUE_TOOL
506
- // above, it is never in tools-manifest.json (RESEARCH Key Finding 3), so a
507
- // manifest regenerate can never drop it. Deliberately split from
506
+ // above, it is never in tools-manifest.stock.json (RESEARCH Key Finding 3),
507
+ // so a manifest edit can never drop it. Deliberately split from
508
508
  // vice_diagnose (D-03): this tool NEVER gates on a verdict, so there is no
509
509
  // "confirm"/"mode" argument and no shared state between the two tools to
510
510
  // keep in sync -- the separation itself is the safety.
@@ -1584,11 +1584,11 @@ tools[DIAGNOSE_TOOL.name] = buildViceTool(stockDispatch.resolveAdvertisedToolDef
1584
1584
  // Backend-INDEPENDENT by construction (plan 29-01): the anno_* family never
1585
1585
  // touches VICE at all -- it reaches a PROXY-LOCAL SQLite annotation store
1586
1586
  // this repo owns, opened and closed inside the runner itself, so there is no
1587
- // fork/stock distinction to make. The family is in NEITHER
1588
- // tools-manifest.json NOR tools-manifest.stock.json: both are regenerated by
1589
- // refresh-manifest.ts from a live HOST VICE server's own tools/list, and a
1590
- // local store file is never that host -- a hand-added entry in either
1591
- // manifest would be silently wiped on the next refresh.
1587
+ // fork/stock distinction to make. The family is deliberately absent from
1588
+ // tools-manifest.stock.json, which records what a live HOST VICE server
1589
+ // answers and never a proxy-local store file. Registering the family here,
1590
+ // rather than listing it there, keeps that manifest an honest record of the
1591
+ // emulator surface.
1592
1592
  // Registered here via buildViceTool() directly (the SAME pattern
1593
1593
  // RESULT_CONTINUE_TOOL above uses), so no anno_* runner ever reaches
1594
1594
  // stockDispatch: there is no generic-dispatch surface left anywhere in this