@henols/c64-re-tools 0.1.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 (35) hide show
  1. package/README.md +61 -0
  2. package/bin/cli.mjs +226 -0
  3. package/package.json +53 -0
  4. package/skills/acme-build/SKILL.md +224 -0
  5. package/skills/acme-build/scripts/acme.mjs +263 -0
  6. package/skills/acme-build/template.a +39 -0
  7. package/skills/c64-memory-mapping/SKILL.md +199 -0
  8. package/skills/c64-memory-mapping/memmap.json +8800 -0
  9. package/skills/c64-memory-mapping/scripts/driver.mjs +553 -0
  10. package/skills/c64-program-recon/SKILL.md +172 -0
  11. package/skills/c64-program-recon/references/control-flow.md +174 -0
  12. package/skills/c64-program-recon/references/graphics.md +73 -0
  13. package/skills/c64-program-recon/references/observation-hazards.md +118 -0
  14. package/skills/c64-program-recon/references/reconstruction.md +128 -0
  15. package/skills/c64-program-recon/references/sound-and-input.md +68 -0
  16. package/skills/c64-program-recon/references/tool-selection.md +55 -0
  17. package/skills/c64-program-recon/scripts/derive.mjs +364 -0
  18. package/skills/c64-program-recon/templates/memory-map.template.md +62 -0
  19. package/skills/c64-provenance-diff/SKILL.md +257 -0
  20. package/skills/c64-provenance-diff/scripts/diff-images.mjs +981 -0
  21. package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +665 -0
  22. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +383 -0
  23. package/skills/c64-ram-capture/SKILL.md +306 -0
  24. package/skills/c64-ram-capture/scripts/compare.mjs +258 -0
  25. package/skills/c64-ram-capture/scripts/d64-parse.mjs +243 -0
  26. package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +243 -0
  27. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +317 -0
  28. package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +133 -0
  29. package/skills/c64-ram-capture/scripts/project-paths.mjs +81 -0
  30. package/skills/c64-ram-capture/scripts/releases.mjs +109 -0
  31. package/skills/c64-ram-capture/scripts/test-corpus.mjs +75 -0
  32. package/skills/c64-ram-capture/scripts/watch-loads.mjs +575 -0
  33. package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +339 -0
  34. package/skills/c64-ram-capture/templates/capture-record.template.md +59 -0
  35. package/skills/vice-wedge-triage/SKILL.md +149 -0
@@ -0,0 +1,575 @@
1
+ #!/usr/bin/env node
2
+ // The on-demand-load detector's pure logic (01-04 Task 1). Every function
3
+ // here takes already-fetched data as an argument or reads a committed file
4
+ // -- nothing in this module contacts the emulator, ever. The single
5
+ // permitted route to the emulator is the executing agent's own
6
+ // `mcp__plugin_c64-re-tools_vice__*` tool calls (see .claude/CLAUDE.md "Emulator Access");
7
+ // arming, resuming, polling, disassembling and reading memory all happen in
8
+ // the agent's own turn, and the observations land in a committed hit-log
9
+ // JSON (`recovery/<release>/dumps/<release>-loading-hits.json`) that this
10
+ // module reads back. The import-purity guard test in
11
+ // tools/watch-loads.test.mjs is the mechanical statement of that boundary:
12
+ // every import specifier in this file resolves to a `node:` built-in or a
13
+ // sibling file inside tools/, so this module cannot acquire an outside
14
+ // dependency without the guard failing.
15
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
16
+ import { createHash } from "node:crypto";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname, join, resolve, relative } from "node:path";
19
+
20
+ import { loadRegistry, release as getReleaseEntry, upsertRelease } from "./releases.mjs";
21
+ import { projectRoot, dataRoot } from "./project-paths.mjs";
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+ const REPO_ROOT = projectRoot();
25
+ const RECOVERY_DIR = dataRoot();
26
+
27
+ const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
28
+
29
+ function rel(p) {
30
+ return relative(REPO_ROOT, p);
31
+ }
32
+
33
+ // ------------------------------------------------------------ address math
34
+
35
+ /** Parse `"$08B1"`, `"0x8b1"`, a decimal string, or a number into an integer. */
36
+ export function addrNum(a) {
37
+ if (typeof a === "number") return a;
38
+ if (typeof a === "string") {
39
+ const s = a.trim();
40
+ if (s.startsWith("$")) return parseInt(s.slice(1), 16);
41
+ if (/^0x/i.test(s)) return parseInt(s, 16);
42
+ const n = Number(s);
43
+ if (Number.isNaN(n)) throw new Error(`addrNum: cannot parse address from "${a}"`);
44
+ return n;
45
+ }
46
+ throw new Error(`addrNum: cannot parse address from ${JSON.stringify(a)}`);
47
+ }
48
+
49
+ /** Format an integer as a canonical `$XXXX` 4-hex-digit address string. */
50
+ export function hex4(n) {
51
+ return "$" + (n & 0xffff).toString(16).toUpperCase().padStart(4, "0");
52
+ }
53
+
54
+ // -------------------------------------------------------------- hit-log I/O
55
+
56
+ function hitLogPath(releaseId) {
57
+ return join(RECOVERY_DIR, releaseId, "dumps", `${releaseId}-loading-hits.json`);
58
+ }
59
+
60
+ /** Read and JSON.parse a release's committed boundary hit-log artifact. */
61
+ export function readHitLog(releaseId) {
62
+ const p = hitLogPath(releaseId);
63
+ if (!existsSync(p)) {
64
+ throw new Error(`readHitLog: no hit log at ${rel(p)} for release "${releaseId}"`);
65
+ }
66
+ return JSON.parse(readFileSync(p, "utf8"));
67
+ }
68
+
69
+ /**
70
+ * Structural validation of a hit-log's boundary-artifact shape: every
71
+ * `armed` entry must carry the `checkpoint_num` the arming call returned,
72
+ * and `teardown.checkpoints_remaining` must be present -- both are the
73
+ * "the delete call's own word is never the proof" invariant made mechanical.
74
+ */
75
+ export function validateHitLog(log) {
76
+ const errors = [];
77
+ for (const a of log.armed ?? []) {
78
+ if (a.checkpoint_num === undefined || a.checkpoint_num === null) {
79
+ errors.push(`armed sentinel "${a.name}" is missing checkpoint_num`);
80
+ }
81
+ }
82
+ if (!log.teardown || log.teardown.checkpoints_remaining === undefined || log.teardown.checkpoints_remaining === null) {
83
+ errors.push("teardown.checkpoints_remaining is not recorded");
84
+ }
85
+ return { ok: errors.length === 0, errors };
86
+ }
87
+
88
+ // --------------------------------------------------------------- WATCH_SET
89
+
90
+ function loadManifestForRelease(rel) {
91
+ const dump = (rel.dumps ?? []).find((d) => d.label === "run1");
92
+ if (!dump || !dump.range_manifest) {
93
+ throw new Error(`WATCH_SET: release "${rel.id}" has no run1 dump with a range_manifest recorded`);
94
+ }
95
+ const manifestPath = join(REPO_ROOT, dump.range_manifest);
96
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
97
+ }
98
+
99
+ /**
100
+ * Resolve the two-tier sentinel set for one release from registry data and
101
+ * that release's run1 range manifest -- never hardcoded. `stopping` tier is
102
+ * one sentinel per `loader_ranges` entry (the loader-reentry sentinels that
103
+ * must never fire again after the dump point per D-10). `counting` tier is
104
+ * one sentinel per never-populated range in the run1 manifest, plus one
105
+ * register sentinel on CIA2 port A ($DD00), which carries both the VIC
106
+ * bank-select bits and the bit-banged serial-bus lines a KERNAL-bypassing
107
+ * raw-sector loader toggles directly -- the primary on-demand-load sentinel
108
+ * precisely because such a loader leaves no KERNAL vector activity to watch
109
+ * instead.
110
+ */
111
+ export function WATCH_SET(releaseId, { registry, manifest } = {}) {
112
+ const reg = registry ?? loadRegistry();
113
+ const rel = reg.releases.find((r) => r.id === releaseId);
114
+ if (!rel) {
115
+ throw new Error(`WATCH_SET: unknown release "${releaseId}" -- known releases: ${reg.releases.map((r) => r.id).join(", ")}`);
116
+ }
117
+ const loaderRanges = rel.loader_ranges ?? [];
118
+ if (loaderRanges.length === 0) {
119
+ throw new Error(
120
+ `WATCH_SET: release "${releaseId}" has no loader_ranges recorded -- derive and record loader_ranges ` +
121
+ `(earn them live against a disassembly, per plan Task 2 step 1) before resolving a watch set; a set ` +
122
+ `with no re-entry sentinel in it is not a two-tier set`
123
+ );
124
+ }
125
+ const map = manifest ?? loadManifestForRelease(rel);
126
+ const neverPopulated = (map.ranges ?? []).filter((r) => r.kind === "unused");
127
+
128
+ const sentinels = [];
129
+ for (const lr of loaderRanges) {
130
+ const start = addrNum(lr.start);
131
+ const end = addrNum(lr.end);
132
+ sentinels.push({
133
+ name: `loader:${hex4(start)}-${hex4(end)}`,
134
+ kind: "loader-reentry",
135
+ tier: "stopping",
136
+ type: "exec",
137
+ start,
138
+ end,
139
+ reason: lr.note ?? "loader-reentry range: must never fire again after the dump point (D-10)",
140
+ evidence: lr.evidence ?? "",
141
+ });
142
+ }
143
+ for (const nr of neverPopulated) {
144
+ const start = addrNum(nr.start);
145
+ const end = addrNum(nr.end);
146
+ sentinels.push({
147
+ name: `unused:${hex4(start)}-${hex4(end)}`,
148
+ kind: "never-populated",
149
+ tier: "counting",
150
+ type: "write",
151
+ start,
152
+ end,
153
+ reason: nr.note ?? "never-populated range in the run1 capture -- any write during gameplay is a candidate",
154
+ evidence: nr.note ?? "",
155
+ });
156
+ }
157
+ sentinels.push({
158
+ name: "reg:$DD00",
159
+ kind: "register",
160
+ tier: "counting",
161
+ type: "write",
162
+ start: 0xdd00,
163
+ end: 0xdd00,
164
+ reason:
165
+ "CIA2 port A -- VIC-II bank-select bits (0-1) plus the bit-banged serial-bus lines (ATN/CLOCK/DATA, bits 3-5) " +
166
+ "a KERNAL-bypassing raw-sector loader toggles directly; the primary on-demand-load sentinel because such a " +
167
+ "loader leaves no KERNAL vector activity to watch instead",
168
+ evidence:
169
+ "c64-memory-mapping skill memmap: $DD00 bits 0-1 select the VIC bank (00=bank3 $C000-$FFFF ... 11=bank0 " +
170
+ "$0000-$3FFF); bits 3-5 are the serial bus ATN OUT/CLOCK OUT/DATA OUT lines",
171
+ });
172
+ return sentinels;
173
+ }
174
+
175
+ // ----------------------------------------------------------- attributeAddress
176
+
177
+ /**
178
+ * Resolve `addr` to exactly one sentinel's name. Validates the *whole*
179
+ * sentinel set for overlap/duplication on every call -- a configuration
180
+ * error is a property of the set, not of the one address being queried, so
181
+ * it must be caught regardless of which address happens to be asked about.
182
+ * Abutting ranges (one range's `end` immediately followed by the next
183
+ * range's `start`) are never flagged: they are adjacent, not overlapping.
184
+ */
185
+ export function attributeAddress(addr, sentinels) {
186
+ const a = addrNum(addr);
187
+ for (let i = 0; i < sentinels.length; i++) {
188
+ for (let j = i + 1; j < sentinels.length; j++) {
189
+ const s1 = sentinels[i];
190
+ const s2 = sentinels[j];
191
+ const s1s = addrNum(s1.start);
192
+ const s1e = addrNum(s1.end);
193
+ const s2s = addrNum(s2.start);
194
+ const s2e = addrNum(s2.end);
195
+ if (s1s <= s2e && s2s <= s1e) {
196
+ throw new Error(
197
+ `attributeAddress: overlapping or duplicate sentinel ranges "${s1.name}" (${hex4(s1s)}-${hex4(s1e)}) and ` +
198
+ `"${s2.name}" (${hex4(s2s)}-${hex4(s2e)}) -- refusing to resolve a winner by precedence`
199
+ );
200
+ }
201
+ }
202
+ }
203
+ const matches = sentinels.filter((s) => a >= addrNum(s.start) && a <= addrNum(s.end));
204
+ if (matches.length === 0) {
205
+ return { matched: false, name: null, address: a };
206
+ }
207
+ return { matched: true, name: matches[0].name, address: a };
208
+ }
209
+
210
+ // ----------------------------------------------------------------- reportHits
211
+
212
+ /**
213
+ * Total order over a hit log: cycle ascending, then address ascending, then
214
+ * sentinel name ascending -- so two hits sharing both cycle and address
215
+ * still have one defined position, and re-reporting an unchanged log is
216
+ * byte-identical. Accepts either a bare array of hit records or the whole
217
+ * boundary-artifact object (reading its `.hits` field); an absent/empty
218
+ * `hits` array reports as an empty result rather than throwing.
219
+ */
220
+ export function reportHits(hitLog) {
221
+ const hits = Array.isArray(hitLog) ? hitLog : hitLog?.hits ?? [];
222
+ return [...hits].sort((a, b) => {
223
+ const ca = a.cycle ?? 0;
224
+ const cb = b.cycle ?? 0;
225
+ if (ca !== cb) return ca - cb;
226
+ const aa = addrNum(a.address);
227
+ const ab = addrNum(b.address);
228
+ if (aa !== ab) return aa - ab;
229
+ return String(a.sentinel ?? "").localeCompare(String(b.sentinel ?? ""));
230
+ });
231
+ }
232
+
233
+ // ------------------------------------------------------------------ idleGate
234
+
235
+ /**
236
+ * The mechanical half of the idle check. Passes only when every
237
+ * `stopping`-tier sentinel recorded exactly zero hits AND the recorded
238
+ * `cycles_advanced` is greater than zero -- a machine that did not execute
239
+ * proves nothing, whatever the hit counts say. Otherwise names the
240
+ * violating sentinels with their counts.
241
+ */
242
+ export function idleGate(calibration) {
243
+ const cyclesAdvanced = calibration?.cycles_advanced;
244
+ const sentinels = calibration?.sentinels ?? [];
245
+ const violations = sentinels
246
+ .filter((s) => s.tier === "stopping" && s.hits !== 0)
247
+ .map((s) => ({ name: s.name, hits: s.hits }));
248
+ const cyclesOk = typeof cyclesAdvanced === "number" && cyclesAdvanced > 0;
249
+ const reasons = [];
250
+ if (!cyclesOk) {
251
+ reasons.push(`cycles_advanced (${cyclesAdvanced}) is not greater than zero -- a machine that did not execute proves nothing`);
252
+ }
253
+ if (violations.length > 0) {
254
+ reasons.push(
255
+ `stopping-tier sentinel(s) recorded non-zero idle hits: ${violations.map((v) => `${v.name}=${v.hits}`).join(", ")}`
256
+ );
257
+ }
258
+ return { ok: cyclesOk && violations.length === 0, cycles_advanced: cyclesAdvanced, violations, reasons };
259
+ }
260
+
261
+ // ---------------------------------------------------------------- classifyHit
262
+
263
+ /**
264
+ * Returns `unattributed` unless the hit record carries a non-empty program
265
+ * counter, backtrace and disassembly -- only then does it return the
266
+ * recorded classification (`gameplay-write` or `load-candidate`). An
267
+ * unattributed hit is reported as exactly that, never as a bare count.
268
+ */
269
+ export function classifyHit(hit) {
270
+ const hasPc = hit?.pc !== undefined && hit?.pc !== null && hit?.pc !== "";
271
+ const hasBacktrace = Array.isArray(hit?.backtrace) ? hit.backtrace.length > 0 : !!hit?.backtrace;
272
+ const hasDisassembly = !!hit?.disassembly;
273
+ if (!hasPc || !hasBacktrace || !hasDisassembly) return "unattributed";
274
+ if (hit.classification === "gameplay-write" || hit.classification === "load-candidate") {
275
+ return hit.classification;
276
+ }
277
+ return "unattributed";
278
+ }
279
+
280
+ // ------------------------------------------------------------ screenSignature
281
+
282
+ /**
283
+ * Hash the 1000 bytes of screen matrix the agent read (hex string in,
284
+ * digest out) together with the sprite-enable register value. Screenshots
285
+ * are human-audit artifacts and are never hashed here or anywhere else in
286
+ * this project: an encoder can emit different bytes for pixel-identical
287
+ * images, and this project deliberately installs no image-decoding library
288
+ * (D-18) to decode-then-hash instead.
289
+ */
290
+ export function screenSignature(screenMatrixHex, spriteEnable) {
291
+ const buf = Buffer.from(screenMatrixHex, "hex");
292
+ if (buf.length !== 1000) {
293
+ throw new Error(`screenSignature: expected 1000 bytes of screen matrix hex, got ${buf.length} bytes`);
294
+ }
295
+ const digest = createHash("sha256").update(buf).digest("hex");
296
+ return { digest, sprite_enable: spriteEnable ?? null };
297
+ }
298
+
299
+ // --------------------------------------------------------------- recordWatchSet
300
+
301
+ /** Persist a resolved sentinel set into the registry under `watch_set`. */
302
+ export function recordWatchSet(releaseId, watchSet) {
303
+ return upsertRelease(releaseId, (r) => ({ ...r, watch_set: watchSet }));
304
+ }
305
+
306
+ // ----------------------------------------------------------------- renderLoading
307
+
308
+ function escapeCell(text) {
309
+ return String(text ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
310
+ }
311
+
312
+ function fmtRange(s) {
313
+ return `${hex4(addrNum(s.start))}-${hex4(addrNum(s.end))}`;
314
+ }
315
+
316
+ function renderReleaseSection(id, log) {
317
+ const hits = reportHits(log);
318
+ const count = hits.length;
319
+ let s = `## Release: ${id}\n\n`;
320
+
321
+ s += `**Load-event count:**\n\n${count}\n\n`;
322
+
323
+ if (log.run_status === "blocked") {
324
+ if (count === 0) {
325
+ s += `> **⚠ THIS IS NOT AN EVIDENCED ZERO.** The count above is \`0\` only because no live ` +
326
+ `emulator work reached completion for this release this run -- it is a bare absence of ` +
327
+ `attempted measurement, not a null result earned by an idle calibration on a machine proven ` +
328
+ `to have executed. ${log.run_status_note ?? ""}\n\n`;
329
+ } else {
330
+ s += `> **⚠ THIS IS A PARTIAL RESULT, NOT A COMPLETED COVERAGE CLAIM.** The count above (\`${count}\`) ` +
331
+ `reflects genuinely attributed hits from the portion of the play-through that did complete before this ` +
332
+ `run was blocked -- it is not evidence that no further load events exist beyond what was reached. ` +
333
+ `${log.run_status_note ?? ""}\n\n`;
334
+ }
335
+ }
336
+
337
+ s += `**Route:** the executing agent's own \`mcp__plugin_c64-re-tools_vice__*\` tool calls -- machine ${log.machine ?? "unknown"}, ` +
338
+ `video standard ${log.video_standard ?? "unknown"}, VICE server version ${log.vice_version ?? "unknown"}.\n\n`;
339
+
340
+ s += `### Armed set\n\n`;
341
+ s += "| Sentinel | Kind | Tier | Type | Range | Reason | Evidence | Idle hits |\n";
342
+ s += "|---|---|---|---|---|---|---|---|\n";
343
+ for (const a of log.armed ?? []) {
344
+ s += `| ${a.name} | ${a.kind} | ${a.tier} | ${a.type} | ${fmtRange(a)} | ${escapeCell(a.reason)} | ${escapeCell(a.evidence)} | ${a.idle_hits ?? ""} |\n`;
345
+ }
346
+ s += "\n";
347
+
348
+ s += `### Idle calibration\n\n`;
349
+ const cal = log.idle_calibration ?? {};
350
+ s += `Cycles advanced during the no-input idle window: **${cal.cycles_advanced ?? "unrecorded"}**.\n\n`;
351
+ s += "| Sentinel | Tier | Range | Idle hits |\n|---|---|---|---|\n";
352
+ for (const sn of cal.sentinels ?? []) {
353
+ s += `| ${sn.name} | ${sn.tier} | ${sn.start !== undefined ? fmtRange(sn) : ""} | ${sn.hits} |\n`;
354
+ }
355
+ s += "\n";
356
+
357
+ s += `### Counting-tier probe\n\n`;
358
+ const probe = log.counting_tier_probe ?? {};
359
+ s += `Observed hit count: ${probe.hit_count ?? "unrecorded"}. Execution stopped during the probe: ${probe.execution_stopped ?? "unrecorded"}.\n\n`;
360
+ if (probe.fallback_taken) {
361
+ s += `**Fallback taken:** the counting tier could not count without stopping. ${probe.fallback_note ?? ""}\n\n`;
362
+ }
363
+
364
+ s += `### Coverage reached\n\n`;
365
+ s += "| Milestone | Reached | Screen signature | Cycles advanced | Retries | Screenshot |\n|---|---|---|---|---|---|\n";
366
+ for (const m of log.milestones ?? []) {
367
+ s += `| ${m.name} | ${m.reached ? "yes" : "no"} | ${m.screen_signature ?? ""} | ${m.cycles_advanced ?? ""} | ${m.retries ?? 0} | ${m.screenshot ?? ""} |\n`;
368
+ }
369
+ s += "\n";
370
+
371
+ s += `### States not reached\n\n`;
372
+ const notReachedMilestones = (log.milestones ?? []).filter((m) => !m.reached);
373
+ const scopeBoundary = log.scope_not_attempted ?? [];
374
+ if (notReachedMilestones.length === 0 && scopeBoundary.length === 0) {
375
+ s += "(nothing recorded as not reached -- if this looks wrong, the record is incomplete, not the coverage)\n\n";
376
+ } else {
377
+ for (const m of notReachedMilestones) {
378
+ s += `- **${m.name}**: not reached. ${m.not_reached_reason ?? ""}\n`;
379
+ }
380
+ for (const item of scopeBoundary) {
381
+ s += `- ${item}\n`;
382
+ }
383
+ s += "\n";
384
+ }
385
+
386
+ s += `### Attributed hits\n\n`;
387
+ if (hits.length === 0) {
388
+ s += "(no hits recorded above the idle floor)\n\n";
389
+ } else {
390
+ s += "| Cycle | Address | Sentinel | Tier | Classification | Evidence |\n|---|---|---|---|---|---|\n";
391
+ for (const h of hits) {
392
+ const cls = h.classification ?? classifyHit(h);
393
+ s += `| ${h.cycle} | ${h.address} | ${h.sentinel} | ${h.tier ?? ""} | ${cls} | ${escapeCell(h.disassembly ?? "")} |\n`;
394
+ }
395
+ s += "\n";
396
+ }
397
+
398
+ const loadCandidates = hits.filter((h) => (h.classification ?? classifyHit(h)) === "load-candidate");
399
+ s += `### Supplementary dumps\n\n`;
400
+ if (loadCandidates.length === 0) {
401
+ s += "None -- no hit was classified `load-candidate` for this release.\n\n";
402
+ } else {
403
+ for (const h of loadCandidates) {
404
+ s += `- Hit at ${h.address} (cycle ${h.cycle}): supplementary dump \`${h.supplementary_dump ?? "unrecorded"}\`, ` +
405
+ `registry ref \`${h.load_event_ref ?? "unrecorded"}\`. Reproducibility bar: a single capture, decided in ` +
406
+ `this plan because the claim is about an observed moment rather than a stable state -- if D-13 resolves to ` +
407
+ `absorbing loaded content into the canonical image, this region must be re-captured at the primary dumps' ` +
408
+ `three-run bar before Phase 4 treats it as a round-trip diff target.\n`;
409
+ }
410
+ s += "\n";
411
+ }
412
+
413
+ s += `### Hand-off to plan 02-02\n\n`;
414
+ s += "The registry's `watch_set` entries for this release are the re-armable specification: plan 02-02's own " +
415
+ "executing agent re-arms the same set by issuing the same `mcp__plugin_c64-re-tools_vice__vice_checkpoint_add` calls during Phase " +
416
+ "2's exhaustive all-chambers trace, and interprets what it observes with this module's pure `attributeAddress`, " +
417
+ "`reportHits` and `classifyHit` functions. This is a hand-off of data and procedure, not an executable -- plan " +
418
+ "02-02's own plan text should describe agent-performed arming with acceptance criteria over a committed record " +
419
+ "rather than over an exit code. A late hit there reopens this document.\n\n";
420
+
421
+ s += `### Input sequence notes\n\n`;
422
+ s += (log.input_notes ?? "(no input notes recorded)") + "\n\n";
423
+ s += "Per D-12 this is plain notes, not a `verify/scripts/` artifact -- VERIFY-01 in Phase 3 owns the real " +
424
+ "input-script format; these notes are a seed for it, not a pre-empting specification.\n\n";
425
+
426
+ s += `### Teardown proof\n\n`;
427
+ const teardown = log.teardown ?? {};
428
+ s += `Checkpoints remaining after teardown, from an explicit \`mcp__plugin_c64-re-tools_vice__vice_checkpoint_list\` enumeration: ` +
429
+ `**${teardown.checkpoints_remaining ?? "unrecorded"}** (enumerated at ${teardown.enumerated_at ?? "unrecorded"}).\n\n`;
430
+
431
+ if ((log.identity_changes ?? []).length > 0) {
432
+ s += `### Identity changes\n\n`;
433
+ for (const c of log.identity_changes) {
434
+ s += `- ${JSON.stringify(c)}\n`;
435
+ }
436
+ s += "\n";
437
+ }
438
+
439
+ return s;
440
+ }
441
+
442
+ /**
443
+ * Render `recovery/LOADING.md` from a list of `{ id, log }` entries, each
444
+ * `log` being one release's validated boundary hit-log artifact. Pure
445
+ * string templating over already-fetched data -- nothing here reads a file
446
+ * or contacts anything; the CLI `render` verb below is what reads the
447
+ * hit-log files and writes the result.
448
+ */
449
+ export function renderLoading(entries) {
450
+ let out = "# `recovery/LOADING.md` -- the on-demand-load detection record\n\n";
451
+ out +=
452
+ "This document is the absence-as-evidence record: per release, the armed set with its justification, the " +
453
+ "idle calibration result, the coverage reached with a mechanical arrival proof per milestone, the states not " +
454
+ "reached, the attributed hits, and the teardown enumeration. Every measurement below was fetched by the " +
455
+ "executing agent's own `mcp__plugin_c64-re-tools_vice__*` tool calls; `.claude/skills/c64-ram-capture/scripts/watch-loads.mjs` and `.claude/skills/c64-ram-capture/scripts/dump-artifacts.mjs` hold " +
456
+ "only the pure logic that resolves, attributes, orders and renders it -- neither module contacted the " +
457
+ "emulator.\n\n";
458
+ for (const { id, log } of entries) {
459
+ out += renderReleaseSection(id, log);
460
+ }
461
+ return out;
462
+ }
463
+
464
+ // -------------------------------------------------------------------- CLI
465
+
466
+ function optValue(rest, name) {
467
+ const i = rest.indexOf(`--${name}`);
468
+ return i === -1 ? undefined : rest[i + 1];
469
+ }
470
+
471
+ const VERBS = {
472
+ resolve(rest) {
473
+ const releaseId = optValue(rest, "release");
474
+ if (!releaseId) die("usage: resolve --release <id> [--json]");
475
+ const watchSet = WATCH_SET(releaseId);
476
+ recordWatchSet(releaseId, watchSet);
477
+ const result = { release: releaseId, count: watchSet.length, watch_set: watchSet };
478
+ if (rest.includes("--json")) {
479
+ console.log(JSON.stringify(result, null, 2));
480
+ } else {
481
+ console.log(`${releaseId}: resolved ${watchSet.length} sentinel(s)`);
482
+ for (const s of watchSet) console.log(` ${s.name} tier=${s.tier} type=${s.type} ${fmtRange(s)}`);
483
+ }
484
+ },
485
+
486
+ attribute(rest) {
487
+ const releaseId = optValue(rest, "release");
488
+ const addrArg = optValue(rest, "addr");
489
+ if (!releaseId || !addrArg) die("usage: attribute --release <id> --addr <address> [--json]");
490
+ const relEntry = getReleaseEntry(releaseId);
491
+ const sentinels = relEntry.watch_set && relEntry.watch_set.length ? relEntry.watch_set : WATCH_SET(releaseId);
492
+ const result = attributeAddress(addrArg, sentinels);
493
+ if (rest.includes("--json")) {
494
+ console.log(JSON.stringify(result, null, 2));
495
+ } else {
496
+ console.log(result.matched ? `${addrArg} -> ${result.name}` : `${addrArg} -> unmatched`);
497
+ }
498
+ },
499
+
500
+ report(rest) {
501
+ const releaseId = optValue(rest, "release");
502
+ if (!releaseId) die("usage: report --release <id> [--json]");
503
+ const log = readHitLog(releaseId);
504
+ const hits = reportHits(log);
505
+ const result = { release: releaseId, count: hits.length, hits };
506
+ if (rest.includes("--json")) {
507
+ console.log(JSON.stringify(result, null, 2));
508
+ } else {
509
+ console.log(`${releaseId}: ${hits.length} hit(s)`);
510
+ for (const h of hits) {
511
+ console.log(` cycle=${h.cycle} addr=${h.address} sentinel=${h.sentinel} classification=${h.classification ?? classifyHit(h)}`);
512
+ }
513
+ }
514
+ },
515
+
516
+ "check-idle"(rest) {
517
+ const releaseId = optValue(rest, "release");
518
+ if (!releaseId) die("usage: check-idle --release <id> [--json]");
519
+ const log = readHitLog(releaseId);
520
+ if (!log.idle_calibration) die(`check-idle: release "${releaseId}" hit log has no idle_calibration recorded`);
521
+ const gate = idleGate(log.idle_calibration);
522
+ const result = { release: releaseId, ...gate, sentinels: log.idle_calibration.sentinels ?? [] };
523
+ if (rest.includes("--json")) {
524
+ console.log(JSON.stringify(result, null, 2));
525
+ } else {
526
+ console.log(`${releaseId}: cycles_advanced=${gate.cycles_advanced} ok=${gate.ok}`);
527
+ for (const s of result.sentinels) console.log(` ${s.name}: tier=${s.tier} ${s.start !== undefined ? fmtRange(s) : ""} hits=${s.hits}`);
528
+ if (!gate.ok) for (const r of gate.reasons) console.error(` - ${r}`);
529
+ }
530
+ process.exitCode = gate.ok ? 0 : 1;
531
+ },
532
+
533
+ signature(rest) {
534
+ const hex = optValue(rest, "hex");
535
+ const spriteEnable = optValue(rest, "sprite-enable");
536
+ if (!hex) die("usage: signature --hex <1000-byte-hex> [--sprite-enable <n>] [--json]");
537
+ const result = screenSignature(hex, spriteEnable !== undefined ? Number(spriteEnable) : null);
538
+ if (rest.includes("--json")) {
539
+ console.log(JSON.stringify(result, null, 2));
540
+ } else {
541
+ console.log(`${result.digest} sprite_enable=${result.sprite_enable}`);
542
+ }
543
+ },
544
+
545
+ render(rest) {
546
+ const reg = loadRegistry();
547
+ const only = optValue(rest, "release");
548
+ const releaseIds = only ? [only] : reg.releases.map((r) => r.id);
549
+ const entries = [];
550
+ for (const id of releaseIds) {
551
+ const p = hitLogPath(id);
552
+ if (!existsSync(p)) continue;
553
+ entries.push({ id, log: JSON.parse(readFileSync(p, "utf8")) });
554
+ }
555
+ const markdown = renderLoading(entries);
556
+ const outPath = join(RECOVERY_DIR, "LOADING.md");
557
+ writeFileSync(outPath, markdown);
558
+ const result = { path: rel(outPath), releases: entries.map((e) => e.id) };
559
+ if (rest.includes("--json")) {
560
+ console.log(JSON.stringify(result, null, 2));
561
+ } else {
562
+ console.log(`wrote ${rel(outPath)} for releases: ${result.releases.join(", ")}`);
563
+ }
564
+ },
565
+ };
566
+
567
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
568
+ const [cmd, ...rest] = process.argv.slice(2);
569
+ if (!cmd || !VERBS[cmd]) {
570
+ console.log(`usage: node ${fileURLToPath(import.meta.url)} <resolve|attribute|report|check-idle|signature|render> [--release <id>] [--json]`);
571
+ process.exitCode = cmd ? 1 : 0;
572
+ } else {
573
+ VERBS[cmd](rest);
574
+ }
575
+ }