@henols/c64-re-tools 0.2.1 → 0.2.3

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 (46) hide show
  1. package/README.md +1 -1
  2. package/THIRD-PARTY-NOTICES.md +26 -0
  3. package/bin/cli.mjs +18 -7
  4. package/package.json +6 -4
  5. package/skills/acme-build/SKILL.md +83 -33
  6. package/skills/acme-build/scripts/acme.mjs +159 -64
  7. package/skills/acme-build/template.a +1 -1
  8. package/skills/c64-disk-access/SKILL.md +156 -0
  9. package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
  10. package/skills/c64-memory-mapping/SKILL.md +419 -23
  11. package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
  12. package/skills/c64-petcat/SKILL.md +87 -0
  13. package/skills/c64-petcat/scripts/petcat.mjs +221 -0
  14. package/skills/c64-program-recon/SKILL.md +497 -92
  15. package/skills/c64-program-recon/references/control-flow.md +12 -15
  16. package/skills/c64-program-recon/references/graphics.md +1 -1
  17. package/skills/c64-program-recon/references/observation-hazards.md +18 -16
  18. package/skills/c64-program-recon/references/reconstruction.md +11 -6
  19. package/skills/c64-program-recon/references/sound-and-input.md +6 -8
  20. package/skills/c64-program-recon/references/tool-selection.md +37 -18
  21. package/skills/c64-program-recon/scripts/packer-finding.mjs +709 -0
  22. package/skills/c64-program-recon/templates/memory-map.template.md +27 -13
  23. package/skills/c64-provenance-diff/SKILL.md +43 -8
  24. package/skills/c64-provenance-diff/scripts/diff-images.mjs +9 -6
  25. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +20 -8
  26. package/skills/c64-ram-capture/RELEASES.json.example +17 -0
  27. package/skills/c64-ram-capture/SKILL.md +147 -46
  28. package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
  29. package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
  30. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
  31. package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
  32. package/skills/c64-ram-capture/scripts/project-paths.mjs +1 -1
  33. package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
  34. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
  35. package/skills/c64-ram-capture/scripts/watch-loads.mjs +19 -13
  36. package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
  37. package/skills/c64-ram-capture/transients/README.md +136 -0
  38. package/skills/routine-queue-walker/SKILL.md +365 -0
  39. package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
  40. package/skills/vice-wedge-triage/SKILL.md +104 -97
  41. package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +0 -665
  42. package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
  43. package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +0 -243
  44. package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +0 -133
  45. package/skills/c64-ram-capture/scripts/test-corpus.mjs +0 -75
  46. package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +0 -339
package/README.md CHANGED
@@ -43,7 +43,7 @@ you pass `--force`.
43
43
  ## Requirements
44
44
 
45
45
  - **This installer** runs on Node ≥ 18.
46
- - **The VICE MCP server** it wires up requires **Node ≥ 22.18** (or ≥ 23.6), and a
46
+ - **The VICE MCP server** it wires up requires **Node ≥ 24**, and a
47
47
  host with VICE (`x64sc`) reachable from the MCP client. See the
48
48
  [`@henols/vice-mcp`](https://www.npmjs.com/package/@henols/vice-mcp) readme.
49
49
 
@@ -0,0 +1,26 @@
1
+ # Third-Party Notices for `@henols/c64-re-tools`
2
+
3
+ This package is MIT-licensed, copyright Henrik Olsson (see `LICENSE` at the
4
+ repository root). It ships the C64 reverse-engineering **skill playbooks**
5
+ under `skills/` plus the installer CLI under `bin/`, and it declares one
6
+ runtime dependency, `@henols/vice-mcp`, which carries its own notices file.
7
+
8
+ This document covers only the third-party material that ships **in this
9
+ package**. It is deliberately not a second, competing inventory: the canonical
10
+ third-party notices for the whole repository — including the cc65 opcode-table
11
+ transcription and the ACME/VICE not-incorporated scope notes, none of which
12
+ ship here — live in the `@henols/vice-mcp` package at
13
+ `src/mcp/vice/THIRD-PARTY-NOTICES.md` in the repository, and inside that
14
+ package's own tarball as `THIRD-PARTY-NOTICES.md`. Read that file for the full
15
+ inventory; read this one for what `@henols/c64-re-tools` itself carries.
16
+
17
+ ## Incorporated material
18
+
19
+ None. The skill playbooks under `skills/` are this project's own prose,
20
+ written against this project's own curated tool surface.
21
+
22
+ ## Runtime dependency
23
+
24
+ - **`@henols/vice-mcp`** — this repository's own MCP server package, MIT. Its
25
+ own third-party notices ship inside it as `THIRD-PARTY-NOTICES.md`, and that
26
+ file is the canonical inventory referred to at the top of this document.
package/bin/cli.mjs CHANGED
@@ -13,6 +13,9 @@
13
13
  import { spawnSync } from "node:child_process";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { dirname, join, resolve } from "node:path";
16
+ // Entry-point dispatch guard (see the bottom of this file): the same idiom
17
+ // `src/skills/c64-memory-mapping/scripts/driver.mjs` already uses, so a bare
18
+ // `import` from a test file does not also execute the CLI.
16
19
  import {
17
20
  existsSync,
18
21
  readFileSync,
@@ -27,11 +30,11 @@ const PKG_ROOT = dirname(HERE);
27
30
  const SKILLS_SRC = join(PKG_ROOT, "skills");
28
31
 
29
32
  // The single version-resolution seam this repo maintains is
30
- // `.claude/mcp/vice/version.ts` (quick-260819-tsz, D-5). This package
33
+ // `src/mcp/vice/version.ts` (quick-260819-tsz, D-5). This package
31
34
  // deliberately does NOT import it: it ships without the seam file (its
32
35
  // `files[]` is `bin/`, `skills/`, `README.md`) and targets node >= 18, which
33
36
  // cannot type-strip the seam's `.ts` the way the vice-mcp package's own
34
- // node >= 22.18 runtime can. What follows is exactly the seam's own
37
+ // node >= 24 runtime can. What follows is exactly the seam's own
35
38
  // precedence step 1 -- "read my own package.json's `.version`, trust it
36
39
  // when it is a real published number" -- not a second, independent
37
40
  // implementation of the resolution algorithm; there is no template/`-`
@@ -45,11 +48,11 @@ const SELF_VERSION = typeof SELF.version === "string" ? SELF.version : "0.0.0";
45
48
  const MCP_PKG = "@henols/vice-mcp";
46
49
  // The dev placeholder every derived, publishable version string carries in
47
50
  // the working tree (R-2, quick-260819-tsz). Defined authoritatively as
48
- // `DEV_PLACEHOLDER` in `.claude/mcp/vice/version.ts` -- repeated here as a
51
+ // `DEV_PLACEHOLDER` in `src/mcp/vice/version.ts` -- repeated here as a
49
52
  // literal, NOT imported, because this package deliberately ships without
50
53
  // that seam file (see the comment above) and targets node >= 18, which
51
54
  // cannot type-strip a `.ts` import the way the vice-mcp package's own
52
- // node >= 22.18 runtime can. This is the same disclosed divergence as
55
+ // node >= 24 runtime can. This is the same disclosed divergence as
53
56
  // `SELF_VERSION` above: one literal, kept in sync by hand, documented here
54
57
  // so a future edit to the seam's placeholder is not missed.
55
58
  const MCP_DEV_PLACEHOLDER = "0.0.0-dev";
@@ -124,7 +127,7 @@ What it does:
124
127
  1. Copies bundled skills into <target>/.claude/skills/
125
128
  2. Adds a 'vice' server to <target>/.mcp.json (other servers are preserved)
126
129
 
127
- Requires Node >= 22.18 to RUN the vice MCP server (this installer runs on Node >= 18).`;
130
+ Requires Node >= 24 to RUN the vice MCP server (this installer runs on Node >= 18).`;
128
131
 
129
132
  function viceServerEntry(vendor) {
130
133
  return {
@@ -262,8 +265,16 @@ function main() {
262
265
  console.error("Dry run -- nothing was written.");
263
266
  } else {
264
267
  console.error("Done. Restart Claude Code in this project so it picks up the skills and MCP server.");
265
- console.error("Note: running the vice MCP server requires Node >= 22.18 (or >= 23.6).");
268
+ console.error("Note: running the vice MCP server requires Node >= 24.");
266
269
  }
267
270
  }
268
271
 
269
- main();
272
+ // Only dispatch when this module is the process entry point -- i.e. run as
273
+ // `node bin/cli.mjs ...` or via the `c64-re-tools` bin shim -- not when
274
+ // imported by a test (installer/wire-mcp.test.mjs imports `wireMcp`/`readJson`
275
+ // below and must not trigger a real install as a side effect).
276
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
277
+ main();
278
+ }
279
+
280
+ export { wireMcp, readJson };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/c64-re-tools",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Installer that adds the C64 reverse-engineering skills and the VICE emulator MCP server (@henols/vice-mcp) to a project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "skills/",
12
- "README.md"
12
+ "README.md",
13
+ "THIRD-PARTY-NOTICES.md"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=18"
@@ -44,10 +45,11 @@
44
45
  "installer"
45
46
  ],
46
47
  "dependencies": {
47
- "@henols/vice-mcp": "0.2.1"
48
+ "@henols/vice-mcp": "0.2.3"
48
49
  },
49
50
  "scripts": {
50
51
  "sync-skills": "node scripts/sync-skills.mjs",
51
- "prepack": "node scripts/sync-skills.mjs"
52
+ "prepack": "node scripts/sync-skills.mjs",
53
+ "test": "node --test '*.test.mjs'"
52
54
  }
53
55
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: acme-build
3
- description: Assemble Commodore 64 6510 assembly with the ACME cross assembler. Use when asked to assemble, build, compile or link .a/.asm 6502/6510 source, produce a C64 .prg, scaffold a new C64 program, or list the symbols a program uses.
3
+ description: Assemble Commodore 64 6510 assembly with the ACME cross assembler. Use when asked to assemble, build, compile or link .a/.asm 6502/6510 source, produce a C64 .prg, scaffold a new C64 program, or list which symbols an assembled build actually used from its symbol file.
4
4
  ---
5
5
 
6
6
  # Assembling C64 source with ACME
@@ -8,7 +8,7 @@ description: Assemble Commodore 64 6510 assembly with the ACME cross assembler.
8
8
  Source in, `.prg` out. Everything goes through one script:
9
9
 
10
10
  ```bash
11
- A=.claude/skills/acme-build/scripts/acme.mjs # from the repo root
11
+ A=src/skills/acme-build/scripts/acme.mjs # from the repo root
12
12
 
13
13
  node $A new game.asm # scaffold a C64 program
14
14
  node $A build game.asm # assemble -> .prg .sym .vs .rep
@@ -22,6 +22,13 @@ absent `run` verb is not an omission). It contacts nothing.
22
22
  Options: `-o FILE` `--out-dir DIR` `-f FORMAT` `--setpc ADDR` `-DSYM=VAL`
23
23
  `-I DIR` `--no-report` `--json`.
24
24
 
25
+ `-I DIR` is resolved **workspace-relative** to the project root the host
26
+ broker was launched with — the same resolution `source`/`--out-dir` already
27
+ go through — before it ever reaches the assembler. An absolute or escaping
28
+ `-I` directory is refused by the seam rather than passed to ACME; this is a
29
+ documented contract change from earlier releases, when an absolute include
30
+ reached the assembler unchecked.
31
+
25
32
  ## Build
26
33
 
27
34
  ```bash
@@ -132,38 +139,81 @@ you also assemble by hand, so these stay recognised as mnemonics.
132
139
 
133
140
  ## Disassembly
134
141
 
135
- This skill does not disassemble. Static disassembly of a `.prg` or flat 64K image
136
- is a **required prerequisite** of this plugin, not an optional accelerator:
137
- regenerator2000, reached through
142
+ This skill does not disassemble. The route that does is `anno export-asm`, and
143
+ it lives in the `anno` CLI rather than here.
144
+
145
+ **Dated withdrawal 2026-08-29, dated return 2026-08-31 — both halves are kept,
146
+ because the withdrawal is the record of why the route is shaped the way it is.**
147
+ Whole-program static disassembly was WITHDRAWN on 2026-08-29 rather than left standing on an unverified reassembly claim: the removed route
148
+ settled correctness with a transcript parser, and the recorded false pass that
149
+ discipline exists against read `ACME not found in PATH (skipped)` / `All
150
+ roundtrip verifications passed.` / `EXIT=0` — exit zero, an aggregate line
151
+ reading as a full pass, and the one assembler this project cares about never
152
+ having run. On **2026-08-31 the route returned**, rebuilt over the annotation
153
+ store as `anno export-asm`. It is not a rename of what was removed: its
154
+ correctness is settled by **assembling the output with a real ACME and diffing
155
+ the bytes against the input** — never by an exit code, and never by a string
156
+ match on the exporter's own output.
157
+
158
+ **The live invocation:**
138
159
 
139
160
  ```bash
140
- npx -y @henols/vice-mcp r2000 export-asm game.prg # npm installs
141
- node <plugin-root>/.claude/mcp/vice/vice-proxy.ts r2000 export-asm game.prg # in-repo/plugin
161
+ npx -y @henols/vice-mcp anno export-asm game.prg --store game.annostore --out game-src
162
+ node <plugin-root>/src/mcp/vice/vice-proxy.ts anno export-asm game.prg --store game.annostore
142
163
  ```
143
164
 
144
- A recursive-descent disassembler with an auto-analyzer does not render strings,
145
- tables and the BASIC stub as instructions, so there are no out-of-range labels
146
- to hand-define and no illegal-opcode lines to re-indent the caveats this
147
- section used to carry were structural to a flat linear decoder and do not
148
- apply here. The exported source is verified reassemblable by a real ACME
149
- via `vice-mcp r2000 verify` (evidence:
150
- `.planning/phases/10-adoption-boundaries-automated-bootstrap-and-the-removal/evidence/10-verify-transcript.txt`).
151
- `c64-program-recon` points at this same route; it is not restated there.
165
+ `<image>` and `--store` are **two separate arguments and neither is derived from
166
+ the other** the image supplies the bytes, the store supplies the names, ranges,
167
+ typed regions and comments, and naming one does not name the other. `--out` names
168
+ a **directory** the whole export is written into, defaulting to the image's
169
+ basename stem beside the **store**, not beside the image, because the export is a
170
+ generated view of the annotations. The directory holds a root file that sources
171
+ the rest, one file per annotation scope, and an `unscoped.a` for anything inside
172
+ no scope. A non-empty destination is refused rather than overwritten unless you
173
+ pass `--force`.
152
174
 
153
- ## Setup
175
+ **The verb writes source text and runs no assembler.** Its own second output line
176
+ says so, in as many words:
177
+
178
+ ```
179
+ export-asm: this file has NOT been assembled -- this command writes source text and runs no assembler.
180
+ ```
181
+
182
+ The real-ACME byte-diff oracle is **test-only** — it lives in
183
+ `src/mcp/vice/acme-verify.ts`, is exercised by `acme-verify.test.ts` (hard-failed
184
+ in CI with `VICE_REQUIRE_ACME=1`), and is deliberately absent from the published
185
+ package, so nothing on the runtime path can reach it. A clean `export-asm` run is
186
+ therefore evidence that source was written and nothing more; it is not an
187
+ assembler verdict. If you need to know the emitted source reassembles, assemble
188
+ it yourself — that is what this skill's own build route is for.
154
189
 
155
- `acme` on `$PATH` is the **only** requirement. The scaffold that `new` writes
156
- assembles against a bare install with no standard hardware-register library —
157
- that's deliberate: neither a plain `~/.local/bin/acme` build nor the Debian
158
- trixie `apt` candidate ships one, so a scaffold that depended on it would fail
159
- to assemble on a fresh install (Phase 8.1 FINDING-A1).
190
+ **Reading one range at a time is still the right move for a single routine.**
191
+ `anno_read_region` and `anno_disassemble` render an explicit inclusive range out
192
+ of the image on demand, capped at 4096 bytes per call and REFUSED by name above
193
+ the cap rather than truncated. `c64-program-recon` documents that route and this
194
+ history together; it is not restated there.
195
+
196
+ ## Setup
160
197
 
161
- `$ACME` and the wrapper's auto-probe (`$ACME`, `/usr/local/share/acme`,
162
- `/usr/share/acme`, `/usr/lib/acme`, `~/.acme`) still exist and still matter
163
- but only for **your own** sources that use angle-bracket includes (see
164
- "Writing source" above), not for the scaffold. If you have that library
165
- somewhere, point `$ACME` at its directory and angle-bracket includes work as
166
- before; if you don't, the scaffold doesn't need it.
198
+ `acme` on `$PATH` **on the host**, never inside a container — is the **only**
199
+ requirement. The scaffold that `new` writes assembles against a bare install
200
+ with no standard hardware-register library that's deliberate: neither a
201
+ plain `~/.local/bin/acme` build nor the Debian trixie `apt` candidate ships
202
+ one, so a scaffold that depended on it would fail to assemble on a fresh
203
+ install.
204
+
205
+ **Route:** `scripts/acme.mjs` never spawns `acme` itself
206
+ and never probes a container PATH for it — a container has no such PATH to
207
+ probe (the project owner's rule of 2026-08-28). The script reaches the
208
+ assembler only through the host-tool execution seam
209
+ (`src/mcp/vice/host-tool.mts`'s `acme.build` allowlist entry), which runs on
210
+ the HOST and probes the library there — the same handful of conventional
211
+ install locations `acme.mts`'s own `findAcmeLib()` names, none of them
212
+ documented a second time here. `$ACME` still matters for **your own** sources
213
+ that use angle-bracket includes (see "Writing source" above), not for the
214
+ scaffold — but set it in the environment the **host** broker process sees, not
215
+ this script's own environment, since the probe now runs host-side inside the
216
+ seam's executor. If you don't have that library, the scaffold doesn't need it.
167
217
 
168
218
  Re-checked against ACME release 0.97 "Zem" (31 Jan 2021). CI now assembles
169
219
  the shipped scaffold on every build with `$ACME` cleared (the "Assemble the
@@ -171,7 +221,7 @@ acme-build scaffold (library-free)" step in `.github/workflows/ci.yml`), so
171
221
  this claim is re-checkable rather than a one-machine observation.
172
222
 
173
223
  Copy `acme.mjs` into any project's `.claude/skills/acme-build/scripts/`, and
174
- `template.a` into `.claude/skills/acme-build/`, to use this elsewhere.
224
+ `template.a` into `.claude/skills/acme-build/`, to use this elsewhere (the path Claude Code auto-discovers and `installSkills()` deploys to).
175
225
 
176
226
  ## Which skill does what
177
227
 
@@ -182,7 +232,7 @@ This one turns source into bytes. It does not restate what the others carry.
182
232
  | Where to start on an unknown program, and which address to read next | `c64-program-recon` |
183
233
  | What a specific address or bit means, or annotating a listing | `c64-memory-mapping` — `node … lookup '$D018'` |
184
234
  | A verified 64K image, or comparing two captures | `c64-ram-capture` |
185
- | Static disassembly of a `.prg` or flat image | `vice-mcp r2000 export-asm` (see Disassembly above) |
235
+ | Static disassembly of a `.prg` or flat image | **`anno export-asm`** — withdrawn 2026-08-29, returned 2026-08-31 behind a real-ACME byte-diff oracle that is test-only, so the verb writes source and assembles nothing (see Disassembly above) |
186
236
  | **Source in, `.prg` out** | here |
187
237
 
188
238
  ## References
@@ -192,10 +242,10 @@ This one turns source into bytes. It does not restate what the others carry.
192
242
  | `scripts/acme.mjs` | The driver. Its comments are the contract for every flag above |
193
243
  | `template.a` | The scaffold `new` writes: BASIC stub with a computed `SYS`, five local hardware constants (no library needed), no `!to` |
194
244
 
195
- Findings that make RE faster go in `.planning/RE-FINDINGS.md` **at the moment you
196
- find them**, graded with `Evidence:` and `Confidence:`. Promote by re-logging with
197
- the new evidence, never by editing a grade in place. File-changing work enters
198
- through a GSD command (`/gsd-quick`).
245
+ Record findings that make RE faster in your own project notes **at the moment you
246
+ find them**, graded with `Evidence:` and `Confidence:`. Promote a finding by
247
+ re-logging it with the new evidence, never by editing an old grade in place the
248
+ grade is only worth anything if it says what was actually known when it was written.
199
249
 
200
250
  ## Troubleshooting
201
251
 
@@ -2,30 +2,114 @@
2
2
  // ACME -> C64 assembler driver. Target is fixed: C64, 6510 CPU, cbm output.
3
3
  // Scope is assembling only: source in, .prg + symbol files out. Running the
4
4
  // result on a C64 belongs to the emulator skill.
5
- import { spawnSync } from "node:child_process";
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
- import { dirname, join, basename, relative, isAbsolute } from "node:path";
5
+ //
6
+ // The assembler is reached ONLY through the host-tool execution seam -- the
7
+ // project owner's rule of 2026-08-28 is that this script runs container-side,
8
+ // `acme` lives host-side, and there is no container PATH to find it on. This file used to spawn `acme` directly (a synchronous
9
+ // `spawnSync("acme", args, { env })`) and probed FOUR fixed HOST paths
10
+ // (`/usr/local/share/acme`, `/usr/share/acme`, `/usr/lib/acme`, `~/.acme`)
11
+ // for its `<...>`-include library -- both are exactly what the owner's rule
12
+ // says cannot work from inside a container. The spawn and the library probe
13
+ // both moved to `src/mcp/vice/host-tool.mts`'s `acme.build` allowlist entry;
14
+ // this file now only constructs a TYPED request and reads the produced files
15
+ // back off the shared workspace tree.
16
+ //
17
+ // WHAT NOT TO DO: never reintroduce a local child-process call to the
18
+ // assembler as a fallback when the seam is unreachable -- a fallback that
19
+ // works on the developer's own host and silently fails inside a container is
20
+ // the exact failure this seam exists to remove. A seam refusal is reported
21
+ // and the build fails; it is never retried by spawning `acme` here.
22
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
23
+ import { dirname, join, basename, relative, isAbsolute, resolve, sep } from "node:path";
8
24
  import { fileURLToPath } from "node:url";
25
+ import { spawn } from "node:child_process";
26
+
27
+ import { resolveMcpModule, refusalMessage } from "../../c64-ram-capture/scripts/mcp-module.mjs";
9
28
 
10
29
  const SELF = fileURLToPath(import.meta.url);
11
30
  const HERE = dirname(SELF);
12
31
 
13
- // The ACME library (<cbm/c64/vic.a> and friends) lives wherever the package put
14
- // it. Probe instead of assuming; validated by a file we actually include.
15
- const LIB_MARKER = join("cbm", "c64", "vic.a");
16
- function findAcmeLib() {
17
- const tried = [];
18
- for (const c of [
19
- process.env.ACME,
20
- "/usr/local/share/acme", "/usr/share/acme", "/usr/lib/acme",
21
- process.env.HOME && join(process.env.HOME, ".acme"),
22
- ].filter(Boolean)) {
23
- tried.push(c);
24
- if (existsSync(join(c, LIB_MARKER))) return { path: c, tried };
32
+ /** The MCP-side module this script reaches -- never imported statically
33
+ * (cross-package: this file ships in `@henols/c64-re-tools`, the seam client
34
+ * ships in `@henols/vice-mcp`), only located via the ladder and invoked with
35
+ * `process.execPath`, the interpreter already running this script, on an
36
+ * in-tree module -- not an external host binary. */
37
+ const HOST_TOOL_CLIENT_FILE = "host-tool-client.ts";
38
+
39
+ /**
40
+ * Invokes the host-tool execution seam for `tool`/`args`, rooted at
41
+ * `repoRoot` for THIS invocation's workspace-relative path resolution.
42
+ * Never rejects: a resolution failure, a spawn failure, or unparseable
43
+ * output all resolve to `{ ok: false, message }` -- the same shape a tool's
44
+ * own refusal uses, so a caller never needs a try/catch.
45
+ */
46
+ function invokeSeam(tool, args, repoRoot) {
47
+ return new Promise((resolvePromise) => {
48
+ const resolved = resolveMcpModule(HOST_TOOL_CLIENT_FILE);
49
+ if (!resolved.ok) {
50
+ resolvePromise({ ok: false, message: refusalMessage(HOST_TOOL_CLIENT_FILE, resolved.rungs) });
51
+ return;
52
+ }
53
+
54
+ const cliArgs = [resolved.path, "run", "--tool", tool, "--args", JSON.stringify(args), "--repo-root", repoRoot];
55
+ let child;
56
+ try {
57
+ child = spawn(process.execPath, cliArgs, { stdio: ["ignore", "pipe", "pipe"] });
58
+ } catch (e) {
59
+ resolvePromise({ ok: false, message: e instanceof Error ? e.message : String(e) });
60
+ return;
61
+ }
62
+
63
+ let stdout = "";
64
+ let stderr = "";
65
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
66
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
67
+ child.on("error", (err) => resolvePromise({ ok: false, message: err.message }));
68
+ child.on("close", () => {
69
+ const lines = stdout.split("\n").filter((line) => line.trim() !== "");
70
+ const last = lines[lines.length - 1];
71
+ if (last === undefined) {
72
+ resolvePromise({ ok: false, message: `host-tool-client.ts produced no output${stderr ? ` (stderr: ${stderr})` : ""}` });
73
+ return;
74
+ }
75
+ try {
76
+ resolvePromise(JSON.parse(last));
77
+ } catch {
78
+ resolvePromise({ ok: false, message: `host-tool-client.ts produced non-JSON output: ${last}` });
79
+ }
80
+ });
81
+ });
82
+ }
83
+
84
+ /** The smallest common ancestor directory of two absolute paths -- computed,
85
+ * never a fixed guess, so the request's `--repo-root` for THIS invocation is
86
+ * always exactly big enough to contain both the source and the output
87
+ * directory, and no bigger. This is what keeps a build entirely outside this
88
+ * project's own tree (this repo's `skill-acme-build-cli.test.ts`'s own
89
+ * scratch directories under the SYSTEM temp dir, and CI's own
90
+ * `RUNNER_TEMP`-rooted scaffold check) working after the migration: the
91
+ * seam's `resolveWorkspacePath()` refuses any path outside its given root, so
92
+ * the root for one invocation is chosen to be wherever that invocation's own
93
+ * files actually live, never a client-supplied absolute path sent as-is. */
94
+ function commonAncestorDir(a, b) {
95
+ const partsA = resolve(a).split(sep);
96
+ const partsB = resolve(b).split(sep);
97
+ const common = [];
98
+ for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
99
+ if (partsA[i] === partsB[i]) common.push(partsA[i]);
100
+ else break;
25
101
  }
26
- return { path: null, tried };
102
+ const joined = common.join(sep);
103
+ return joined === "" ? sep : joined;
104
+ }
105
+
106
+ /** `path.relative()`, except the "same directory" case yields `"."` rather
107
+ * than `""` -- the seam's `resolveWorkspacePath()` refuses an empty string,
108
+ * but accepts `"."` as a no-op relative reference to its own root. */
109
+ function toRel(root, abs) {
110
+ const r = relative(root, abs);
111
+ return r === "" ? "." : r;
27
112
  }
28
- const ACME_LIB = findAcmeLib();
29
113
 
30
114
  // How to refer to this script in hints, from wherever we were run.
31
115
  function selfPath() {
@@ -90,54 +174,65 @@ function curateLabels(vsPath, symbols) {
90
174
  return { kept: kept.length, dropped };
91
175
  }
92
176
 
93
- function build(src, opts) {
177
+ async function build(src, opts) {
94
178
  if (!existsSync(src)) die(`no such source file: ${src}`);
179
+
180
+ const srcAbs = resolve(src);
95
181
  // Side files follow the .prg, not the source: two -DVARIANT builds of one
96
182
  // source must not overwrite each other's symbol tables.
97
- const prg = opts.out || join(opts.outDir || dirname(src),
98
- basename(src).replace(/\.(a|asm|s)$/i, "") + ".prg");
99
- const outDir = dirname(prg);
100
- if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
101
- const stem = prg.replace(/\.prg$/i, "");
102
-
103
- const args = [
104
- "--cpu", "6510", // C64: enables the 6510 illegal opcodes
105
- "-f", opts.format || "cbm", // cbm = 2-byte load address, what LOAD wants
106
- "-Wtype-mismatch", // catches a missing '#' on an immediate
107
- "--strict-segments", // overlapping segments are reported as errors
108
- "--msvc", // machine-parseable diagnostics
109
- "-v1", // report the address range actually emitted
110
- "-o", prg,
111
- "-l", `${stem}.sym`,
112
- "--vicelabels", `${stem}.vs`,
113
- ];
114
- if (!opts.noReport) args.push("-r", `${stem}.rep`);
115
- for (const d of opts.defines) args.push(`-D${d}`);
116
- for (const i of opts.includes) args.push("-I", i);
117
- if (opts.setpc) args.push("--setpc", opts.setpc);
118
- args.push(src);
119
-
120
- // `<cbm/c64/vic.a>` style includes resolve through the ACME env var, so set
121
- // it here rather than depending on the shell environment carrying it.
122
- const env = { ...process.env };
123
- if (ACME_LIB.path) env.ACME = ACME_LIB.path;
124
- const r = spawnSync("acme", args, { encoding: "utf8", env });
125
- if (r.error) {
126
- die(r.error.code === "ENOENT"
127
- ? "install the ACME cross assembler and put `acme` on PATH"
128
- : String(r.error));
183
+ const desiredPrg = opts.out
184
+ ? resolve(opts.out)
185
+ : join(resolve(opts.outDir || dirname(src)), basename(src).replace(/\.(a|asm|s)$/i, "") + ".prg");
186
+ const desiredOutDirAbs = dirname(desiredPrg);
187
+ if (!existsSync(desiredOutDirAbs)) mkdirSync(desiredOutDirAbs, { recursive: true });
188
+ const desiredStem = desiredPrg.replace(/\.prg$/i, "");
189
+
190
+ // Workspace-relative request construction (A-03): the root for THIS
191
+ // invocation is the smallest ancestor containing both the source and the
192
+ // output directory -- see commonAncestorDir()'s own header.
193
+ const repoRoot = commonAncestorDir(dirname(srcAbs), desiredOutDirAbs);
194
+ const autoOutDirAbs = dirname(srcAbs); // the executor's own default when outDir is omitted
195
+
196
+ const args = { source: toRel(repoRoot, srcAbs) };
197
+ if (desiredOutDirAbs !== autoOutDirAbs) args.outDir = toRel(repoRoot, desiredOutDirAbs);
198
+ if (opts.format) args.format = opts.format;
199
+ if (opts.setpc) args.setpc = opts.setpc;
200
+ if (opts.defines && opts.defines.length) args.defines = opts.defines;
201
+ if (opts.includes && opts.includes.length) args.includes = opts.includes;
202
+ if (opts.noReport) args.noReport = true;
203
+
204
+ const response = await invokeSeam("acme.build", args, repoRoot);
205
+
206
+ if (!response.ok) {
207
+ // A seam-level refusal (unresolvable seam, unreachable broker, a bad
208
+ // request) -- never a local fallback that spawns the assembler itself.
209
+ die(response.message);
129
210
  }
130
211
 
131
- const diags = parseDiagnostics(((r.stderr || "") + (r.stdout || "")).trim());
132
- if (diags.some((d) => /ACME.*environment variable/i.test(d.message))) {
133
- diags.push({
134
- file: null, line: null, severity: "note", zone: null,
135
- message: `for <...> includes, set $ACME to the directory holding ${LIB_MARKER} ` +
136
- `(looked in: ${ACME_LIB.tried.join(", ")})`,
137
- });
212
+ // The executor always names outputs after the SOURCE's own basename (never
213
+ // a caller-chosen stem) -- see host-tool.mts's buildHostToolArgv(). When
214
+ // `-o`/`--out-dir` asked for a DIFFERENT stem (a rename, not just a
215
+ // different directory), the produced files are moved here to the exact
216
+ // requested names -- a workspace file operation, not a second copy of
217
+ // argv construction.
218
+ const autoStem = join(desiredOutDirAbs, basename(srcAbs).replace(/\.(a|asm|s)$/i, ""));
219
+ if (autoStem !== desiredStem) {
220
+ for (const ext of [".prg", ".sym", ".vs", ".rep"]) {
221
+ const from = `${autoStem}${ext}`;
222
+ const to = `${desiredStem}${ext}`;
223
+ if (existsSync(from) && from !== to) {
224
+ mkdirSync(dirname(to), { recursive: true });
225
+ renameSync(from, to);
226
+ }
227
+ }
138
228
  }
229
+
230
+ const stem = desiredStem;
231
+ const prg = desiredPrg;
232
+
233
+ const diags = parseDiagnostics((response.stderrTail || "").trim());
139
234
  const errors = diags.filter((d) => d.severity.endsWith("error"));
140
- const ok = r.status === 0 && existsSync(prg);
235
+ const ok = response.exitStatus === 0 && existsSync(prg);
141
236
 
142
237
  let range = null, size = null, symbols = [], labels = null;
143
238
  if (ok) {
@@ -176,16 +271,16 @@ function reportBuild(res, { json }) {
176
271
 
177
272
  // -------------------------------------------------------------------- verbs
178
273
 
179
- function cmdBuild(argv) {
274
+ async function cmdBuild(argv) {
180
275
  const o = parseOpts(argv);
181
- const res = build(o.src, o);
276
+ const res = await build(o.src, o);
182
277
  reportBuild(res, o);
183
278
  process.exit(res.ok ? 0 : 1);
184
279
  }
185
280
 
186
- function cmdSym(argv) {
281
+ async function cmdSym(argv) {
187
282
  const o = parseOpts(argv);
188
- const res = build(o.src, { ...o, noReport: true });
283
+ const res = await build(o.src, { ...o, noReport: true });
189
284
  if (!res.ok) { reportBuild(res, o); process.exit(1); }
190
285
  const used = res.symbols.filter((s) => s.used).sort((a, b) => a.name.localeCompare(b.name));
191
286
  if (o.json) { console.log(JSON.stringify(used, null, 2)); return; }
@@ -199,7 +294,7 @@ function cmdNew(argv) {
199
294
  if (!path) die("usage: new <file.a>");
200
295
  if (existsSync(path)) die(`${path} already exists`);
201
296
  // template.a lives at the skill root, one level up from scripts/, by
202
- // decision (D-03): only .mjs modules move into scripts/.
297
+ // decision: only .mjs modules move into scripts/.
203
298
  writeFileSync(path, readFileSync(join(HERE, "..", "template.a"), "utf8"));
204
299
  console.log(`wrote ${path}`);
205
300
  console.log(`next: node ${selfPath()} build ${path}`);
@@ -243,4 +338,4 @@ options: -o FILE --out-dir DIR -f FORMAT --setpc ADDR -DSYM=VAL -I DIR
243
338
  --no-report --json`);
244
339
  process.exit(cmd ? 1 : 0);
245
340
  }
246
- VERBS[cmd](rest);
341
+ await VERBS[cmd](rest);
@@ -6,7 +6,7 @@
6
6
  ; No angle-bracket library includes here on purpose. Neither documented ACME
7
7
  ; provisioning route ships the standard hardware-register library - a bare
8
8
  ; `~/.local/bin/acme` install and the Debian trixie `apt` candidate were both
9
- ; verified to lack it (Phase 8.1 FINDING-A1) - so a scaffold that depends on
9
+ ; verified to lack it - so a scaffold that depends on
10
10
  ; it fails to assemble on a fresh install. The five constants below are
11
11
  ; exactly what this scaffold's body uses, defined locally instead. If you
12
12
  ; want the full library for your own sources' angle-bracket includes, set