@henols/vice-mcp 0.2.0 → 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 (49) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/anno-acme-ident.ts +97 -0
  4. package/anno-cli.ts +1465 -0
  5. package/anno-confidence.ts +233 -0
  6. package/anno-coverage.ts +2465 -0
  7. package/anno-d64.ts +310 -0
  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/anno-memmap-render.ts +672 -0
  14. package/anno-regbits-gen.ts +421 -0
  15. package/anno-regbits.json +1370 -0
  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 -3
  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 +33 -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 +168 -0
package/block-class.ts ADDED
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ // block-class.ts
3
+ //
4
+ // The ONE place that translates an annotation store's own block-type
5
+ // vocabulary into a neutral block class. Nothing else in this tree may
6
+ // compare a store block-type string, and nothing else may read a store block
7
+ // listing on the census's behalf (SEAM-03).
8
+ //
9
+ // ---------------------------------------------------------------------------
10
+ // WHY THIS FILE EXISTS
11
+ // ---------------------------------------------------------------------------
12
+ // `anno-coverage.ts` is the coverage instrument: it implements two Validated
13
+ // requirements and measures how much of a binary has actually been reverse-
14
+ // engineered. Its census is a pure function of the raw bytes and the seed set
15
+ // the caller supplies -- deliberately, because deriving completeness from the
16
+ // store's own block table measures the annotator's bookkeeping rather than
17
+ // the annotation.
18
+ //
19
+ // Yet the census's ONLY tie to the external analyser this project rents its
20
+ // annotation store from was four inline comparisons against that analyser's
21
+ // Rust `Display` strings, spread across three code regions and reached from
22
+ // two lookup call sites. Four string literals were the entire reason a
23
+ // 2,292-line instrument looked like glue around somebody else's data model.
24
+ // Any prefix-driven deletion of the rented substrate would have taken the
25
+ // instrument with it.
26
+ //
27
+ // So the tie lives here, in one small module, behind one named function. A
28
+ // later phase swaps THIS module -- the store's vocabulary changes, the census
29
+ // does not. That is the whole point: the boundary is the module, never an
30
+ // argument threaded through the census.
31
+ //
32
+ // The neutral classes are LOWERCASE. That lowercase-ness was once claimed as
33
+ // a protection in its own right -- the argument being that a capitalised
34
+ // store spelling could not accidentally agree with it, so a comparison site
35
+ // left behind somewhere else would get a different answer and move a measured
36
+ // number, loudly. THAT PREMISE IS NOW FALSE, and the loss is recorded here
37
+ // rather than left to be rediscovered: this project's own
38
+ // store's vocabulary is LOWERCASE, and two of its twelve members -- the code
39
+ // spelling and the undefined spelling -- are string-identical to their
40
+ // neutral classes. A left-behind raw comparison against the store's own
41
+ // spelling therefore CAN accidentally agree now, silently, which is exactly
42
+ // what the old rationale promised could not happen.
43
+ //
44
+ // Two guards replace it, and they are why the mapping below is still
45
+ // defended. Neither is a claim in a header:
46
+ //
47
+ // (a) the derived TOTAL cross-check in `block-class.test.ts`. It iterates
48
+ // the store's frozen twelve-member vocabulary from its single home and
49
+ // asserts the class this module returns for EVERY member, with its own
50
+ // non-vacuity assertions on the counts. It reddens the moment either
51
+ // side drifts; a spot check would not.
52
+ // (b) `anno-coverage.test.ts`'s zero-overlap substitutability proof. Its
53
+ // substituted vocabulary (`EXECUTABLE_EXTENT` and its two siblings)
54
+ // shares no string with EITHER accepted vocabulary, so a left-behind
55
+ // comparison site is still observable there -- which is why that
56
+ // vocabulary list is DERIVED from both, not hand-written at four
57
+ // entries.
58
+ //
59
+ // ---------------------------------------------------------------------------
60
+ // WHAT NOT TO DO -- each of these is a specific, named trap
61
+ // ---------------------------------------------------------------------------
62
+ // 1. NEVER accept the census, the raw program bytes, a decoder, or a
63
+ // confidence grade as an argument here. `anno-coverage.ts` records a
64
+ // BYTES-VERSUS-STORE independence axis: one side classifies an address
65
+ // using only the raw bytes and the census, the other using only the
66
+ // store's own documentation, and NEITHER SIDE READS THE OTHER'S INPUT.
67
+ // That axis is the reason its reproducibility figure means anything.
68
+ // One extra argument here collapses it -- and it collapses QUIETLY: the
69
+ // independence test would keep passing while the claim it protects
70
+ // became void. The signature below is two arguments and must stay two.
71
+ // 2. NEVER compare a store vocabulary string outside this module. A second
72
+ // comparison site is a second answer to "what class is this address",
73
+ // and the census must have exactly one.
74
+ // 3. NEVER hold module-level mutable state. The lookup is a pure function
75
+ // of its two arguments, so interleaved or repeated calls cannot observe
76
+ // each other. There is nothing to reset and nothing to synchronise.
77
+ // 4. NEVER import anything census-side, disassembler-side, transport-side
78
+ // or path-translation-side. This module's import list is EMPTY and a
79
+ // committed structural assertion in `block-class.test.ts` keeps it that
80
+ // way -- see trap 1 for why the emptiness is load-bearing rather than
81
+ // tidy.
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // The neutral vocabulary
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * The three neutral block classes every consumer speaks. Lowercase tokens.
89
+ *
90
+ * These tokens were once ALSO offered as a guard in their own right, on the
91
+ * ground that no store spelling could collide with them. Two of the store's
92
+ * twelve members now do collide, so that reading is gone -- see this file's
93
+ * header for the loss and for the two derived cross-checks that defend the
94
+ * mapping instead.
95
+ *
96
+ * Three-valued and no finer. The consumers compare this against a
97
+ * classification derived from completely different inputs, so a richer
98
+ * vocabulary would manufacture disagreement out of vocabulary drift rather
99
+ * than measure anything.
100
+ */
101
+ export type BlockClass = "code" | "data" | "undefined";
102
+
103
+ /**
104
+ * One entry of a store's block listing, exactly as the curated read tool
105
+ * returns it.
106
+ *
107
+ * `type` carries the STORE'S OWN vocabulary -- whatever spelling the
108
+ * annotation substrate happens to use for its block kinds. This module is
109
+ * the only place in the tree that interprets that field. A consumer that
110
+ * reads `.type` and compares it is re-opening the boundary this file exists
111
+ * to close.
112
+ */
113
+ export interface BlockEntry {
114
+ start_address: number;
115
+ end_address: number;
116
+ /** The store's own block-kind spelling. Interpreted HERE and nowhere else. */
117
+ type: string;
118
+ }
119
+
120
+ /**
121
+ * The shape of a block classifier: given a store's block listing and an
122
+ * address, the neutral class covering that address, or `null` when no block
123
+ * covers it.
124
+ *
125
+ * Named as a type so a consumer can be handed a substitute implementation
126
+ * for a substitutability proof without that consumer ever naming a concrete
127
+ * store vocabulary.
128
+ */
129
+ export type BlockClassifier = (blocks: readonly BlockEntry[], address: number) => BlockClass | null;
130
+
131
+ /**
132
+ * The one production classifier.
133
+ *
134
+ * A linear scan, first-match-wins, with both range ends INCLUSIVE. A
135
+ * `null`/`undefined` hole in the array is skipped rather than thrown on --
136
+ * the listing arrives from a project file this process did not author. That
137
+ * premise applies to `blocks` ITSELF as well, so a non-array argument returns
138
+ * `null` here rather than throwing a `TypeError` out of the `for ... of`
139
+ * (IN-04). Both production callers already pre-guard with
140
+ * `Array.isArray(blocks) ? blocks : []`, so nothing is reachable today; the
141
+ * point is that the defence now lives in the module that DOCUMENTS the
142
+ * premise, instead of only in callers outside it.
143
+ *
144
+ * The mapping is total by construction over BOTH ACCEPTED VOCABULARIES: a
145
+ * code spelling from either becomes `"code"`, an undefined spelling from
146
+ * either becomes `"undefined"`, and EVERY other spelling becomes `"data"` --
147
+ * whether it belongs to one of the two vocabularies or to neither. That
148
+ * fallthrough is not a simplification; it is exactly what the two comparisons
149
+ * this function replaced did when read together, and it is what keeps an
150
+ * unrecognised spelling -- including one from a producer this module has
151
+ * never heard of -- from silently reading as code.
152
+ *
153
+ * The two arms are two VOCABULARIES that happen to differ in case, never one
154
+ * vocabulary compared case-insensitively. A case-insensitive or
155
+ * whitespace-trimming comparison here would silently accept a third spelling
156
+ * nobody chose; `block-class.test.ts` pins that it does not.
157
+ */
158
+ export const blockClassAt: BlockClassifier = (blocks, address) => {
159
+ if (!Array.isArray(blocks)) return null;
160
+ for (const block of blocks) {
161
+ if (!block) continue;
162
+ if (address >= block.start_address && address <= block.end_address) {
163
+ // TWO ACCEPTED VOCABULARIES, one arm each, in the same order so the
164
+ // pairing reads at a glance. Deliberately NOT folded into a
165
+ // case-insensitive test -- see this function's doc comment.
166
+ //
167
+ // This project's own annotation store, whose twelve block types are
168
+ // lowercase and live in one frozen home the test cross-checks against:
169
+ if (block.type === "code") return "code";
170
+ if (block.type === "undefined") return "undefined";
171
+ // TRANSITIONAL -- the capitalised vocabulary, whose PRODUCER (the
172
+ // external analyser this project used to rent an annotation store from,
173
+ // whose Rust `Display` emitted these spellings) IS GONE AS OF PHASE 29,
174
+ // 2026-08-29. The trigger the previous comment named has therefore
175
+ // already fired, and the arm still stands. That is a decision, recorded
176
+ // here rather than left to be rediscovered as an inert branch:
177
+ //
178
+ // WHY IT SURVIVES ITS OWN TRIGGER: every committed coverage fixture
179
+ // under `fixtures/coverage/**/store.json` is still SPELLED in this
180
+ // vocabulary ("Code", "Byte", "Undefined"). Deleting the two arms
181
+ // below today reclassifies every fixture block as `data` -- silently,
182
+ // because `data` is the total fallthrough and no error is raised
183
+ // anywhere. The fixtures are the census's own controls, so that would
184
+ // move the numbers the controls exist to pin.
185
+ //
186
+ // THE NEW REMOVAL TRIGGER is therefore the FIXTURES being re-spelled
187
+ // into the store's own lowercase vocabulary -- not the producer being
188
+ // deleted, which has happened. Re-spell the fixtures (and their
189
+ // generator) first, observe the census unchanged, then delete these
190
+ // two lines.
191
+ //
192
+ // FATE: carried as a Phase 32 guard-fate item ("every guard pinned to
193
+ // the deleted subject has a recorded fate"), so the ledger picks this
194
+ // arm up deliberately instead of finding it red or inert in CI.
195
+ if (block.type === "Code") return "code";
196
+ if (block.type === "Undefined") return "undefined";
197
+ return "data";
198
+ }
199
+ }
200
+ return null;
201
+ };
package/build.ts CHANGED
@@ -138,7 +138,7 @@ export interface BuildOptions {
138
138
  * `renameSync()` per artifact, and `rename(2)` fails `EXDEV` across mounts.
139
139
  * That is why the original implementation staged at `dirname(outDirAbs)`.
140
140
  * 2. **Outside any directory a test walks.** Staging at `dirname(outDirAbs)`
141
- * put a transient `.build-tmp-*` inside `.claude/mcp/vice/`, and
141
+ * put a transient `.build-tmp-*` inside `src/mcp/vice/`, and
142
142
  * `vice-mcp-selector-docs.test.ts`'s `walkFiles()` recurses through every
143
143
  * directory there except `node_modules` — so a concurrent walk descended
144
144
  * into the staging dir and died `ENOENT` when the rename removed it. That
@@ -280,7 +280,9 @@ export const CAPABILITY_REGISTRY: readonly CapabilityEntry[] = [
280
280
  providedBy: "fork",
281
281
  reason:
282
282
  "Full resource get/set access was descoped; the fork's tool is a hand-curated whitelist " +
283
- "subset that never shipped on stock.",
283
+ "subset that never shipped on stock. Its advertised WarpMode resource is fork-only: stock " +
284
+ "has no runtime warp resource at all, and warp on stock is a launch-time flag, not a " +
285
+ "resource that can be toggled while running.",
284
286
  },
285
287
  {
286
288
  name: "vice_joystick_tap",
package/disasm-decoder.ts CHANGED
@@ -1,24 +1,24 @@
1
1
  // disasm-decoder.ts
2
2
  //
3
3
  // The pure `decode(bytes, startAddress, opts) -> Instruction[]` function --
4
- // D-05's standalone module. Phase 5's backtrace (DERIV-02) and Phase 6's
5
- // CPU-history decode (GAIN-01) import THIS file directly, never a tool
6
- // module, so a protocol import here would force those consumers to pull in
7
- // transport code they do not need. This module has no emulator, no
8
- // protocol, no network -- its only input is a byte array. Note: DERIV-02 and
9
- // GAIN-01 were both cut from v0.2.0 scope on 2026-08-17 -- see the
10
- // startAddress bound below, which is now defense-in-depth on a currently
11
- // unreachable path rather than a guard against a live in-process caller.
4
+ // D-05's standalone module, kept import-free of `stock-*.ts`/`vice*.ts`/any
5
+ // `node:` builtin so any future non-tool consumer (a decode call with no
6
+ // socket in the picture) could depend on this one file without dragging in
7
+ // transport code. Two originally-planned consumers never landed: Phase 5's
8
+ // backtrace (DERIV-02) and a CPU-history decode (GAIN-01) were both cut
9
+ // from v0.2.0 scope on 2026-08-17 -- see the startAddress bound below,
10
+ // which is now defense-in-depth on a currently unreachable path rather
11
+ // than a guard against a live in-process caller.
12
12
  //
13
13
  // ---------------------------------------------------------------------------
14
14
  // WHY THIS FILE EXISTS RATHER THAN LIVING INSIDE THE TOOL HANDLER
15
15
  // ---------------------------------------------------------------------------
16
- // `stock-disassemble.ts` (04-05) is the tool-facing consumer, but three other
17
- // consumers need decoding without a socket in the picture at all: the
18
- // renderer (04-04), Phase 5's backtrace and Phase 6's CPU-history decode.
19
- // Keeping decode() import-free of `stock-*.ts`/`vice*.ts`/any `node:`
20
- // builtin means all four can depend on this one file without dragging in
21
- // transport code.
16
+ // `stock-disassemble.ts` (04-05) is the tool-facing consumer; the renderer
17
+ // (04-04) is the only other one that exists. Two more were planned (Phase
18
+ // 5's backtrace, DERIV-02, and a CPU-history decode, GAIN-01) but both
19
+ // were cut from v0.2.0 scope on 2026-08-17 before being built. Keeping
20
+ // decode() import-free of `stock-*.ts`/`vice*.ts`/any `node:` builtin means
21
+ // both existing consumers can depend on this file without transport code.
22
22
  //
23
23
  // ---------------------------------------------------------------------------
24
24
  // WHAT NOT TO DO
package/disasm-opcodes.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  // Mnemonic naming: two deliberate departures from cc65's own spelling
44
44
  // ---------------------------------------------------------------------------
45
45
  // cc65 spells two illegal opcodes differently from ACME's verified
46
- // `!cpu 6510` mnemonic set (`.claude/skills/acme-build/SKILL.md`: `lax dcp
46
+ // `!cpu 6510` mnemonic set (`src/skills/acme-build/SKILL.md`: `lax dcp
47
47
  // sax slo rla sre rra isc anc alr arr sbx las tas sha shx shy jam`). This
48
48
  // table follows ACME's names, not cc65's, at exactly these two opcodes, so
49
49
  // that `acmeExpressible` below can be computed by simple set membership
@@ -100,9 +100,9 @@
100
100
  // ---------------------------------------------------------------------------
101
101
  // WHY THIS FILE EXISTS
102
102
  // ---------------------------------------------------------------------------
103
- // This is the one table the decoder (04-03), Phase 5's backtrace (DERIV-02)
104
- // and Phase 6's CPU-history decode (GAIN-01) all read instruction lengths
105
- // from. A wrong length here silently desynchronises every instruction after
103
+ // This is the one table the decoder (04-03) and Phase 5's backtrace
104
+ // (DERIV-02) read instruction lengths from. GAIN-01's CPU-history decode, a
105
+ // third would-be consumer, was cut with the whole of Stock-Only Gains, 2026-08-17 (ROADMAP.md). A wrong length here silently desynchronises every instruction after
106
106
  // it in every one of those consumers (criterion 2's own wording) -- there is
107
107
  // no runtime check downstream that would catch a transcription typo on its
108
108
  // own; that is what `disasm-opcodes.test.ts`'s independent bit-pattern
@@ -7,8 +7,8 @@
7
7
  // `stock-disassemble.ts` (04-05) wires the real symbol resolver in via
8
8
  // `RenderOptions.symbolFor`; this file never imports `stock-address.ts`
9
9
  // itself, so it stays importable by anything that only has an
10
- // `Instruction[]` in hand (Phase 5's backtrace, Phase 6's CPU-history decode
11
- // -- neither has a live symbol store).
10
+ // `Instruction[]` in hand. Both originally-planned such consumers (Phase 5's
11
+ // backtrace, a CPU-history decode) were cut from v0.2.0 scope on 2026-08-17.
12
12
  //
13
13
  // ---------------------------------------------------------------------------
14
14
  // WHY THIS FILE EXISTS
package/hostpath.ts CHANGED
@@ -28,7 +28,7 @@
28
28
  // towards mechanism 1.
29
29
  //
30
30
  // Node >= 18. No dependencies, no network, nothing machine-specific: copy this
31
- // file into any devcontainer-based project's .claude/mcp/vice/ and it works.
31
+ // file into any devcontainer-based project's src/mcp/vice/ and it works.
32
32
  //
33
33
  // HOSTING CHOICE (01.6.1-02, Criterion B / RESEARCH §3.4 Option B): this
34
34
  // module takes the workspace root as an ARGUMENT (an optional `workspaceRoot`
@@ -78,7 +78,7 @@ export interface PruneResourcesResult {
78
78
  export type ResourceStatus = "missing" | "present" | "diverged";
79
79
 
80
80
  /** This module directory's resources/ subdirectory -- a plain SIBLING of this
81
- * module (scripts/ was flattened away in the .claude/mcp/vice/ move), the
81
+ * module (scripts/ was flattened away in the src/mcp/vice/ move), the
82
82
  * tracked source of truth every deployed tools/ file is copied from. Getting
83
83
  * this hop wrong is silent and total: readdirSync() throws inside
84
84
  * resourceEntries(), but ensureResourcesInstalled() catches everything by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/vice-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": {
@@ -53,6 +53,26 @@
53
53
  "stock-run-until.ts",
54
54
  "stock-diagnose.ts",
55
55
  "stock-recycle.ts",
56
+ "anno-d64.ts",
57
+ "anno-cli.ts",
58
+ "anno-symbols.ts",
59
+ "anno-regbits-gen.ts",
60
+ "anno-regbits.json",
61
+ "anno-enum-gen.ts",
62
+ "anno-acme-ident.ts",
63
+ "anno-confidence.ts",
64
+ "anno-memmap-render.ts",
65
+ "anno-export-asm.ts",
66
+ "anno-coverage.ts",
67
+ "block-class.ts",
68
+ "prg-image.ts",
69
+ "anno-types.ts",
70
+ "anno-index.ts",
71
+ "anno-store.ts",
72
+ "anno-tools.ts",
73
+ "anno-derive.ts",
74
+ "anno-details.ts",
75
+ "anno-register.ts",
56
76
  "resources",
57
77
  "tools-manifest.json",
58
78
  "tools-manifest.stock.json",
@@ -60,7 +80,7 @@
60
80
  "THIRD-PARTY-NOTICES.md"
61
81
  ],
62
82
  "engines": {
63
- "node": ">=22.18.0"
83
+ "node": ">=24.0.0"
64
84
  },
65
85
  "publishConfig": {
66
86
  "access": "public"
@@ -73,7 +93,7 @@
73
93
  "repository": {
74
94
  "type": "git",
75
95
  "url": "git+https://github.com/henols/c64-re-tools.git",
76
- "directory": ".claude/mcp/vice"
96
+ "directory": "src/mcp/vice"
77
97
  },
78
98
  "homepage": "https://github.com/henols/c64-re-tools#readme",
79
99
  "bugs": {
package/prg-image.ts ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // prg-image.ts -- the ONE authoritative place in this repo holding pure C64
3
+ // image byte-layout knowledge: how a `.prg` splits into a load address plus a
4
+ // body, what load address a flat 64K RAM capture has, and how to invert a
5
+ // gzip-then-base64 payload back into bytes. These are facts about C64 file
6
+ // formats and about one payload encoding -- they depend on no external
7
+ // analyser, no emulator, and no annotation store, so they belong in a module
8
+ // none of those can take with them when it goes away.
9
+ //
10
+ // This module performs NO filesystem and NO network I/O: every function takes
11
+ // bytes (or a base64 string) and returns values. Callers obtain and persist
12
+ // the bytes themselves. That is the same claim `anno-project.ts` makes about
13
+ // itself, and it must remain true of both files now that the split has
14
+ // happened -- a structural test in `prg-image.test.ts` asserts it from this
15
+ // module's own source rather than trusting this paragraph.
16
+ //
17
+ // WHY THIS FILE EXISTS SEPARATELY: these three functions used to live in
18
+ // `anno-project.ts`, the module that builds a `.regen2000proj` file for the
19
+ // the external analyser analyser. They were never about that analyser. One of them
20
+ // is imported statically by the byte-coverage census (`anno-coverage.ts`), a
21
+ // capability that must keep working independently of whether this repo still
22
+ // drives that analyser at all -- so a census whose only route to a payload
23
+ // decoder ran through analyser glue was one deletion away from breaking with
24
+ // no announcement (SEAM-02). Extracting them under a name that carries no
25
+ // analyser prefix removes that coupling outright instead of recording it as a
26
+ // hazard to remember later. There is deliberately NO re-export left behind in
27
+ // `anno-project.ts`: a compatibility shim would leave the coupling fully
28
+ // intact while looking finished.
29
+ //
30
+ // THIS MODULE MUST BE LISTED IN `package.json`'s `files[]`. It is reachable
31
+ // from the published entry point's import closure, and the STATIC route is
32
+ // named here first because it is the stronger reachability claim: `anno-
33
+ // tools.ts` -- the curated `anno_*` MCP tool surface -- imports `parsePrg` and
34
+ // `flatImageOrigin` from here with a plain top-level import, and `vice-proxy.ts`
35
+ // imports `anno-tools.ts` statically. `anno-coverage.ts` (the byte-coverage
36
+ // census) imports `decodeRawData`, `parsePrg` and `flatImageOrigin` for the
37
+ // same three facts on the CLI's route. `scripts/check-npm-packages.mjs` walks
38
+ // that closure over `files[]` and fails the pack the moment a reachable module
39
+ // sits outside the listed set, exactly as `anno-d64.ts`'s own header records
40
+ // for the same reason.
41
+ //
42
+ // CORRECTED 2026-08-30 (WR-07, plan 29-16). This paragraph previously named
43
+ // `anno-cli.ts` as importing `parsePrg` and `flatImageOrigin` and rested the
44
+ // whole reachability claim on the DYNAMIC import that reaches that file. That
45
+ // was doubly wrong: `anno-cli.ts` imported neither symbol (it imported
46
+ // `decodeRawData` only), and it now imports nothing from here at all -- its
47
+ // image decode delegates to `anno-coverage.ts`'s `loadProjectImage()`. A
48
+ // stated reason for shipping a file has to be true or it is worse than absent.
49
+ //
50
+ // WHAT NOT TO DO:
51
+ // - Never give any function here a filesystem PATH parameter. They take byte
52
+ // arrays and a base64 string, which is precisely what keeps path traversal
53
+ // out of this module's threat surface entirely. Path resolution belongs to
54
+ // the CLI. For the same reason this module imports nothing from either of
55
+ // this repo's two host/container path-translation seams; that absence is
56
+ // asserted structurally by `hostpath-consumers.test.ts`, not merely stated
57
+ // here.
58
+ // - Never relax, reword or reorder either input refusal below. The concrete
59
+ // incident: a 4096-byte flat `.raw` capture fell through to the `.prg`
60
+ // parser, whose first two bytes become the load address, so a truncated
61
+ // capture silently "bootstrapped" with an origin read backwards out of its
62
+ // own payload bytes and exited zero -- every downstream address wrong, no
63
+ // diagnostic. The refusal message texts are a user-visible contract: both
64
+ // `anno-tools.ts`'s `loadImage()` and `anno-coverage.ts`'s
65
+ // `loadProjectImage()` prefix them with the caller's own image path, and
66
+ // tests on both routes match on their wording, so a reworded message
67
+ // breaks a test for a reason that looks unrelated. (Attribution corrected
68
+ // 2026-08-30, WR-07: this line named `anno-cli.ts`, which prefixed them
69
+ // through a `bootstrap` verb deleted by D-14 on 2026-08-29.)
70
+ // - Never add the dispatch ORDER discipline here. Which check runs first for
71
+ // a given input extension belongs to the two loaders that own it --
72
+ // `anno-tools.ts`'s `loadImage()` for the MCP tool surface and
73
+ // `anno-coverage.ts`'s `loadProjectImage()` for the coverage verb. Both
74
+ // dispatch `.raw`/`.bin` by extension BEFORE any length check, so that
75
+ // `flatImageOrigin`'s named refusal is always reachable for those two
76
+ // extensions. Neither function below may start inferring what kind of
77
+ // image it was handed.
78
+
79
+ import { gunzipSync } from "node:zlib";
80
+
81
+ /**
82
+ * Parses a `.prg` file: a little-endian 2-byte load address followed by the
83
+ * payload bytes. This is the C64 program-file convention every C64 loader
84
+ * (and this project's own `acme-build` output) already follows.
85
+ */
86
+ export function parsePrg(bytes: Uint8Array): { origin: number; body: Uint8Array } {
87
+ if (bytes.length < 3) {
88
+ throw new Error(
89
+ `parsePrg: input is ${bytes.length} byte(s) -- a .prg needs at least 3 bytes (2-byte load address plus at least 1 payload byte)`,
90
+ );
91
+ }
92
+ const origin = bytes[0]! | (bytes[1]! << 8);
93
+ const body = bytes.subarray(2);
94
+ return { origin, body };
95
+ }
96
+
97
+ /**
98
+ * Returns the load address (`0`) for a flat 64K RAM capture, and throws for
99
+ * anything else. Flat 64K is in scope because `ANNO-06` names it directly
100
+ * and it is exactly the shape `c64-ram-capture` already produces (D-03) --
101
+ * this function does not attempt to support any other flat-image size.
102
+ */
103
+ export function flatImageOrigin(bytes: Uint8Array): number {
104
+ if (bytes.length !== 65536) {
105
+ throw new Error(
106
+ `flatImageOrigin: input is ${bytes.length} byte(s) -- a flat 64K capture must be exactly 65536 bytes`,
107
+ );
108
+ }
109
+ return 0;
110
+ }
111
+
112
+ /**
113
+ * The inverse of a gzip-then-base64 payload encoding: base64-decode, then
114
+ * gunzip. Exported so tests can prove the payload round-trips exactly,
115
+ * rather than asserting against an opaque blob.
116
+ */
117
+ export function decodeRawData(base64: string): Uint8Array {
118
+ return gunzipSync(Buffer.from(base64, "base64"));
119
+ }
package/repo-root.ts CHANGED
@@ -39,6 +39,19 @@
39
39
  // `scripts/` segment. Branch 4's hop count below moved from four levels to
40
40
  // three to match. Branches 1-3 are depth-independent (an env var check, then
41
41
  // a `.git` ancestor walk) and needed no change.
42
+ //
43
+ // FOURTH MOVE (phase 16-04, 2026-08-23): the module directory relocated
44
+ // again, from `.claude/mcp/vice/` to `src/mcp/vice/` (packaging and repo
45
+ // shape). `src`/`mcp`/`vice` is the same three path segments below the
46
+ // repository root as the old `.claude`/`mcp`/`vice` shape -- this is
47
+ // reviewed and confirmed, not assumed, from the segment count itself. Branch
48
+ // 4's hop count was therefore reviewed and deliberately left unchanged: this
49
+ // move relocates the same flat, three-segment shape elsewhere directly under
50
+ // the root, which is a different move from nesting authored sources one
51
+ // level deeper INSIDE this directory (which would make four, and IS what
52
+ // branches 1-3's depth-independence and branch 4's fixed hop count would not
53
+ // survive). See repo-root.test.ts's own standing caution for that
54
+ // distinction, drawn explicitly there for the first time by this move.
42
55
  import { existsSync } from "node:fs";
43
56
  import { fileURLToPath } from "node:url";
44
57
  import { dirname, join, resolve, sep } from "node:path";
@@ -62,6 +75,8 @@ let warnedNoMarkerFound = false;
62
75
  export interface RepoRootOptions {
63
76
  from?: string;
64
77
  env?: NodeJS.ProcessEnv;
78
+ /** Test seam for the marker walk; production uses node:fs existsSync. */
79
+ exists?: (path: string) => boolean;
65
80
  }
66
81
 
67
82
  /**
@@ -84,7 +99,7 @@ function isInside(child: string, parent: string): boolean {
84
99
  * Claude Code exports for the workspace it is driving. This is the ONLY
85
100
  * branch that is correct when this module is consumed as an installed
86
101
  * plugin: the MCP's own files then live under the plugin install dir
87
- * (e.g. `~/.claude/plugins/<marketplace>/<plugin>/.claude/mcp/vice/`),
102
+ * (e.g. `~/.claude/plugins/<marketplace>/<plugin>/src/mcp/vice/`),
88
103
  * NOT inside the project the user is working in, so neither the
89
104
  * `from`-relative `.git` walk (branch 2, which would find the plugin's
90
105
  * OWN checkout) nor a CONTAINER_WORKSPACE_PATH containment check
@@ -108,13 +123,13 @@ function isInside(child: string, parent: string): boolean {
108
123
  * be exactly the quiet-wrong-answer failure class this file exists to
109
124
  * prevent, so this path emits a one-time stderr note naming both paths.
110
125
  * 4. Otherwise, three levels up from `from`, with a one-time stderr note.
111
- * Last resort only -- three levels is what `<root>/.claude/mcp/<server>/`
126
+ * Last resort only -- three levels is what `<root>/src/mcp/<server>/`
112
127
  * implies. In this repo branch 4 never actually runs (there is always a
113
128
  * `.git` ancestor), which is exactly why the paired synthetic test in
114
129
  * repo-root.test.ts is the only thing that would catch a wrong hop
115
130
  * count here.
116
131
  */
117
- export function repoRoot({ from = HERE, env = process.env }: RepoRootOptions = {}): string {
132
+ export function repoRoot({ from = HERE, env = process.env, exists = existsSync }: RepoRootOptions = {}): string {
118
133
  // Branch 0 (plugin-consumption signal): Claude Code sets CLAUDE_PROJECT_DIR
119
134
  // to the root of the workspace it is driving. When this module runs as an
120
135
  // installed plugin its own files sit outside that workspace, so this is the
@@ -134,7 +149,7 @@ export function repoRoot({ from = HERE, env = process.env }: RepoRootOptions = {
134
149
 
135
150
  let dir = resolve(from);
136
151
  while (true) {
137
- if (existsSync(join(dir, ".git"))) {
152
+ if (exists(join(dir, ".git"))) {
138
153
  return dir;
139
154
  }
140
155
  const parent = dirname(dir);
@@ -160,7 +175,7 @@ export function repoRoot({ from = HERE, env = process.env }: RepoRootOptions = {
160
175
  const fallback = resolve(from, "..", "..", "..");
161
176
  console.error(
162
177
  `warn: could not find a .git ancestor above ${from} and CONTAINER_WORKSPACE_PATH is not set -- ` +
163
- `falling back to three levels up (${fallback}), the shape <root>/.claude/mcp/<server>/ implies. ` +
178
+ `falling back to three levels up (${fallback}), the shape <root>/src/mcp/<server>/ implies. ` +
164
179
  `This is a last resort; if it's wrong, set CONTAINER_WORKSPACE_PATH or run from inside a git repo.`
165
180
  );
166
181
  }
@@ -101,10 +101,14 @@ let warnedRemoteMonitorBindWidened = false;
101
101
  * the binmon address already used -- one resolution, not two. When
102
102
  * `remoteMonitorPort` is omitted (undefined), the returned argv is
103
103
  * byte-identical to what this function always returned -- no
104
- * `-remotemonitor` at all. `-remotemonitoraddress`'s exact spelling is
105
- * `[ASSUMED]` by symmetry with `-binarymonitoraddress` (RESEARCH.md
106
- * Assumption A1) and is filed as probe debt under
107
- * `.planning/todos/pending/`. Widening THIS bind away from loopback emits
104
+ * `-remotemonitor` at all. `-remotemonitoraddress`'s exact spelling was
105
+ * live-probed against a real fork-3.10 binary and CONFIRMED (RESEARCH.md
106
+ * Assumption A1, closed by 13-PROBE-RESULTS.md §A1: the flag bound a real,
107
+ * accepting text-monitor listener, corroborated independently by
108
+ * `ss -ltnp`). Genuine stock 3.9 was not independently probed in that run --
109
+ * the spelling itself is a symmetrical CLI flag pair and is not
110
+ * version-sensitive, so this is recorded as a low-risk carry-forward rather
111
+ * than implied stock-3.9 coverage. Widening THIS bind away from loopback emits
108
112
  * its own one-time stderr note (`warnedRemoteMonitorBindWidened`), naming
109
113
  * the resolved address and stating that VICE's TEXT monitor accepts
110
114
  * arbitrary monitor commands and is unauthenticated -- Phase 3 dials
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # .claude/mcp/vice/resources/vice-launcher.sh
2
+ # src/mcp/vice/resources/vice-launcher.sh
3
3
  #
4
4
  # HAND-AUTHORED -- not generated. It lives beside the generated vice-broker.mjs
5
5
  # purely because install-resources.mjs deploys the whole resources/ directory
@@ -38,7 +38,7 @@ SELF_DIR="$(cd "$(dirname "$SELF_PATH")" && pwd)"
38
38
  #
39
39
  # WHY THIS FUNCTION EXISTS AT ALL: a fixed `".."` hop (`REPO_ROOT="$(cd
40
40
  # "$(dirname "$SELF_PATH")/.." && pwd)"`) is wrong from this launcher's own
41
- # location, `.claude/mcp/vice/resources/` -- four levels below the repo root,
41
+ # location, `src/mcp/vice/resources/` -- four levels below the repo root,
42
42
  # not one. NOTHING would error on a wrong fixed hop count: the script would
43
43
  # just read a permanently-empty `.vice-supervisor` state directory forever,
44
44
  # and restart detection would quietly stop working while every command kept
@@ -73,7 +73,7 @@ _REPO_ROOT_WARNED_NO_MARKER=0
73
73
  # paths.
74
74
  # 4. Otherwise, a location-shaped last resort, also with a one-time stderr
75
75
  # note: FOUR levels up when <absolute-dir>'s own directory is named
76
- # `resources` (matching `<root>/.claude/mcp/vice/resources`),
76
+ # `resources` (matching `<root>/src/mcp/vice/resources`),
77
77
  # ONE level up otherwise.
78
78
  resolve_repo_root() {
79
79
  local from="$1" dir parent base
package/stock-address.ts CHANGED
@@ -23,8 +23,8 @@
23
23
  // hex by default" rule belongs to the checkpoint-condition emitter, not
24
24
  // this parser. Do not conflate the two.
25
25
  // - Never implement symbol resolution in Phase 3. setSymbolResolver() is
26
- // a deliberately empty extension point until Phase 5's DERIV-04 symbol
27
- // store installs a real one; the default here stays `null`.
26
+ // the one extension point for it: it starts `null` and stays that way
27
+ // until stock-symbols.ts's `vice_symbols_load` installs a real resolver.
28
28
  // - Never add a second resolver holder. `nameFor` (address -> name,
29
29
  // DISASM-06's first consumer, Phase 4) and `resolve` (name -> address,
30
30
  // Phase 3) live on the SAME `SymbolResolver` object, read from the SAME
@@ -46,9 +46,9 @@ export interface SymbolResolver {
46
46
  // -- no symbol resolution happens until a later phase installs one.
47
47
  let symbolResolver: SymbolResolver | null = null;
48
48
 
49
- /** The deliberately-empty extension point Phase 5's DERIV-04 symbol store
50
- * fills. Passing `null` (the Phase 3 default) restores the "no symbol table
51
- * loaded" refusal. */
49
+ /** The extension point stock-symbols.ts's `vice_symbols_load` fills at
50
+ * runtime. Passing `null` (the default before any load) restores the "no
51
+ * symbol table loaded" refusal. */
52
52
  export function setSymbolResolver(resolver: SymbolResolver | null): void {
53
53
  symbolResolver = resolver;
54
54
  }