@henols/vice-mcp 0.1.12 → 0.2.1

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.
@@ -75,6 +75,29 @@ package**, so ACME's licence does not attach to anything shipped. ACME never
75
75
  appears in `.claude/mcp/vice/package.json`'s `files[]`, `dependencies`, or
76
76
  `devDependencies` — it is an apt/CI-installed tool, never an npm package.
77
77
 
78
+ ## Build/CI tools — not incorporated: regenerator2000
79
+
80
+ regenerator2000 is invoked as an **external CLI subprocess** (Phase 10's
81
+ `R2000-09` bootstrap and `R2000-06` reassembly proof) against a real,
82
+ locally-installed `regenerator2000` binary. **No regenerator2000 source,
83
+ data table, or output is included in this repository or in either
84
+ published package**, so its licence does not attach to anything shipped.
85
+
86
+ Its licence is **`MIT OR Apache-2.0`** — dual, at the user's option —
87
+ confirmed from the crate's own crates.io licence field, with both
88
+ `LICENSE-MIT` and `LICENSE-APACHE` shipping in the crate. (Do not read this
89
+ as Apache-2.0 alone: that stale, single-licence claim is what `R2000-03`'s
90
+ own requirement text and `.planning/notes/regenerator2000-integration.md`
91
+ still carried before Phase 10 corrected it here.)
92
+
93
+ Verified version `0.9.20`, published 2026-07-11 by `ricardoquesada`
94
+ (matching the linked GitHub repository owner), checked 2026-08-20 — so this
95
+ provenance claim is re-checkable against a specific release.
96
+
97
+ regenerator2000 never appears in `.claude/mcp/vice/package.json`'s
98
+ `files[]`, `dependencies`, or `devDependencies` — it is a `cargo
99
+ install`-provided tool, never an npm package.
100
+
78
101
  ## Explicitly NOT a source: VICE
79
102
 
80
103
  VICE is GPL-2 and this repository is MIT. **No opcode fact, protocol
@@ -196,8 +196,33 @@ export interface BackendCacheRecord {
196
196
  * quad; only a live VICE_INFO reply over an established connection can. */
197
197
  versionQuad?: string;
198
198
  cpuHistoryAvailable?: boolean;
199
+ /** CR-01 (07-REVIEW.md re-review): the CLIENT-side schema version that
200
+ * produced `cpuHistoryAvailable`. The version quad answers "is this the
201
+ * same VICE build?"; it cannot answer "was the code that decided this
202
+ * capability the code running now?". Without this field a capability
203
+ * answer derived by a buggy parser was un-invalidatable by any code
204
+ * change -- only a VICE upgrade could clear it. A record whose value
205
+ * differs from CAPABILITY_SCHEMA_VERSION (including ABSENT, which is
206
+ * every record written before this field existed) reads back as `stale`.
207
+ * Bump CAPABILITY_SCHEMA_VERSION in the same commit as any change to how
208
+ * a capability is probed or decoded. */
209
+ capabilitySchema?: number;
199
210
  }
200
211
 
212
+ /** The client-side capability-decision schema version stamped into every
213
+ * capability record this module writes (see BackendCacheRecord's own field
214
+ * comment). BUMP THIS whenever the client's capability probing or the
215
+ * decoding it depends on changes, so records decided by the older code are
216
+ * re-probed instead of trusted.
217
+ *
218
+ * History:
219
+ * 1 -- plan 02-08's original probe (implicit; never written to disk).
220
+ * 2 -- CR-01 fix: decode failures are no longer persisted at all, and the
221
+ * CPUHISTORY_GET body layout was corrected (07-12). Every record
222
+ * written before this constant existed lacks the field and is
223
+ * therefore stale, which is exactly the intent. */
224
+ export const CAPABILITY_SCHEMA_VERSION = 2;
225
+
201
226
  function isPlainObject(value: unknown): value is Record<string, unknown> {
202
227
  return typeof value === "object" && value !== null && !Array.isArray(value);
203
228
  }
@@ -243,6 +268,7 @@ function readCacheRecord(supervisorDir: string): BackendCacheRecord | null {
243
268
  };
244
269
  if (typeof parsed.versionQuad === "string") record.versionQuad = parsed.versionQuad;
245
270
  if (typeof parsed.cpuHistoryAvailable === "boolean") record.cpuHistoryAvailable = parsed.cpuHistoryAvailable;
271
+ if (typeof parsed.capabilitySchema === "number") record.capabilitySchema = parsed.capabilitySchema;
246
272
  return record;
247
273
  }
248
274
 
@@ -511,11 +537,23 @@ export function resolvedBackend(deps: ResolvedBackendDeps = {}): ResolvedBackend
511
537
  export interface CapabilityRecordResult {
512
538
  versionQuad?: string;
513
539
  cpuHistoryAvailable?: boolean;
514
- /** True only when `observedVersionQuad` was given AND differs from the
515
- * stored value -- the caller has just seen a DIFFERENT VICE build than
516
- * whatever wrote this record (the binary was swapped since the last
517
- * capability determination), so the stored answer cannot be trusted and
518
- * must be re-determined rather than reused. */
540
+ /** The client-side schema version stamped on the record that was read
541
+ * (absent for any record written before CAPABILITY_SCHEMA_VERSION
542
+ * existed). Surfaced so a caller can log WHY a record was stale. */
543
+ capabilitySchema?: number;
544
+ /** True when the caller has just seen a DIFFERENT VICE build than
545
+ * whatever wrote this record (`observedVersionQuad` was given AND differs
546
+ * from the stored value -- the binary was swapped since the last
547
+ * capability determination), OR when the record's `capabilitySchema` is
548
+ * not the CAPABILITY_SCHEMA_VERSION this client decides capabilities
549
+ * with. Either way the stored answer cannot be trusted and must be
550
+ * re-determined rather than reused.
551
+ *
552
+ * CR-01 (07-REVIEW.md re-review) added the schema half: keying staleness
553
+ * on the version quad alone meant a capability answer decided by a buggy
554
+ * client parser survived every subsequent parser fix, and could only be
555
+ * cleared by upgrading VICE or hand-deleting a file under
556
+ * .vice-supervisor/ that nothing tells the user about. */
519
557
  stale: boolean;
520
558
  }
521
559
 
@@ -547,12 +585,20 @@ export function readCapabilityRecord(binPath: string, deps: CapabilityDeps = {})
547
585
  if (!existing || existing.resolvedPath !== resolvedPath) return null;
548
586
  if (existing.versionQuad === undefined && existing.cpuHistoryAvailable === undefined) return null;
549
587
 
550
- const stale =
588
+ const versionMismatch =
551
589
  deps.observedVersionQuad !== undefined &&
552
590
  existing.versionQuad !== undefined &&
553
591
  existing.versionQuad !== deps.observedVersionQuad;
554
-
555
- return { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
592
+ // CR-01: a record decided by a different client-side capability schema is
593
+ // as untrustworthy as one decided against a different binary. Absent
594
+ // counts as a mismatch -- that is every record written before the field
595
+ // existed, i.e. every record a possibly-broken parser could have written.
596
+ const schemaMismatch = existing.capabilitySchema !== CAPABILITY_SCHEMA_VERSION;
597
+ const stale = versionMismatch || schemaMismatch;
598
+
599
+ const result: CapabilityRecordResult = { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
600
+ if (existing.capabilitySchema !== undefined) result.capabilitySchema = existing.capabilitySchema;
601
+ return result;
556
602
  }
557
603
 
558
604
  /** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
@@ -591,5 +637,9 @@ export function writeCapabilityRecord(
591
637
  probedAt: existing.probedAt,
592
638
  versionQuad: capability.versionQuad,
593
639
  cpuHistoryAvailable: capability.cpuHistoryAvailable,
640
+ // CR-01: stamp WHICH client decided this, so a later parser change
641
+ // invalidates it. Never accept this from the caller -- it describes this
642
+ // module's own code, not anything the caller observed.
643
+ capabilitySchema: CAPABILITY_SCHEMA_VERSION,
594
644
  });
595
645
  }
@@ -0,0 +1,388 @@
1
+ // capability-registry.ts
2
+ //
3
+ // WHY THIS FILE EXISTS (BACK-05): today, a tool name the ACTIVE backend does
4
+ // not advertise falls straight through vice-proxy.ts's CallToolRequestSchema
5
+ // override to its generic `Unknown tool: ${name}` fallback -- the exact same
6
+ // message a genuine typo gets. That is indistinguishable and unhelpful: a
7
+ // caller cannot tell "you misspelled this" from "this tool exists, but only
8
+ // on the other backend, for this specific reason." This module is the ONE
9
+ // authoritative place holding that per-backend capability data -- names,
10
+ // reason categories, reason text, and which backend actually provides each
11
+ // one -- so a future call site (the runtime refusal wired in plan 08-02, the
12
+ // generated support table in plan 08-03, the skill-text lint in plan 08-04)
13
+ // reads exactly one source rather than re-deriving or hand-copying it.
14
+ //
15
+ // WHAT NOT TO DO: do not hand-maintain a second copy of this data anywhere
16
+ // else in the repo (D-E; see CLAUDE.md's "re-deriving a cross-cutting seam
17
+ // locally" anti-pattern). If a consumer needs this data in a different
18
+ // shape, import CAPABILITY_REGISTRY and reshape it there -- never re-type
19
+ // the 26 entries or their reasons from memory.
20
+ //
21
+ // SECURITY POSTURE: this module is a READ-ONLY MESSAGE-TEXT LOOKUP and is
22
+ // NEVER an authorization boundary. DENY_LIST (vice.ts) remains the only
23
+ // refusal in this tree that is a security control; that check runs first, at
24
+ // every call site, and this module never overrides or duplicates it. Every
25
+ // string held here is already public: it names only information already
26
+ // published in docs/stock-vice-parity.md and this public repository -- no
27
+ // credential, no secret, no host, no port, no path, and no tool name that is
28
+ // not already present in one of the two shipped manifests
29
+ // (tools-manifest.json, tools-manifest.stock.json).
30
+ //
31
+ // This is the exact shape vice.ts's DENY_LIST / denyListRefusalMessage()
32
+ // already established: one exported readonly array, one exported
33
+ // message-rendering function, keyed by hazard/reason shape rather than one
34
+ // wording reused for every entry -- because telling a caller the wrong
35
+ // reason shape for what is otherwise the same permanent refusal invites a
36
+ // pointless retry (that function's own doc comment makes the identical
37
+ // point).
38
+ //
39
+ // EXCLUDED, DELIBERATELY (see docs/stock-vice-parity.md and
40
+ // 08-RESEARCH.md's "Capability Delta Registry"):
41
+ // - "vice_diagnose" and "vice_recycle" are NOT capability gaps: they are
42
+ // synthetic, proxy-local tools registered on BOTH backends by
43
+ // vice-proxy.ts's buildBackendAwareTool()/resolveAdvertisedToolDefinition()
44
+ // synthetic-registration call sites, never listed in either raw manifest
45
+ // JSON file. A naive set-difference over the two manifests misclassifies
46
+ // them as a divergence; they are not one, and including them here would
47
+ // be a factual error, not merely an omission.
48
+ // - "initialize", "notifications_initialized", "tools_call", "tools_list"
49
+ // are already refused by vice.ts's DENY_LIST, which runs strictly BEFORE
50
+ // any capability-registry lookup and owns a different hazard shape
51
+ // entirely (confused-deputy bypass, not a missing capability). They must
52
+ // keep being owned there, not duplicated here.
53
+ //
54
+ // This module imports nothing at runtime -- the only import is a type-only
55
+ // import of ViceBackend, which is erased by Node's type-stripping -- so it
56
+ // has no transport, no filesystem, and no process dependency of its own.
57
+ import type { ViceBackend } from "./backend-detect.mts";
58
+
59
+ /**
60
+ * Three reason categories, matching the distinctions the project's own docs
61
+ * already draw (docs/stock-vice-parity.md SS A/B):
62
+ * - "hardware": no 1:1 opcode can ever exist because of a hardware or
63
+ * firmware property (a write-only register, per-read recomputation, no
64
+ * monitor command for a physical line). Permanent; nothing to build.
65
+ * - "descoped": theoretically buildable client-side (an opcode or
66
+ * equivalent already exists) but cut from v0.2.0 scope because no
67
+ * shipped skill calls it.
68
+ * - "stock-only-gain": the reverse direction -- stock has a native opcode
69
+ * the fork's custom HTTP API never exposed an equivalent RPC for.
70
+ */
71
+ export type CapabilityCategory = "hardware" | "descoped" | "stock-only-gain";
72
+
73
+ /**
74
+ * One row of capability data. `providedBy` names the backend that DOES have
75
+ * the capability (never the backend refusing it). `reason` is one sentence
76
+ * of user-facing prose ending in a full stop -- no planning identifier
77
+ * (BACK-05, SKILL-01, DERIV-*, SHOT-*, GAIN-*, "Phase N") belongs in this
78
+ * field; those are internal routing annotations, not something a caller
79
+ * calling a tool by name should ever see. `alternative`, when present, names
80
+ * a concrete route that exists on the OTHER backend today -- omit it rather
81
+ * than inventing one where none exists.
82
+ */
83
+ export interface CapabilityEntry {
84
+ name: string;
85
+ category: CapabilityCategory;
86
+ providedBy: ViceBackend;
87
+ reason: string;
88
+ alternative?: string;
89
+ }
90
+
91
+ const KEYBOARD_ALTERNATIVE =
92
+ "vice_keyboard_type / vice_keyboard_petscii inject text through the KERNAL keyboard buffer, " +
93
+ "and vice_joystick_set covers most in-game input -- but a program polling $DC00/$DC01 directly " +
94
+ "will not see buffer injection.";
95
+
96
+ /**
97
+ * The 26-entry capability delta: every tool one backend advertises that the
98
+ * other genuinely does not, after excluding registration artifacts
99
+ * (vice_diagnose/vice_recycle) and DENY_LIST's own four meta-tools -- see
100
+ * the header comment above and 08-RESEARCH.md's "Capability Delta Registry"
101
+ * for the full accounting this array is derived from.
102
+ */
103
+ export const CAPABILITY_REGISTRY: readonly CapabilityEntry[] = [
104
+ // --- hardware (6), providedBy: fork -------------------------------------
105
+ {
106
+ name: "vice_sid_get_state",
107
+ category: "hardware",
108
+ providedBy: "fork",
109
+ reason:
110
+ "SID's $D400-$D418 registers are write-only in hardware, and the binary monitor exposes " +
111
+ "no SID read command.",
112
+ },
113
+ {
114
+ name: "vice_keyboard_matrix",
115
+ category: "hardware",
116
+ providedBy: "fork",
117
+ reason:
118
+ "The binary monitor's KEYBOARD_FEED (0x72) only injects PETSCII buffer text; the emulator " +
119
+ "recomputes CIA port B from its own keyboard array on every read, so there is no wire " +
120
+ "command that can drive the raw matrix.",
121
+ alternative: KEYBOARD_ALTERNATIVE,
122
+ },
123
+ {
124
+ name: "vice_keyboard_restore",
125
+ category: "hardware",
126
+ providedBy: "fork",
127
+ reason:
128
+ "RESTORE pulses the NMI line directly; it is not part of the keyboard matrix, and " +
129
+ "KEYBOARD_FEED has no way to produce it.",
130
+ alternative: KEYBOARD_ALTERNATIVE,
131
+ },
132
+ {
133
+ name: "vice_keyboard_chord",
134
+ category: "hardware",
135
+ providedBy: "fork",
136
+ reason:
137
+ "KEYBOARD_FEED injects a whole string at a time; it has no primitive for holding multiple " +
138
+ "keys down together for a span of frames.",
139
+ alternative: KEYBOARD_ALTERNATIVE,
140
+ },
141
+ {
142
+ name: "vice_keyboard_key_press",
143
+ category: "hardware",
144
+ providedBy: "fork",
145
+ reason:
146
+ "KEYBOARD_FEED has no hold/release primitive -- it injects a complete string, not an " +
147
+ "individual key-down event.",
148
+ alternative: KEYBOARD_ALTERNATIVE,
149
+ },
150
+ {
151
+ name: "vice_keyboard_key_release",
152
+ category: "hardware",
153
+ providedBy: "fork",
154
+ reason:
155
+ "KEYBOARD_FEED has no hold/release primitive -- it injects a complete string, not an " +
156
+ "individual key-up event.",
157
+ alternative: KEYBOARD_ALTERNATIVE,
158
+ },
159
+
160
+ // --- descoped (18), providedBy: fork -------------------------------------
161
+ {
162
+ name: "vice_disk_detach",
163
+ category: "descoped",
164
+ providedBy: "fork",
165
+ reason:
166
+ "No detach opcode exists on the stock binary monitor; attaching a different disk image " +
167
+ "covers the same workflow.",
168
+ },
169
+ {
170
+ name: "vice_disk_read_sector",
171
+ category: "descoped",
172
+ providedBy: "fork",
173
+ reason:
174
+ "Reading a sector would require parsing the .d64 file client-side rather than calling a " +
175
+ "live-drive opcode, and no shipped skill calls it.",
176
+ },
177
+ {
178
+ name: "vice_display_screenshot",
179
+ category: "descoped",
180
+ providedBy: "fork",
181
+ reason:
182
+ "The client-side PNG encoder for the INDEXED8 framebuffer DISPLAY_GET returns was descoped " +
183
+ "because no shipped skill calls it.",
184
+ },
185
+ {
186
+ name: "vice_display_get_dimensions",
187
+ category: "descoped",
188
+ providedBy: "fork",
189
+ reason: "Descoped alongside vice_display_screenshot -- no shipped skill calls it.",
190
+ },
191
+ {
192
+ name: "vice_backtrace",
193
+ category: "descoped",
194
+ providedBy: "fork",
195
+ reason: "No shipped skill calls it.",
196
+ },
197
+ {
198
+ name: "vice_checkpoint_group_add",
199
+ category: "descoped",
200
+ providedBy: "fork",
201
+ reason: "No shipped skill calls any checkpoint-group tool.",
202
+ },
203
+ {
204
+ name: "vice_checkpoint_group_create",
205
+ category: "descoped",
206
+ providedBy: "fork",
207
+ reason: "No shipped skill calls any checkpoint-group tool.",
208
+ },
209
+ {
210
+ name: "vice_checkpoint_group_list",
211
+ category: "descoped",
212
+ providedBy: "fork",
213
+ reason: "No shipped skill calls any checkpoint-group tool.",
214
+ },
215
+ {
216
+ name: "vice_checkpoint_group_toggle",
217
+ category: "descoped",
218
+ providedBy: "fork",
219
+ reason: "No shipped skill calls any checkpoint-group tool.",
220
+ },
221
+ {
222
+ name: "vice_checkpoint_set_ignore_count",
223
+ category: "descoped",
224
+ providedBy: "fork",
225
+ reason:
226
+ "No native wire ignore-count exists; the only implementation would require resuming the " +
227
+ "machine on every ignored hit, which the no-unrequested-resume policy forbids. " +
228
+ "CHECKPOINT_INFO's reply still reports an existing ignore count read-only.",
229
+ },
230
+ {
231
+ name: "vice_cia_set_state",
232
+ category: "descoped",
233
+ providedBy: "fork",
234
+ reason:
235
+ "The write half of a tool whose read half already ships on stock; no shipped skill calls " +
236
+ "the write half.",
237
+ },
238
+ {
239
+ name: "vice_vicii_set_state",
240
+ category: "descoped",
241
+ providedBy: "fork",
242
+ reason:
243
+ "The write half of a tool whose read half already ships on stock; no shipped skill calls " +
244
+ "the write half.",
245
+ },
246
+ {
247
+ name: "vice_sprite_set",
248
+ category: "descoped",
249
+ providedBy: "fork",
250
+ reason:
251
+ "The write half of a tool whose read half already ships on stock; no shipped skill calls " +
252
+ "the write half.",
253
+ },
254
+ {
255
+ name: "vice_memory_fill",
256
+ category: "descoped",
257
+ providedBy: "fork",
258
+ reason: "No shipped skill calls it.",
259
+ },
260
+ {
261
+ name: "vice_sid_set_state",
262
+ category: "descoped",
263
+ providedBy: "fork",
264
+ reason:
265
+ "SID writes work fine over MEM_SET at $D400-$D418 -- this is not a hardware loss, only " +
266
+ "reads are write-only in hardware. It is simply not implemented because no shipped skill " +
267
+ "calls it.",
268
+ },
269
+ {
270
+ name: "vice_machine_config_get",
271
+ category: "descoped",
272
+ providedBy: "fork",
273
+ reason:
274
+ "Full resource get/set access was descoped; the fork's tool is a hand-curated whitelist " +
275
+ "subset that never shipped on stock.",
276
+ },
277
+ {
278
+ name: "vice_machine_config_set",
279
+ category: "descoped",
280
+ providedBy: "fork",
281
+ reason:
282
+ "Full resource get/set access was descoped; the fork's tool is a hand-curated whitelist " +
283
+ "subset that never shipped on stock.",
284
+ },
285
+ {
286
+ name: "vice_joystick_tap",
287
+ category: "descoped",
288
+ providedBy: "fork",
289
+ reason:
290
+ "Requires running the machine for a measured hold-then-release interval, which depends on " +
291
+ "timing infrastructure; not yet built, and no shipped skill calls it.",
292
+ },
293
+
294
+ // --- stock-only-gain (2), providedBy: stock ------------------------------
295
+ {
296
+ name: "vice_execution_until_return",
297
+ category: "stock-only-gain",
298
+ providedBy: "stock",
299
+ reason: "The fork's custom HTTP API has no equivalent RPC; this is the native EXECUTE_UNTIL_RETURN opcode.",
300
+ },
301
+ {
302
+ name: "vice_registers_available",
303
+ category: "stock-only-gain",
304
+ providedBy: "stock",
305
+ reason: "The fork has no equivalent enumeration call; this is the native REGISTERS_AVAILABLE opcode.",
306
+ },
307
+ ];
308
+
309
+ /**
310
+ * Plain name-keyed lookup over CAPABILITY_REGISTRY. `name` is untrusted
311
+ * `request.params.name` from the wire: it is used ONLY as an equality
312
+ * comparison value below. Never interpolate it into a path, a command, or
313
+ * anything executed.
314
+ */
315
+ export function capabilityEntryFor(name: string): CapabilityEntry | undefined {
316
+ return CAPABILITY_REGISTRY.find((entry) => entry.name === name);
317
+ }
318
+
319
+ /**
320
+ * Renders the BACK-05 refusal for `name` on `activeBackend`, or `undefined`
321
+ * when there is nothing to refuse -- mirroring denyListRefusalMessage()'s
322
+ * keyed-by-hazard-shape contract (vice.ts).
323
+ *
324
+ * Returns `undefined` when:
325
+ * - no registry entry exists for `name` (a genuinely unknown tool name --
326
+ * a typo -- must still fall through to the generic "Unknown tool"
327
+ * message at the call site, not this function's wording); or
328
+ * - `entry.providedBy === activeBackend` (defensive: the active backend
329
+ * already advertises this tool, so a miss here is a genuine
330
+ * unknown-tool case, not a capability gap, and must not be
331
+ * misclassified as one).
332
+ *
333
+ * Otherwise renders one of three wordings, selected by `entry.category`:
334
+ * - "hardware": names the tool, states it is unrecoverable on
335
+ * `activeBackend`, gives `entry.reason`, then names `entry.providedBy`
336
+ * with the actionable `Set VICE_BACKEND=...`, then `entry.alternative`
337
+ * when present. No "wait for a later phase" framing -- none is coming
338
+ * for a hardware loss, but a stock route may still exist and must be
339
+ * named here, since this is where the caller actually reads it.
340
+ * - "descoped": names the tool, states it is not implemented on
341
+ * `activeBackend`, gives `entry.reason`, then `entry.providedBy` and
342
+ * `Set VICE_BACKEND=...`, then `entry.alternative` when present. The
343
+ * literal token "unrecoverable" must NEVER appear in this wording: a
344
+ * reader told "not supported" for both a hardware loss and a merely
345
+ * unbuilt tool cannot tell which one is worth filing an issue about.
346
+ * - "stock-only-gain": names the tool, states it is not implemented on the
347
+ * fork backend specifically, gives `entry.reason`, then
348
+ * `Set VICE_BACKEND=stock`.
349
+ */
350
+ export function capabilityRefusalMessage(
351
+ name: string,
352
+ activeBackend: ViceBackend,
353
+ ): string | undefined {
354
+ const entry = capabilityEntryFor(name);
355
+ if (!entry) return undefined;
356
+ if (entry.providedBy === activeBackend) return undefined;
357
+
358
+ // `alternative` is rendered in EVERY branch that has one. It used to be
359
+ // read only inside the "descoped" branch, which made the field dead at
360
+ // runtime: all five entries that carry an alternative are category
361
+ // "hardware", and no "descoped" entry has one. The generated support
362
+ // table, the skill playbooks and README all printed the stock route while
363
+ // the runtime refusal -- the one surface BACK-05 exists for -- dropped it.
364
+ // Do not re-scope this back into a single branch.
365
+ const alt = entry.alternative ? ` ${entry.alternative}` : "";
366
+
367
+ if (entry.category === "hardware") {
368
+ return (
369
+ `${entry.name} is unrecoverable on the ${activeBackend} backend: ${entry.reason} ` +
370
+ `Use the ${entry.providedBy} backend instead (Set VICE_BACKEND=${entry.providedBy}).${alt}`
371
+ );
372
+ }
373
+
374
+ if (entry.category === "descoped") {
375
+ return (
376
+ `${entry.name} is not implemented on the ${activeBackend} backend: ${entry.reason} ` +
377
+ `Use the ${entry.providedBy} backend instead (Set VICE_BACKEND=${entry.providedBy}).${alt}`
378
+ );
379
+ }
380
+
381
+ // category === "stock-only-gain": only reachable with activeBackend ===
382
+ // "fork" and entry.providedBy === "stock", since the same-backend guard
383
+ // above already excluded the activeBackend === "stock" case.
384
+ return (
385
+ `${entry.name} is not implemented on the fork backend: ${entry.reason} ` +
386
+ `Use the stock backend instead (Set VICE_BACKEND=stock).${alt}`
387
+ );
388
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/vice-mcp",
3
- "version": "0.1.12",
3
+ "version": "0.2.1",
4
4
  "description": "VICE emulator MCP server for C64 reverse-engineering: a stdio MCP server that proxies vice tools to a host VICE MCP server.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "vice-sync.ts",
14
14
  "vice-probe.ts",
15
15
  "vice-broker-client.ts",
16
+ "version.ts",
16
17
  "stock-protocol.ts",
17
18
  "stock-connect.ts",
18
19
  "containerpath.ts",
@@ -24,6 +25,7 @@
24
25
  "build.ts",
25
26
  "container-guard.mts",
26
27
  "backend-detect.mts",
28
+ "capability-registry.ts",
27
29
  "stock-dispatch.ts",
28
30
  "stock-derived.ts",
29
31
  "stock-handler.ts",
@@ -42,6 +44,29 @@
42
44
  "disasm-opcodes.ts",
43
45
  "disasm-decoder.ts",
44
46
  "disasm-renderer.ts",
47
+ "stock-memory-search.ts",
48
+ "stock-symbols.ts",
49
+ "stock-vicii.ts",
50
+ "stock-cia.ts",
51
+ "stock-sprites.ts",
52
+ "stock-timing.ts",
53
+ "stock-run-until.ts",
54
+ "stock-diagnose.ts",
55
+ "stock-recycle.ts",
56
+ "r2000-launch.ts",
57
+ "r2000-project.ts",
58
+ "r2000-d64.ts",
59
+ "r2000-cli.ts",
60
+ "r2000-verify.ts",
61
+ "r2000-mcp-client.ts",
62
+ "r2000-tools.ts",
63
+ "r2000-symbols.ts",
64
+ "r2000-regbits-gen.ts",
65
+ "r2000-regbits.json",
66
+ "r2000-enum-gen.ts",
67
+ "r2000-acme-ident.ts",
68
+ "r2000-confidence.ts",
69
+ "r2000-memmap-render.ts",
45
70
  "resources",
46
71
  "tools-manifest.json",
47
72
  "tools-manifest.stock.json",
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // r2000-acme-ident.ts -- the ONE authoritative place in this repo for the
3
+ // ACME identifier policy (T-11-ENUM-NAME / T-11-NAME-INJECT): what makes a
4
+ // string a legal ACME symbol/label name, and the single check function that
5
+ // decides it.
6
+ //
7
+ // WHY THIS MODULE EXISTS: this policy started life inside r2000-enum-gen.ts,
8
+ // consumed only by its own `createOrUpdateEnum()`/`sanitizeVariantMap()`.
9
+ // T-11-NAME-INJECT widened the finding to a SECOND entry route --
10
+ // `r2000_set_label_name` (both outer and batch-inner) in r2000-tools.ts, and
11
+ // `importLabels()` in r2000-symbols.ts -- and `r2000-enum-gen.ts` statically
12
+ // imports `runR2000Tool` FROM `r2000-tools.ts`, so `r2000-tools.ts` cannot
13
+ // import the policy back from `r2000-enum-gen.ts` without forming a module
14
+ // cycle. This module has no import from anywhere else in this repo, so
15
+ // every one of those consumers (and any future one) can import it directly.
16
+ //
17
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: `MAX_ACME_IDENTIFIER_LENGTH`,
18
+ // `ACME_IDENT_RE`, `ACME_RESERVED_MNEMONICS` and `assertLegalAcmeIdentifier()`
19
+ // -- the complete, only definition of "legal ACME identifier" in this repo.
20
+ // `r2000-enum-gen.ts` re-exports `assertLegalAcmeIdentifier` /
21
+ // `MAX_ACME_IDENTIFIER_LENGTH` from here so its own existing consumers and
22
+ // tests keep their current import path; it does not hold a second copy.
23
+ //
24
+ // WHAT NOT TO DO, named concretely:
25
+ // - Never add a second identifier regex anywhere in this repo. The
26
+ // `r2000-regbits-gen.ts` / `r2000-regbits.test.ts` copies of
27
+ // `ACME_IDENT_RE` predate this module and are out of this plan's scope
28
+ // (260821-a86) to consolidate -- but no NEW copy should be added; import
29
+ // this module's `assertLegalAcmeIdentifier()` instead.
30
+ // - Never sanitize, quote, or auto-correct an illegal identifier here or
31
+ // in any caller. This function's contract is REJECT, per
32
+ // T-11-REGBITS-PROSE and T-11-ENUM-NAME precedent: a malformed name is a
33
+ // bug to surface, never silently rewritten into something else -- the
34
+ // caller-visible name must never diverge from what actually gets
35
+ // exported into ACME source.
36
+ // - Never import anything from this repo into this module. It must stay
37
+ // importable by both `r2000-tools.ts` and `r2000-enum-gen.ts` (which
38
+ // imports `runR2000Tool` FROM `r2000-tools.ts`) without a cycle.
39
+ export const MAX_ACME_IDENTIFIER_LENGTH = 200;
40
+
41
+ const ACME_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
+
43
+ /**
44
+ * The 6502/6510 instruction mnemonics -- the ONLY category of reserved word
45
+ * actually measured to collide with a bare identifier in real ACME 0.97.
46
+ * Verified live on this host: `LDA = $05` is REJECTED ("No value given." /
47
+ * "Garbage data at end of statement" -- ACME parses `LDA` positionally as
48
+ * the opcode, not as an assignable symbol), while `A = $05` is ACCEPTED
49
+ * (register-letter shorthand is not reserved as a bare symbol name). This
50
+ * list is therefore a measured, not a guessed, reservation.
51
+ */
52
+ const ACME_RESERVED_MNEMONICS: ReadonlySet<string> = new Set([
53
+ "ADC", "AND", "ASL", "BCC", "BCS", "BEQ", "BIT", "BMI", "BNE", "BPL", "BRK", "BVC",
54
+ "BVS", "CLC", "CLD", "CLI", "CLV", "CMP", "CPX", "CPY", "DEC", "DEX", "DEY", "EOR",
55
+ "INC", "INX", "INY", "JMP", "JSR", "LDA", "LDX", "LDY", "LSR", "NOP", "ORA", "PHA",
56
+ "PHP", "PLA", "PLP", "ROL", "ROR", "RTI", "RTS", "SBC", "SEC", "SED", "SEI", "STA",
57
+ "STX", "STY", "TAX", "TAY", "TSX", "TXA", "TXS", "TYA",
58
+ ]);
59
+
60
+ /**
61
+ * Refuses `id` (naming it as `what` in the thrown message) unless it is a
62
+ * legal ACME identifier: matches `^[A-Za-z_][A-Za-z0-9_]*$`, is no longer
63
+ * than `MAX_ACME_IDENTIFIER_LENGTH`, and does not collide (case-
64
+ * insensitively) with a reserved 6502/6510 mnemonic (see
65
+ * `ACME_RESERVED_MNEMONICS`'s own header comment for how that specific list
66
+ * was measured, not assumed).
67
+ *
68
+ * T-11-ENUM-NAME (the highest-value threat in this phase): regenerator2000
69
+ * validates only the ENUM name server-side
70
+ * (`app_state.rs:443`, `validate_new_enum_name`) and performs ZERO
71
+ * validation on variant names -- they flow straight into
72
+ * `format!("{}_{}", enum_name, variant)` at export time
73
+ * (`formatter_acme.rs:367-369`). This function is called on BOTH the enum
74
+ * name and every variant name, and is called BEFORE any
75
+ * `r2000_create_project_enum`/`r2000_update_project_enum` call reaches
76
+ * `runR2000Tool()` -- proven zero-spawn in `r2000-enum-gen.test.ts`.
77
+ */
78
+ export function assertLegalAcmeIdentifier(id: string, what: string): void {
79
+ if (id.length === 0) {
80
+ throw new Error(`${what}: identifier must not be empty`);
81
+ }
82
+ if (id.length > MAX_ACME_IDENTIFIER_LENGTH) {
83
+ throw new Error(
84
+ `${what}: identifier "${id.slice(0, 40)}..." is ${id.length} characters, exceeding the ` +
85
+ `${MAX_ACME_IDENTIFIER_LENGTH}-character ceiling`,
86
+ );
87
+ }
88
+ if (!ACME_IDENT_RE.test(id)) {
89
+ throw new Error(`${what}: "${JSON.stringify(id)}" is not a legal ACME identifier -- must match ${ACME_IDENT_RE}`);
90
+ }
91
+ if (ACME_RESERVED_MNEMONICS.has(id.toUpperCase())) {
92
+ throw new Error(
93
+ `${what}: "${id}" collides with the reserved 6502/6510 mnemonic ${id.toUpperCase()} (verified rejected ` +
94
+ `by real ACME 0.97 -- see this module's ACME_RESERVED_MNEMONICS header comment)`,
95
+ );
96
+ }
97
+ }