@henols/c64-re-tools 0.2.2 → 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 (36) hide show
  1. package/package.json +2 -2
  2. package/skills/acme-build/SKILL.md +39 -23
  3. package/skills/acme-build/scripts/acme.mjs +159 -64
  4. package/skills/acme-build/template.a +1 -1
  5. package/skills/c64-disk-access/SKILL.md +156 -0
  6. package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
  7. package/skills/c64-memory-mapping/SKILL.md +30 -23
  8. package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
  9. package/skills/c64-petcat/SKILL.md +87 -0
  10. package/skills/c64-petcat/scripts/petcat.mjs +221 -0
  11. package/skills/c64-program-recon/SKILL.md +93 -39
  12. package/skills/c64-program-recon/references/control-flow.md +12 -15
  13. package/skills/c64-program-recon/references/graphics.md +1 -1
  14. package/skills/c64-program-recon/references/observation-hazards.md +18 -16
  15. package/skills/c64-program-recon/references/reconstruction.md +1 -2
  16. package/skills/c64-program-recon/references/sound-and-input.md +6 -8
  17. package/skills/c64-program-recon/references/tool-selection.md +36 -17
  18. package/skills/c64-program-recon/scripts/packer-finding.mjs +165 -87
  19. package/skills/c64-program-recon/templates/memory-map.template.md +2 -2
  20. package/skills/c64-provenance-diff/SKILL.md +40 -5
  21. package/skills/c64-provenance-diff/scripts/diff-images.mjs +8 -8
  22. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +7 -5
  23. package/skills/c64-ram-capture/SKILL.md +112 -44
  24. package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
  25. package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
  26. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
  27. package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
  28. package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
  29. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
  30. package/skills/c64-ram-capture/scripts/watch-loads.mjs +15 -15
  31. package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
  32. package/skills/c64-ram-capture/transients/README.md +136 -0
  33. package/skills/routine-queue-walker/SKILL.md +114 -22
  34. package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
  35. package/skills/vice-wedge-triage/SKILL.md +96 -89
  36. package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
@@ -0,0 +1,569 @@
1
+ #!/usr/bin/env node
2
+ // c1541 -> disk-image reader driver. Read-only: directory, block allocation
3
+ // map, a named file's sector chain, and a named file's raw bytes. No write,
4
+ // format, delete or any other mutating verb is reachable from this script --
5
+ // deliberately.
6
+ //
7
+ // Reached ONLY through the host-tool execution seam -- the project owner's
8
+ // rule of 2026-08-28 is that this script runs container-side, `c1541` lives
9
+ // host-side, and there is no container PATH to find it on. This file never spawns c1541 itself; it constructs a TYPED
10
+ // request per capability (`c1541.bam`/`c1541.dir`/`c1541.entry`/
11
+ // `c1541.chain`/`c1541.read`) and reads the produced files back off the
12
+ // shared workspace tree, mirroring src/skills/acme-build/scripts/acme.mjs's
13
+ // own invokeSeam() shape verbatim.
14
+ //
15
+ // WHAT NOT TO DO:
16
+ // - Never spawn the disk-image utility (`c1541`) directly from this
17
+ // script, even as a "just this once" fallback. A direct call works on
18
+ // the developer's own host and silently fails inside a container -- the
19
+ // exact failure this seam exists to remove.
20
+ // - Never fall back to reading the image bytes locally (re-parsing the
21
+ // `.d64` in this script) when the seam refuses. A seam refusal is
22
+ // reported as `{ ok: false, message }`; it is never retried by
23
+ // re-implementing the read here.
24
+ import { readFileSync } from "node:fs";
25
+ import { dirname, relative, isAbsolute, resolve, sep } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { spawn } from "node:child_process";
28
+
29
+ import { resolveMcpModule, refusalMessage } from "../../c64-ram-capture/scripts/mcp-module.mjs";
30
+
31
+ const SELF = fileURLToPath(import.meta.url);
32
+ const HERE = dirname(SELF);
33
+
34
+ /** The MCP-side module this script reaches -- never imported statically
35
+ * (cross-package: this file ships in `@henols/c64-re-tools`, the seam client
36
+ * ships in `@henols/vice-mcp`), only located via the ladder and invoked with
37
+ * `process.execPath`, the interpreter already running this script, on an
38
+ * in-tree module -- not an external host binary. */
39
+ const HOST_TOOL_CLIENT_FILE = "host-tool-client.ts";
40
+
41
+ /**
42
+ * Invokes the host-tool execution seam for `tool`/`args`, rooted at
43
+ * `repoRoot` for THIS invocation's workspace-relative path resolution.
44
+ * Never rejects: a resolution failure, a spawn failure, or unparseable
45
+ * output all resolve to `{ ok: false, message }` -- the same shape a tool's
46
+ * own refusal uses, so a caller never needs a try/catch. Copied verbatim
47
+ * from acme.mjs's own invokeSeam() -- see this file's own header for why a
48
+ * shared import is not possible across the two npm packages.
49
+ */
50
+ function invokeSeam(tool, args, repoRoot) {
51
+ return new Promise((resolvePromise) => {
52
+ const resolved = resolveMcpModule(HOST_TOOL_CLIENT_FILE);
53
+ if (!resolved.ok) {
54
+ resolvePromise({ ok: false, message: refusalMessage(HOST_TOOL_CLIENT_FILE, resolved.rungs) });
55
+ return;
56
+ }
57
+
58
+ const cliArgs = [resolved.path, "run", "--tool", tool, "--args", JSON.stringify(args), "--repo-root", repoRoot];
59
+ let child;
60
+ try {
61
+ child = spawn(process.execPath, cliArgs, { stdio: ["ignore", "pipe", "pipe"] });
62
+ } catch (e) {
63
+ resolvePromise({ ok: false, message: e instanceof Error ? e.message : String(e) });
64
+ return;
65
+ }
66
+
67
+ let stdout = "";
68
+ let stderr = "";
69
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
70
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
71
+ child.on("error", (err) => resolvePromise({ ok: false, message: err.message }));
72
+ child.on("close", () => {
73
+ const lines = stdout.split("\n").filter((line) => line.trim() !== "");
74
+ const last = lines[lines.length - 1];
75
+ if (last === undefined) {
76
+ resolvePromise({ ok: false, message: `host-tool-client.ts produced no output${stderr ? ` (stderr: ${stderr})` : ""}` });
77
+ return;
78
+ }
79
+ try {
80
+ resolvePromise(JSON.parse(last));
81
+ } catch {
82
+ resolvePromise({ ok: false, message: `host-tool-client.ts produced non-JSON output: ${last}` });
83
+ }
84
+ });
85
+ });
86
+ }
87
+
88
+ /** The smallest common ancestor directory of two absolute paths -- computed,
89
+ * never a fixed guess, so the request's `--repo-root` for THIS invocation is
90
+ * always exactly big enough to contain both the image and the output
91
+ * directory, and no bigger. Copied verbatim from acme.mjs's own
92
+ * commonAncestorDir() -- see this file's own header for why a shared import
93
+ * is not possible. */
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;
101
+ }
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;
112
+ }
113
+
114
+ // How to refer to this script in hints, from wherever we were run.
115
+ function selfPath() {
116
+ const r = relative(process.cwd(), SELF);
117
+ return !r || r.startsWith("..") || isAbsolute(r) ? SELF : r;
118
+ }
119
+
120
+ const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
121
+
122
+ // ------------------------------------------------------------- capabilities
123
+
124
+ /** The one place a subcommand name maps to its `host_tool` id. */
125
+ const VERB_TO_TOOL = {
126
+ bam: "c1541.bam",
127
+ dir: "c1541.dir",
128
+ entry: "c1541.entry",
129
+ chain: "c1541.chain",
130
+ read: "c1541.read",
131
+ };
132
+
133
+ // ---------------------------------------------------------------- audit
134
+ //
135
+ // Ports the fakery detector the skill-side pure-parse module carried at its
136
+ // own `parseDirectory()` (that module -- `src/skills/c64-ram-capture/`'s own
137
+ // disk-image reader -- has since been deleted) onto this skill's own
138
+ // seam-reached capabilities, now that `c1541` is the only `.d64` route.
139
+ // Composes THREE
140
+ // existing capabilities -- one `dir` call for names/block counts, one `bam`
141
+ // call for the per-sector allocation map, and one `entry` call PER NAME for
142
+ // that file's own claimed first track/sector and its directory sector's
143
+ // "next directory T/S" pointer -- rather than adding a seventh tool id.
144
+ //
145
+ // Same three named-reason signatures as the replaced parser, PLUS a
146
+ // genuinely SHARPER third signature: the per-SECTOR allocation map
147
+ // `c1541.bam` returns lets this check test the file's EXACT claimed first
148
+ // sector, not merely whether its whole track is free.
149
+ // 1. block count is 0
150
+ // 2. first track/sector is outside the image's own geometry
151
+ // 3. the first SECTOR the allocation map reports free -- the file cannot
152
+ // really start there
153
+ //
154
+ // Plus the chain guard the replaced parser carried and `c1541` itself does
155
+ // not: a visited set over every "next directory
156
+ // T/S" pointer observed, seeded with the directory's own starting sector
157
+ // (18/1, the standard 1541 layout every other convention in this project
158
+ // already assumes), and a second visited set over every entry's own claimed
159
+ // first track/sector -- a repeat in EITHER produces a named `chain_error`.
160
+ //
161
+ // Composition detail worth recording: this audit's outer loop walks a
162
+ // FIXED, already-known list of names (`dir`'s own listing), never a raw
163
+ // track/sector-following walk the way the replaced parser did -- so unlike
164
+ // that parser, this walk cannot loop forever by construction, regardless of
165
+ // the chain guard. The chain guard here is therefore a DETECTION signal
166
+ // (does the disk's own metadata contain a cycle) rather than a hang
167
+ // preventer, and this audit deliberately keeps auditing every remaining
168
+ // name after the first chain_error is recorded, rather than aborting --
169
+ // otherwise a corrupt disk whose FIRST file happens to reveal the cycle
170
+ // would hide every later file's own independent flags, which is exactly the
171
+ // wrong trade for a detector whose job is finding every fabricated entry.
172
+
173
+ /** Strips ANSI colour escape codes -- `c1541`'s own captured output colours
174
+ * "OPENCBM"/"Error" and the seam's refusal message quotes that text
175
+ * verbatim, so an opaque entry-lookup failure reason would otherwise carry
176
+ * raw escape bytes into a human-read report. */
177
+ function stripAnsi(text) {
178
+ // eslint-disable-next-line no-control-regex
179
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
180
+ }
181
+
182
+ /** The four sector-count zones of a standard 35-track 1541 image -- same
183
+ * table the deleted skill-side pure-parse module used, copied rather than
184
+ * imported since that module no longer exists. */
185
+ export function sectorsPerTrack(track) {
186
+ if (!Number.isInteger(track) || track < 1 || track > 35) return null;
187
+ if (track <= 17) return 21;
188
+ if (track <= 24) return 19;
189
+ if (track <= 30) return 18;
190
+ return 17;
191
+ }
192
+
193
+ /** The directory chain's own starting sector on a standard 1541 image --
194
+ * the deleted skill-side pure-parse module's own default
195
+ * (`{ startTrack = 18, startSector = 1 }`). Seeded into the
196
+ * next-directory visited set BEFORE any entry is
197
+ * read, so a next-directory pointer that refers back to this sector -- a
198
+ * genuine self-reference, the shape this plan's own corrupt fixture plants
199
+ * -- is caught on the very first entry that reports it. */
200
+ const DIRECTORY_START_TRACK = 18;
201
+ const DIRECTORY_START_SECTOR = 1;
202
+
203
+ /** Parses `c1541.dir`'s captured listing into `{ name, blocks }` pairs,
204
+ * skipping the disk-header line (no recognised file-type word) and the
205
+ * trailing "<N> blocks free." line. MEASURED against the committed
206
+ * synthetic.d64/synthetic-corrupt.d64 fixtures. */
207
+ export function parseDirListing(stdout) {
208
+ const entries = [];
209
+ for (const line of stdout.split(/\r?\n/)) {
210
+ const m = line.match(/^\s*(\d+)\s+"([^"]*)"\s+\*?(prg|seq|usr|rel|del)\b/i);
211
+ if (!m) continue;
212
+ entries.push({ name: m[2].replace(/\s+$/, ""), blocks: Number(m[1]) });
213
+ }
214
+ return entries;
215
+ }
216
+
217
+ /** Parses `c1541.bam`'s captured allocation grid into `Map<track,
218
+ * Set<allocatedSectorIndex>>`. Each row is a 1-2 digit track number
219
+ * followed by at least two spaces, then a run of `*`/`.` characters (with
220
+ * internal spacing purely for readability, stripped here) -- MEASURED
221
+ * against the committed fixtures; the two header rows (column-index
222
+ * scaffolding) never match this shape, since their own digits are not
223
+ * followed by whitespace. */
224
+ export function parseBamAllocation(stdout) {
225
+ const map = new Map();
226
+ for (const line of stdout.split(/\r?\n/)) {
227
+ const m = line.match(/^\s*(\d{1,2})\s{2,}([*.\s]+)$/);
228
+ if (!m) continue;
229
+ const track = Number(m[1]);
230
+ const cells = m[2].replace(/\s+/g, "");
231
+ const allocated = new Set();
232
+ for (let i = 0; i < cells.length; i++) if (cells[i] === "*") allocated.add(i);
233
+ map.set(track, allocated);
234
+ }
235
+ return map;
236
+ }
237
+
238
+ /** Parses a SUCCESSFUL `c1541.entry` response's captured listing for the
239
+ * file's own claimed first track/sector and blocks, plus its directory
240
+ * sector's "Next directory T/S" pointer (a property of the SECTOR the entry
241
+ * lives in, not of the file itself -- every entry sharing that sector
242
+ * reports the same value). Returns `null` if the declared `T/S:` line is
243
+ * absent (should not happen for an `ok:true` response, since the seam's own
244
+ * classifier already required it -- defensive only). */
245
+ export function parseEntryFields(stdout) {
246
+ const tsMatch = stdout.match(/T\/S:\s*(\d+)\/(\d+),\s*(\d+)\s*blocks/);
247
+ if (!tsMatch) return null;
248
+ const nextMatch = stdout.match(/Next directory T\/S:\s*(\d+)\/(\d+)/);
249
+ return {
250
+ firstTrack: Number(tsMatch[1]),
251
+ firstSector: Number(tsMatch[2]),
252
+ blocks: Number(tsMatch[3]),
253
+ nextDirTrack: nextMatch ? Number(nextMatch[1]) : null,
254
+ nextDirSector: nextMatch ? Number(nextMatch[2]) : null,
255
+ };
256
+ }
257
+
258
+ /** Salvages a claimed first track/sector out of a REFUSED `c1541.entry`
259
+ * response's own message, for the specific case MEASURED this plan (an
260
+ * out-of-geometry first track/sector makes `c1541` itself fail to read the
261
+ * file before it ever prints a `T/S:` line, so the seam's classifier
262
+ * refuses with no usable stdout at all -- see fixtures/c1541/README.md).
263
+ * The classifier's own refusal message carries a tail of the real captured
264
+ * text, which for this failure mode names the exact track/sector `c1541`
265
+ * tried and could not read. Returns `null` when the message carries no such
266
+ * substring -- the caller then falls back to an opaque "entry lookup
267
+ * failed" reason rather than fabricating a track/sector. */
268
+ export function salvageFirstTsFromRefusal(message) {
269
+ const m = message.match(/Error reading T:(\d+)\s*S:(\d+)/);
270
+ if (!m) return null;
271
+ return { firstTrack: Number(m[1]), firstSector: Number(m[2]) };
272
+ }
273
+
274
+ /**
275
+ * The pure detector core -- takes already-composed per-name records (each
276
+ * either a resolved `{ name, blocks, firstTrack, firstSector, nextDirTrack,
277
+ * nextDirSector }`, or `{ name, blocks, entryFailed: true, reason }` for a
278
+ * name whose `c1541.entry` call itself could not be resolved) plus the BAM
279
+ * allocation map, and applies the three named-reason signatures and the
280
+ * chain guard. Never calls the seam itself -- fully unit-testable with
281
+ * synthetic records, no `c1541` binary required.
282
+ */
283
+ export function auditEntries(records, { bamAllocated }) {
284
+ const visitedFirstTS = new Set([`${DIRECTORY_START_TRACK}/${DIRECTORY_START_SECTOR}`]);
285
+ const visitedNextDir = new Set([`${DIRECTORY_START_TRACK}/${DIRECTORY_START_SECTOR}`]);
286
+ let chainError = null;
287
+ const entries = [];
288
+
289
+ for (const r of records) {
290
+ const reasons = [];
291
+ let firstTrack = null;
292
+ let firstSector = null;
293
+
294
+ if (r.entryFailed) {
295
+ if (r.salvagedFirstTrack !== undefined && r.salvagedFirstTrack !== null) {
296
+ // A concise reason -- the "first track/sector ... outside the image"
297
+ // reason below already names the salvaged value, so this only notes
298
+ // WHY the entry lookup itself never printed a T/S: line, without
299
+ // repeating the full raw transcript (ANSI colour codes and all).
300
+ reasons.push("c1541 could not read this entry's own directory record at all -- see the geometry reason below");
301
+ firstTrack = r.salvagedFirstTrack;
302
+ firstSector = r.salvagedFirstSector;
303
+ } else {
304
+ reasons.push(`c1541 could not resolve this entry's directory record: ${stripAnsi(r.reason)}`);
305
+ }
306
+ } else {
307
+ firstTrack = r.firstTrack;
308
+ firstSector = r.firstSector;
309
+ }
310
+
311
+ if (r.blocks === 0) reasons.push("block count is 0");
312
+
313
+ if (firstTrack !== null) {
314
+ const spt = sectorsPerTrack(firstTrack);
315
+ const inGeometry = spt !== null && firstSector !== null && firstSector >= 0 && firstSector < spt;
316
+ if (!inGeometry) {
317
+ reasons.push(`first track/sector ${firstTrack}/${firstSector} is outside the image`);
318
+ } else {
319
+ const allocated = bamAllocated.get(firstTrack);
320
+ if (allocated && !allocated.has(firstSector)) {
321
+ reasons.push(
322
+ `first sector ${firstTrack}/${firstSector} is reported free by the allocation map -- the file cannot really start there`,
323
+ );
324
+ }
325
+ const key = `${firstTrack}/${firstSector}`;
326
+ if (visitedFirstTS.has(key) && chainError === null) {
327
+ chainError = `two entries claim the same first track/sector ${key} -- a fabricated or corrupted directory record`;
328
+ }
329
+ visitedFirstTS.add(key);
330
+ }
331
+ }
332
+
333
+ if (!r.entryFailed && r.nextDirTrack !== null && r.nextDirTrack !== 0) {
334
+ const nextKey = `${r.nextDirTrack}/${r.nextDirSector}`;
335
+ if (visitedNextDir.has(nextKey) && chainError === null) {
336
+ chainError = `directory chain revisited ${nextKey} -- stopped to avoid an infinite loop (self-referential or cyclic next-sector pointer)`;
337
+ }
338
+ visitedNextDir.add(nextKey);
339
+ }
340
+
341
+ entries.push({
342
+ name: r.name,
343
+ blocks: r.blocks,
344
+ first_track: firstTrack,
345
+ first_sector: firstSector,
346
+ suspicious: reasons.length > 0,
347
+ suspicious_reasons: reasons,
348
+ });
349
+ }
350
+
351
+ return { entries, chain_error: chainError };
352
+ }
353
+
354
+ /** Reads back the produced listing file for an `ok:true` seam response's
355
+ * first result -- mirrors `augmentEntryResponse()`'s own read-back
356
+ * convention above (display-purposed parsing over an already-decided
357
+ * response, never a second oracle). Returns `""` if the file cannot be
358
+ * read. */
359
+ function readSeamOutputText(response) {
360
+ const path = response.results?.[0]?.path;
361
+ if (!path) return "";
362
+ try {
363
+ return readFileSync(path, "utf8");
364
+ } catch {
365
+ return "";
366
+ }
367
+ }
368
+
369
+ /** Composes `c1541.dir` + `c1541.bam` + one `c1541.entry` call per name into
370
+ * the pure `auditEntries()` detector above. When ANY composed seam call
371
+ * fails outright (`ok:false`), the whole audit fails with that call's own
372
+ * refusal reported verbatim -- there is no byte-level fallback:
373
+ * never read the image bytes locally when the seam is unreachable, because
374
+ * a fallback that works on the developer's own host and silently fails
375
+ * inside a container is the exact failure this seam exists to remove.
376
+ */
377
+ async function runAudit(argv) {
378
+ const o = parseOpts(argv);
379
+ if (!o.image) die(`usage: audit --image <path.d64> [--out-dir <dir>] [--json]`);
380
+
381
+ const imageAbs = resolve(o.image);
382
+ const outDirAbs = o.outDir ? resolve(o.outDir) : dirname(imageAbs);
383
+ const repoRoot = commonAncestorDir(dirname(imageAbs), outDirAbs);
384
+ const baseArgs = { image: toRel(repoRoot, imageAbs) };
385
+ if (o.outDir) baseArgs.outDir = toRel(repoRoot, outDirAbs);
386
+
387
+ const dirResp = await invokeSeam("c1541.dir", baseArgs, repoRoot);
388
+ if (!dirResp.ok) {
389
+ report(dirResp, o);
390
+ process.exit(1);
391
+ }
392
+ const names = parseDirListing(readSeamOutputText(dirResp));
393
+
394
+ const bamResp = await invokeSeam("c1541.bam", baseArgs, repoRoot);
395
+ if (!bamResp.ok) {
396
+ report(bamResp, o);
397
+ process.exit(1);
398
+ }
399
+ const bamAllocated = parseBamAllocation(readSeamOutputText(bamResp));
400
+
401
+ const records = [];
402
+ for (const { name, blocks } of names) {
403
+ const entryResp = await invokeSeam("c1541.entry", { ...baseArgs, name }, repoRoot);
404
+ if (entryResp.ok) {
405
+ const fields = parseEntryFields(readSeamOutputText(entryResp));
406
+ records.push(fields ? { name, blocks, ...fields } : { name, blocks, entryFailed: true, reason: "entry response carried no T/S: line" });
407
+ } else {
408
+ const salvaged = salvageFirstTsFromRefusal(entryResp.message);
409
+ records.push({
410
+ name,
411
+ blocks,
412
+ entryFailed: true,
413
+ reason: entryResp.message,
414
+ salvagedFirstTrack: salvaged?.firstTrack ?? null,
415
+ salvagedFirstSector: salvaged?.firstSector ?? null,
416
+ });
417
+ }
418
+ }
419
+
420
+ const result = auditEntries(records, { bamAllocated });
421
+ if (o.json) {
422
+ console.log(JSON.stringify(result));
423
+ } else {
424
+ for (const e of result.entries) {
425
+ const flag = e.suspicious ? ` SUSPICIOUS: ${e.suspicious_reasons.join("; ")}` : "";
426
+ console.log(`"${e.name}" first=${e.first_track}/${e.first_sector} blocks=${e.blocks}${flag}`);
427
+ }
428
+ if (result.chain_error) console.log(`chain error: ${result.chain_error}`);
429
+ }
430
+ process.exit(0);
431
+ }
432
+
433
+ /** `entry`/`chain`/`read` all require a CBM name -- the seam itself refuses
434
+ * a request missing `name` for these, but failing fast here gives a plain
435
+ * usage message rather than a round-trip to the seam for a mistake this
436
+ * script can already see. */
437
+ const VERBS_REQUIRING_NAME = new Set(["entry", "chain", "read"]);
438
+
439
+ /**
440
+ * Runs one c1541 capability against `--image` (required), `--name`
441
+ * (required for entry/chain/read, ignored for bam/dir), and `--out-dir`
442
+ * (optional, defaults to the seam's own dirname(image) default exactly as
443
+ * acme.build's own outDir default does). Prints the seam's response
444
+ * verbatim as one line of JSON when `--json` is given -- the response IS
445
+ * the reportable shape (`{ ok, tool, exitStatus, results, stderrTail }` /
446
+ * `{ ok: false, message }`), so no reshaping happens here except for
447
+ * `entry`, whose own listing file this script additionally parses for the
448
+ * first track/sector (see augmentEntryResponse() below).
449
+ */
450
+ async function runCapability(verb, argv) {
451
+ const tool = VERB_TO_TOOL[verb];
452
+ if (!tool) die(`unknown c1541 capability: ${verb}`);
453
+
454
+ const o = parseOpts(argv);
455
+ if (!o.image) die(`usage: ${verb} --image <path.d64> [--name <cbm-name>] [--out-dir <dir>] [--json]`);
456
+ if (VERBS_REQUIRING_NAME.has(verb) && !o.name) {
457
+ die(`usage: ${verb} --image <path.d64> --name <cbm-name> [--out-dir <dir>] [--json]`);
458
+ }
459
+
460
+ const imageAbs = resolve(o.image);
461
+ const outDirAbs = o.outDir ? resolve(o.outDir) : dirname(imageAbs);
462
+
463
+ // Workspace-relative request construction (mirrors acme.mjs's own build()):
464
+ // the root for THIS invocation is the smallest ancestor containing both
465
+ // the image and the output directory.
466
+ const repoRoot = commonAncestorDir(dirname(imageAbs), outDirAbs);
467
+ const args = { image: toRel(repoRoot, imageAbs) };
468
+ if (o.outDir) args.outDir = toRel(repoRoot, outDirAbs);
469
+ if (o.name !== undefined) args.name = o.name;
470
+
471
+ let response = await invokeSeam(tool, args, repoRoot);
472
+ if (verb === "entry") response = augmentEntryResponse(response);
473
+ report(response, o);
474
+ process.exit(response.ok ? 0 : 1);
475
+ }
476
+
477
+ /** Reads `c1541.entry`'s own listing file back and parses its `T/S:
478
+ * <t>/<s>, <n> blocks` line (MEASURED against the committed fixture,
479
+ * fixtures/c1541/README.md) into numeric `firstTrack`/`firstSector` fields
480
+ * on the response -- never a second copy of the classifier's own oracle;
481
+ * this parses purely for DISPLAY, after the seam has already decided
482
+ * ok/not-ok. A response the seam reported as a failure, or a listing this
483
+ * parse cannot make sense of, is returned UNCHANGED -- never a thrown
484
+ * error and never a fabricated track/sector pair. */
485
+ function augmentEntryResponse(response) {
486
+ if (!response.ok) return response;
487
+ const listingPath = response.results?.[0]?.path;
488
+ if (!listingPath) return response;
489
+ let text;
490
+ try {
491
+ text = readFileSync(listingPath, "utf8");
492
+ } catch {
493
+ return response;
494
+ }
495
+ const m = text.match(/T\/S:\s*(\d+)\/(\d+),\s*(\d+)\s*blocks/);
496
+ if (!m) return response;
497
+ return { ...response, firstTrack: Number(m[1]), firstSector: Number(m[2]) };
498
+ }
499
+
500
+ function report(response, { json }) {
501
+ if (json) {
502
+ console.log(JSON.stringify(response));
503
+ return;
504
+ }
505
+ if (!response.ok) {
506
+ console.error(`c1541 call FAILED: ${response.message}`);
507
+ return;
508
+ }
509
+ for (const r of response.results ?? []) {
510
+ console.log(`${r.path} (${r.byteLength} bytes, sha256 ${r.sha256})`);
511
+ }
512
+ if (response.firstTrack !== undefined) {
513
+ console.log(`first track/sector: ${response.firstTrack}/${response.firstSector}`);
514
+ }
515
+ }
516
+
517
+ // ------------------------------------------------------------------ options
518
+
519
+ function parseOpts(argv) {
520
+ const o = { json: false };
521
+ for (let i = 0; i < argv.length; i++) {
522
+ const a = argv[i];
523
+ if (a === "--json") o.json = true;
524
+ else if (a === "--image") o.image = argv[++i];
525
+ else if (a === "--name") o.name = argv[++i];
526
+ else if (a === "--out-dir") o.outDir = argv[++i];
527
+ }
528
+ return o;
529
+ }
530
+
531
+ // --------------------------------------------------------------------- main
532
+ //
533
+ // The CLI dispatch below MUST be guarded to run only when this file is the actual
534
+ // entry point, not merely imported -- c1541.test.mjs (this plan) imports
535
+ // auditEntries()/parseDirListing()/etc. as a pure library, and an unguarded
536
+ // dispatch would run this section with the TEST RUNNER's own process.argv
537
+ // (no recognised command) on every import, printing the usage banner and
538
+ // calling process.exit(0) before a single test() call ever registers.
539
+ // Mirrors the deleted skill-side pure-parse module's own entry-point guard
540
+ // (`resolve(process.argv[1]) === fileURLToPath(import.meta.url)`) rather
541
+ // than inventing a second shape.
542
+
543
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
544
+ const [cmd, ...rest] = process.argv.slice(2);
545
+ const VERBS = {
546
+ bam: (argv) => runCapability("bam", argv),
547
+ dir: (argv) => runCapability("dir", argv),
548
+ entry: (argv) => runCapability("entry", argv),
549
+ chain: (argv) => runCapability("chain", argv),
550
+ read: (argv) => runCapability("read", argv),
551
+ audit: (argv) => runAudit(argv),
552
+ };
553
+ if (!cmd || !VERBS[cmd]) {
554
+ console.log(`usage: node ${selfPath()} <command> [options]
555
+
556
+ bam --image <path.d64> [--out-dir <dir>] [--json] block allocation map
557
+ dir --image <path.d64> [--out-dir <dir>] [--json] directory listing
558
+ entry --image <path.d64> --name <cbm-name> [--out-dir <dir>] [--json] one directory entry's raw fields
559
+ chain --image <path.d64> --name <cbm-name> [--out-dir <dir>] [--json] a named file's sector chain
560
+ read --image <path.d64> --name <cbm-name> [--out-dir <dir>] [--json] extract a named file's bytes
561
+ audit --image <path.d64> [--out-dir <dir>] [--json] find fabricated/corrupted directory entries
562
+
563
+ Read-only -- no -format/-write/-bwrite/-delete verb is reachable from this script.
564
+
565
+ options: --image PATH --name CBM-NAME --out-dir DIR --json`);
566
+ process.exit(cmd ? 1 : 0);
567
+ }
568
+ await VERBS[cmd](rest);
569
+ }