@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
@@ -5,9 +5,8 @@ description: Decide whether a VICE emulator that has stopped responding is genui
5
5
 
6
6
  # Triage a VICE that stopped moving
7
7
 
8
- **On the fork, four states look identical from outside; on stock, it is five, and the intuitive
9
- fix destroys a healthy machine in more than one of them.** Work the order below. Do not start with
10
- a remedy.
8
+ **Five states look identical from outside, and the intuitive fix destroys a healthy machine in
9
+ more than one of them.** Work the order below. Do not start with a remedy.
11
10
 
12
11
  | State | Cheap tell | Safe action |
13
12
  |---|---|---|
@@ -15,22 +14,20 @@ a remedy.
15
14
  | **Stopped itself at your checkpoint** | An armed *stopping* checkpoint on the live IRQ path | Delete/disable the checkpoint. **Never recycle** |
16
15
  | **Crashed and respawned** | The proxy raises epoch drift on the next forwarded call | Void the run, reboot from scratch. Already handled for you |
17
16
  | **Genuinely wedged** | Two consecutive cycle brackets read exactly `0` | `vice_recycle` with a reason, as a last resort |
18
- | **Monitor held elsewhere (stock only)** | A second client already holds this instance's single binary-monitor socket | Find the other holder. **Never recycle** — the instance is healthy, just claimed elsewhere |
17
+ | **Contended between the two channels** | Two brackets would read zero, but `evidence.channelContention.held` is true and `bracketsRun` is 0 | Wait for the other channel's operation to finish, or find its holder. **Never recycle** — the instance is healthy and is answering |
18
+ | **Monitor held elsewhere** | A second client already holds this instance's single binary-monitor socket | Find the other holder. **Never recycle** — the instance is healthy, just claimed elsewhere |
19
19
 
20
20
  ```
21
21
  mcp__plugin_c64-re-tools_vice__vice_diagnose # one call, no arguments, answers which state it is
22
22
  ```
23
23
 
24
- `vice_diagnose`'s verdict vocabulary differs by backend, because stock VICE's binary monitor
25
- services exactly one client and the fork's non-pausing `vice_ping` has no stock equivalent (see
26
- `docs/stock-vice-parity.md` D-03 for the full reasoning). The fork answers `restarted`,
27
- `checkpoint_trap`, `wedged`, `stale_read_path`, `live`; stock answers `restarted`,
28
- `checkpoint_trap`, `wedged`, `monitor_held_elsewhere`, `live`. Read the tool's own schema for the
29
- exact contract on whichever backend is active as of **07-16 (WR-07)** this instruction is
30
- finally sound: `tools/list`'s advertised stock schema is the corrected stock manifest entry, not
31
- the fork's synthetic literal it was silently overwritten by before. Stock `vice_diagnose` can also
32
- answer a `diagnosis_unavailable` outcome when no verdict could be established at all — that is
33
- **not** a sixth verdict; see the table below.
24
+ `vice_diagnose`'s verdict vocabulary is `restarted`, `checkpoint_trap`, `wedged`,
25
+ `monitor_held_elsewhere`, `live`. The binary monitor services exactly one client, so a second
26
+ connection sits unserviced in the backlog with no reply and no EOF — a state that looks exactly
27
+ like a wedge unless it is named, hence `monitor_held_elsewhere`. Read the tool's own schema for
28
+ the exact contract: `tools/list`'s advertised schema is the real manifest entry. `vice_diagnose`
29
+ can also answer a `diagnosis_unavailable` outcome when no verdict could be established at all
30
+ that is **not** a sixth verdict; see the table below.
34
31
 
35
32
  ## The order
36
33
 
@@ -44,12 +41,12 @@ answer a `diagnosis_unavailable` outcome when no verdict could be established at
44
41
  `machinePaused` plus `machinePausedSource` (07-15), so you can tell an actual observation from
45
42
  an inference: `observed` means a wire `stopped`/`resumed`/`jam` event directly reported the
46
43
  state; `structural` means it was inferred from the fact that every stock read halts the machine
47
- (D-05), not from a specific event; `no_session` means no session was ever obtained (e.g. the
44
+ not from a specific event; `no_session` means no session was ever obtained (e.g. the
48
45
  `monitor_held_elsewhere` verdict, or a `diagnosis_unavailable` acquisition failure) so no claim
49
46
  about pause state is being made at all.
50
47
  4. **If the verdict is `wedged`, capture evidence before recovering.** `vice_recycle` requires a
51
- `reason`, and that string is written verbatim into a permanent, repo-tracked incident record
52
- under `.planning/incidents/` **before anything is killed**. That record is the evidence
48
+ `reason`, and that string is written verbatim into a permanent incident record under
49
+ `.c64-re-tools/incidents/` **before anything is killed**. That record is the evidence
53
50
  capture — there is no separate ritual to perform, and a lazy `reason` is a lost incident.
54
51
  5. **Recycling changes the restart epoch.** Any run in flight is void. Resume from the last
55
52
  recorded milestone snapshot, never from where the wedge happened.
@@ -58,17 +55,16 @@ answer a `diagnosis_unavailable` outcome when no verdict could be established at
58
55
 
59
56
  | Verdict | What it means | Do |
60
57
  |---|---|---|
61
- | `live` | Cycles advanced | Resume and carry on. Suspect your own checkpoint conditions, not the emulator — **unless `evidence.jamObserved` is true** (below) |
58
+ | `live` | Cycles advanced | Resume and carry on. Suspect your own checkpoint conditions, not the emulator — **unless `evidence.jamObserved` is true, or `evidence.channelContention.held` is true** (both below) |
62
59
  | `checkpoint_trap` | The machine stopped **itself** at an armed checkpoint | `vice_checkpoint_delete` or `vice_checkpoint_toggle` it, or `vice_execution_step` past it, then re-run `diagnose`. **Recycling here destroys a healthy instance** |
63
60
  | `restarted` | The epoch changed — a crash-and-respawn already happened | The run is void. `c64-ram-capture` § Void a run gives the artifact procedure. Reboot from `vice_disk_attach` |
64
- | `stale_read_path` **(fork only)** | Some reads move while others do not | Do not trust any measurement taken across the boundary. Treat as void and re-derive |
65
- | `monitor_held_elsewhere` **(stock only)** | A different client already holds this instance's single binary-monitor slot | Release or identify the other holder. **Never a reason to recycle**recycling here destroys an instance that is not even wedged |
66
- | `wedged` | Two brackets, zero cycles, no epoch change | Last resort: `vice_recycle` with a real reason **but check `evidence.jamObserved` first** (below) |
67
- | `diagnosis_unavailable` **(stock only, non-verdict outcome — not one of the five)** | No verdict could be established at all; the message starts `vice_diagnose: diagnosis_unavailable (<reason>)`. The machine's state is **UNKNOWN**, not any of the five above | **Do not recycle on this answer alone.** Read the reason class in the message and act on it — see below |
61
+ | `monitor_held_elsewhere` | A different client already holds this instance's single binary-monitor slot | Release or identify the other holder. **Never a reason to recycle** recycling here destroys an instance that is not even wedged |
62
+ | `wedged` | Two brackets, zero cycles, no epoch change | Last resort: `vice_recycle` with a real reason **but check `evidence.jamObserved` first** (below). `wedged` is structurally unreachable while `evidence.channelContention.held` is true reaching `wedged` at all means no monitor channel held halt authority when the brackets ran |
63
+ | `diagnosis_unavailable` **(non-verdict outcome not one of the five)** | No verdict could be established at all; the message starts `vice_diagnose: diagnosis_unavailable (<reason>)`. The machine's state is **UNKNOWN**, not any of the five above | **Do not recycle on this answer alone.** Read the reason class in the message and act on it — see below |
68
64
 
69
- ### `evidence.jamObserved` — read it before acting on `wedged` *or* `live` (stock only, 07-REVIEW WR-04)
65
+ ### `evidence.jamObserved` — read it before acting on `wedged` *or* `live`
70
66
 
71
- Every stock `vice_diagnose` verdict carries `evidence.jamObserved` (always present, never omitted).
67
+ Every `vice_diagnose` verdict carries `evidence.jamObserved` (always present, never omitted).
72
68
  It is `true` once a `JAM` (0x61) event has arrived on this instance's wire — the 6510 executed an
73
69
  illegal opcode and **the CPU is dead regardless of the verdict above**. The flag latches: it stays
74
70
  true for the rest of the session.
@@ -85,31 +81,53 @@ rather than a sixth verdict:
85
81
  itself is healthy. Treat a `live` verdict with `jamObserved: true` as a false negative on liveness,
86
82
  and a `wedged` verdict with it as a false positive on wedging.
87
83
 
84
+ ### `evidence.channelContention` — read it before acting on `wedged` or `live`
85
+
86
+ Every `vice_diagnose` verdict carries `evidence.channelContention` (always present, never
87
+ omitted). `held` is `true` when the **other** monitor channel — the binary monitor or the
88
+ `-remotemonitor` text channel, whichever this call is not — currently holds this instance's halt
89
+ authority. When `held` is `true` the verdict is always `live`, with `evidence.bracketsRun: 0` —
90
+ meaning **no cycle bracket was run at all**, not that a bracket ran and read zero. The four detail
91
+ fields name the holder: `channel` (`"binary"` or `"text"`), `operation`, `grantId`, and `heldMs` (a
92
+ non-negative whole-millisecond duration) — all four are `null` when `held` is `false`.
93
+
94
+ **`channelContention.held: true` is never a reason to call `vice_recycle`** — the instance is
95
+ healthy and is answering; the remedy is to wait for the other channel's operation to finish, or to
96
+ find its holder, exactly as the opening table's contention row says.
97
+
98
+ Deliberate asymmetry with `jamObserved`, worth stating so a later reader does not "fix" it:
99
+ `jamObserved` qualifies `wedged` in this prose above and still lets that verdict return — the caller
100
+ has to read the evidence and choose not to recycle. `channelContention` is guarded in **code**, in
101
+ `vice_diagnose`'s own handler, and makes `wedged` structurally unreachable while a foreign hold is
102
+ live — the only route to `wedged` is through a liveness bracket, and a bracket only runs when this
103
+ instance's own diagnose call actually held the lock. The two are different on purpose: the cost of
104
+ being wrong about contention is a destructive recycle of a healthy instance, not merely a
105
+ misdiagnosis.
106
+
88
107
  ### `diagnosis_unavailable` — reason classes and response (07-15)
89
108
 
90
109
  `diagnosis_unavailable` is what `vice_diagnose` answers, on the `isError:true` channel, when it
91
- could not reach any of the five verdicts above — including a CR-01-class decode failure. It is
110
+ could not reach any of the five verdicts above — including a protocol-decode failure. It is
92
111
  never added to the verdict enum and is never grounds to `vice_recycle` by itself: the message says
93
112
  so explicitly. **Every** `isError:true` answer this tool can produce carries this prefix — there is
94
- no unclassified no-verdict path left (07-REVIEW.md WR-02). Eight reason classes exist, each with
113
+ no unclassified no-verdict path left. Eight reason classes exist, each with
95
114
  its own next move:
96
115
 
97
116
  | Reason | What it means | Do |
98
117
  |---|---|---|
99
118
  | `connection_lost` | The socket died mid-session | Retry once. If it recurs, treat as a real transport problem, not a wedge |
100
119
  | `request_timeout` | The wire went silent past the request bound | Retry once. If it recurs, fall to the manual cycle bracket below |
101
- | `monitor_acquisition_timeout` | Another client holds the monitor and the wait bound expired | Wait for the current holder to release, then retry — this is the bounded sibling of `monitor_held_elsewhere`, not a wedge. **The abandoned acquisition is not cancelled** (07-REVIEW WR-19): a session may be established moments after this answer, so a later-appearing held session is not a ghost. Its real outcome is written to stderr |
120
+ | `monitor_acquisition_timeout` | Another client holds the monitor and the wait bound expired | Wait for the current holder to release, then retry — this is the bounded sibling of `monitor_held_elsewhere`, not a wedge. **The abandoned acquisition is not cancelled**: a session may be established moments after this answer, so a later-appearing held session is not a ghost. Its real outcome is written to stderr |
102
121
  | `session_refused` | The broker/lease itself refused the session | Read the raw detail in the message; this is a broker-level problem, not an emulator state |
103
122
  | `protocol_decode_failure` | This build answered a frame the client cannot decode | Report it as a tool defect — check `docs/stock-vice-parity.md`'s `CPUHISTORY_GET` history for a known class of this — and fall back to the manual cycle bracket below |
104
123
  | `evidence_gathering_failed` | A session was obtained but a read needed to build the verdict failed | `vice_execution_run` may be needed to unstick a stalled read path, then retry |
105
- | `liveness_unmeasurable` | The liveness bracket could not be **measured at all** — no `CPUHISTORY_GET` (needs VICE ≥ 3.10) and no `LIN`/`CYC` enumerated. **The expected outcome on a stock 3.9-class build**, e.g. every current Debian/Ubuntu package | **Not a wedge and not a tool defect.** A bracket that cannot measure is not one that measured zero. Judge liveness from outside the monitor (screenshot, process state), or use the fork backend. Retrying will produce the same answer |
124
+ | `liveness_unmeasurable` | The liveness bracket could not be **measured at all** — no `CPUHISTORY_GET` (needs VICE ≥ 3.10) and no `LIN`/`CYC` enumerated. **The expected outcome on a stock 3.9-class build**, e.g. every current Debian/Ubuntu package | **Not a wedge and not a tool defect.** A bracket that cannot measure is not one that measured zero. Judge liveness from outside the monitor (screenshot, process state). Retrying will produce the same answer |
106
125
  | `unknown` | None of the above classified the failure | Read the raw detail in the message; retry once before escalating |
107
126
 
108
127
  ## What is not recoverable
109
128
 
110
- **A checkpoint trap may be the onset without being the whole story.** In the recorded incident
111
- (`.planning/todos/pending/2026-08-01-vice-registers-frozen-after-reset-during-01-04-task2.md`)
112
- checkpoint delete, then a soft reset, then a hard reset, then an explicit single step **all** left
129
+ **A checkpoint trap may be the onset without being the whole story.** In the one recorded
130
+ incident of this kind, checkpoint delete, then a soft reset, then a hard reset, then an explicit single step **all** left
113
131
  the machine frozen, in sequence. Deleting the checkpoint is not guaranteed to unfreeze anything.
114
132
 
115
133
  If a bracket still reads zero after the checkpoint is gone, the verdict becomes `wedged` and
@@ -128,40 +146,36 @@ at a checkpoint — VICE's flag flips before the trap fires. Checkpoint bookkeep
128
146
  (`vice_checkpoint_add`/`list`/`delete`) also keeps returning healthy, self-consistent responses
129
147
  throughout a real wedge, so "the tools respond" proves nothing.
130
148
 
131
- **A `vice_run_until` on an address that is never reached looks exactly like a wedge — on the fork,
132
- still without a bound.** Its `cycles` parameter is *"not yet implemented"* on both backends, and the
133
- fork has no timeout to bound the wait for an address either an unreachable address there is
134
- unbounded and indistinguishable from a wedge. **On stock, passing `cycles` is now REFUSED rather
135
- than ignored** (07-REVIEW WR-18) including alongside `address`, where it used to be silently
136
- dropped while the answer still reported `reached: true`. Unexpected argument names are refused by
137
- name too, so a `timeoutMs`/`timeout_ms` typo can no longer run with the default bound in silence.
138
- **On stock, this is now bounded (D-02):** pass `timeout_ms` (default 30000, clamped to a ceiling of
139
- 600000); an unreachable address returns an explicit, bounded `timedOut: true` answer with the
140
- temporary checkpoint already cleaned up rather than looking like a wedge. **Two further
141
- behaviours (07-14, closing WR-01/WR-02):** every non-error answer, hit or timeout, carries
142
- `machineHalted` plus a `machineHaltedNote` naming the resume call the tool halts the machine on
143
- every read and says so explicitly. **`machineHalted` is `true` on a hit and on a timeout whose
144
- cleanup delete was answered (`cleanup: "deleted"` / `"already_gone"`); it is `false` when
145
- `cleanup: "delete_failed"` or the socket is already gone** and the run-state projection does not
146
- say `"stopped"`. **Do not read `machineHalted: false` as "still running"** it means nothing here
147
- could establish the state, `machineHaltedNote` says so, and the next call should be
148
- `vice_diagnose`, not `vice_execution_run` (which may not reach the instance at all). And a timeout whose cleanup
149
- delete lands on an already-gone race no longer asserts `reached: false` outright: it reads the
150
- program counter and resolves the race (`raceResolved: "pc_at_address"` / `"pc_elsewhere"`), or, if
151
- the PC read itself fails, omits `reached` entirely and reports `reachedUnknown: true`
152
- (`raceResolved: "unresolved"`). **An absent `reached` is not "false"** check `reachedUnknown`
153
- before assuming a miss. The underlying judgement is unchanged and still the right first question on
154
- either backend: before concluding anything, check whether you asked the machine to run to an
155
- address it cannot reach. **Confidence: HIGH on stock for the reach/timeout mechanism**
156
- live-confirmed against genuine, unmodified `/usr/bin/x64sc` (VICE 3.9) and `/usr/local/bin/x64sc`
157
- (VICE 3.10): a real KERNAL address ($EA31) reached within its timeout, an unreached one ($C000)
158
- timing out with the checkpoint deleted (07-10's live pass). **MEDIUM for the WR-01/WR-02 honesty
159
- fields above** unit-proven (`stock-run-until.test.ts`, 21/21, 07-14) but not independently
160
- re-exercised against a real emulator by this gap-closure batch. **MEDIUM on the fork** — read off
161
- the tool schema, not reproduced.
162
-
163
- **A second binary-monitor client is contention, not a wedge, and it has a cheap tell — stock
164
- only.** Stock VICE's binary monitor services exactly one client; a second `connect()` sits
149
+ **A `vice_run_until` on an address that is never reached looks exactly like a wedge.** Its
150
+ `cycles` parameter is *"not yet implemented"*, and passing it is REFUSED rather than ignored —
151
+ including alongside `address`, where it used to be silently dropped while the answer still
152
+ reported `reached: true`. Unexpected argument names are refused by name too, so a
153
+ `timeoutMs`/`timeout_ms` typo can no longer run with the default bound in silence. **This is
154
+ bounded:** pass `timeout_ms` (default 30000, clamped to a ceiling of 600000); an unreachable
155
+ address returns an explicit, bounded `timedOut: true` answer with the temporary checkpoint
156
+ already cleaned up rather than looking like a wedge. **Two further behaviours:** every
157
+ non-error answer, hit or timeout, carries `machineHalted` plus a `machineHaltedNote` naming the
158
+ resume call the tool halts the machine on every read and says so explicitly. **`machineHalted`
159
+ is `true` on a hit and on a timeout whose cleanup delete was answered (`cleanup: "deleted"` /
160
+ `"already_gone"`); it is `false` when `cleanup: "delete_failed"` or the socket is already gone**
161
+ and the run-state projection does not say `"stopped"`. **Do not read `machineHalted: false` as
162
+ "still running"** it means nothing here could establish the state, `machineHaltedNote` says so,
163
+ and the next call should be `vice_diagnose`, not `vice_execution_run` (which may not reach the
164
+ instance at all). And a timeout whose cleanup delete lands on an already-gone race no longer
165
+ asserts `reached: false` outright: it reads the program counter and resolves the race
166
+ (`raceResolved: "pc_at_address"` / `"pc_elsewhere"`), or, if the PC read itself fails, omits
167
+ `reached` entirely and reports `reachedUnknown: true` (`raceResolved: "unresolved"`). **An absent
168
+ `reached` is not "false"** check `reachedUnknown` before assuming a miss. The underlying
169
+ judgement is unchanged and still the right first question: before concluding anything, check
170
+ whether you asked the machine to run to an address it cannot reach. **Confidence: HIGH for the
171
+ reach/timeout mechanism** live-confirmed against genuine, unmodified `/usr/bin/x64sc` (VICE
172
+ 3.9) and `/usr/local/bin/x64sc` (VICE 3.10): a real KERNAL address ($EA31) reached within its
173
+ timeout, an unreached one ($C000) timing out with the checkpoint deleted. **MEDIUM for the
174
+ honesty fields above** (`machineHalted`, `raceResolved`, `reachedUnknown`) unit-proven
175
+ (`stock-run-until.test.ts`, 21/21) but not independently re-exercised against a real emulator.
176
+
177
+ **A second binary-monitor client is contention, not a wedge, and it has a cheap tell.** The
178
+ binary monitor services exactly one client; a second `connect()` sits
165
179
  unserviced in the backlog with no reply and no EOF. The discriminator: a socket that *accepts* the
166
180
  connection but never answers is contention, not a hung emulator — and the broker itself already
167
181
  knows whether it holds a lease on that port, which is the thing a human or agent can actually go
@@ -183,29 +197,22 @@ exists, that is a **host action for a human** — say so and stop. Nothing conta
183
197
  the emulator by another route.
184
198
 
185
199
  When the broker is up but you want the raw measurement, the cycle bracket is the only trustworthy
186
- liveness test. **On the fork**, it is four calls:
187
-
188
- 1. `vice_cycles_stopwatch` `{action: "reset"}`
189
- 2. `vice_execution_run`
190
- 3. `vice_ping` ×3 — the one call measured non-pausing (986,693 cycles/s while polling vs 991,569
191
- fully quiet). Never poll with a state read; those pause and do not resume
192
- 4. `vice_cycles_stopwatch` `{action: "read"}`
193
-
194
- **Exactly `0`, twice in a row, is a wedge.** Cycles advancing but far below ~991,000/s is a third
195
- thing — merely slow, a separate documented hazard measured at ~6,000/s when a loop polls without
196
- re-resuming. Read all state first, poll with `vice_ping`, resume exactly once at the end.
197
-
198
- **On stock, there is no non-pausing call at all — any inbound byte halts the machine — so the
199
- `vice_ping` ×3 poll measures nothing there and is fork-only.** The stock equivalent is the same
200
- bracket shape with zero calls during the wait:
200
+ liveness test. There is no non-pausing call at all — any inbound byte halts the machine, so a
201
+ `vice_ping` poll during the wait would measure nothing. The procedure is four calls with **zero
202
+ calls during the wait**:
201
203
 
202
204
  1. `vice_cycles_stopwatch` `{action: "reset"}`
203
205
  2. `vice_execution_run`
204
206
  3. A real wall-clock wait, with **no calls at all** during it
205
207
  4. `vice_cycles_stopwatch` `{action: "read"}`
206
208
 
207
- `vice_diagnose` already runs exactly this bracket internally on stock, so the manual fallback above
208
- is only for when the broker itself is unreachable and `vice_diagnose` cannot be called at all.
209
+ **Exactly `0`, twice in a row, is a wedge.** Cycles advancing but far below the expected rate for
210
+ a real wait is a third thing merely slow, a separate documented hazard measured at ~6,000/s
211
+ when a loop polls without re-resuming instead of waiting clean. Never poll with a state read;
212
+ those pause and do not resume.
213
+
214
+ `vice_diagnose` already runs exactly this bracket internally, so the manual fallback above is
215
+ only for when the broker itself is unreachable and `vice_diagnose` cannot be called at all.
209
216
 
210
217
  **Enumerate your own checkpoints before running any bracket.** `vice_checkpoint_list`, then
211
218
  resolve the live IRQ handler (`$0314/$0315`, or `$FFFE/$FFFF` when `$01` has the ROMs banked out).
@@ -225,13 +232,13 @@ session, the last three all on that call).
225
232
  | Checkpoint delete / reset / step can all fail to recover | One recorded incident, all four attempts in sequence | HIGH, single incident |
226
233
  | A checkpoint trap explains all three recorded "silent stalls" | Cross-read, 3/3 correlation, mechanism consistent with every symptom — **not reproduced** | MEDIUM |
227
234
  | `vice_diagnose`'s five-verdict path behaves as its schema says | Schema read, and cross-checked against the tracked implementation's own report builders. **Not exercised end to end** | MEDIUM |
228
- | `vice_run_until` has no working timeout **(fork only)** | Its schema says `cycles` is "not yet implemented"; the fork has no `timeout_ms` bound | MEDIUM |
229
- | Stock's five-verdict path (`restarted`, `checkpoint_trap`, `wedged`, `monitor_held_elsewhere`, `live`) and its bounded `vice_run_until` | Unit-proven (40/40 `stock-diagnose.test.ts`, 21/21 `stock-run-until.test.ts`, 07-15/07-14). **Live-proven** against genuine `/usr/bin/x64sc` (VICE 3.9) and `/usr/local/bin/x64sc` (VICE 3.10) for `live` (07-10), `checkpoint_trap`, `wedged` (confirmed on both capability routes `frame_position` on 3.9, `cpu_history` on 3.10) and `restarted` (07-17). `monitor_held_elsewhere`'s **socket-level** contention bound is live-proven (07-13, ~1501-1502ms against a 1500ms bound). **UPDATED 2026-08-18 (quick task 260818-obc, `stock-live-broker-monitor.test.ts`, command `VICE_LIVE_BROKER_BIN=/usr/bin/x64sc` (or `/usr/local/bin/x64sc`) `node --test stock-live-broker-monitor.test.ts`):** both remaining unit-only residuals are now ALSO live-proven, on both binaries, in one real run — the **broker-mediated** `monitor_held_elsewhere` verdict (a real second `claimMonitor()` refusal from a genuine host broker daemon, naming the other real grant's id, settling in 1ms against the 10000ms bound) and the **broker-supervised** (not test-performed) `restarted` respawn (the host broker's OWN crash supervision relaunched the killed instance; `vice_diagnose` answered `restarted` with `baselineEpoch:1`/`currentEpoch:2` at zero-to-minimal emulator cost). `vice_run_until`'s reach/timeout mechanism is live-proven against both binaries (07-10); its WR-01/WR-02 honesty fields (`machineHalted`, `raceResolved`, `reachedUnknown`) remain unit-proven only (07-14) — NOT re-exercised live by this task, no blanket claim made here | HIGH for the five verdicts (including both the broker-mediated `monitor_held_elsewhere` path and the broker-supervised `restarted` path, both now live-proven) and the run_until reach/timeout mechanism; MEDIUM for the run_until honesty fields only, which stay unit-only |
235
+ | Stock's five-verdict path (`restarted`, `checkpoint_trap`, `wedged`, `monitor_held_elsewhere`, `live`) and its bounded `vice_run_until` | Unit-proven (40/40 `stock-diagnose.test.ts`, 21/21 `stock-run-until.test.ts`). **Live-proven** against genuine `/usr/bin/x64sc` (VICE 3.9) and `/usr/local/bin/x64sc` (VICE 3.10) for `live`, `checkpoint_trap`, `wedged` (confirmed on both capability routes — `frame_position` on 3.9, `cpu_history` on 3.10) and `restarted`. `monitor_held_elsewhere`'s **socket-level** contention bound is live-proven (~1501-1502ms against a 1500ms bound). **UPDATED 2026-08-18** (`stock-live-broker-monitor.test.ts`, command `VICE_LIVE_BROKER_BIN=/usr/bin/x64sc` (or `/usr/local/bin/x64sc`) `node --test stock-live-broker-monitor.test.ts`): both remaining unit-only residuals are now ALSO live-proven, on both binaries, in one real run — the **broker-mediated** `monitor_held_elsewhere` verdict (a real second `claimMonitor()` refusal from a genuine host broker daemon, naming the other real grant's id, settling in 1ms against the 10000ms bound) and the **broker-supervised** (not test-performed) `restarted` respawn (the host broker's OWN crash supervision relaunched the killed instance; `vice_diagnose` answered `restarted` with `baselineEpoch:1`/`currentEpoch:2` at zero-to-minimal emulator cost). `vice_run_until`'s reach/timeout mechanism is live-proven against both binaries; its honesty fields (`machineHalted`, `raceResolved`, `reachedUnknown`) remain unit-proven only — NOT re-exercised live, no blanket claim made here | HIGH for the five verdicts (including both the broker-mediated `monitor_held_elsewhere` path and the broker-supervised `restarted` path, both now live-proven) and the run_until reach/timeout mechanism; MEDIUM for the run_until honesty fields only, which stay unit-only |
236
+ | `evidence.channelContention`: always present, `wedged` unreachable while contended, verdict `live` with `bracketsRun:0` while a foreign hold is live | Unit-proven in `stock-diagnose.test.ts` (65/65) — the always-present field on every verdict including both `session === null` paths, the `live`-with-`bracketsRun:0` answer on a foreign hold, `wedged` never reached while contended, and the discriminating-power case where an uncontended double-zero still answers `wedged`. **Live-reproduced against genuine stock `/usr/bin/x64sc` (VICE 3.9) only** 2026-09-09, command `VICE_LIVE_STOCK_BIN=/usr/bin/x64sc node --test text-monitor-live.test.ts`: with the text channel holding a real `device c:` command, a concurrent `vice_diagnose` answered `verdict=live, evidence={"bracketsRun":0,"jamObserved":false,"channelContention":{"held":true,"channel":"text","operation":"device c:","grantId":"unknown","heldMs":3499}}`; after release, the same call answered `verdict=live, evidence={"bracketsRun":1,...,"channelContention":{"held":false,"channel":null,"operation":null,"grantId":null,"heldMs":null}}`, confirming a real bracket ran once uncontended | **MEDIUM** a single binary's basis: the text channel is a stock-backend-only capability, so it is proven where it ships (genuine stock `/usr/bin/x64sc`, VICE 3.9), not against a second binary the way the neighbouring HIGH row above is |
230
237
 
231
- Full provenance in `.planning/RE-FINDINGS.md`. **Log a new incident there at the moment you hit
232
- it**, graded with `Evidence:` and `Confidence:`; promote by re-logging, never by editing a grade.
233
- VICE MCP defects go to `.planning/todos/pending/` rather than being fixed inline. File-changing
234
- work enters through a GSD command (`/gsd-quick`).
238
+ **Log a new incident in your own project notes at the moment you hit it**, graded the same way
239
+ (`Evidence:` and `Confidence:`); promote a grade by re-logging, never by editing the old entry in
240
+ place. A defect in the `vice` MCP tools themselves is worth filing as a bug rather than working
241
+ around inline the workaround outlives the memory of why it was needed.
235
242
 
236
243
  ## Which skill does what
237
244
 
@@ -258,5 +265,5 @@ others carry.
258
265
  | Zero cycles, nothing armed, epoch unchanged | A wedge. `vice_recycle` with a reason that names the evidence |
259
266
  | A run "survived a reset" | Distrust it. You cannot read the epoch to confirm — but an unintended respawn inside the bracket would have raised a drift error on the next forwarded call, so absence of that error is the only evidence available |
260
267
  | `vice_recycle` refused for a missing reason | It is required, by design — the reason *is* the incident record |
261
- | `vice_diagnose` answers `monitor_held_elsewhere`, or a call hangs with no reply and no EOF (stock only) | Not a wedge. Find the other client holding this instance's single binary-monitor slot |
268
+ | `vice_diagnose` answers `monitor_held_elsewhere`, or a call hangs with no reply and no EOF | Not a wedge. Find the other client holding this instance's single binary-monitor slot |
262
269
  </content>
@@ -1,243 +0,0 @@
1
- #!/usr/bin/env node
2
- // Direct byte-level .d64 parser -- the permanent, sanctioned replacement for
3
- // the forbidden vice_disk_list tool (T-01-03). Never calls any vice_* tool
4
- // or touches the emulator at all: this is pure Node over the disk-image
5
- // bytes, which is why it works whether or not VICE happens to be up.
6
- //
7
- // Standard 35-track 1541 layout: BAM at track 18 sector 0, directory chain
8
- // from track 18 sector 1, four sector-count zones (21/19/18/17 sectors per
9
- // track). Plain 174848-byte, 35-track, no-error-info images are assumed
10
- // are plain 174848-byte, 35-track, no-error-info images -- no extended
11
- // (40-track) or error-byte variants to handle.
12
- import { readFileSync } from "node:fs";
13
- import { fileURLToPath } from "node:url";
14
- import { resolve } from "node:path";
15
-
16
- const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
17
-
18
- export function readImage(path) {
19
- return readFileSync(path);
20
- }
21
-
22
- /** The four sector-count zones of a standard 35-track 1541 image. */
23
- export function sectorsPerTrack(track) {
24
- if (!Number.isInteger(track) || track < 1 || track > 35) {
25
- throw new Error(`sectorsPerTrack: track ${track} out of range 1-35`);
26
- }
27
- if (track <= 17) return 21;
28
- if (track <= 24) return 19;
29
- if (track <= 30) return 18;
30
- return 17;
31
- }
32
-
33
- /** Byte offset of the start of {track, sector} in a flat 35-track image. */
34
- export function tsToOffset(track, sector) {
35
- if (!Number.isInteger(track) || track < 1 || track > 35) {
36
- throw new Error(`tsToOffset: track ${track} out of range 1-35`);
37
- }
38
- const spt = sectorsPerTrack(track);
39
- if (!Number.isInteger(sector) || sector < 0 || sector >= spt) {
40
- throw new Error(`tsToOffset: sector ${sector} out of range for track ${track} (0-${spt - 1}, this track has ${spt} sectors)`);
41
- }
42
- let offset = 0;
43
- for (let t = 1; t < track; t++) offset += sectorsPerTrack(t) * 256;
44
- return offset + sector * 256;
45
- }
46
-
47
- function isInImage(track, sector) {
48
- if (!Number.isInteger(track) || track < 1 || track > 35) return false;
49
- if (!Number.isInteger(sector) || sector < 0) return false;
50
- return sector < sectorsPerTrack(track);
51
- }
52
-
53
- /**
54
- * Directory/disk-name bytes are PETSCII, padded with $A0. Every byte this
55
- * project's two disks actually use in a name (A-Z, digits, space, parens)
56
- * sits at the same code point in PETSCII as in ASCII/Latin-1, so only the
57
- * $A0 padding needs stripping -- there is no general PETSCII<->ASCII table
58
- * here, on purpose, since one is not needed for what these disks contain.
59
- */
60
- function petsciiName(bytes) {
61
- let end = bytes.length;
62
- while (end > 0 && bytes[end - 1] === 0xa0) end--;
63
- return Buffer.from(bytes.subarray(0, end)).toString("latin1");
64
- }
65
-
66
- const FILE_TYPES = { 0: "DEL", 1: "SEQ", 2: "PRG", 3: "USR", 4: "REL" };
67
-
68
- /** Read track 18 sector 0: disk name/id, DOS type, and per-track free counts. */
69
- export function parseBam(buffer) {
70
- const off = tsToOffset(18, 0);
71
- const bam = buffer.subarray(off, off + 256);
72
-
73
- const perTrack = [];
74
- for (let t = 1; t <= 35; t++) {
75
- const eoff = 4 + (t - 1) * 4;
76
- perTrack.push({
77
- track: t,
78
- free: bam[eoff],
79
- sectors_per_track: sectorsPerTrack(t),
80
- bitmap: [bam[eoff + 1], bam[eoff + 2], bam[eoff + 3]],
81
- });
82
- }
83
-
84
- const occupiedTracks = perTrack.filter((t) => t.free < t.sectors_per_track).map((t) => t.track);
85
- const occupiedRanges = [];
86
- for (const t of occupiedTracks) {
87
- const last = occupiedRanges[occupiedRanges.length - 1];
88
- if (last && last.end === t - 1) last.end = t;
89
- else occupiedRanges.push({ start: t, end: t });
90
- }
91
-
92
- return {
93
- first_dir_track: bam[0],
94
- first_dir_sector: bam[1],
95
- dos_version: bam[2],
96
- disk_name: petsciiName(bam.subarray(0x90, 0x90 + 16)),
97
- disk_id: Buffer.from(bam.subarray(0xa2, 0xa2 + 2)).toString("latin1"),
98
- dos_type: Buffer.from(bam.subarray(0xa5, 0xa5 + 2)).toString("latin1"),
99
- per_track: perTrack,
100
- occupied_tracks: occupiedTracks,
101
- occupied_ranges: occupiedRanges,
102
- };
103
- }
104
-
105
- function isTrackFullyFree(bam, track) {
106
- const entry = bam.per_track.find((t) => t.track === track);
107
- return !!entry && entry.free === entry.sectors_per_track;
108
- }
109
-
110
- /**
111
- * Walk the directory chain from track 18 sector 1 (by default). Guards
112
- * against a malicious or corrupt next-sector pointer with a visited set: a
113
- * sector, once processed, can never be re-entered, so even a
114
- * self-referential or cyclic pointer stops the walk (reported in
115
- * `chain_error`) rather than looping forever.
116
- *
117
- * Each entry is flagged `suspicious` -- with the specific reason(s) named,
118
- * never a bare boolean -- when its block count is 0, when its first
119
- * track/sector falls outside the image, or when its first track/sector
120
- * points into a track the BAM reports as entirely free (0 sectors
121
- * allocated): exactly the signature of a faked directory entry that claims
122
- * a file that was never actually written to disk.
123
- */
124
- export function parseDirectory(buffer, { startTrack = 18, startSector = 1 } = {}) {
125
- const bam = parseBam(buffer);
126
- const entries = [];
127
- const visited = new Set();
128
- let track = startTrack;
129
- let sector = startSector;
130
- let chainError = null;
131
-
132
- for (;;) {
133
- const key = `${track}/${sector}`;
134
- if (visited.has(key)) {
135
- chainError = `directory chain revisited ${key} -- stopped to avoid an infinite loop (self-referential or cyclic next-sector pointer)`;
136
- break;
137
- }
138
- visited.add(key);
139
- if (!isInImage(track, sector)) {
140
- chainError = `directory chain pointer ${key} is outside the image -- stopped`;
141
- break;
142
- }
143
-
144
- const off = tsToOffset(track, sector);
145
- const sec = buffer.subarray(off, off + 256);
146
- const nextTrack = sec[0];
147
- const nextSector = sec[1];
148
-
149
- for (let i = 0; i < 8; i++) {
150
- const e = sec.subarray(i * 32, i * 32 + 32);
151
- const typeByte = e[2];
152
- const firstTrack = e[3];
153
- const firstSector = e[4];
154
- const nameBytes = e.subarray(5, 21);
155
- const blocks = e[30] | (e[31] << 8);
156
-
157
- // An all-zero type byte with a blank/padded name is an unused slot,
158
- // not a file -- never listed as an entry.
159
- const isEmptySlot =
160
- typeByte === 0 && firstTrack === 0 && firstSector === 0 &&
161
- [...nameBytes].every((b) => b === 0xa0 || b === 0x00);
162
- if (isEmptySlot) continue;
163
-
164
- const reasons = [];
165
- if (blocks === 0) reasons.push("block count is 0");
166
- if (!isInImage(firstTrack, firstSector)) {
167
- reasons.push(`first track/sector ${firstTrack}/${firstSector} is outside the image`);
168
- } else if (isTrackFullyFree(bam, firstTrack)) {
169
- reasons.push(`first track ${firstTrack} is reported entirely free by the BAM (0 sectors allocated) -- the file cannot really start there`);
170
- }
171
-
172
- entries.push({
173
- dir_track: track,
174
- dir_sector: sector,
175
- entry_index: i,
176
- type: FILE_TYPES[typeByte & 0x0f] ?? `unknown(0x${(typeByte & 0x0f).toString(16)})`,
177
- closed: !!(typeByte & 0x80),
178
- locked: !!(typeByte & 0x40),
179
- name: petsciiName(nameBytes),
180
- first_track: firstTrack,
181
- first_sector: firstSector,
182
- blocks,
183
- suspicious: reasons.length > 0,
184
- suspicious_reasons: reasons,
185
- });
186
- }
187
-
188
- if (nextTrack === 0) break; // end of chain, by DOS convention
189
- track = nextTrack;
190
- sector = nextSector;
191
- }
192
-
193
- return { entries, chain_error: chainError };
194
- }
195
-
196
- // -------------------------------------------------------------------- CLI
197
-
198
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
199
- const [cmd, ...rest] = process.argv.slice(2);
200
- const opt = (name, fallback) => {
201
- const i = rest.indexOf(`--${name}`);
202
- return i === -1 ? fallback : rest[i + 1];
203
- };
204
- const jsonFlag = rest.includes("--json");
205
-
206
- function run() {
207
- if (cmd !== "directory" && cmd !== "bam") {
208
- console.log(`usage: node ${fileURLToPath(import.meta.url)} <directory|bam> --image <path.d64> [--json]`);
209
- process.exitCode = cmd ? 1 : 0;
210
- return;
211
- }
212
- const imagePath = opt("image");
213
- if (!imagePath) die(`usage: ${cmd} --image <path.d64> [--json]`);
214
- const buffer = readImage(resolve(imagePath));
215
-
216
- if (cmd === "directory") {
217
- const result = parseDirectory(buffer);
218
- if (jsonFlag) {
219
- console.log(JSON.stringify(result, null, 2));
220
- } else {
221
- for (const e of result.entries) {
222
- const flag = e.suspicious ? ` SUSPICIOUS: ${e.suspicious_reasons.join("; ")}` : "";
223
- console.log(`${e.type} "${e.name}" first=${e.first_track}/${e.first_sector} blocks=${e.blocks}${flag}`);
224
- }
225
- if (result.chain_error) console.log(`chain error: ${result.chain_error}`);
226
- }
227
- return;
228
- }
229
-
230
- const bam = parseBam(buffer);
231
- if (jsonFlag) {
232
- console.log(JSON.stringify(bam, null, 2));
233
- } else {
234
- console.log(`disk name: "${bam.disk_name}" id: ${bam.disk_id} dos type: ${bam.dos_type}`);
235
- console.log(`first dir sector: ${bam.first_dir_track}/${bam.first_dir_sector}`);
236
- console.log(
237
- `occupied track ranges: ${bam.occupied_ranges.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`)).join(", ")}`
238
- );
239
- }
240
- }
241
-
242
- run();
243
- }