@henols/vice-mcp 0.2.4 → 0.2.5

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.
package/anno-store.ts CHANGED
@@ -12,9 +12,7 @@
12
12
  // surface can change under a patch release, and it emits an
13
13
  // `ExperimentalWarning` on first load. A dependency with that profile earns a
14
14
  // blast radius of exactly one file -- and, more to the point, a CONFINEMENT
15
- // THAT IS ASSERTED rather than promised. `anno-seam.test.ts` scans the shipped
16
- // module set and fails if any second module names the specifier, through any of
17
- // its four working access routes.
15
+ // THAT IS ASSERTED rather than promised.
18
16
  //
19
17
  // Three measured facts shaped the code below, and each one is here because the
20
18
  // obvious reading of SQLite's behaviour is wrong:
@@ -67,8 +65,6 @@
67
65
  // methods on `DatabaseSync.prototype`, and not through the constructor
68
66
  // option that permits them. Both exist, and either one turns this
69
67
  // module's caller-supplied FILE ARGUMENT into arbitrary code loading.
70
- // `anno-seam.test.ts` asserts all three names are absent from this
71
- // module's code.
72
68
  // 2. NEVER write a double-quoted SQL string literal. `node:sqlite` disables
73
69
  // the double-quoted-string misfeature by default, so
74
70
  // `insert into t values ("a")` throws `no such column: "a"` rather than
@@ -447,8 +443,7 @@ function fsyncPath(path: string): void {
447
443
  * itself (a snapshot image path, a staging path, or the live store path
448
444
  * `revertTo` already resolved), where there is no caller argument left to
449
445
  * confine. Every such site below carries a one-line comment naming the
450
- * module-derived value that produced its path, and `anno-seam.test.ts` pins
451
- * that no other shipped module names the option at all.
446
+ * module-derived value that produced its path.
452
447
  *
453
448
  * When `workspaceRoot` is supplied the path is confined to it first. The
454
449
  * fresh-versus-existing decision is made with `existsSync` BEFORE the
@@ -741,10 +736,7 @@ export const NO_RETAINED_REVISION = -1;
741
736
  * writer's in-flight snapshot -- the exact loss this reconciliation exists to
742
737
  * prevent, committed by the repair itself.
743
738
  *
744
- * A frozen `RegExp` literal is NOT module-level mutable state: the scan in
745
- * `anno-seam.test.ts` matches `new Map|Set|WeakMap|WeakSet` and array/object
746
- * initialisers, so this constant sits outside it by construction rather than
747
- * by exemption.
739
+ * A frozen `RegExp` literal is NOT module-level mutable state.
748
740
  */
749
741
  const SNAPSHOT_FILE_PATTERN = /^r(\d+)\.db$/;
750
742
 
@@ -1189,10 +1181,7 @@ export function reconcileSnapshotRing(handle: AnnoStoreHandle): { droppedFiles:
1189
1181
 
1190
1182
  // STEP 4. Close the sweep's own transaction through THE module's single
1191
1183
  // commit site. It must be `commitTransaction` and never a second
1192
- // `handle.db.exec` of the bare word: `anno-seam.test.ts` asserts this module
1193
- // contains exactly ONE such statement, because the durability proof's planted
1194
- // violation must have a single site -- a second literal would split that
1195
- // planting and let half of it survive.
1184
+ // `handle.db.exec` of the bare word.
1196
1185
  commitTransaction(handle.db);
1197
1186
  } catch {
1198
1187
  // ROLLED BACK INSIDE ITS OWN SWALLOWING `try`: there is nothing useful to
@@ -1373,9 +1362,6 @@ export function pruneSnapshots(handle: AnnoStoreHandle): boolean {
1373
1362
  * has to drive the IDENTICAL staging code the production writer uses. A
1374
1363
  * hand-copied variant inside a test can drift out of agreement with the real
1375
1364
  * one, and a proof that agrees with a copy proves nothing about the original.
1376
- * `anno-seam.test.ts` asserts that no shipped module other than this one so
1377
- * much as names it -- the same bound `applyWriteWithoutCommit` carries, by the
1378
- * same mechanism rather than a second one.
1379
1365
  *
1380
1366
  * THE STAGING SUFFIX IS DELIBERATELY OUTSIDE `SNAPSHOT_FILE_PATTERN`. That
1381
1367
  * pattern is anchored on `r<digits>.db`, and `reconcileSnapshotRing`'s
@@ -1823,7 +1809,7 @@ function runWriteSequence<T>(
1823
1809
  // `rolledBack` is carried in `data` as well as in the prose so a caller
1824
1810
  // can branch on the fact instead of substring-matching a message.
1825
1811
  // The wording here is FREE. It used to be constrained: the
1826
- // single-commit-site control in `anno-seam.test.ts` counted the WORD
1812
+ // single-commit-site control counted the WORD
1827
1813
  // `commit` over this module's stripped source, so a `step` value
1828
1814
  // reading "commit ..." reddened a control in a different file. A later
1829
1815
  // revision replaced that count with a match on `exec()` calls carrying a bare
@@ -1893,8 +1879,7 @@ export function applyWrite<T>(
1893
1879
  * real one.
1894
1880
  *
1895
1881
  * Its only caller is a spawned, test-only helper that is deliberately absent
1896
- * from `package.json`'s `files[]`, and `anno-seam.test.ts` asserts that no
1897
- * shipped module other than this one so much as names it.
1882
+ * from `package.json`'s `files[]`.
1898
1883
  */
1899
1884
  export function applyWriteWithoutCommit<T>(
1900
1885
  handle: AnnoStoreHandle,
@@ -36,9 +36,7 @@
36
36
  // This module performs NO filesystem and NO network I/O: every function takes
37
37
  // bytes, an already-parsed JSON value, or a string array, and returns values.
38
38
  // Callers obtain and persist the bytes themselves. That is the same claim
39
- // `prg-image.ts` and `vsf-slice.ts`'s library region make about themselves, and
40
- // `capture-predicate.test.ts` asserts it from this module's own source rather
41
- // than trusting this paragraph.
39
+ // `prg-image.ts` and `vsf-slice.ts`'s library region make about themselves.
42
40
  //
43
41
  // WHY THIS FILE EXISTS, AND WHAT IT IS NOT: the existing
44
42
  // `src/skills/c64-ram-capture/scripts/compare.mjs` is a VOCABULARY ANALOG
@@ -96,9 +94,7 @@
96
94
  // fold it into the slicer -- the slicer returns the port bytes precisely so
97
95
  // this module can apply them exactly once.
98
96
  // - Never give any function here a filesystem PATH parameter, and never
99
- // import either of this repo's host/container path-translation seams. Both
100
- // absences are asserted structurally by `capture-predicate.test.ts`, not
101
- // merely stated here.
97
+ // import either of this repo's host/container path-translation seams.
102
98
  // - Never import `stop-oracle.ts` from here, on any route, static or
103
99
  // dynamic. The captured 64K is the DEPENDENT VARIABLE the stop-identity
104
100
  // oracle certifies; a predicate that could reach the oracle -- or an oracle
package/evid-ingest.ts CHANGED
@@ -52,9 +52,7 @@
52
52
  // 4. NEVER open a store here. This module imports nothing from
53
53
  // `anno-store.ts` and touches no filesystem, transport or
54
54
  // child-process -- the write happens in `anno-tools.ts`'s dispatch
55
- // arm, which is what keeps this module out of `anno-seam.test.ts`'s
56
- // single-consumer set (`anno-store.ts` remains the one module naming
57
- // `node:sqlite`).
55
+ // arm.
58
56
  // 5. NEVER read `.read` or `.write` off an `AccessFlags` value anywhere in
59
57
  // this file. Only `.execute` is ever inspected -- read-and-write-only
60
58
  // access is deliberately not this layer's concern (see the header
package/memmap-lookup.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // path here is repo-relative and derived from this module's own location, the
10
10
  // same posture `anno-regbits-gen.ts` and `dxa-blocks.ts` take for themselves.
11
11
  // It also NEVER NAMES `node:sqlite` and NEVER OPENS THE ANNOTATION STORE:
12
- // `anno-store.ts` is the one module `anno-seam.test.ts` allows to name that
12
+ // `anno-store.ts` is the one module permitted to name that
13
13
  // dependency, and this module answers a pure question about a static JSON
14
14
  // file that has nothing to do with the store's own persistence.
15
15
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/vice-mcp",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "VICE emulator MCP server for C64 reverse-engineering: a stdio MCP server that proxies vice tools to a host VICE MCP server.",
5
5
  "type": "module",
6
6
  "bin": {
package/stock-derived.ts CHANGED
@@ -106,6 +106,7 @@ export const STOCK_DERIVED_TOOLS: ReadonlySet<string> = new Set([
106
106
  "vice_profile_flat", // Plan 42-07, PARSE-02 -- text-channel flat-profile tool, needsSession:false (text-tools.ts)
107
107
  "vice_backtrace", // Plan 42-07, PARSE-02/D-42-4 -- text-channel backtrace tool, shares the fork's own name, needsSession:false (text-tools.ts)
108
108
  "vice_io_registers", // Plan 42-07, PARSE-02 -- text-channel register-decode tool, needsSession:false (text-tools.ts)
109
+ "vice_program_load", // Plan 50-04, route-d -- text-channel program-load tool for the committed hazard-subject fixture, needsSession:false (text-tools.ts)
109
110
  ]);
110
111
 
111
112
  /**
package/stock-dispatch.ts CHANGED
@@ -75,7 +75,7 @@ import { handleCyclesStopwatch, forgetTimingForOtherTargets } from "./stock-timi
75
75
  import { handleRunUntil } from "./stock-run-until.ts";
76
76
  import { handleDiagnoseStock } from "./stock-diagnose.ts";
77
77
  import { handleRecycleStock } from "./stock-recycle.ts";
78
- import { handleDeviceConsole, handleWarpSet, handleMemmapShow, handleMemmapZap, handleCpuHistory, handleProfileFlat, handleBacktrace, handleIoRegisters } from "./text-tools.ts";
78
+ import { handleDeviceConsole, handleWarpSet, handleMemmapShow, handleMemmapZap, handleCpuHistory, handleProfileFlat, handleBacktrace, handleIoRegisters, handleProgramLoad } from "./text-tools.ts";
79
79
 
80
80
  // Re-exported so Phase 2's existing import surface (and its 921-line test
81
81
  // file) keeps working unchanged -- these four names used to be DEFINED
@@ -832,6 +832,10 @@ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
832
832
  vice_profile_flat: withDerivedTool("vice_profile_flat", { needsSession: false }, handleProfileFlat),
833
833
  vice_backtrace: withDerivedTool("vice_backtrace", { needsSession: false }, handleBacktrace),
834
834
  vice_io_registers: withDerivedTool("vice_io_registers", { needsSession: false }, handleIoRegisters),
835
+ // Plan 50-04 (route-d): reaches text-protocol.ts's widened `load` verb --
836
+ // same needsSession:false reasoning as its five siblings above (each
837
+ // resolves its own lease and takes only the text channel's own lock).
838
+ vice_program_load: withDerivedTool("vice_program_load", { needsSession: false }, handleProgramLoad),
835
839
  };
836
840
 
837
841
  /** Looks up the table entry for `name` -- `undefined` on a miss, never a
package/text-protocol.ts CHANGED
@@ -51,24 +51,33 @@
51
51
  // outbound command must come from TEXT_COMMAND_ALLOWLIST below;
52
52
  // command() refuses anything else BY NAME, before a single byte reaches
53
53
  // the socket (D-01). The ONE stated, bounded exception (D-42-1, plan
54
- // 42-04): TEXT_COMMAND_PARAM_SPECS lets exactly three of those eight
55
- // verbs also carry a caller-chosen value, but that value is always a
56
- // typed, bounded number -- never a string, never a rest-of-line
57
- // passthrough, never a `params` field -- validated and rendered by
58
- // buildTextCommand(), the ONE place such a string is built, and
59
- // accepted as dialable only when isDialableTextCommandForVerb()'s own
60
- // re-render round trip reproduces it byte-for-byte. This does not
54
+ // 42-04, widened plan 50-04): TEXT_COMMAND_PARAM_SPECS lets exactly
55
+ // four of the allowlisted verbs also carry a caller-chosen value, but
56
+ // that value is always a typed, bounded number -- never a string, never
57
+ // a rest-of-line passthrough, never a `params` field -- validated and
58
+ // rendered by buildTextCommand(), the ONE place such a string is built,
59
+ // and accepted as dialable only when isDialableTextCommandForVerb()'s
60
+ // own re-render round trip reproduces it byte-for-byte. This does not
61
61
  // widen what "free-text command" means above; it narrows one bounded
62
- // numeric slot per verb.
62
+ // numeric slot per verb. Plan 50-04's `load` entry is the sole
63
+ // exception to "never a string": the FILENAME half of that one entry is
64
+ // not a caller-supplied string at all -- it is baked into the verb's
65
+ // own frozen identity, a reviewed literal chosen by this file, never a
66
+ // value a caller passes in. Only the device NUMBER is the caller's
67
+ // bounded value, exactly like every other entry in this table. See
68
+ // TEXT_COMMAND_PARAM_SPECS's own `load` entry below for the full
69
+ // rationale.
63
70
  // - Never frame a response by a timeout, a byte count, or any fallback
64
71
  // that hands back a plausible-looking partial payload. A response that
65
72
  // cannot be honestly framed refuses by name (TextFramingError), naming
66
73
  // what was actually observed -- never a guess.
67
74
  import { EventEmitter } from "node:events";
68
75
  import net from "node:net";
76
+ import { join } from "node:path";
69
77
 
70
78
  import { ViceError } from "./vice-errors.ts";
71
79
  import { acquireChannelLock, currentChannelLockHolder } from "./channel-lock.ts";
80
+ import { repoRoot } from "./repo-root.ts";
72
81
 
73
82
  // ---------------------------------------------------------------------------
74
83
  // The prompt terminator and the closed command allowlist.
@@ -117,10 +126,38 @@ export const PROMPT_RE = /\(C:\$[0-9A-Fa-f]{4}\)\s*$/;
117
126
  * subtracting a prior baseline after the fact. The sibling verb `memmapsave`
118
127
  * is deliberately NOT added here -- it writes a host file, and this
119
128
  * allowlist's own membership test (`text-protocol.test.ts`) refuses any
120
- * entry whose name matches the pattern that spells the words `load` or
121
- * `save`, the same file-touching-verb rule `device c:`, `warp on/off`,
129
+ * entry whose name matches the pattern that spells the word `save`, the
130
+ * same file-touching-WRITE-verb rule `device c:`, `warp on/off`,
122
131
  * `memmapshow`, `prof flat`, `chis`, `bt`, `io` and `prof on/off` already
123
132
  * satisfy.
133
+ *
134
+ * `load` (plan 50-04, 2026-09-15, a conscious, measured widening, and the
135
+ * FIRST one that reverses part of the load/save refusal rather than adding
136
+ * a fresh always-safe verb): Phase 50 needed a way to get a committed
137
+ * `.prg` into a running stock VICE for a live capture, and the project's
138
+ * only other route -- `vice_autostart` against a bare `.prg` -- was
139
+ * MEASURED to fail with monitor error `0x8f` (see
140
+ * `fixtures/hazard-subject/FIXTURE-DESIGN.md`), with no committed `.d64`
141
+ * writer anywhere in this tree to fall back to. The developer was shown
142
+ * three routes that added no capability, a hand-authored disk image, or a
143
+ * new file-WRITING host-tool capability -- and chose a FOURTH: the text
144
+ * monitor's own `load` command (`load "<filename>" <device> [<address>]`,
145
+ * VICE Manual ch. 12, `mon_parse.y`'s `disk_rules` grammar), because it
146
+ * READS a host file and writes nothing back to the host. That is the
147
+ * load-bearing distinction this widening rests on: this allowlist's own
148
+ * rule above was never "no `load` or `save`" in principle, it was "no verb
149
+ * that touches a host file" -- and `memmapsave`'s rejection above is a
150
+ * WRITE. `load` is a READ, and the developer judged the original rule to
151
+ * have over-reached for the read direction (recorded in
152
+ * `.planning/phases/50-equivalence-and-modifiability/evidence/LOAD-ROUTE.md`).
153
+ * This is NOT a general "load anything" capability: the entry added to
154
+ * TEXT_COMMAND_PARAM_SPECS below bakes in the ONE committed fixture path
155
+ * this phase's tracer plan targets as part of the verb's own frozen
156
+ * identity, never a caller-supplied filename -- and, since plan 50-05, one
157
+ * such frozen entry per member of the closed HAZARD_SUBJECT_PRG_RELPATHS
158
+ * table, still never a caller-supplied filename. See those entries' own
159
+ * comment for why a caller still cannot choose what gets loaded. `save`
160
+ * remains refused exactly as before; only the read direction moved.
124
161
  */
125
162
  export const TEXT_COMMAND_ALLOWLIST = Object.freeze([
126
163
  "device c:",
@@ -139,17 +176,24 @@ export const TEXT_COMMAND_ALLOWLIST = Object.freeze([
139
176
  export type TextCommand = (typeof TEXT_COMMAND_ALLOWLIST)[number];
140
177
 
141
178
  // ---------------------------------------------------------------------------
142
- // Parameterized commands (D-42-1, plan 42-04). Three of the eight
143
- // allowlisted verbs -- "chis", "prof flat", "io" -- were captured on the
179
+ // Parameterized commands (D-42-1, plan 42-04, widened plan 50-04). Three of
180
+ // the allowlisted verbs -- "chis", "prof flat", "io" -- were captured on the
144
181
  // real wire carrying a caller-chosen value ("chis 4", "prof flat 5",
145
182
  // "io $d020" -- see fixtures/textmon/{cpu-history,flat-profile,
146
183
  // register-decode}-stock.json's own "command" field), so a bare literal
147
184
  // alone cannot reach them meaningfully. TEXT_COMMAND_ALLOWLIST above is NOT
148
- // widened for this: it stays exactly the eight frozen literals, and its own
149
- // membership assertion is unaffected. TEXT_COMMAND_PARAM_SPECS is a SIBLING
150
- // table describing, for the subset of verbs that take one, the bounded
151
- // typed value each accepts and the ONE renderer that turns a validated
152
- // value into the exact command string VICE was captured accepting.
185
+ // widened for this: its own membership assertion is unaffected by any entry
186
+ // here. TEXT_COMMAND_PARAM_SPECS is a SIBLING table describing, for the
187
+ // subset of verbs that take one, the bounded typed value each accepts and
188
+ // the ONE renderer that turns a validated value into the exact command
189
+ // string VICE accepts. Plan 50-04 adds a "load" entry, and plan 50-05 turns
190
+ // that single entry into one DERIVED entry per committed subject (see
191
+ // HAZARD_SUBJECT_PRG_RELPATHS) -- see those entries' own comment below and
192
+ // TEXT_COMMAND_ALLOWLIST's doc comment above for the full
193
+ // rationale; unlike the first three, this one is not sourced from a
194
+ // committed live-captured fixture (no live capture was run to add it -- the
195
+ // syntax is sourced directly from VICE's own upstream grammar and manual,
196
+ // cited on the entry itself).
153
197
  // ---------------------------------------------------------------------------
154
198
 
155
199
  /** The parameter kind a spec entry declares. "count" bounds a decimal
@@ -175,16 +219,241 @@ function renderAddressParam(verb: string, value: number): string {
175
219
  return `${verb} $${value.toString(16).padStart(4, "0")}`;
176
220
  }
177
221
 
222
+ /**
223
+ * The CLOSED set of committed fixtures the `load` widening below may load,
224
+ * as an id -> BASENAME table (plan 50-04 committed the first member; plan
225
+ * 50-05 turned the single constant into this table).
226
+ *
227
+ * WHY A TABLE AND NOT ONE CONSTANT PER SUBJECT. Plan 50-04's own note here
228
+ * said a later plan adding a second committed subject would add "its OWN new
229
+ * constant and its OWN new TEXT_COMMAND_PARAM_SPECS entry". Plan 50-05 is
230
+ * that later plan, and plan 50-06 needs two more. Three hand-copied
231
+ * constants, three hand-copied spec entries and three hand-copied verb
232
+ * strings in text-tools.ts is three chances to mis-copy a path and load the
233
+ * WRONG subject into a capture that then silently becomes evidence for the
234
+ * wrong binary. The table removes that class of mistake: adding a subject is
235
+ * ONE reviewed row here, and every spec entry, every verb string and the
236
+ * tool's own accepted id set are all derived from it.
237
+ *
238
+ * WHAT DID NOT CHANGE, AND MUST NOT. Every path here is still a reviewed
239
+ * literal chosen by THIS file. A caller never supplies a path, a basename or
240
+ * any fragment of one. The tool's `subject` argument (text-tools.ts) is an
241
+ * enumerated ID that is looked up in this frozen table by exact membership
242
+ * and is NEVER concatenated into a command string -- an id this table does
243
+ * not carry is refused BY NAME, so the set of loadable files stays exactly
244
+ * as closed as it was when it held one entry. TextCommandParamKind is
245
+ * likewise untouched: the only caller-supplied VALUE is still the bounded
246
+ * device NUMBER, and no parameter kind in this module accepts a string
247
+ * domain (text-protocol.test.ts pins both facts, at runtime and at source
248
+ * level).
249
+ *
250
+ * WHAT NOT TO DO: do not add a function that builds a path from caller
251
+ * input, and do not widen a row into anything a caller can steer. Every row
252
+ * below is a REVIEWED LITERAL spelled out in this file, whole.
253
+ *
254
+ * WHY THE ROWS CARRY A WHOLE REPO-RELATIVE PATH AND NOT A BARE BASENAME
255
+ * (plan 50-06). Plan 50-05 wrote each row as a basename and joined a single
256
+ * fixed `src/mcp/vice/fixtures/hazard-subject` directory onto it, and said
257
+ * in this very comment that a build artifact outside that directory "gets a
258
+ * reviewed row of its own, spelled out here the same way". Plan 50-06 is the
259
+ * plan with that artifact: its rebuild `.prg` is produced from the committed
260
+ * annotation store and lands under the PHASE EVIDENCE directory, not the
261
+ * fixture directory, because it is an output of this phase rather than a
262
+ * committed fixture. A basename-plus-fixed-directory row cannot spell that,
263
+ * so the row now carries the whole repo-relative path as an array of
264
+ * reviewed segments. Nothing about the CLOSURE changed: the path is still
265
+ * chosen entirely by this file, a caller still supplies no path, no
266
+ * basename, no directory and no fragment of one, and the only thing a
267
+ * caller ever names is an id this table's own keys define.
268
+ *
269
+ * `misaligned` is deliberately ABSENT: `hazard-subject-misaligned.prg` is a
270
+ * committed fixture, but no plan loads it into a running emulator -- it is
271
+ * consumed offline by the hazard-report gate. The set is what is actually
272
+ * dialed, not every fixture that happens to exist, and a committed test
273
+ * asserts it stays undialable so the boundary is this table rather than a
274
+ * directory.
275
+ */
276
+ export const HAZARD_SUBJECT_PRG_RELPATHS = Object.freeze({
277
+ /** Plan 50-04's tracer-slice subject -- the original. */
278
+ original: Object.freeze(["src", "mcp", "vice", "fixtures", "hazard-subject", "hazard-subject.prg"]),
279
+ /** Plan 50-02's regressed twin: three planted single-bit regressions at
280
+ * the immediates feeding $D020, $D015 and $D018. Plan 50-05's red
281
+ * control. */
282
+ regressed: Object.freeze(["src", "mcp", "vice", "fixtures", "hazard-subject", "hazard-subject-regressed.prg"]),
283
+ /** Plan 50-02's modified subject: one behaviour removed and one added.
284
+ * Plan 50-06's modifiability observation. */
285
+ modified: Object.freeze(["src", "mcp", "vice", "fixtures", "hazard-subject", "hazard-subject-modified.prg"]),
286
+ /** Plan 50-06's REBUILD: the committed subject re-produced from its own
287
+ * committed annotation store through importStoreDocument() ->
288
+ * exportAsmTree() -> verifyAcmeAssemblesTree(), recorded in
289
+ * `.planning/phases/50-equivalence-and-modifiability/evidence/REBUILD.md`.
290
+ * The only row that is not a committed fixture, and the reason the rows
291
+ * carry a whole repo-relative path -- see this table's own comment. */
292
+ rebuild: Object.freeze([
293
+ ".planning",
294
+ "phases",
295
+ "50-equivalence-and-modifiability",
296
+ "evidence",
297
+ "hazard-subject-rebuild.prg",
298
+ ]),
299
+ /** Plan 50-08's exported-edit subject: the same one-behaviour-removed,
300
+ * one-behaviour-added pair `modified` carries, made this time in a file
301
+ * `exportAsmTree()` itself emitted (`scope_087a.a`) rather than in the
302
+ * hand-written `modified` fixture family, reassembled through the same
303
+ * single oracle against a pre-registered byte manifest committed at
304
+ * `fixtures/hazard-subject/exported-edit.manifest.json`. See
305
+ * `docs/phase50-exported-edit-findings.md` and
306
+ * `docs/phase50-exported-modifiability-transcript.md`. */
307
+ "exported-edit": Object.freeze([
308
+ "src",
309
+ "mcp",
310
+ "vice",
311
+ "fixtures",
312
+ "hazard-subject",
313
+ "hazard-subject-exported-edit.prg",
314
+ ]),
315
+ } as const);
316
+
317
+ /** The id half of HAZARD_SUBJECT_PRG_RELPATHS -- the only thing a caller
318
+ * ever names, and never a path. */
319
+ export type HazardSubjectId = keyof typeof HAZARD_SUBJECT_PRG_RELPATHS;
320
+
321
+ /** The frozen id list, in table order. Exported so text-tools.ts can state
322
+ * the accepted set in its refusal message without re-typing it. */
323
+ export const HAZARD_SUBJECT_IDS: readonly HazardSubjectId[] = Object.freeze(
324
+ Object.keys(HAZARD_SUBJECT_PRG_RELPATHS) as HazardSubjectId[],
325
+ );
326
+
327
+ /** Each row's own last segment, DERIVED from the table above rather than
328
+ * spelled a second time. Preserved by name because committed tests already
329
+ * bind it, and because "which file does this id name" is a question worth
330
+ * answering without re-walking the path. */
331
+ export const HAZARD_SUBJECT_PRG_BASENAMES: Readonly<Record<HazardSubjectId, string>> = Object.freeze(
332
+ Object.fromEntries(
333
+ HAZARD_SUBJECT_IDS.map((id) => {
334
+ const segments = HAZARD_SUBJECT_PRG_RELPATHS[id];
335
+ return [id, segments[segments.length - 1]] as const;
336
+ }),
337
+ ) as Record<HazardSubjectId, string>,
338
+ );
339
+
340
+ /** True only for an id this table actually carries. The ONE membership test
341
+ * -- `Object.keys`-derived rather than a prototype lookup, so an inherited
342
+ * name ("constructor", "__proto__", "toString") can never test true. */
343
+ export function isHazardSubjectId(value: unknown): value is HazardSubjectId {
344
+ return typeof value === "string" && (HAZARD_SUBJECT_IDS as readonly string[]).includes(value);
345
+ }
346
+
347
+ /**
348
+ * Absolute host path to one member of the closed table above. Three members
349
+ * are committed fixtures; `rebuild` is plan 50-06's own build artifact under
350
+ * the phase evidence directory, which is why the table's rows carry a whole
351
+ * repo-relative path rather than a basename joined onto one fixed directory.
352
+ *
353
+ * Resolved through repoRoot() rather than hard-coded as a relative string:
354
+ * `broker-launch.mts` spawns `x64sc` with no explicit `cwd` (checked
355
+ * directly in this session -- no `cwd` option anywhere in that file), so a
356
+ * repo-relative string would resolve against whatever directory the broker
357
+ * process itself happened to be started from, not necessarily this
358
+ * repository. An absolute path removes that ambiguity. Residual, stated
359
+ * risk (not solved here): this is the CONTAINER-side path as seen by this
360
+ * Node process; on a genuinely containerized deployment (this project has
361
+ * none today -- host-developed, no devcontainer) the host process actually
362
+ * running `x64sc` would need this translated through `hostpath.ts` first.
363
+ * That translation is deliberately NOT added here, matching this project's
364
+ * existing "solve the general host/container case only where it is
365
+ * actually exercised" discipline -- a later plan that runs this widening
366
+ * through a real container split adds it then.
367
+ */
368
+ export function hazardSubjectPrgPath(id: HazardSubjectId): string {
369
+ return join(repoRoot(), ...HAZARD_SUBJECT_PRG_RELPATHS[id]);
370
+ }
371
+
372
+ /** THE ONE place a `load "<path>"` verb string is spelled, for any subject.
373
+ * Both the TEXT_COMMAND_PARAM_SPECS keys below and text-tools.ts's own
374
+ * lookup go through this function, so the table key and the dialed verb can
375
+ * never drift apart into two literals that differ by a character. */
376
+ export function hazardSubjectLoadVerb(id: HazardSubjectId): string {
377
+ return `load "${hazardSubjectPrgPath(id)}"`;
378
+ }
379
+
380
+ /** Plan 50-04's original constant, preserved by name and by value: it is
381
+ * exactly the `original` member of the table above. Kept because several
382
+ * committed tests and text-tools.ts already bind this name, and because the
383
+ * default subject is still the original. */
384
+ export const HAZARD_SUBJECT_PRG_PATH: string = hazardSubjectPrgPath("original");
385
+
386
+ /**
387
+ * One frozen `load "<path>"` spec per member of HAZARD_SUBJECT_PRG_RELPATHS,
388
+ * derived from that closed table rather than hand-copied per subject (plan
389
+ * 50-05). Every entry is identical except for the reviewed path baked into
390
+ * its own key: same "count" kind, same 0-11 device bound, same renderer. The
391
+ * DERIVATION is the point -- a hand-copied entry per subject is how a path
392
+ * and its renderer drift apart, and a renderer that disagrees with its own
393
+ * key fails isDialableTextCommandForVerb()'s round trip and refuses the
394
+ * command outright, which is a confusing way to discover a typo.
395
+ */
396
+ const HAZARD_SUBJECT_LOAD_SPECS: Readonly<Record<string, TextCommandParamSpec>> = Object.freeze(
397
+ Object.fromEntries(
398
+ HAZARD_SUBJECT_IDS.map((id) => {
399
+ const verb = hazardSubjectLoadVerb(id);
400
+ return [
401
+ verb,
402
+ Object.freeze({
403
+ kind: "count",
404
+ min: 0,
405
+ max: 11,
406
+ render: (value: number) => renderCountParam(verb, value),
407
+ } satisfies TextCommandParamSpec),
408
+ ] as const;
409
+ }),
410
+ ),
411
+ );
412
+
178
413
  /**
179
414
  * Frozen, per-verb parameter specs (D-42-1). Keyed by the verb exactly as
180
- * it appears in TEXT_COMMAND_ALLOWLIST above. A verb with no entry here
181
- * takes no parameter -- it keeps dialing its bare frozen literal, unchanged
182
- * (the three no-parameter verbs -- "device c:", "warp on", "warp off" --
183
- * are deliberately absent). The count bound is 1 through 65535: one because
184
- * a zero-row request is not a request, and 65535 because that is the same
185
- * 16-bit domain the CPU-history count lives in on this machine
186
- * (CPUHISTORY_GET's own count field, monitor_binary.c:1492). The address
187
- * bound is 0 through 65535, the full 16-bit machine address space.
415
+ * it appears in TEXT_COMMAND_ALLOWLIST above -- with ONE exception, the
416
+ * `load` family (plan 50-04, one entry per committed subject since plan
417
+ * 50-05), whose keys are full frozen literals that already embed their own
418
+ * filename argument (see below); none of them ever appears in
419
+ * TEXT_COMMAND_ALLOWLIST as a bare entry, because `load "<file>"` with no
420
+ * device number is not valid VICE syntax on its own (`mon_parse.y`'s
421
+ * `disk_rules: CMD_LOAD filename device_num opt_address` requires the
422
+ * device number) -- unlike "chis"/"prof flat"/"io", which ARE independently
423
+ * valid bare and so are also listed in TEXT_COMMAND_ALLOWLIST.
424
+ *
425
+ * A verb with no entry here takes no parameter -- it keeps dialing its bare
426
+ * frozen literal, unchanged (the three no-parameter verbs -- "device c:",
427
+ * "warp on", "warp off" -- are deliberately absent). The count bound is 1
428
+ * through 65535 for "chis"/"prof flat": one because a zero-row request is
429
+ * not a request, and 65535 because that is the same 16-bit domain the
430
+ * CPU-history count lives in on this machine (CPUHISTORY_GET's own count
431
+ * field, monitor_binary.c:1492). The address bound for "io" is 0 through
432
+ * 65535, the full 16-bit machine address space.
433
+ *
434
+ * `load "<one committed subject path>"` (plan 50-04, 2026-09-15; one entry
435
+ * per subject since plan 50-05): the single bounded value is still the
436
+ * DEVICE NUMBER, per VICE's own documented syntax
437
+ * (`load "<filename>" <device> [<address>]`, VICE Manual ch. 12 -- "If
438
+ * device is 0, the file is read from the file system"). The address
439
+ * argument is deliberately never offered here: omitting it makes VICE use
440
+ * the load address embedded in the `.prg` file's own two-byte header, which
441
+ * is exactly what a committed machine-code fixture needs and removes a
442
+ * second numeric slot this project would otherwise have to bound and
443
+ * justify for no present use. This reuses the SAME "count" kind and the
444
+ * SAME `${verb} ${value}` rendering `renderCountParam()` already produces
445
+ * for "chis"/"prof flat" -- no new TextCommandParamKind, no new render
446
+ * shape; only the verb string itself is new, and it is a reviewed literal,
447
+ * never a caller-supplied string, and the SUBJECT is chosen by an
448
+ * enumerated id looked up in that same frozen table, never by a path a
449
+ * caller passes in (see TEXT_COMMAND_ALLOWLIST's own `load` paragraph above
450
+ * and HAZARD_SUBJECT_PRG_RELPATHS). Bound 0 through 11: 0 is the one value this phase
451
+ * exercises (host filesystem, per the manual quote above); 1 through 11
452
+ * spans this project's own documented device-number range elsewhere
453
+ * (CLAUDE.md's wire memspace note: units 8-11 are the four IEC disk
454
+ * drives this codebase ever names) -- a deliberately narrow bound, not the
455
+ * full addressable device range VICE itself accepts, because nothing in
456
+ * this project has a reason to dial anything wider yet.
188
457
  */
189
458
  export const TEXT_COMMAND_PARAM_SPECS: Readonly<Record<string, TextCommandParamSpec>> = Object.freeze({
190
459
  chis: Object.freeze({
@@ -205,6 +474,7 @@ export const TEXT_COMMAND_PARAM_SPECS: Readonly<Record<string, TextCommandParamS
205
474
  max: 65535,
206
475
  render: (value: number) => renderAddressParam("io", value),
207
476
  }),
477
+ ...HAZARD_SUBJECT_LOAD_SPECS,
208
478
  } satisfies Record<string, TextCommandParamSpec>);
209
479
 
210
480
  function isSafeIntegerNumber(value: unknown): value is number {
package/text-tools.ts CHANGED
@@ -69,7 +69,15 @@
69
69
  // withChannelLockHeld()'s own discipline in stock-dispatch.ts.
70
70
  // - Never embed a phase number in any string or template literal here.
71
71
  import { textConnect, textDisconnect } from "./text-connect.ts";
72
- import { withTextChannelLock, buildTextCommand, type TextMonitorClient } from "./text-protocol.ts";
72
+ import {
73
+ withTextChannelLock,
74
+ buildTextCommand,
75
+ hazardSubjectLoadVerb,
76
+ isHazardSubjectId,
77
+ HAZARD_SUBJECT_IDS,
78
+ type HazardSubjectId,
79
+ type TextMonitorClient,
80
+ } from "./text-protocol.ts";
73
81
  import { MonitorOwnershipError } from "./vice-broker-client.ts";
74
82
  import { ChannelLockTimeoutError } from "./channel-lock.ts";
75
83
  import { isErrorText, derivedAnswer, convertHandshakeError, convertWireError, type StockToolResult } from "./stock-handler.ts";
@@ -776,3 +784,100 @@ export async function handleIoRegisters(args: Record<string, unknown>, deps: Sto
776
784
  });
777
785
  });
778
786
  }
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // Plan 50-04 (route-d): the tool that reaches text-protocol.ts's widened
790
+ // `load` verb. See TEXT_COMMAND_ALLOWLIST's own `load` paragraph and
791
+ // TEXT_COMMAND_PARAM_SPECS's own `load` entry (both text-protocol.ts) for
792
+ // the full rationale this handler leans on without repeating it.
793
+ // ---------------------------------------------------------------------------
794
+
795
+ /**
796
+ * The subject loaded when the caller names none. Plan 50-04's behaviour,
797
+ * unchanged: an omitted `subject` dials exactly the path that plan's single
798
+ * frozen entry dialed.
799
+ */
800
+ const DEFAULT_SUBJECT: HazardSubjectId = "original";
801
+
802
+ /**
803
+ * Resolve the caller's `subject` argument to one of text-protocol.ts's own
804
+ * frozen ids, or to `null` for anything else.
805
+ *
806
+ * WHY THIS IS NOT A FILENAME PARAMETER, AND MUST NEVER BECOME ONE. The value
807
+ * a caller supplies here is an ID, checked for exact membership in
808
+ * HAZARD_SUBJECT_IDS and then used only as a LOOKUP KEY -- it is never
809
+ * concatenated into a command string, never joined onto a path, and never
810
+ * reaches the socket in any form. The dialed verb is built by
811
+ * hazardSubjectLoadVerb() from the reviewed literal text-protocol.ts's own
812
+ * closed table carries. So the set of host files this tool can ever load is
813
+ * exactly that table, whatever a caller sends. An unrecognised id is refused
814
+ * BY NAME before any text-monitor byte is written, the same way
815
+ * buildTextCommand() refuses an out-of-bounds device.
816
+ */
817
+ function resolveSubjectId(raw: unknown): HazardSubjectId | null {
818
+ if (raw === undefined) return DEFAULT_SUBJECT;
819
+ return isHazardSubjectId(raw) ? raw : null;
820
+ }
821
+
822
+ /**
823
+ * `vice_program_load` -- the shipped tool that reaches plan 50-04's widened
824
+ * `load` verb (route-d, `.planning/phases/50-equivalence-and-modifiability/evidence/LOAD-ROUTE.md`).
825
+ * Dials VICE's text-monitor `load "<file>" <device>` command for ONE member
826
+ * of text-protocol.ts's closed HAZARD_SUBJECT_PRG_RELPATHS table, each
827
+ * baked into its own frozen allowlist identity -- this handler takes NO
828
+ * filename argument at all, so there is nothing here for a caller to
829
+ * inject; the loaded path can never be anything other than a reviewed
830
+ * literal that table already carries.
831
+ *
832
+ * Takes two OPTIONAL parameters. `subject` is an enumerated id from that
833
+ * table ("original", "regressed", "modified", "rebuild"). It defaults to
834
+ * "original", which is exactly plan 50-04's behaviour, and an id the table
835
+ * does not carry is refused by name (see resolveSubjectId() above for why an
836
+ * id is not a filename). "rebuild" (plan 50-06) is the one id whose row
837
+ * resolves outside the fixture directory -- a build artifact under the phase
838
+ * evidence directory -- which is why the table's rows carry a whole
839
+ * repo-relative path. `device`: an omitted device defaults
840
+ * to 0 ("the file is read from the file system", VICE Manual ch. 12).
841
+ * `buildTextCommand()` alone validates and bounds the device (0 through 11,
842
+ * TEXT_COMMAND_PARAM_SPECS's own entry for this verb) -- this handler
843
+ * duplicates no bound, mirroring `handleCpuHistory()`'s own discipline of
844
+ * never re-stating a spec's own bound in a second place.
845
+ *
846
+ * No address argument is offered, and none ever will be through this tool:
847
+ * omitting it makes VICE use the load address embedded in the `.prg` file's
848
+ * own two-byte header, which is exactly what a committed machine-code
849
+ * fixture needs -- text-protocol.ts's own `load` entry documents this same
850
+ * choice and why a second numeric slot is not worth bounding for no present
851
+ * use.
852
+ */
853
+ export async function handleProgramLoad(args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
854
+ const { device, subject } = args;
855
+ const subjectId = resolveSubjectId(subject);
856
+ if (subjectId === null) {
857
+ return isErrorText(
858
+ `vice_program_load: "subject" must be one of ${HAZARD_SUBJECT_IDS.map((id) => JSON.stringify(id)).join(", ")} ` +
859
+ `(got ${JSON.stringify(subject)}) -- refusing before any text-monitor byte is written; this tool never accepts a filename`,
860
+ );
861
+ }
862
+ const resolvedDevice = device === undefined ? 0 : device;
863
+ const built = buildTextCommand(hazardSubjectLoadVerb(subjectId), resolvedDevice);
864
+ if (!built.ok) {
865
+ return isErrorText(`vice_program_load: ${built.message} -- refusing before any text-monitor byte is written`);
866
+ }
867
+ const command = built.command;
868
+
869
+ return withTextTool("vice_program_load", deps, async (client) => {
870
+ const response = await client.command(command);
871
+ return derivedAnswer({
872
+ command,
873
+ device: resolvedDevice,
874
+ subject: subjectId,
875
+ response,
876
+ note:
877
+ `loads the reviewed Phase 50 hazard subject "${subjectId}", baked into this verb's own frozen ` +
878
+ "identity (plan 50-04 route-d; one frozen verb per subject since plan 50-05) -- no filename is ever " +
879
+ "caller-supplied, only an enumerated subject id; the load address comes from the .prg file's own " +
880
+ "two-byte header, since no address argument is offered",
881
+ });
882
+ });
883
+ }
@@ -4505,6 +4505,69 @@
4505
4505
  "runState"
4506
4506
  ]
4507
4507
  }
4508
+ },
4509
+ {
4510
+ "name": "vice_program_load",
4511
+ "description": "Stock-only, no fork counterpart -- reaches VICE's text monitor over the -remotemonitor channel and halts the machine for the duration of the command, exactly as a binary-monitor command does. Issues \"load \\\"<path>\\\" <device>\" for ONE member of a closed, reviewed Phase 50 hazard-subject table -- four committed fixtures under fixtures/hazard-subject/ plus the rebuild produced under the phase evidence directory. Every loadable path is baked into this tool's own frozen dialed verbs and is never a caller-supplied argument, so there is nothing here for a caller to inject. Takes an OPTIONAL \"subject\", an enumerated subject id, defaulting to \"original\". Takes an OPTIONAL \"device\" number, 0 through 11; an omitted device defaults to 0 (\"the file is read from the file system\", VICE Manual ch. 12). No address argument is offered -- omitting it makes VICE use the load address embedded in the .prg file's own two-byte header, which is exactly what these committed machine-code fixtures need.",
4512
+ "inputSchema": {
4513
+ "type": "object",
4514
+ "properties": {
4515
+ "device": {
4516
+ "type": "integer",
4517
+ "minimum": 0,
4518
+ "maximum": 11,
4519
+ "description": "OPTIONAL. IEC device number VICE loads from. Defaults to 0 (the host filesystem) when omitted. 1 through 11 span this project's own documented device-number range elsewhere; this tool never accepts a filename."
4520
+ },
4521
+ "subject": {
4522
+ "type": "string",
4523
+ "enum": [
4524
+ "original",
4525
+ "regressed",
4526
+ "modified",
4527
+ "rebuild",
4528
+ "exported-edit"
4529
+ ],
4530
+ "description": "OPTIONAL. Which reviewed Phase 50 hazard subject to load, named by id, never by path. \"original\" is the tracer-slice subject and is the default. \"regressed\" is its three-single-bit-regression twin. \"modified\" is the one-behaviour-removed, one-behaviour-added variant made by hand in the fixture's own source. \"rebuild\" is the original re-produced from its own committed annotation store, and is the one id that resolves outside the fixture directory. \"exported-edit\" is the same one-behaviour-removed, one-behaviour-added pair made instead in a file exportAsmTree() itself emitted, reassembled against a pre-registered byte manifest. This tool never accepts a filename."
4531
+ }
4532
+ },
4533
+ "additionalProperties": false
4534
+ },
4535
+ "outputSchema": {
4536
+ "type": "object",
4537
+ "properties": {
4538
+ "command": {
4539
+ "type": "string"
4540
+ },
4541
+ "device": {
4542
+ "type": "integer"
4543
+ },
4544
+ "response": {
4545
+ "type": "string"
4546
+ },
4547
+ "note": {
4548
+ "type": "string"
4549
+ },
4550
+ "runState": {
4551
+ "type": "string",
4552
+ "enum": [
4553
+ "running",
4554
+ "stopped",
4555
+ "unknown"
4556
+ ]
4557
+ },
4558
+ "subject": {
4559
+ "type": "string"
4560
+ }
4561
+ },
4562
+ "required": [
4563
+ "command",
4564
+ "device",
4565
+ "subject",
4566
+ "response",
4567
+ "note",
4568
+ "runState"
4569
+ ]
4570
+ }
4508
4571
  }
4509
4572
  ]
4510
4573
  }