@henols/c64-re-tools 0.2.2 → 0.2.4

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 (37) hide show
  1. package/bin/cli.mjs +7 -4
  2. package/package.json +2 -2
  3. package/skills/acme-build/SKILL.md +39 -23
  4. package/skills/acme-build/scripts/acme.mjs +159 -64
  5. package/skills/acme-build/template.a +1 -1
  6. package/skills/c64-disk-access/SKILL.md +156 -0
  7. package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
  8. package/skills/c64-memory-mapping/SKILL.md +30 -23
  9. package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
  10. package/skills/c64-petcat/SKILL.md +87 -0
  11. package/skills/c64-petcat/scripts/petcat.mjs +221 -0
  12. package/skills/c64-program-recon/SKILL.md +93 -39
  13. package/skills/c64-program-recon/references/control-flow.md +12 -15
  14. package/skills/c64-program-recon/references/graphics.md +1 -1
  15. package/skills/c64-program-recon/references/observation-hazards.md +18 -16
  16. package/skills/c64-program-recon/references/reconstruction.md +1 -2
  17. package/skills/c64-program-recon/references/sound-and-input.md +6 -8
  18. package/skills/c64-program-recon/references/tool-selection.md +36 -17
  19. package/skills/c64-program-recon/scripts/packer-finding.mjs +165 -87
  20. package/skills/c64-program-recon/templates/memory-map.template.md +2 -2
  21. package/skills/c64-provenance-diff/SKILL.md +40 -5
  22. package/skills/c64-provenance-diff/scripts/diff-images.mjs +8 -8
  23. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +7 -5
  24. package/skills/c64-ram-capture/SKILL.md +112 -44
  25. package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
  26. package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
  27. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
  28. package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
  29. package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
  30. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
  31. package/skills/c64-ram-capture/scripts/watch-loads.mjs +15 -15
  32. package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
  33. package/skills/c64-ram-capture/transients/README.md +136 -0
  34. package/skills/routine-queue-walker/SKILL.md +114 -22
  35. package/skills/routine-queue-walker/scripts/completeness-report.mjs +465 -0
  36. package/skills/vice-wedge-triage/SKILL.md +96 -89
  37. package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
package/bin/cli.mjs CHANGED
@@ -30,7 +30,9 @@ const PKG_ROOT = dirname(HERE);
30
30
  const SKILLS_SRC = join(PKG_ROOT, "skills");
31
31
 
32
32
  // The single version-resolution seam this repo maintains is
33
- // `src/mcp/vice/version.ts` (quick-260819-tsz, D-5). This package
33
+ // `src/mcp/vice/version.ts` -- one algorithm for resolving a VERSION
34
+ // template against a published version, kept in exactly one place after
35
+ // this repo once carried several independent copies of it. This package
34
36
  // deliberately does NOT import it: it ships without the seam file (its
35
37
  // `files[]` is `bin/`, `skills/`, `README.md`) and targets node >= 18, which
36
38
  // cannot type-strip the seam's `.ts` the way the vice-mcp package's own
@@ -42,12 +44,13 @@ const SKILLS_SRC = join(PKG_ROOT, "skills");
42
44
  // only ever published with a concrete version already stamped in. That
43
45
  // number is PRODUCED elsewhere: `scripts/version.mjs stamp` writes the
44
46
  // working-tree placeholder, and CI's `npm version` writes the real one at
45
- // publish time. Do not reimplement D-2's template-resolution rules here.
47
+ // publish time. Do not reimplement the seam's own VERSION-template
48
+ // parsing and prefix-resolution rules here.
46
49
  const SELF = readJson(join(PKG_ROOT, "package.json")) ?? {};
47
50
  const SELF_VERSION = typeof SELF.version === "string" ? SELF.version : "0.0.0";
48
51
  const MCP_PKG = "@henols/vice-mcp";
49
52
  // The dev placeholder every derived, publishable version string carries in
50
- // the working tree (R-2, quick-260819-tsz). Defined authoritatively as
53
+ // the working tree outside a stamped release. Defined authoritatively as
51
54
  // `DEV_PLACEHOLDER` in `src/mcp/vice/version.ts` -- repeated here as a
52
55
  // literal, NOT imported, because this package deliberately ships without
53
56
  // that seam file (see the comment above) and targets node >= 18, which
@@ -57,7 +60,7 @@ const MCP_PKG = "@henols/vice-mcp";
57
60
  // so a future edit to the seam's placeholder is not missed.
58
61
  const MCP_DEV_PLACEHOLDER = "0.0.0-dev";
59
62
  // Wire the project to the exact vice-mcp version this installer was built
60
- // against -- EXCEPT when run from an unstamped dev checkout (MED-3):
63
+ // against -- EXCEPT when run from an unstamped dev checkout:
61
64
  // `installer/package.json`'s dependency pin is the permanent working-tree
62
65
  // placeholder outside a CI-stamped publish job, and `@henols/vice-mcp` at
63
66
  // that literal version will never exist on the npm registry. Silently
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/c64-re-tools",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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": {
@@ -45,7 +45,7 @@
45
45
  "installer"
46
46
  ],
47
47
  "dependencies": {
48
- "@henols/vice-mcp": "0.2.2"
48
+ "@henols/vice-mcp": "0.2.4"
49
49
  },
50
50
  "scripts": {
51
51
  "sync-skills": "node scripts/sync-skills.mjs",
@@ -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
@@ -137,8 +144,7 @@ it lives in the `anno` CLI rather than here.
137
144
 
138
145
  **Dated withdrawal 2026-08-29, dated return 2026-08-31 — both halves are kept,
139
146
  because the withdrawal is the record of why the route is shaped the way it is.**
140
- Whole-program static disassembly was WITHDRAWN on 2026-08-29 (`D-02`/`D-14`)
141
- rather than left standing on an unverified reassembly claim: the removed route
147
+ Whole-program static disassembly was WITHDRAWN on 2026-08-29 rather than left standing on an unverified reassembly claim: the removed route
142
148
  settled correctness with a transcript parser, and the recorded false pass that
143
149
  discipline exists against read `ACME not found in PATH (skipped)` / `All
144
150
  roundtrip verifications passed.` / `EXIT=0` — exit zero, an aggregate line
@@ -152,16 +158,19 @@ match on the exporter's own output.
152
158
  **The live invocation:**
153
159
 
154
160
  ```bash
155
- npx -y @henols/vice-mcp anno export-asm game.prg --store game.annostore --out game.a
161
+ npx -y @henols/vice-mcp anno export-asm game.prg --store game.annostore --out game-src
156
162
  node <plugin-root>/src/mcp/vice/vice-proxy.ts anno export-asm game.prg --store game.annostore
157
163
  ```
158
164
 
159
165
  `<image>` and `--store` are **two separate arguments and neither is derived from
160
166
  the other** — the image supplies the bytes, the store supplies the names, ranges,
161
- typed regions and comments, and naming one does not name the other. `--out`
162
- defaults to a `.a` beside the **store**, not beside the image, because the export
163
- is a generated view of the annotations. An existing destination is refused rather
164
- than overwritten unless you pass `--force`.
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`.
165
174
 
166
175
  **The verb writes source text and runs no assembler.** Its own second output line
167
176
  says so, in as many words:
@@ -186,18 +195,25 @@ history together; it is not restated there.
186
195
 
187
196
  ## Setup
188
197
 
189
- `acme` on `$PATH` is the **only** requirement. The scaffold that `new` writes
190
- assembles against a bare install with no standard hardware-register library
191
- that's deliberate: neither a plain `~/.local/bin/acme` build nor the Debian
192
- trixie `apt` candidate ships one, so a scaffold that depended on it would fail
193
- to assemble on a fresh install (Phase 8.1 FINDING-A1).
194
-
195
- `$ACME` and the wrapper's auto-probe (`$ACME`, `/usr/local/share/acme`,
196
- `/usr/share/acme`, `/usr/lib/acme`, `~/.acme`) still exist and still matter —
197
- but only for **your own** sources that use angle-bracket includes (see
198
- "Writing source" above), not for the scaffold. If you have that library
199
- somewhere, point `$ACME` at its directory and angle-bracket includes work as
200
- 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.
201
217
 
202
218
  Re-checked against ACME release 0.97 "Zem" (31 Jan 2021). CI now assembles
203
219
  the shipped scaffold on every build with `$ACME` cleared (the "Assemble the
@@ -226,10 +242,10 @@ This one turns source into bytes. It does not restate what the others carry.
226
242
  | `scripts/acme.mjs` | The driver. Its comments are the contract for every flag above |
227
243
  | `template.a` | The scaffold `new` writes: BASIC stub with a computed `SYS`, five local hardware constants (no library needed), no `!to` |
228
244
 
229
- Findings that make RE faster go in `.planning/RE-FINDINGS.md` **at the moment you
230
- find them**, graded with `Evidence:` and `Confidence:`. Promote by re-logging with
231
- the new evidence, never by editing a grade in place. File-changing work enters
232
- 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.
233
249
 
234
250
  ## Troubleshooting
235
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
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: c64-disk-access
3
+ description: Read a Commodore .d64 disk image's directory, block allocation map, a named file's sector chain, or a named file's raw bytes, using VICE's own c1541 disk tool as the reference implementation, and audit its directory for fabricated or corrupted entries. Use when asked to list what a disk image contains, check a disk's free blocks or block allocation map, trace which sectors a named file occupies on disk, extract a named file's raw bytes from a .d64, or check whether a disk's directory entries are genuine (a cracker-fabricated filename, a corrupted first track/sector, or a cyclic directory chain).
4
+ ---
5
+
6
+ # Reading C64 disk images with c1541
7
+
8
+ Read-only. Six capabilities, one script, one binary (`c1541`) reached only
9
+ through the host-tool execution seam:
10
+
11
+ ```bash
12
+ S=src/skills/c64-disk-access/scripts/c1541.mjs # from the repo root
13
+
14
+ node $S bam --image path/to/image.d64 # block allocation map
15
+ node $S dir --image path/to/image.d64 # what's on the disk
16
+ node $S entry --image path/to/image.d64 --name FILENAME # one directory entry's raw fields
17
+ node $S chain --image path/to/image.d64 --name FILENAME # a named file's sector chain
18
+ node $S read --image path/to/image.d64 --name FILENAME # extract a named file's bytes
19
+ node $S audit --image path/to/image.d64 # find fabricated/corrupted directory entries
20
+ ```
21
+
22
+ The script wraps `c1541` and nothing else — **read-only**. `bam`/`dir`/`audit`
23
+ need only `--image`; `entry`/`chain`/`read` also need `--name`, a CBM
24
+ filename or glob pattern (never a path). No `-format`, `-write`, `-bwrite`
25
+ or `-delete` verb is reachable from here, deliberately: this skill only
26
+ ever reads a disk image, never mutates one.
27
+
28
+ Options: `--image PATH` `--name CBM-NAME` `--out-dir DIR` `--json`.
29
+
30
+ `--out-dir` defaults to the image's own directory, exactly like
31
+ `acme-build`'s own `--out-dir` default. Both `--image` and `--out-dir` are
32
+ resolved **workspace-relative** to the smallest ancestor directory
33
+ containing both, before the request ever reaches the seam — the same
34
+ resolution `acme-build`'s `source`/`--out-dir` already go through. `--name`
35
+ is passed straight through, never resolved as a path; a value beginning
36
+ with `-` is refused by the seam before any child process is spawned (it
37
+ would otherwise be read as a flag by `c1541`'s own CLI).
38
+
39
+ ## Directory listing
40
+
41
+ ```bash
42
+ node $S dir --image game.d64 --json
43
+ ```
44
+
45
+ Prints the seam's response verbatim as one line of JSON:
46
+
47
+ ```json
48
+ {"ok":true,"tool":"c1541.dir","exitStatus":0,"results":[{"path":"/abs/path/game.dir.txt","sha256":"...","byteLength":123}],"stderrTail":""}
49
+ ```
50
+
51
+ `results[0].path` names the listing file the seam wrote — `c1541`'s own
52
+ `-dir` output, captured and digested.
53
+
54
+ ## Block allocation map
55
+
56
+ ```bash
57
+ node $S bam --image game.d64 --json
58
+ ```
59
+
60
+ Same response shape as `dir`; `results[0].path` names a file carrying one
61
+ per-sector allocation row per track (a run of `*`/`.` characters, `*` for
62
+ an allocated sector).
63
+
64
+ ## One directory entry's raw fields
65
+
66
+ ```bash
67
+ node $S entry --image game.d64 --name FILENAME --json
68
+ ```
69
+
70
+ `results[0].path` names a file carrying the entry's raw 32-byte directory
71
+ record (as a hex dump) followed by its `T/S: <t>/<s>, <n> blocks` summary
72
+ line. This script ALSO parses that line back and adds `firstTrack`/
73
+ `firstSector` as numeric fields on the JSON response, purely for display —
74
+ the file itself is still the authoritative source.
75
+
76
+ ## A named file's sector chain
77
+
78
+ ```bash
79
+ node $S chain --image game.d64 --name FILENAME --json
80
+ ```
81
+
82
+ `results[0].path` names a file listing every `(track,sector)` hop the file
83
+ occupies, in order. A single-sector file's chain shows one hop with no
84
+ second tuple on the arrow's right side; a multi-sector file's chain repeats
85
+ the tuple at every hop.
86
+
87
+ ## Extracting a named file's bytes
88
+
89
+ ```bash
90
+ node $S read --image game.d64 --name FILENAME --out-dir /scratch --json
91
+ ```
92
+
93
+ Writes the file's raw bytes (unlike the other four capabilities, this one
94
+ is NOT a captured-stdout listing — `c1541` writes the output file itself).
95
+ `results[0]` carries that file's `path`/`sha256`/`byteLength`.
96
+
97
+ ## Auditing for fabricated or corrupted entries
98
+
99
+ ```bash
100
+ node $S audit --image game.d64 --json
101
+ ```
102
+
103
+ Composes `dir` (names and block counts), `bam` (the per-sector allocation
104
+ map), and one `entry` call per name (each file's own claimed first track/
105
+ sector, and its directory sector's "next directory" pointer) into a ported,
106
+ read-only detector — no `-format`/`-write`/mutating verb, and no seventh
107
+ `host_tool` id; this is three existing capabilities composed client-side.
108
+
109
+ A directory entry is flagged `suspicious`, with **named reasons, never a
110
+ bare boolean**, on any of:
111
+
112
+ 1. its block count is `0`;
113
+ 2. its first track/sector lies outside the image's own geometry (there is
114
+ no such track, or no such sector on that track);
115
+ 3. its first **sector** — not merely its whole track — is reported free by
116
+ the allocation map, meaning the file cannot really start there. This is
117
+ sharper than checking only whether the whole track is free, because the
118
+ per-sector map is available.
119
+
120
+ A cyclic or self-referential directory chain (two entries claiming the same
121
+ first track/sector, or a "next directory" pointer that refers back to a
122
+ sector already seen — including the directory's own starting sector) stops
123
+ being treated as new information and is reported as a top-level
124
+ `chain_error` naming the repeated pointer, rather than looping. Every
125
+ remaining entry is still audited afterward — a chain error on one entry
126
+ never hides another entry's own independent flag.
127
+
128
+ ```json
129
+ {"entries":[{"name":"basicstub","blocks":1,"first_track":17,"first_sector":0,"suspicious":false,"suspicious_reasons":[]}],"chain_error":null}
130
+ ```
131
+
132
+ **A flag is a signal to investigate, not a verdict.** A directory entry a
133
+ cracker fabricated for a file never actually written, a genuinely corrupted
134
+ image, and (rarely) an unusual-but-legitimate disk layout can all produce a
135
+ flag; this command reports what it finds, named, and leaves the
136
+ interpretation to whoever is looking at the disk.
137
+
138
+ ## Failure shape
139
+
140
+ A nonexistent image, a nonexistent named entry, or any other call `c1541`
141
+ cannot service is reported as `{"ok":false,"message":"..."}` with a
142
+ non-zero exit code — **never** a success envelope over an empty or partial
143
+ result. `c1541` itself exits `0` even on a genuine failure (it prints its
144
+ own `Error - ...` lines to stdout instead); the seam's own classifier, not
145
+ the exit code, is what decides success here.
146
+
147
+ ## What this skill does NOT do
148
+
149
+ - **No mutating verb.** `-format`/`-write`/`-bwrite`/`-delete` are never
150
+ reachable from this script, on the wire, or anywhere in this skill's tree
151
+ — only the six read-only capabilities above are exposed.
152
+ - **No direct binary spawn.** `c1541` runs host-side; this script only ever
153
+ constructs a typed request and reads the produced files back off the
154
+ shared workspace tree — the host-tool execution seam is the only route.
155
+ - **No emulator dependency.** This skill names no VICE emulator tool at
156
+ all — it works entirely on files, never on a running machine, by construction.