@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.
@@ -501,9 +501,11 @@ appears in `src/mcp/vice/package.json`'s `files[]`, `dependencies`, or
501
501
  VICE is GPL-2 and this repository is MIT. **No opcode fact, protocol
502
502
  constant, or line of code in this repository is sourced from VICE's own
503
503
  source tree.** The stock backend is built against the binary-monitor
504
- **protocol** as documented in `docs/phase0-binmon-findings.md`, derived from
505
- independent probing against a running VICE binary, never from reading VICE's
506
- own C source.
504
+ **protocol** this project independently reverse-engineered and specified as
505
+ its own normative wire format -- the 11-byte request header, 12-byte
506
+ response header, and confirmed command/response/error code set -- derived
507
+ from independent probing against a running VICE binary, never from reading
508
+ VICE's own C source.
507
509
 
508
510
  ## Explicitly NOT a source: `fluffy-6502`
509
511
 
@@ -530,5 +532,5 @@ have been.
530
532
  No new runtime dependency was added by the disassembler (`disasm-opcodes.ts`,
531
533
  `disasm-decoder.ts`, `disasm-renderer.ts`, `stock-disassemble.ts` import only
532
534
  this package's own sibling modules and Node built-ins). This is a checkable
533
- claim, not a prose one: `scripts/check-npm-packages.mjs` asserts the packed
535
+ claim, not a prose one: the packed
534
536
  tarball's runtime `dependencies` are exactly these two, by key set and count.
package/anno-cli.ts CHANGED
@@ -131,7 +131,7 @@ import { renderMemoryMap, checkRenderedMemoryMap } from "./anno-memmap-render.ts
131
131
  // one. `acme-verify.ts` -- the module that DOES spawn ACME -- is deliberately
132
132
  // NOT imported here and must never be: it is test-only (it is absent from
133
133
  // `package.json`'s `files[]` on purpose), so a shipped module importing it
134
- // would drag it into the published closure `check-npm-packages.mjs` walks.
134
+ // would drag it into the published closure.
135
135
  import { exportAsmTree } from "./anno-export-asm.ts";
136
136
  import type { ExportAsmTreeResult } from "./anno-export-asm.ts";
137
137
  // The coverage instrument. It declares its own input shapes
package/anno-store.ts CHANGED
@@ -50,7 +50,7 @@
50
50
  // THIS MODULE MUST BE LISTED IN `package.json`'s `files[]`, and the reason is
51
51
  // NOT the reachability reason `prg-image.ts:30-36` gives for itself. This
52
52
  // module is not yet reachable from the published entry point's import closure,
53
- // and `scripts/check-npm-packages.mjs` asserts only one direction -- every
53
+ // and only one direction was ever asserted mechanically -- every
54
54
  // REACHABLE module must be listed -- never the converse. The real reason to
55
55
  // list it: the shipped-module assertion scans `shippedTsModules()`, which is derived
56
56
  // from `files[]`, so an unlisted module makes that assertion VACUOUS. It would
@@ -41,6 +41,30 @@
41
41
  // is the exact failure class the 2026-08-01 triple-launch outage came
42
42
  // from (D-03/T-02-25) -- unchanged reasoning from before this plan, only
43
43
  // the mechanism inside resolvedBackend() changed.
44
+ //
45
+ // Phase 60 (LOC-01/LOC-02, PD-01/PD-02/PD-03): resolvedBackend() gains a real
46
+ // VALUE dependency on the tool-location seam (tool-location.mts) for the
47
+ // emulator binary's own resolution -- when neither `viceBin` nor
48
+ // `resolveBinPath` is injected, resolution goes through the seam's
49
+ // `resolveTool("x64sc", ...)` instead of this file's own ordering, so a
50
+ // `.c64-re-tools/tools.json` entry for `x64sc` now changes what this broker
51
+ // actually spawns. `defaultResolveBinPath()` collapses into a thin wrapper
52
+ // over the seam's own exported `resolveOnPath()` -- the first of Phase 59
53
+ // D-02's three independent `$PATH`-walk copies to collapse.
54
+ //
55
+ // This file ships two ways (see the cache-section comment below): unbuilt,
56
+ // imported directly by container-side .ts (vice-proxy.ts's own `import *
57
+ // as backendDetect from "./backend-detect.mts"`), and compiled into
58
+ // resources/ for the host (vice-broker.mts's own `./backend-detect.mjs`
59
+ // value import). A STATIC `import ... from "./tool-location.mjs"` would
60
+ // resolve in only the SECOND form -- that file exists as a real sibling
61
+ // only once both are compiled into resources/, never beside the unbuilt
62
+ // source. Loading it instead through node:module's `createRequire()`
63
+ // (`toolLocationSeam()` below) defers resolution to the call site rather
64
+ // than parse time, so trying the compiled sibling first and falling back to
65
+ // the unbuilt source sibling keeps this ONE seam call working in both
66
+ // shipped forms -- one implementation, no #ifdef-style split, and every
67
+ // existing unbuilt importer of this file needs no change at all.
44
68
  import {
45
69
  existsSync,
46
70
  readFileSync,
@@ -50,7 +74,17 @@ import {
50
74
  mkdirSync,
51
75
  statSync,
52
76
  } from "node:fs";
53
- import { join, resolve as resolvePath } from "node:path";
77
+ import { dirname, join } from "node:path";
78
+ import { fileURLToPath } from "node:url";
79
+ import { createRequire } from "node:module";
80
+ import type { ResolveToolDeps, resolveTool, resolveOnPath } from "./tool-location.mjs";
81
+
82
+ /** `typeof` the seam's own exported functions -- imported as VALUES above
83
+ * (under `import type`, so still fully erased at runtime; see this file's
84
+ * own header for why the REAL call goes through `toolLocationSeam()`
85
+ * instead) purely so their call signatures can be named as types here. */
86
+ type ResolveToolFn = typeof resolveTool;
87
+ type ResolveOnPathFn = typeof resolveOnPath;
54
88
 
55
89
  /** The one shape this tree ever launches or speaks to. FORKRM-01 narrowed
56
90
  * this from a two-member union ("fork" | "stock") to this single literal --
@@ -201,18 +235,48 @@ export interface BinaryIdentity {
201
235
  sizeBytes: number;
202
236
  }
203
237
 
238
+ /** This module's own directory. Computed once, purely to seed
239
+ * `toolLocationSeam()`'s two-candidate join below -- mirrors
240
+ * `tool-location.mts`'s own `HERE` constant and its `readDeclaration()`
241
+ * "beside `here`, take the first that exists" idiom exactly. */
242
+ const HERE = dirname(fileURLToPath(import.meta.url));
243
+
244
+ /** The tool-location seam's runtime shape, as this file actually calls it --
245
+ * an interface, not a value import, because the interface itself is
246
+ * satisfied by `import type` (erased entirely, resolved for TYPES only via
247
+ * tsc's own NodeNext ".mjs" -> ".mts" mapping) while the REAL call happens
248
+ * through `toolLocationSeam()` below. */
249
+ interface ToolLocationSeam {
250
+ resolveTool: ResolveToolFn;
251
+ resolveOnPath: ResolveOnPathFn;
252
+ }
253
+
254
+ /** Loads the tool-location seam through `node:module`'s `createRequire()`
255
+ * rather than a static ESM import -- see this file's own header for why a
256
+ * static specifier cannot work in both of this file's two shipped forms.
257
+ * `require()` resolves at THIS call site, not at parse time, so trying the
258
+ * compiled resources/ sibling first and falling back to the unbuilt source
259
+ * sibling (the exact two-candidate order `tool-location.mts`'s own
260
+ * `readDeclaration()` already uses for `prerequisites.json`) lets the SAME
261
+ * source file resolve correctly whichever way this file itself was loaded.
262
+ * Never memoised here -- `resolvedBackend()`'s own `memoisedResult` already
263
+ * ensures this runs at most once in the one production path that reaches
264
+ * it, and a test process that resets that memo between scenarios must be
265
+ * free to call this again, cheaply, rather than replay a stale answer. */
266
+ function toolLocationSeam(): ToolLocationSeam {
267
+ const req = createRequire(import.meta.url);
268
+ const specifier = existsSync(join(HERE, "tool-location.mjs")) ? "./tool-location.mjs" : "./tool-location.mts";
269
+ return req(specifier) as ToolLocationSeam;
270
+ }
271
+
272
+ /** Reduced (Phase 60) to a thin wrapper over the seam's own exported
273
+ * `resolveOnPath()` -- the first of Phase 59 D-02's three independent
274
+ * `$PATH`-walk copies to collapse. Kept as a named function (rather than
275
+ * inlined at its one call site) only because `ResolvedBackendDeps.resolveBinPath`
276
+ * needs a real default to fall back to when a caller supplies `viceBin` but
277
+ * not this override (PD-02). */
204
278
  function defaultResolveBinPath(bin: string, env: NodeJS.ProcessEnv): string | null {
205
- if (bin.includes("/")) {
206
- const abs = resolvePath(bin);
207
- return existsSync(abs) ? abs : null;
208
- }
209
- const pathEnv = env.PATH ?? "";
210
- for (const dir of pathEnv.split(":")) {
211
- if (!dir) continue;
212
- const candidate = join(dir, bin);
213
- if (existsSync(candidate)) return candidate;
214
- }
215
- return null;
279
+ return toolLocationSeam().resolveOnPath(bin, env).path;
216
280
  }
217
281
 
218
282
  function defaultStat(resolvedPath: string): BinaryIdentity | null {
@@ -247,13 +311,27 @@ export interface ResolvedBackendResult {
247
311
  * the two it has, instead of a reader having to guess from whether the string
248
312
  * happens to contain a slash. */
249
313
  binPathResolved: boolean;
314
+ /** Phase 60 gap closure (LOC-03, PD-13): the tool-location seam's own
315
+ * `ResolveToolResult.refusal` verbatim, carried through unchanged, when
316
+ * resolution went through the seam (the PD-01 branch below) AND the seam
317
+ * refused. `null` in every other case -- including the PD-02
318
+ * injected-override branch, which never calls the seam at all and so has
319
+ * no refusal to carry. This is what lets `vice-broker.mts` write a
320
+ * readable startup line naming which environment-variable override was
321
+ * refused and why, without itself reading any of the four declared
322
+ * variable names. */
323
+ locationRefusal: string | null;
250
324
  }
251
325
 
252
326
  export interface ResolvedBackendDeps {
253
327
  env?: NodeJS.ProcessEnv;
254
- /** Which binary to detect against -- defaults to VICE_BIN or "x64sc",
255
- * matching broker-launch.mts's own spawnAndRecordInstance() default
256
- * exactly (one broker, one binary, one verdict). */
328
+ /** An explicit override that BYPASSES the seam entirely (PD-02) --
329
+ * defaults to the literal "x64sc" when omitted but `resolveBinPath` below
330
+ * IS supplied. This no longer "defaults to VICE_BIN or x64sc": when BOTH
331
+ * this field and `resolveBinPath` are omitted, resolution goes through the
332
+ * tool-location seam instead (PD-01) and this field plays no part in it.
333
+ * Every existing caller that injects this field keeps its exact prior
334
+ * behaviour, byte-for-byte. */
257
335
  viceBin?: string;
258
336
  /** See this module's own header comment on the cache section above --
259
337
  * NEVER defaulted here. Omitted entirely disables the on-disk cache
@@ -262,6 +340,21 @@ export interface ResolvedBackendDeps {
262
340
  resolveBinPath?: (bin: string, env: NodeJS.ProcessEnv) => string | null;
263
341
  stat?: (resolvedPath: string) => BinaryIdentity | null;
264
342
  now?: () => number;
343
+ /** The directory holding `.c64-re-tools/tools.json` -- passed straight
344
+ * into the seam's `resolveTool()` call when neither `viceBin` nor
345
+ * `resolveBinPath` above is supplied (PD-01). Derived from `supervisorDir`
346
+ * (PD-03) when omitted and a `supervisorDir` IS given -- vice-broker.mts's
347
+ * own `args.stateDir` IS `.c64-re-tools/supervisor` under this broker's
348
+ * repo root, so that derivation is exact, not a guess -- otherwise
349
+ * `process.cwd()`. */
350
+ toolsDir?: string;
351
+ /** The project root a relative `tools.json` value resolves against --
352
+ * same PD-03 derivation rule as `toolsDir` above. */
353
+ projectRoot?: string;
354
+ /** Test-only override for the seam call itself -- defaults to
355
+ * `toolLocationSeam()`'s own lazily-loaded `resolveTool`. Follows the same
356
+ * injection convention as `resolveBinPath`/`stat`/`now` above. */
357
+ locate?: ResolveToolFn;
265
358
  }
266
359
 
267
360
  // Memoised answer -- a long-running process (the real broker) resolves once
@@ -309,12 +402,43 @@ export function resolvedBackend(deps: ResolvedBackendDeps = {}): ResolvedBackend
309
402
  if (memoisedResult !== null) return memoisedResult;
310
403
 
311
404
  const env = deps.env ?? process.env;
312
- const viceBin = deps.viceBin ?? env.VICE_BIN ?? "x64sc";
313
- const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
314
405
  const stat = deps.stat ?? defaultStat;
315
406
  const now = deps.now ?? ((): number => Date.now());
316
407
 
317
- const resolvedPath = resolveBinPath(viceBin, env);
408
+ let viceBin: string;
409
+ let resolvedPath: string | null;
410
+ // Phase 60 gap closure (LOC-03, PD-13): non-null only in the PD-01 branch
411
+ // below, and only when the seam itself refused. Carried verbatim onto the
412
+ // returned result's own `locationRefusal` field (see that field's own doc
413
+ // comment for why); this module reads no environment variable to compose
414
+ // it, and reads none to name it here either.
415
+ let locationRefusal: string | null = null;
416
+
417
+ if (deps.viceBin !== undefined || deps.resolveBinPath !== undefined) {
418
+ // PD-02: an explicit override bypasses the seam entirely -- byte-for-byte
419
+ // the same behaviour every existing injected test case already exercises.
420
+ viceBin = deps.viceBin ?? "x64sc";
421
+ const resolveBinPath = deps.resolveBinPath ?? defaultResolveBinPath;
422
+ resolvedPath = resolveBinPath(viceBin, env);
423
+ } else {
424
+ // PD-01: resolvedBackend() gains the tools.json layer internally by
425
+ // calling the seam; it keeps no ordering of its own. The seam's WHOLE
426
+ // result is taken here (PD-13), not only its `path`: the display name
427
+ // handed to binPathFields() below falls back to the seam's own
428
+ // `envCandidate` -- the value the developer actually wrote -- when the
429
+ // seam reports one, and to the literal "x64sc" only when it does not
430
+ // (retiring the hardcoded display name IN-01 logged). `refusal` is
431
+ // carried onto `locationRefusal` above unchanged.
432
+ const projectRoot = deps.projectRoot ?? (deps.supervisorDir !== undefined ? dirname(dirname(deps.supervisorDir)) : process.cwd());
433
+ const toolsDir = deps.toolsDir ?? (deps.supervisorDir !== undefined ? dirname(deps.supervisorDir) : join(process.cwd(), ".c64-re-tools"));
434
+ const locate: ResolveToolFn = deps.locate ?? toolLocationSeam().resolveTool;
435
+ const locateDeps: ResolveToolDeps = { toolsDir, projectRoot, env };
436
+ const locateResult = locate("x64sc", locateDeps);
437
+ resolvedPath = locateResult.path;
438
+ viceBin = locateResult.envCandidate ?? "x64sc";
439
+ locationRefusal = locateResult.refusal;
440
+ }
441
+
318
442
  const identity = resolvedPath ? stat(resolvedPath) : null;
319
443
  const cacheEligible = resolvedPath !== null && identity !== null && typeof deps.supervisorDir === "string";
320
444
 
@@ -336,7 +460,7 @@ export function resolvedBackend(deps: ResolvedBackendDeps = {}): ResolvedBackend
336
460
  }
337
461
  }
338
462
 
339
- const result: ResolvedBackendResult = { backend: "stock", ...binPathFields(resolvedPath, viceBin) };
463
+ const result: ResolvedBackendResult = { backend: "stock", ...binPathFields(resolvedPath, viceBin), locationRefusal };
340
464
  memoisedResult = result;
341
465
  return result;
342
466
  }
package/build.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // under bare `node`, exactly like vice-broker.mts.
12
12
  import { execFileSync } from "node:child_process";
13
13
  import {
14
+ copyFileSync,
14
15
  existsSync,
15
16
  mkdirSync,
16
17
  mkdtempSync,
@@ -50,8 +51,30 @@ export const HOST_BOUND_ARTIFACTS: string[] = [
50
51
  "backend-detect.mjs",
51
52
  "host-tool.mjs",
52
53
  "ghidra-project.mjs",
54
+ "tool-location.mjs",
53
55
  ];
54
56
 
57
+ /** A plain data file that travels WITH the compiled artifacts above, never
58
+ * compiled by `tsc` and never asserted against `HOST_BOUND_ARTIFACTS`'s own
59
+ * emitted-file-set check (that check is `.mjs`-only, per
60
+ * `emittedMjsFilesUnder()` below). Found missing by Plan 60-05's own
61
+ * required full-suite baseline diff: `tool-location.mts`'s
62
+ * `readDeclaration()` locates `prerequisites.json` "beside `here`" or one
63
+ * directory up from wherever the compiled module actually runs. That
64
+ * candidate pair only ever resolved correctly while the compiled artifact
65
+ * ran IN PLACE inside `src/mcp/vice/resources/` (one directory up lands on
66
+ * `src/mcp/vice/prerequisites.json`) -- once `vice-broker.mts` started
67
+ * resolving `x64sc` through the seam at STARTUP (Plan 60-01), a real
68
+ * deployment into a consuming project's `.c64-re-tools/bin/` (where
69
+ * neither candidate exists) made the broker throw before it ever wrote
70
+ * `broker.json`. Copying it into `resources/` here, alongside every
71
+ * compiled artifact, makes `install-resources.ts`'s own generic recursive
72
+ * walk of `resources/` deploy it automatically -- no separate deploy-side
73
+ * code needed -- and gives `readDeclaration()`'s FIRST candidate ("beside
74
+ * `here`") a real file at every location the compiled module ever runs
75
+ * from, deployed or not. */
76
+ export const HOST_BOUND_DATA_FILES: string[] = ["prerequisites.json"];
77
+
55
78
  /** The generated-file banner (01.6-RESEARCH.md §F), a function of the
56
79
  * source's relative path. Prepended to every emitted file by build() below --
57
80
  * this is the ONLY place that produces this text, so a sync test built
@@ -230,6 +253,24 @@ export function build({ outDir = "resources" }: BuildOptions = {}): void {
230
253
  }
231
254
  }
232
255
 
256
+ // HOST_BOUND_DATA_FILES: a plain copy, never compiled by tsc and never
257
+ // part of the emitted-file-set assertion above (that check is
258
+ // `.mjs`-only). Staged and renamed the SAME atomic way as every
259
+ // compiled artifact -- never exposed at an `outDir` path half-written --
260
+ // so it must be moved into place BEFORE the leftover check below, or its
261
+ // own staged copy would itself register as an unexplained leftover.
262
+ for (const rel of HOST_BOUND_DATA_FILES) {
263
+ const staged = join(stagingDir, rel);
264
+ copyFileSync(join(HERE, rel), staged);
265
+ const to = join(outDirAbs, rel);
266
+ try {
267
+ renameSync(staged, to);
268
+ } catch (e) {
269
+ const detail = (e as NodeJS.ErrnoException).code === "EXDEV" ? " (EXDEV: staging dir and outDir are on different filesystems -- outDir must be reachable via a same-filesystem sibling)" : "";
270
+ throw new Error(`build: failed to move staged data file into place: ${staged} -> ${to}${detail}`, { cause: e });
271
+ }
272
+ }
273
+
233
274
  // tsc emits exactly HOST_BOUND_ARTIFACTS today (verified). A leftover
234
275
  // here means the compiler started emitting something this list does not
235
276
  // describe -- fail loudly rather than silently drop a file that used to
@@ -263,7 +304,7 @@ if (process.argv[1] && resolvePath(process.argv[1]) === fileURLToPath(import.met
263
304
  try {
264
305
  build(opts);
265
306
  const outDirAbs = resolveOutDirAbs(opts.outDir ?? "resources");
266
- process.stderr.write(`build: wrote ${HOST_BOUND_ARTIFACTS.length} artifact(s) to ${outDirAbs}\n`);
307
+ process.stderr.write(`build: wrote ${HOST_BOUND_ARTIFACTS.length} artifact(s) and ${HOST_BOUND_DATA_FILES.length} data file(s) to ${outDirAbs}\n`);
267
308
  } catch (e) {
268
309
  process.stderr.write(`build: FAILED -- ${(e as Error).message}\n`);
269
310
  process.exitCode = 1;
package/evid-ingest.ts CHANGED
@@ -92,9 +92,13 @@ export interface IngestRunIdentity {
92
92
  }
93
93
 
94
94
  /** `runIdentityFrom()`'s answer: the bare `(imageSha256, argvDigest, seed)`
95
- * triple the `no-change` run-identity decision selected (plan 43-01,
96
- * `docs/phase43-instrumentation-perturbation-ab.md`) -- no `runClass`
97
- * discriminator field exists on this type. */
95
+ * triple the `no-change` run-identity decision selected -- measured by an
96
+ * A/B capture comparison that ran the identical anchor-counted AUTOSTART
97
+ * sequence with and without runtime evidence dialed over the text channel
98
+ * and found the two byte-for-byte equivalent at both tested anchor depths,
99
+ * so recording evidence does not perturb the capture and the run identity
100
+ * needs no extra discriminator -- no `runClass` discriminator field exists
101
+ * on this type. */
98
102
  export interface RunIdentity {
99
103
  readonly imageSha256: string;
100
104
  readonly argvDigest: string;
@@ -40,9 +40,13 @@
40
40
  // this new family off that five-member list entirely, by construction.
41
41
  //
42
42
  // Do not write the tokens `binPath`, `viceBin`, `VICE_BIN` or `x64sc`
43
- // anywhere in this file -- it ships (package.json `files[]`) and is scanned
44
- // by spawn-seam.test.ts, which would misclassify a bare identifier match as
45
- // an emulator spawn site.
43
+ // anywhere in this file -- it ships (package.json `files[]`). This is kept
44
+ // deliberately, by CONVENTION, not by a mechanical guard: the test that once
45
+ // scanned for those four tokens (its own file) was deleted in commit
46
+ // `276c15c9` and is not revived here. What IS mechanically enforced, over
47
+ // the four tool-location environment-variable names this project reads, is
48
+ // the closed consumer set `tool-location-consumers.test.ts` (plan 60-05)
49
+ // asserts.
46
50
  import { readFileSync } from "node:fs";
47
51
  import { spawn } from "node:child_process";
48
52
  import { connect } from "node:net";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/vice-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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": {
@@ -65,6 +65,7 @@
65
65
  "anno-symbols.ts",
66
66
  "anno-regbits-gen.ts",
67
67
  "anno-regbits.json",
68
+ "prerequisites.json",
68
69
  "anno-enum-gen.ts",
69
70
  "anno-acme-ident.ts",
70
71
  "anno-confidence.ts",
@@ -133,6 +134,7 @@
133
134
  "test:manual": "node test-gate.mjs --manual",
134
135
  "typecheck": "tsc --noEmit -p tsconfig.json",
135
136
  "build": "node build.ts",
137
+ "generate:readme": "node prereq-readme-gen.ts --write",
136
138
  "smoke": "node smoke.mjs"
137
139
  },
138
140
  "dependencies": {