@henols/vice-mcp 0.2.1 → 0.2.2

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 (57) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -24
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-cli.ts +1465 -0
  5. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  6. package/anno-coverage.ts +2465 -0
  7. package/{r2000-d64.ts → anno-d64.ts} +5 -5
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +533 -0
  11. package/anno-export-asm.ts +1310 -0
  12. package/anno-index.ts +150 -0
  13. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  14. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  15. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  16. package/anno-register.ts +240 -0
  17. package/anno-store.ts +3486 -0
  18. package/anno-symbols.ts +266 -0
  19. package/anno-tools.ts +2111 -0
  20. package/anno-types.ts +1636 -0
  21. package/block-class.ts +201 -0
  22. package/build.ts +1 -1
  23. package/capability-registry.ts +3 -1
  24. package/disasm-decoder.ts +14 -14
  25. package/disasm-opcodes.ts +4 -4
  26. package/disasm-renderer.ts +2 -2
  27. package/hostpath.ts +1 -1
  28. package/install-resources.ts +1 -1
  29. package/package.json +23 -17
  30. package/prg-image.ts +119 -0
  31. package/repo-root.ts +20 -5
  32. package/resources/broker-launch.mjs +8 -4
  33. package/resources/vice-launcher.sh +3 -3
  34. package/stock-address.ts +5 -5
  35. package/stock-cia.ts +2 -2
  36. package/stock-condition.ts +7 -7
  37. package/stock-connect.ts +1 -1
  38. package/stock-dispatch.ts +35 -5
  39. package/stock-execution.ts +5 -3
  40. package/stock-input.ts +9 -9
  41. package/stock-machine.ts +17 -6
  42. package/stock-protocol.ts +16 -11
  43. package/stock-registers.ts +54 -29
  44. package/stock-sprites.ts +3 -3
  45. package/stock-symbols.ts +9 -9
  46. package/stock-timing.ts +1 -1
  47. package/stock-vicii.ts +1 -1
  48. package/version.ts +1 -1
  49. package/vice-proxy.ts +68 -46
  50. package/r2000-cli.ts +0 -1103
  51. package/r2000-enum-gen.ts +0 -574
  52. package/r2000-launch.ts +0 -357
  53. package/r2000-mcp-client.ts +0 -596
  54. package/r2000-project.ts +0 -190
  55. package/r2000-symbols.ts +0 -388
  56. package/r2000-tools.ts +0 -914
  57. package/r2000-verify.ts +0 -184
package/r2000-tools.ts DELETED
@@ -1,914 +0,0 @@
1
- #!/usr/bin/env node
2
- // r2000-tools.ts -- the ONE authoritative place in this repo for the curated
3
- // r2000_* tool surface: which 17 of regenerator2000's 28 MCP tools this
4
- // project advertises, the allow-list gate (including its D-33 batch
5
- // recursion), project-path validation, and the runner that drives one
6
- // r2000-mcp-client.ts session per call.
7
- //
8
- // WHY THIS MODULE EXISTS (D-16/D-18): the annotation store is reachable only
9
- // through a CURATED subset of regenerator2000's own tool surface, not a
10
- // 28-tool passthrough -- every tool here earns its place by serving one of
11
- // this phase's four named criteria (see 11-05-PLAN.md's objective table).
12
- // Excluded, each for a recorded reason: the TUI-shaped tools
13
- // (`jump_to_address`, `get_disassembly_cursor`, `read_selected`,
14
- // `toggle_splitter`) have no criterion; `undo`/`redo` are useless under
15
- // D-17's per-call lifecycle (history dies with the spawned process);
16
- // `get_address_details` is excluded by D-32 (see below); and
17
- // `unpack_binary`, `read_region`, `search_memory`, `set_immediate_format`
18
- // have no criterion in this phase.
19
- //
20
- // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: the 17 curated
21
- // `ToolDefinition`s (`R2000_TOOL_DEFINITIONS`), the allow-list
22
- // (`CURATED_R2000_TOOLS`) and its enforcement (`assertCuratedTool()`,
23
- // including the batch-recursion gate), the caller-supplied project-path
24
- // validation (`resolveStorePath()`), and the runner (`runR2000Tool()`) that
25
- // drives r2000-mcp-client.ts. No other module may hand-list a curated tool
26
- // name, hand-validate an r2000 project path, or call
27
- // `r2000-mcp-client.ts` directly -- `vice-proxy.ts` (plan 11-05 Task 2)
28
- // imports `R2000_TOOL_DEFINITIONS`/`runR2000Tool` from here and nothing
29
- // else.
30
- //
31
- // MEASURED: `r2000_batch_execute`'s partial-failure semantics
32
- // (`handler.rs:506-542`, read at execution time against the installed
33
- // regenerator2000-core-0.9.20 crate source). The batch does NOT abort on
34
- // the first failing inner call -- `handle_tool_call_internal()` is called
35
- // per entry inside a loop, and each outcome (`Ok`/`Err`) is pushed into a
36
- // `results` array as `{"status":"success","result":...}` or
37
- // `{"status":"error","error":...}`; the loop always runs to completion and
38
- // the whole response is `{"content":[{"type":"text","text":<pretty-JSON
39
- // results array>}]}`. This is PER-CALL status reporting inside r2000 itself
40
- // -- orthogonal to (and irrelevant to) D-33's OWN refusal, which happens
41
- // entirely on our side, before any request reaches the child at all: an
42
- // uncurated inner name never gets the chance to report a per-call status
43
- // because the whole batch is refused before the spawn.
44
- //
45
- // WHAT NOT TO DO, named concretely:
46
- // - Never widen CURATED_R2000_TOOLS without a criterion recorded in
47
- // 11-05-PLAN.md's objective table. A tool earns its place; it is not
48
- // added because it happens to exist upstream.
49
- // - Never let runR2000Tool() (or anything it calls) reach the VICE
50
- // argument-rewriting/host-forwarding/session-establishment seams, or
51
- // `call()` (vice.ts's transport seam). The r2000_* family never touches
52
- // the emulator -- it is registered proxy-locally via `buildViceTool()`
53
- // (plan 11-05 Task 2), which is what makes CLAUDE.md's "derived tools
54
- // must be intercepted before the host-forwarding seam" constraint moot
55
- // BY CONSTRUCTION for this family, not by an interception.
56
- // - Never add a `tools_call`-shaped meta-tool to this surface -- that is
57
- // exactly the nested-argument smuggling shape `vice.ts`'s `DENY_LIST`
58
- // exists to close, and `r2000_batch_execute` is already this project's
59
- // one sanctioned exception, gated by `assertCuratedTool()`'s own batch
60
- // recursion below.
61
- // - Never import the VICE host-path/container-path translation modules
62
- // here. regenerator2000 runs container-side (D-R4, Rule A16); a project
63
- // path is resolved against `repoRoot()` only. Asserted structurally by
64
- // the closed host-path consumer-set test.
65
- // - Never report `r2000_save_project` as persisted on the strength of its
66
- // own text response -- always route it through
67
- // `r2000-mcp-client.ts`'s `saveAndVerify()`.
68
- import { lstatSync, realpathSync } from "node:fs";
69
- import { basename, dirname, join, resolve, sep } from "node:path";
70
-
71
- import { repoRoot } from "./repo-root.ts";
72
- import { assertLegalAcmeIdentifier } from "./r2000-acme-ident.ts";
73
-
74
- // ---------------------------------------------------------------------------
75
- // The wire shapes this module produces/consumes. Deliberately NOT imported
76
- // from vice-proxy.ts (that file has no exported ToolDefinition/ToolCallResult
77
- // -- both are file-local types there); these are structurally identical so a
78
- // value built here is interchangeable wherever vice-proxy.ts combines it with
79
- // its own manifest-sourced tools.
80
- // ---------------------------------------------------------------------------
81
-
82
- export interface R2000ToolDefinition {
83
- name: string;
84
- description: string;
85
- inputSchema: {
86
- type: "object";
87
- properties: Record<string, unknown>;
88
- required?: string[];
89
- };
90
- // Structural compatibility with vice.ts's own ToolInfo (vice-proxy.ts's
91
- // ToolDefinition alias), which carries this index signature -- lets
92
- // vice-proxy.ts's buildViceTool() accept an R2000ToolDefinition directly,
93
- // with no per-call cast at the plan 11-05 Task 2 registration site.
94
- [key: string]: unknown;
95
- }
96
-
97
- interface ToolCallResult {
98
- content: { type: "text"; text: string }[];
99
- isError: boolean;
100
- }
101
-
102
- function isPlainObject(value: unknown): value is Record<string, unknown> {
103
- return typeof value === "object" && value !== null && !Array.isArray(value);
104
- }
105
-
106
- function okText(text: string): ToolCallResult {
107
- return { content: [{ type: "text", text }], isError: false };
108
- }
109
-
110
- function errText(text: string): ToolCallResult {
111
- return { content: [{ type: "text", text }], isError: true };
112
- }
113
-
114
- /** Converts regenerator2000's own `CallToolResult` (already the same
115
- * `{content:[{type,text}],isError?}` shape our own `ToolCallResult` uses,
116
- * since both follow the MCP spec) into a `ToolCallResult` directly, rather
117
- * than re-wrapping the whole object as a second layer of JSON text. Falls
118
- * back to `okText(JSON.stringify(...))` only for a result that does not
119
- * already carry a `content` array -- defensive, since every curated tool's
120
- * real response has one. */
121
- function toToolCallResult(result: unknown): ToolCallResult {
122
- if (isPlainObject(result) && Array.isArray(result.content)) {
123
- return { content: result.content as ToolCallResult["content"], isError: false };
124
- }
125
- return okText(JSON.stringify(result ?? null));
126
- }
127
-
128
- // ---------------------------------------------------------------------------
129
- // D-19's shared property: every curated tool's inputSchema starts with this
130
- // exact `project` property. One constant, spread into every definition below,
131
- // rather than seventeen hand-typed copies that could drift from each other.
132
- // ---------------------------------------------------------------------------
133
-
134
- const PROJECT_PROPERTY = {
135
- project: {
136
- type: "string",
137
- description:
138
- "Absolute or workspace-relative path to the .regen2000proj annotation store (D-19: every " +
139
- "r2000_* tool takes an explicit project path; there is no ambient session state naming the store).",
140
- },
141
- } as const;
142
-
143
- // ---------------------------------------------------------------------------
144
- // The 64K OutOfRange defect this surface excludes (D-32) -- shared between
145
- // the outer-name refusal and the batch-inner refusal so both read identically.
146
- // ---------------------------------------------------------------------------
147
-
148
- const ADDRESS_DETAILS_REFUSAL =
149
- "r2000_get_address_details is not on the curated r2000_* surface (D-32): on a full 64K project " +
150
- "(exactly what c64-ram-capture produces) it returns {\"type\":\"OutOfRange\"} for EVERY address, " +
151
- "because handler.rs:1894's `raw_data.len() as u16` wraps 65536 to 0. Filed upstream as " +
152
- "https://github.com/ricardoquesada/regenerator2000/issues/42. Its answer is a composite of " +
153
- "instruction semantics, cross-references, labels, comments and block type -- all independently " +
154
- "reachable through r2000_get_binary_info, r2000_get_cross_references, r2000_get_symbols, " +
155
- "r2000_get_comments, r2000_get_blocks and r2000_disassemble, every one of which was measured " +
156
- "working on a 64K project.";
157
-
158
- // ---------------------------------------------------------------------------
159
- // The 17 curated tool definitions (D-18's objective table). Each argument
160
- // shape below was obtained by driving `tools/list` against a real
161
- // `regenerator2000 --mcp-server-stdio 0.9.20` child and copying its own
162
- // argument shapes verbatim (never transcribed from a document), with
163
- // `project` (D-19) prepended to every one.
164
- // ---------------------------------------------------------------------------
165
-
166
- export const R2000_TOOL_DEFINITIONS: readonly R2000ToolDefinition[] = [
167
- {
168
- name: "r2000_set_label_name",
169
- description:
170
- "Sets a user-defined label at a specific MOS 6502 memory address. Use this to name " +
171
- "functions, variables, or jump targets to make the disassembly more readable.",
172
- inputSchema: {
173
- type: "object",
174
- properties: {
175
- ...PROJECT_PROPERTY,
176
- address: {
177
- type: "integer",
178
- description: "The memory address where the label should be set (decimal, e.g. 4096 for $1000).",
179
- },
180
- name: {
181
- type: "string",
182
- description:
183
- "The label name (e.g. 'init_screen', 'loop_start'). Must be a legal ACME identifier: starts " +
184
- "with a letter or underscore, followed by letters/digits/underscores only, and must not be a " +
185
- "6502/6510 mnemonic (e.g. 'LDA'). An illegal name is REJECTED, never sanitized or quoted.",
186
- },
187
- },
188
- required: ["project", "address", "name"],
189
- },
190
- },
191
- {
192
- name: "r2000_set_comment",
193
- description:
194
- "Adds a comment at a specific address. 'line' comments appear on their own line before the " +
195
- "instruction (supports multi-line). 'side' comments appear inline on the same line as the " +
196
- "instruction. Carrier for D-25's [confirmed-code]/[probable-code]/[confirmed-data]/" +
197
- "[probable-data]/[unknown] confidence-prefix convention.",
198
- inputSchema: {
199
- type: "object",
200
- properties: {
201
- ...PROJECT_PROPERTY,
202
- address: { type: "integer", description: "The memory address for the comment (decimal, e.g. 4096 for $1000)." },
203
- comment: { type: "string", description: "The comment text. Do not include the ';' prefix." },
204
- type: {
205
- type: "string",
206
- enum: ["line", "side"],
207
- description: "'line' = comment on its own line before the instruction. 'side' = inline comment on the same line.",
208
- },
209
- },
210
- required: ["project", "address", "comment", "type"],
211
- },
212
- },
213
- {
214
- name: "r2000_set_data_type",
215
- description:
216
- "Sets the data type for a memory region. Use this to mark regions as code, bytes, addresses, " +
217
- "text, split tables, etc.",
218
- inputSchema: {
219
- type: "object",
220
- properties: {
221
- ...PROJECT_PROPERTY,
222
- start_address: { type: "integer", description: "Start of the memory region (inclusive), decimal." },
223
- end_address: { type: "integer", description: "End of the memory region (inclusive), decimal." },
224
- data_type: {
225
- type: "string",
226
- enum: [
227
- "code",
228
- "byte",
229
- "word",
230
- "address",
231
- "petscii",
232
- "screencode",
233
- "lo_hi_address",
234
- "hi_lo_address",
235
- "lo_hi_word",
236
- "hi_lo_word",
237
- "external_file",
238
- "undefined",
239
- ],
240
- description:
241
- "code=MOS 6502 instructions; byte=raw 8-bit data (sprites, charset, tables, unknowns); " +
242
- "word=16-bit LE values; address=16-bit LE pointers (creates X-Refs, use for jump " +
243
- "tables/vectors); petscii=PETSCII text; screencode=Screen code text (data written to " +
244
- "$0400); lo_hi_address=split address table, low bytes first then high bytes (even count " +
245
- "required); hi_lo_address=split address table, high bytes first (even count required); " +
246
- "lo_hi_word=split word table, low bytes first (e.g. SID freq tables); " +
247
- "hi_lo_word=split word table, high bytes first; external_file=large binary blob (SID, " +
248
- "bitmap, charset) to export as-is; undefined=reset region to unknown state.",
249
- },
250
- },
251
- required: ["project", "start_address", "end_address", "data_type"],
252
- },
253
- },
254
- {
255
- name: "r2000_add_scope",
256
- description:
257
- "Adds a scope covering the specified memory range. Useful for a piece of code that is a " +
258
- "routine. Starts a lexical level where all new symbols within this range are in the local " +
259
- "lexical level and are accessible from outside only via explicit scope specification. Nested " +
260
- "scopes are not supported.",
261
- inputSchema: {
262
- type: "object",
263
- properties: {
264
- ...PROJECT_PROPERTY,
265
- start_address: { type: "integer", description: "Start address of the scope (inclusive), decimal." },
266
- end_address: { type: "integer", description: "End address of the scope (inclusive), decimal." },
267
- },
268
- required: ["project", "start_address", "end_address"],
269
- },
270
- },
271
- {
272
- name: "r2000_get_symbols",
273
- description:
274
- "Returns defined labels (user and/or platform) and their addresses. With no arguments " +
275
- "(besides project) returns ALL symbols. Provide optional filters to narrow results: 'names' " +
276
- "resolves specific label names to addresses, 'start_address'/'end_address' limits to an " +
277
- "address range, 'kind' filters by label kind. Filters are combined (AND logic).",
278
- inputSchema: {
279
- type: "object",
280
- properties: {
281
- ...PROJECT_PROPERTY,
282
- names: {
283
- type: "array",
284
- items: { type: "string" },
285
- description: "Optional list of label names to look up. Only symbols whose name matches one of these strings are returned. Case-sensitive.",
286
- },
287
- start_address: { type: "integer", description: "Optional lower bound (inclusive) of the address range to filter by (decimal)." },
288
- end_address: { type: "integer", description: "Optional upper bound (inclusive) of the address range to filter by (decimal)." },
289
- kind: {
290
- type: "string",
291
- enum: ["user", "system", "auto"],
292
- description: "Optional filter to return only labels of a given kind. 'user' = user-defined labels, 'system' = predefined system labels (e.g. KERNAL, hardware registers), 'auto' = auto-generated labels (e.g. s_C000).",
293
- },
294
- },
295
- required: ["project"],
296
- },
297
- },
298
- {
299
- name: "r2000_get_comments",
300
- description:
301
- "Returns user-defined comments and their addresses. Each entry has 'address' (integer), " +
302
- "'type' ('line' or 'side'), and 'comment' (string). With no arguments (besides project) " +
303
- "returns ALL comments. Provide optional filters to narrow results: 'addresses' returns " +
304
- "comments at specific addresses, 'start_address'/'end_address' limits to an address range, " +
305
- "'type' filters by comment type. Filters are combined (AND logic). D-25's confidence-prefix " +
306
- "convention lives in the returned 'comment' text -- filter by prefix on the client side, or " +
307
- "combine with r2000_search_disassembly.",
308
- inputSchema: {
309
- type: "object",
310
- properties: {
311
- ...PROJECT_PROPERTY,
312
- addresses: {
313
- type: "array",
314
- items: { type: "integer" },
315
- description: "Optional list of specific addresses (decimal) to retrieve comments from. Only comments at these addresses are returned.",
316
- },
317
- start_address: { type: "integer", description: "Optional lower bound (inclusive) of the address range to filter by (decimal)." },
318
- end_address: { type: "integer", description: "Optional upper bound (inclusive) of the address range to filter by (decimal)." },
319
- type: { type: "string", enum: ["line", "side"], description: "Optional filter to return only 'line' comments or only 'side' comments." },
320
- },
321
- required: ["project"],
322
- },
323
- },
324
- {
325
- name: "r2000_get_blocks",
326
- description:
327
- "Returns all memory blocks with their address range and type (Code, Byte, Word, Address, " +
328
- "PETSCII, Screencode, Lo/Hi Address, Hi/Lo Address, Lo/Hi Word, Hi/Lo Word, External File, " +
329
- "Undefined). Respects splitters.",
330
- inputSchema: {
331
- type: "object",
332
- properties: {
333
- ...PROJECT_PROPERTY,
334
- block_type: { type: "string", description: "Optional filter to return only blocks of a specific type. Case-insensitive." },
335
- },
336
- required: ["project"],
337
- },
338
- },
339
- {
340
- name: "r2000_get_cross_references",
341
- description: "Get a list of addresses that reference the given address (e.g. JSRs, JMPs, loads).",
342
- inputSchema: {
343
- type: "object",
344
- properties: {
345
- ...PROJECT_PROPERTY,
346
- address: { type: "integer", description: "The target address to find references to (decimal)." },
347
- },
348
- required: ["project", "address"],
349
- },
350
- },
351
- {
352
- name: "r2000_search_disassembly",
353
- description:
354
- "Search the disassembly text for a query string or regular expression. Returns a list of " +
355
- "matching addresses with context (label, mnemonic, operand, comment). Searches labels, " +
356
- "comments, and instructions by default; individual fields can be disabled. `max_results` is " +
357
- "REQUIRED on this surface (no default): regenerator2000's own default is 50, which would " +
358
- "silently truncate a full-program pass -- pass an explicit ceiling and compare the returned " +
359
- "count against it to detect truncation.",
360
- inputSchema: {
361
- type: "object",
362
- properties: {
363
- ...PROJECT_PROPERTY,
364
- query: { type: "string", description: "The search query. Interpreted as a plain case-insensitive substring by default, or as a regex when 'use_regex' is true." },
365
- max_results: { type: "integer", description: "Maximum number of matching addresses to return. No default on this surface -- always pass an explicit value." },
366
- use_regex: { type: "boolean", description: "When true the query is compiled as a case-insensitive regular expression ((?i) is prepended automatically). Defaults to false." },
367
- search_labels: { type: "boolean", description: "Include label names in the search. Defaults to true." },
368
- search_comments: { type: "boolean", description: "Include side and line comments in the search. Defaults to true." },
369
- search_instructions: { type: "boolean", description: "Include mnemonic and operand text in the search. Defaults to true." },
370
- },
371
- required: ["project", "query", "max_results"],
372
- },
373
- },
374
- {
375
- name: "r2000_disassemble",
376
- description:
377
- "Performs a control flow disassembly starting at a specific memory address, tracing " +
378
- "execution paths and automatically converting identified regions to Code blocks.",
379
- inputSchema: {
380
- type: "object",
381
- properties: {
382
- ...PROJECT_PROPERTY,
383
- address: { type: "integer", description: "The target start address for the disassembly flow analysis (decimal)." },
384
- },
385
- required: ["project", "address"],
386
- },
387
- },
388
- {
389
- name: "r2000_get_binary_info",
390
- description:
391
- "Returns the origin address, size in bytes, target platform (e.g. 'Commodore 64'), filename, " +
392
- "user-provided description, entropy of the binary (values higher than 7.5 suggest the binary " +
393
- "might be compressed), and whether the binary may contain undocumented opcodes (a hint, not " +
394
- "guaranteed).",
395
- inputSchema: {
396
- type: "object",
397
- properties: { ...PROJECT_PROPERTY },
398
- required: ["project"],
399
- },
400
- },
401
- {
402
- name: "r2000_create_project_enum",
403
- description: "Creates a new project-specific enum definition embedded in the project file (D-21: project-local, never machine-global).",
404
- inputSchema: {
405
- type: "object",
406
- properties: {
407
- ...PROJECT_PROPERTY,
408
- name: { type: "string", description: "Unique alphanumeric identifier." },
409
- variants: { type: "object", description: "Variant mapping where keys are numeric strings (decimal, hex 0x/$, bin 0b/%) and values are variant names." },
410
- description: { type: "string", description: "Optional summary explaining the enum's purpose." },
411
- },
412
- required: ["project", "name", "variants"],
413
- },
414
- },
415
- {
416
- name: "r2000_update_project_enum",
417
- description: "Updates or renames an existing project-specific enum.",
418
- inputSchema: {
419
- type: "object",
420
- properties: {
421
- ...PROJECT_PROPERTY,
422
- name: { type: "string", description: "Existing name of the enum to update." },
423
- new_name: { type: "string", description: "Optional new name if renaming the enum." },
424
- variants: { type: "object", description: "Optional complete updated variants mapping." },
425
- description: { type: "string", description: "Optional updated summary explaining the enum's purpose." },
426
- },
427
- required: ["project", "name"],
428
- },
429
- },
430
- {
431
- name: "r2000_delete_project_enum",
432
- description:
433
- "Deletes a project-specific enum from the project. A regenerated enum set must be able to " +
434
- "replace an old one (R2000-13's re-runnable generation).",
435
- inputSchema: {
436
- type: "object",
437
- properties: {
438
- ...PROJECT_PROPERTY,
439
- name: { type: "string", description: "The name of the enum to delete." },
440
- force: { type: "boolean", description: "If false, fails if the enum has active usages in the disassembly. Set to true to override." },
441
- },
442
- required: ["project", "name"],
443
- },
444
- },
445
- {
446
- name: "r2000_apply_enum_usage",
447
- description:
448
- "Applies an enum definition to format the immediate operand or constant reference at a " +
449
- "specific address. If name is omitted or empty, clears the enum usage.",
450
- inputSchema: {
451
- type: "object",
452
- properties: {
453
- ...PROJECT_PROPERTY,
454
- address: { type: "integer", description: "The target instruction address (decimal)." },
455
- name: { type: "string", description: "The unique name of the enum to apply (e.g., 'vic_registers'). Omit or send empty to clear." },
456
- },
457
- required: ["project", "address"],
458
- },
459
- },
460
- {
461
- name: "r2000_save_project",
462
- description:
463
- "Explicitly saves the current project state to the .regen2000proj file named by 'project'. " +
464
- "Every OTHER mutating tool on this surface already saves automatically at the end of its own " +
465
- "call (D-17's per-call lifecycle), so this tool is rarely required standalone -- it exists as " +
466
- "an explicit flush, and as the natural last entry inside an r2000_batch_execute call. This " +
467
- "surface takes ONLY 'project' -- the underlying regenerator2000 tool takes no arguments of its " +
468
- "own and errors when its project_path is unset (handler.rs:350-352,1264-1271), which is " +
469
- "exactly why the path comes from this session's own process launch. Persistence is verified " +
470
- "independently by re-reading the project file's content hash from disk (T-11-FALSESUCCESS) -- " +
471
- "never trusted on the strength of regenerator2000's own success text.",
472
- inputSchema: {
473
- type: "object",
474
- properties: { ...PROJECT_PROPERTY },
475
- required: ["project"],
476
- },
477
- },
478
- {
479
- name: "r2000_batch_execute",
480
- description:
481
- "Executes multiple tool calls sequentially in a single r2000 session. Use only when you have " +
482
- "5+ independent operations to perform at once (e.g. marking many regions, renaming many " +
483
- "labels). Do not use for operations that depend on each other's results. Every inner " +
484
- "calls[].name is validated against this surface's own curated set BEFORE any request reaches " +
485
- "regenerator2000 -- a batch containing even one uncurated inner name is refused WHOLE (D-33).",
486
- inputSchema: {
487
- type: "object",
488
- properties: {
489
- ...PROJECT_PROPERTY,
490
- calls: {
491
- type: "array",
492
- items: {
493
- type: "object",
494
- properties: {
495
- name: { type: "string", description: "Name of the tool to execute -- must be one of this surface's curated r2000_* names." },
496
- arguments: { type: "object", description: "Arguments for the tool (same shape as that tool's own inputSchema, minus 'project')." },
497
- },
498
- required: ["name", "arguments"],
499
- },
500
- description: "List of tool calls to execute sequentially, inside one loaded r2000 session.",
501
- },
502
- },
503
- required: ["project", "calls"],
504
- },
505
- },
506
- ] as const;
507
-
508
- // ---------------------------------------------------------------------------
509
- // The allow-list gate (D-18/D-33). Derived from R2000_TOOL_DEFINITIONS itself
510
- // -- one array, not two hand-typed lists that could drift apart (the
511
- // set-equality test in r2000-tools.test.ts proves this holds, in both
512
- // directions, rather than assuming it from this derivation alone).
513
- // ---------------------------------------------------------------------------
514
-
515
- export const CURATED_R2000_TOOLS: readonly string[] = R2000_TOOL_DEFINITIONS.map((def) => def.name);
516
-
517
- export interface R2000UncuratedToolErrorOptions {
518
- toolName: string;
519
- batchIndex?: number;
520
- }
521
-
522
- /** Thrown by `assertCuratedTool()` when a name is outside the curated set --
523
- * whether at the outer dispatch or nested inside an `r2000_batch_execute`
524
- * payload. `toolName` names the offending tool (never this class's own
525
- * `.name`, which stays the class name per this repo's `R2000ViceFlagError`/
526
- * `StockSymbolsError` convention); `batchIndex` is set only for a refusal
527
- * discovered while walking a batch's `calls` array. */
528
- export class R2000UncuratedToolError extends Error {
529
- toolName: string;
530
- batchIndex?: number;
531
-
532
- constructor(message: string, { toolName, batchIndex }: R2000UncuratedToolErrorOptions) {
533
- super(message);
534
- this.name = "R2000UncuratedToolError";
535
- this.toolName = toolName;
536
- this.batchIndex = batchIndex;
537
- }
538
- }
539
-
540
- export interface R2000LabelNameErrorOptions {
541
- labelName: string;
542
- batchIndex?: number;
543
- }
544
-
545
- /** Thrown by `assertLegalLabelArg()` (called from `assertCuratedTool()` and
546
- * `assertCuratedBatch()`) when an `r2000_set_label_name` call's `name`
547
- * argument is not a legal ACME identifier -- T-11-NAME-INJECT, closed. The
548
- * policy is REJECT, never sanitize or quote: a malformed name is a bug to
549
- * surface, so the store's printed name can never diverge from what actually
550
- * gets exported into ACME source. `labelName` names the offending value
551
- * (never this class's own `.name`, which stays the class name per this
552
- * module's `R2000UncuratedToolError` convention); `batchIndex` is set only
553
- * for a refusal discovered while walking a batch's `calls` array. */
554
- export class R2000LabelNameError extends Error {
555
- labelName: string;
556
- batchIndex?: number;
557
-
558
- constructor(message: string, { labelName, batchIndex }: R2000LabelNameErrorOptions) {
559
- super(message);
560
- this.name = "R2000LabelNameError";
561
- this.labelName = labelName;
562
- this.batchIndex = batchIndex;
563
- }
564
- }
565
-
566
- /** Validates an `r2000_set_label_name` call's `name` argument against the
567
- * one ACME identifier seam (`r2000-acme-ident.ts`'s `assertLegalAcmeIdentifier()`),
568
- * re-throwing as `R2000LabelNameError` on failure. A no-op when `args` is
569
- * not a plain object carrying a string `name` -- that shape is a different
570
- * concern (a missing/malformed required argument), not this function's.
571
- * Called from BOTH `assertCuratedTool()` (the outer dispatch) and
572
- * `assertCuratedBatch()` (the batch-inner call), so the refusal fires
573
- * identically whether `r2000_set_label_name` is called directly or smuggled
574
- * inside an `r2000_batch_execute` payload -- before `runR2000Tool()`'s own
575
- * `try` block either way, which means a refusal REJECTS the returned
576
- * promise rather than resolving `{isError:true}`. That asymmetry is WR-02
577
- * (out of scope for this plan, 260821-a86) -- the same posture the existing
578
- * uncurated-name refusal above already takes, not a new oversight. */
579
- function assertLegalLabelArg(args: unknown, batchIndex?: number): void {
580
- if (!isPlainObject(args) || typeof args.name !== "string") return;
581
- const name = args.name;
582
- try {
583
- assertLegalAcmeIdentifier(name, "r2000_set_label_name name");
584
- } catch (err) {
585
- const reason = err instanceof Error ? err.message : String(err);
586
- throw new R2000LabelNameError(
587
- `r2000_set_label_name refused${batchIndex !== undefined ? ` (calls[${batchIndex}])` : ""}: "${name}" is not ` +
588
- `a legal ACME identifier (${reason}) -- REJECTED, never sanitized or quoted: the store's printed name must ` +
589
- "never diverge from the exported symbol.",
590
- { labelName: name, batchIndex },
591
- );
592
- }
593
- }
594
-
595
- /** Walks a `r2000_batch_execute` payload's `calls` array and refuses the
596
- * WHOLE batch if any inner call's name is outside `CURATED_R2000_TOOLS`, or
597
- * if a `calls` entry is malformed (not an object, or missing a string
598
- * `name`) -- a malformed payload is a REFUSAL, never treated as an empty
599
- * batch that passes through. Recurses into a nested `r2000_batch_execute`
600
- * (upstream permits arbitrary tool names inside a batch, including another
601
- * batch call) so a two-level smuggling attempt is caught the same way a
602
- * one-level one is. Also refuses WHOLE on an illegal `r2000_set_label_name`
603
- * name (T-11-NAME-INJECT), naming the offending `calls[i]`. */
604
- function assertCuratedBatch(args: unknown): void {
605
- if (!isPlainObject(args) || !Array.isArray(args.calls)) {
606
- throw new R2000UncuratedToolError(
607
- "r2000_batch_execute refused: \"calls\" must be an array of {name, arguments} objects -- a " +
608
- "malformed batch payload is treated as a refusal, never as an empty batch that passes through.",
609
- { toolName: "r2000_batch_execute" },
610
- );
611
- }
612
- const calls = args.calls as unknown[];
613
- calls.forEach((call, i) => {
614
- if (!isPlainObject(call) || typeof call.name !== "string") {
615
- throw new R2000UncuratedToolError(
616
- `r2000_batch_execute refused WHOLE: calls[${i}] is malformed (missing a string "name") -- ` +
617
- "treated as a refusal, never as an empty batch that passes through.",
618
- { toolName: "r2000_batch_execute", batchIndex: i },
619
- );
620
- }
621
- if (call.name === "r2000_get_address_details") {
622
- throw new R2000UncuratedToolError(
623
- `r2000_batch_execute refused WHOLE: calls[${i}].name is "r2000_get_address_details" -- ${ADDRESS_DETAILS_REFUSAL}`,
624
- { toolName: call.name, batchIndex: i },
625
- );
626
- }
627
- if (!CURATED_R2000_TOOLS.includes(call.name)) {
628
- throw new R2000UncuratedToolError(
629
- `r2000_batch_execute refused WHOLE: calls[${i}].name "${call.name}" is outside the curated ` +
630
- "r2000_* tool surface -- a batch is refused whole if any inner name is outside the curated set (D-33).",
631
- { toolName: call.name, batchIndex: i },
632
- );
633
- }
634
- if (call.name === "r2000_set_label_name") {
635
- assertLegalLabelArg(call.arguments, i);
636
- }
637
- if (call.name === "r2000_batch_execute") {
638
- assertCuratedBatch(call.arguments);
639
- }
640
- });
641
- }
642
-
643
- /**
644
- * The allow-list gate. Its body's FIRST check is set membership (WHAT NOT TO
645
- * DO above, and the module header's own discipline mirroring `vice.ts`'s
646
- * `DENY_LIST` precedent inverted into an allow-list): refuses `name` outright
647
- * when it is not in `CURATED_R2000_TOOLS`, with a dedicated message for
648
- * `r2000_get_address_details` naming the 64K defect and the upstream issue
649
- * (D-32) rather than a generic "unknown tool" refusal. When `name` is
650
- * `r2000_set_label_name`, additionally validates `args.name` via
651
- * `assertLegalLabelArg()` (T-11-NAME-INJECT, closed). When `name` is
652
- * `r2000_batch_execute`, additionally walks `args.calls` via
653
- * `assertCuratedBatch()` -- refusing the WHOLE batch if any inner name is
654
- * outside the set, per D-33, or carries an illegal label name.
655
- */
656
- export function assertCuratedTool(name: string, args?: unknown): void {
657
- if (name === "r2000_get_address_details") {
658
- throw new R2000UncuratedToolError(ADDRESS_DETAILS_REFUSAL, { toolName: name });
659
- }
660
- if (!CURATED_R2000_TOOLS.includes(name)) {
661
- throw new R2000UncuratedToolError(
662
- `"${name}" is not part of the curated r2000_* tool surface. Resolution routes: implement it and ` +
663
- "add it to R2000_TOOL_DEFINITIONS with a named criterion, or remove the caller reference.",
664
- { toolName: name },
665
- );
666
- }
667
- if (name === "r2000_set_label_name") {
668
- assertLegalLabelArg(args);
669
- }
670
- if (name === "r2000_batch_execute") {
671
- assertCuratedBatch(args);
672
- }
673
- }
674
-
675
- // ---------------------------------------------------------------------------
676
- // Project-path validation (T-11-PATH-ESCAPE, WR-01 -- closed). Same posture
677
- // stock-symbols.ts takes for `.lbl` files: an LLM-supplied path reaching a
678
- // spawned child process. Resolve against repoRoot(), refuse an extension
679
- // other than .regen2000proj, and refuse anything that escapes the workspace
680
- // root either directly or via a symlink. WR-01's finding was that a
681
- // not-yet-existing leaf under a directory symlink bypassed containment
682
- // entirely (the ENOENT catch fell back to the literal, unresolved path).
683
- // Containment is now enforced against the DEEPEST EXISTING ancestor's
684
- // realpath, plus the literal remaining path segments rebuilt on top of it
685
- // (`resolveViaDeepestExistingAncestor()` below) -- and every one of those
686
- // remaining segments is itself lstat-guarded against being an unresolved
687
- // (e.g. dangling) symlink, which would otherwise slip through the
688
- // ancestor-realpath check the same way the original leaf did.
689
- // ---------------------------------------------------------------------------
690
-
691
- export class R2000StorePathError extends Error {
692
- constructor(message: string) {
693
- super(message);
694
- this.name = "R2000StorePathError";
695
- }
696
- }
697
-
698
- function isContained(candidate: string, root: string): boolean {
699
- return candidate === root || candidate.startsWith(root + sep);
700
- }
701
-
702
- /** Walks up from `dirname(resolved)` toward the filesystem root, collecting
703
- * the literal path segments skipped along the way, until it finds the
704
- * DEEPEST ancestor for which `realpathSync` succeeds. Rebuilds the
705
- * candidate as `join(ancestorReal, ...remainingSegments)` -- but first
706
- * lstat-guards every remaining segment: a symlink whose own target does not
707
- * exist yet (or otherwise fails to resolve) still redirects a later create
708
- * outside the workspace, and `realpathSync` on the full path reports plain
709
- * ENOENT for it, so it would otherwise slip through the ancestor-realpath
710
- * check entirely. `lstatSync`'s own ENOENT (a genuinely absent component,
711
- * the tolerated case) is swallowed; any OTHER lstat error propagates as a
712
- * refusal. Called only from `resolveStorePath()`'s ENOENT branch below. */
713
- function resolveViaDeepestExistingAncestor(resolved: string, root: string): string {
714
- const remaining: string[] = [basename(resolved)];
715
- let current = dirname(resolved);
716
- let ancestorReal: string;
717
- for (;;) {
718
- try {
719
- ancestorReal = realpathSync(current);
720
- break;
721
- } catch (err) {
722
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
723
- throw new R2000StorePathError(
724
- `could not resolve ancestor "${current}" while resolving "${resolved}" (${err instanceof Error ? err.message : String(err)})`,
725
- );
726
- }
727
- const parent = dirname(current);
728
- if (parent === current) {
729
- throw new R2000StorePathError(
730
- `could not find any existing ancestor while resolving "${resolved}" under workspace root "${root}"`,
731
- );
732
- }
733
- remaining.unshift(basename(current));
734
- current = parent;
735
- }
736
- }
737
-
738
- let accumulated = ancestorReal;
739
- for (const segment of remaining) {
740
- accumulated = join(accumulated, segment);
741
- let st;
742
- try {
743
- st = lstatSync(accumulated);
744
- } catch (err) {
745
- if ((err as NodeJS.ErrnoException).code === "ENOENT") continue; // genuinely absent -- tolerated
746
- throw new R2000StorePathError(
747
- `could not stat "${accumulated}" while resolving "${resolved}" (${err instanceof Error ? err.message : String(err)})`,
748
- );
749
- }
750
- if (st.isSymbolicLink()) {
751
- throw new R2000StorePathError(
752
- `"${resolved}" contains a symlink component at "${accumulated}" that does not itself resolve (dangling, or ` +
753
- "otherwise unreadable via realpathSync) -- refusing rather than risk a later create redirecting outside " +
754
- `the workspace root (${root})`,
755
- );
756
- }
757
- }
758
-
759
- return accumulated;
760
- }
761
-
762
- /** Resolves `project` against `repoRoot()`, refusing anything that does not
763
- * end in `.regen2000proj`, or that escapes the workspace either directly or
764
- * via a symlink. Tolerant of the path not existing yet (ENOENT during the
765
- * symlink-resolution step) since `r2000_save_project` can create a fresh
766
- * store -- unlike stock-symbols.ts's `.lbl` reader, this module never reads
767
- * the file itself, so a missing project is regenerator2000's own concern to
768
- * report, not this function's. On ENOENT, containment is enforced against
769
- * the deepest EXISTING ancestor's realpath plus the literal remaining
770
- * segments (`resolveViaDeepestExistingAncestor()`), closing WR-01 /
771
- * T-11-PATH-ESCAPE: the previous ENOENT fallback (`real = resolved`, the
772
- * literal, unresolved path) let a directory symlink one or more levels up
773
- * from a not-yet-existing leaf bypass containment entirely. */
774
- export function resolveStorePath(project: unknown): string {
775
- if (typeof project !== "string" || project.trim() === "") {
776
- throw new R2000StorePathError(
777
- `project must be a non-empty string, got ${typeof project === "string" ? "an empty/whitespace-only string" : typeof project}`,
778
- );
779
- }
780
- const trimmed = project.trim();
781
- if (!trimmed.toLowerCase().endsWith(".regen2000proj")) {
782
- throw new R2000StorePathError(
783
- `"${trimmed}" must end in .regen2000proj -- refusing to hand a non-project path to a spawned regenerator2000 child`,
784
- );
785
- }
786
-
787
- const root = repoRoot();
788
- const resolved = resolve(root, trimmed);
789
-
790
- if (!isContained(resolved, root)) {
791
- throw new R2000StorePathError(`"${resolved}" is outside the workspace root (${root}) -- an r2000 project path must live inside the workspace`);
792
- }
793
-
794
- let realRoot: string;
795
- try {
796
- realRoot = realpathSync(root);
797
- } catch {
798
- realRoot = root;
799
- }
800
-
801
- let real: string;
802
- try {
803
- real = realpathSync(resolved);
804
- } catch (err) {
805
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
806
- throw new R2000StorePathError(`could not resolve "${resolved}" (${err instanceof Error ? err.message : String(err)})`);
807
- }
808
- // ENOENT is fine here -- r2000_save_project may create the file fresh.
809
- // Walk up to the deepest EXISTING ancestor's realpath and rebuild the
810
- // candidate from it, rather than falling back to the literal
811
- // (unresolved) `resolved` path -- see resolveViaDeepestExistingAncestor().
812
- real = resolveViaDeepestExistingAncestor(resolved, root);
813
- }
814
-
815
- if (!isContained(real, realRoot)) {
816
- throw new R2000StorePathError(
817
- `"${resolved}" resolves (via symlink) to "${real}", which is outside the workspace root ` +
818
- `(${realRoot === root ? realRoot : `${root}, canonically ${realRoot}`}) -- an r2000 project path must live inside the workspace`,
819
- );
820
- }
821
-
822
- return real;
823
- }
824
-
825
- // ---------------------------------------------------------------------------
826
- // Read-only tools never need a save -- calling one leaves project state
827
- // unchanged. Every OTHER curated tool (besides r2000_save_project itself)
828
- // mutates state (a label, a comment, a block classification, a scope, an
829
- // enum, or -- for r2000_disassemble -- the blocks a control-flow trace
830
- // converts to Code) and MUST be saved before its session exits, or D-17's
831
- // own per-call lifecycle ("spawn, load, mutate, r2000_save_project, exit")
832
- // silently loses the mutation: a spawned child that exits without saving
833
- // discards every in-memory change, so a caller who wrote a label in one
834
- // runR2000Tool() call and expected a LATER call to see it would be exactly
835
- // as vulnerable to a lost-write bug as r2000_save_project's own
836
- // T-11-FALSESUCCESS trap -- just with no error at all. This is why every
837
- // mutating tool call below saves internally, inside the SAME session, rather
838
- // than requiring a caller to remember a separate r2000_save_project call.
839
- //
840
- // That internal auto-save deliberately calls the PLAIN underlying
841
- // `r2000_save_project` (no hash verification), never `saveAndVerify()`: an
842
- // idempotent mutation (e.g. setting a label to the value it already has) is
843
- // a legitimate no-op whose save correctly produces an UNCHANGED file hash,
844
- // and `saveAndVerify()`'s whole contract is "throw when the hash does not
845
- // change" -- applying it to every internal auto-save would misreport that
846
- // legitimate no-op as T-11-FALSESUCCESS. `saveAndVerify()` is reserved for
847
- // the ONE case the plan names explicitly: when `r2000_save_project` is the
848
- // OUTER tool a caller invoked by name.
849
- // ---------------------------------------------------------------------------
850
-
851
- const READ_ONLY_R2000_TOOLS: ReadonlySet<string> = new Set([
852
- "r2000_get_symbols",
853
- "r2000_get_comments",
854
- "r2000_get_blocks",
855
- "r2000_get_cross_references",
856
- "r2000_search_disassembly",
857
- "r2000_get_binary_info",
858
- ]);
859
-
860
- // ---------------------------------------------------------------------------
861
- // The runner. Drives r2000-mcp-client.ts via a DYNAMIC import so importing
862
- // R2000_TOOL_DEFINITIONS (registration, at vice-proxy.ts module scope) costs
863
- // no child process and no socket -- only calling a tool actually spawns one.
864
- // ---------------------------------------------------------------------------
865
-
866
- /**
867
- * Runs one curated `r2000_*` tool call. First statement: `assertCuratedTool`.
868
- * Second: `resolveStorePath`. Only after both pass does this function reach
869
- * for `r2000-mcp-client.ts` (dynamically imported, so registering the tool
870
- * definitions above never pays for it).
871
- *
872
- * `r2000_save_project` (called directly by name) is routed through
873
- * `saveAndVerify()` so a save is never reported on the child's own text
874
- * response alone (T-11-FALSESUCCESS). Every OTHER mutating tool (everything
875
- * outside `READ_ONLY_R2000_TOOLS` and not `r2000_save_project` itself) saves
876
- * internally, inside the same session, immediately after its own call, using
877
- * a PLAIN save (see the comment above `READ_ONLY_R2000_TOOLS` for why that
878
- * internal save must not be hash-verified).
879
- */
880
- export async function runR2000Tool(name: string, args: unknown): Promise<ToolCallResult> {
881
- assertCuratedTool(name, args);
882
- const projectPath = resolveStorePath(isPlainObject(args) ? args.project : undefined);
883
-
884
- const { withR2000Session, saveAndVerify } = await import("./r2000-mcp-client.ts");
885
-
886
- const rest: Record<string, unknown> = isPlainObject(args) ? { ...args } : {};
887
- delete rest.project;
888
-
889
- try {
890
- if (name === "r2000_save_project") {
891
- const result = await withR2000Session(projectPath, (call) => saveAndVerify(projectPath, call));
892
- return okText(JSON.stringify(result));
893
- }
894
-
895
- if (READ_ONLY_R2000_TOOLS.has(name)) {
896
- const result = await withR2000Session(projectPath, (call) => call(name, rest));
897
- return toToolCallResult(result);
898
- }
899
-
900
- // A mutating tool (including r2000_batch_execute, whose own inner calls
901
- // all run inside this SAME session per regenerator2000's own
902
- // batch_execute implementation): call, then save PLAINLY (no hash
903
- // verification -- see the block comment above), before the session
904
- // exits.
905
- const result = await withR2000Session(projectPath, async (call) => {
906
- const callResult = await call(name, rest);
907
- await call("r2000_save_project", {});
908
- return callResult;
909
- });
910
- return toToolCallResult(result);
911
- } catch (err) {
912
- return errText(`${name} failed: ${err instanceof Error ? err.message : String(err)}`);
913
- }
914
- }