@henols/vice-mcp 0.2.3 → 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.
@@ -6,15 +6,15 @@
6
6
  // rebuild.
7
7
  // host-tool.mts
8
8
  //
9
- // Phase 34, plan 34-01 (SEAM-01..SEAM-03, tracer): the host-bound executor
10
- // for the host-tool control op. A container-side caller (host-tool-client.ts)
11
- // reaches this module over broker-control.mts's `host_tool` op -- never
12
- // directly -- and this module is the ONE place that turns an untrusted wire
13
- // request into a real child process on the HOST, outside any container.
14
- // Motivated by the project owner's own rule of 2026-08-28
15
- // (.planning/seeds/host-tool-executor.md): a skill script runs container-side,
16
- // the binaries it needs (acme, and later dxa/Ghidra/c1541/petcat/cartconv)
17
- // live host-side, and there is no container PATH to find them on.
9
+ // This is the host-bound executor for the host-tool control op. A
10
+ // container-side caller (host-tool-client.ts) reaches this module over
11
+ // broker-control.mts's `host_tool` op -- never directly -- and this module is
12
+ // the ONE place that turns an untrusted wire request into a real child
13
+ // process on the HOST, outside any container. Motivated by the project
14
+ // owner's own rule, recorded once a skill script's own binaries (acme, and
15
+ // later dxa/Ghidra/c1541/petcat/cartconv) turned out to need to live
16
+ // host-side: a skill script runs container-side and there is no container
17
+ // PATH to find them on.
18
18
  //
19
19
  // THIS IS THE ONE AUTHORITATIVE PLACE for three things, none of which may be
20
20
  // re-derived anywhere else:
@@ -30,8 +30,8 @@
30
30
  // never a shell string, and bounded by a per-invocation timeout (T-34-02,
31
31
  // T-34-05).
32
32
  //
33
- // WHAT NOT TO DO, each naming the prohibition it guards (must_haves.prohibitions,
34
- // 34-01-PLAN.md):
33
+ // WHAT NOT TO DO, each naming the prohibition it guards (recorded because a
34
+ // fast sweep of this file could plausibly re-add any one of them):
35
35
  // - No generic wire op that accepts a raw argv array or a raw command
36
36
  // string for a host tool -- argv is constructed server-side from typed
37
37
  // fields only (T-34-01).
@@ -44,13 +44,13 @@
44
44
  // anno-types.ts's storePathWithinWorkspace() and its own incident
45
45
  // history by name) before the prefix comparison, and the comparison is
46
46
  // over the WALKED (real) paths, never the lexical join -- a purely
47
- // lexical path.resolve() + startsWith() check is exactly what CR-05
48
- // (34-VERIFICATION.md gap 3) found: a symlink planted inside the
49
- // workspace defeated it live. This covers EVERY path-bearing wire field
47
+ // lexical path.resolve() + startsWith() check is exactly what a live
48
+ // symlink test found: a symlink planted inside the workspace defeated
49
+ // it, satisfying the prefix check lexically while a real write through
50
+ // it landed outside the root. This covers EVERY path-bearing wire field
50
51
  // on every tool, not only the ones present when this file was first
51
52
  // written: acme.build's source/outDir AND each entry of its includes
52
- // array (34-07, CR-03), and ghidra.analyze's importPath AND its
53
- // preScript/postScript (34-07, CR-02).
53
+ // array, and ghidra.analyze's importPath AND its preScript/postScript.
54
54
  // Two residuals recorded beside the guarantee, not hidden past it: the
55
55
  // check-then-open window between this decision and the child process's
56
56
  // own open is NOT closed here -- the child is a third-party binary
@@ -59,14 +59,14 @@
59
59
  // comparison is byte-wise over the resolved strings with no Unicode
60
60
  // normalisation, so two spellings differing only in normalisation form
61
61
  // are two distinct paths here (same residual anno-confinement.test.ts
62
- // records for the same comparison). A third note, A-16
63
- // (docs/phase34-host-tool-seam-decisions.md): because the return value
64
- // is now the REAL path, on a host whose workspace root is itself reached
65
- // through a symlink the response `path` need not match any member of
66
- // hostRootCandidates(), and containerPath() throws rather than passing
67
- // an untranslatable path through -- HOST_WORKSPACE_PATH naming the real
68
- // root is the pre-existing mitigation; this is a recorded limit, not a
69
- // widened hostpath.ts consumer set.
62
+ // records for the same comparison). A third residual, recorded rather
63
+ // than hidden: because the return value is now the REAL path, on a
64
+ // host whose workspace root is itself reached through a symlink the
65
+ // response `path` need not match any member of hostRootCandidates(),
66
+ // and containerPath() throws rather than passing an untranslatable
67
+ // path through -- HOST_WORKSPACE_PATH naming the real root is the
68
+ // pre-existing mitigation; this is a recorded limit, not a widened
69
+ // hostpath.ts consumer set.
70
70
  // - No inline byte payload on a host-tool response, at any result size --
71
71
  // every result crosses as `{ path, sha256, byteLength }`, never bytes.
72
72
  // - No second copy of a tool's argv construction -- buildHostToolArgv() is
@@ -81,49 +81,48 @@
81
81
  // `resources/host-tool.mjs` artifact, added to build.ts's HOST_BOUND_ARTIFACTS
82
82
  // and tsconfig.build.json's include[] in the same commit as this file).
83
83
  //
84
- // Phase 34, plan 34-03 (A-06, SEAM-04): this module's first SIBLING import.
85
- // `ghidra-project.mjs` is a VALUE import (not type-only) because the rule
86
- // must be enforced where `analyzeHeadless` is actually spawned -- inside the
87
- // broker process -- which is why `ghidra-project.mts` ships as a compiled
88
- // `resources/*.mjs` artifact exactly like this file does. A `.mjs`-specifier
89
- // value import only resolves once both siblings are compiled into
90
- // resources/ (the same reason plan 34-01's A-04 already has
91
- // host-tool.test.ts reach THIS module as the committed artifact). The
92
- // dot-segment rule and the per-run project location are NEVER copied here --
93
- // this module reaches them through the one place that owns them.
84
+ // This module's first SIBLING import. `ghidra-project.mjs` is a VALUE
85
+ // import (not type-only) because the rule must be enforced where
86
+ // `analyzeHeadless` is actually spawned -- inside the broker process --
87
+ // which is why `ghidra-project.mts` ships as a compiled `resources/*.mjs`
88
+ // artifact exactly like this file does. A `.mjs`-specifier value import
89
+ // only resolves once both siblings are compiled into resources/ (the same
90
+ // reason host-tool.test.ts reaches THIS module as the committed artifact,
91
+ // not the unbuilt source). The dot-segment rule and the per-run project
92
+ // location are NEVER copied here -- this module reaches them through the
93
+ // one place that owns them.
94
94
  import { spawn } from "node:child_process";
95
95
  import { createHash } from "node:crypto";
96
96
  import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
97
97
  import { basename, dirname, isAbsolute, join, resolve as resolvePath, sep } from "node:path";
98
98
  import { fileURLToPath } from "node:url";
99
99
  import { resolveGhidraProject, buildAnalyzeHeadlessArgv, hasDotPrefixedSegment, installedLanguageIds, GHIDRA_STOCK_6502_LANGUAGE_FILES, GHIDRA_IMPORT_ROUTES, importRouteBaseAddr, LANGUAGE_ID_PATTERN, LOADER_BASE_ADDR_PATTERN, RUN_ID_PATTERN, } from "./ghidra-project.mjs";
100
- // Phase 40, plan 40-02 (T-40-02-04, D-13): this module's SECOND sibling
101
- // import. `resolvedBackend()` is the ONE place that decides which x64sc
102
- // build is on this host (backend-detect.mts's own header) -- findSiblingBinary()
103
- // below resolves c1541/petcat as siblings of THAT resolved binary rather than
104
- // by a bare-name spawn, which a host carrying both a stock and a fork build
105
- // (MEASURED live on this project's own dev host: /usr/local/bin/x64sc is the
106
- // fork, /usr/bin/x64sc is genuine stock, and $PATH resolves the fork first)
107
- // would otherwise silently answer with whichever build's directory happens
108
- // to sort first. A VALUE import, exactly like ghidra-project.mjs above, for
109
- // the same reason: it is invoked where the sibling binary is actually
110
- // resolved, inside this process. resolvedBackend() is itself memoised at
111
- // module scope (backend-detect.mts's own `memoisedResult`) and this project's
112
- // broker already calls it once at startup before the control listener binds
113
- // (vice-broker.mts's run()) -- for the control-plane route this call below
114
- // is therefore always a cache hit, never a second probe. The host route (no
115
- // broker in the loop, see this plan's own host_route_note) has no such
100
+ // This module's SECOND sibling import. `resolvedBackend()` is the ONE
101
+ // place that decides which x64sc build is on this host (backend-detect.mts's
102
+ // own header) -- findSiblingBinary() below resolves c1541/petcat as siblings
103
+ // of THAT resolved binary rather than by a bare-name spawn, which a host
104
+ // carrying both a stock and a fork build (MEASURED live on this project's
105
+ // own dev host: /usr/local/bin/x64sc is the fork, /usr/bin/x64sc is genuine
106
+ // stock, and $PATH resolves the fork first) would otherwise silently answer
107
+ // with whichever build's directory happens to sort first. A VALUE import,
108
+ // exactly like ghidra-project.mjs above, for the same reason: it is invoked
109
+ // where the sibling binary is actually resolved, inside this process.
110
+ // resolvedBackend() is itself memoised at module scope (backend-detect.mts's
111
+ // own `memoisedResult`) and this project's broker already calls it once at
112
+ // startup before the control listener binds (vice-broker.mts's run()) -- for
113
+ // the control-plane route this call below is therefore always a cache hit,
114
+ // never a second probe. The host route (no broker in the loop) has no such
116
115
  // warm memo and pays one `--help` probe per invocation, mirroring the
117
116
  // existing, already-accepted cost vice-broker.mts's own startup call pays
118
117
  // once per broker lifetime -- never re-probed per c1541.* call within the
119
118
  // SAME process, per findSiblingBinary()'s own memo below.
120
119
  import { resolvedBackend } from "./backend-detect.mjs";
121
- // Phase 35, plan 35-01 (A-01): this module's own directory, used ONLY to
122
- // compute the vendored dxa binary's fixed path. Never an environment-variable
123
- // override: dxa is vendored AND built by this project (unlike
124
- // ACME_BIN/GHIDRA_HOME, which name a HOST PREREQUISITE a user installs
125
- // anywhere), so an override could only ever select a binary this project did
126
- // not build and did not pin -- precisely what DXA-01 forbids.
120
+ // This module's own directory, used ONLY to compute the vendored dxa
121
+ // binary's fixed path. Never an environment-variable override: dxa is
122
+ // vendored AND built by this project (unlike ACME_BIN/GHIDRA_HOME, which
123
+ // name a HOST PREREQUISITE a user installs anywhere), so an override could
124
+ // only ever select a binary this project did not build and did not pin --
125
+ // a substitution this seam must never allow.
127
126
  const HERE = dirname(fileURLToPath(import.meta.url));
128
127
  export const HOST_TOOL_IDS = Object.freeze([
129
128
  "acme.build",
@@ -140,46 +139,46 @@ export const HOST_TOOL_IDS = Object.freeze([
140
139
  "petcat.decode",
141
140
  ]);
142
141
  /** Per-tool accepted argument-key lists, built with `Object.create(null)`
143
- * (the vsf-slice.mjs WR-04 idiom) so no prototype key can ever resolve to a
144
- * value here even if a future caller indexed it with an untrusted string
145
- * directly -- belt-and-suspenders alongside the array-membership check
146
- * above, which is what actually guards the lookup below.
142
+ * (the same prototype-null idiom vsf-slice.mjs uses) so no prototype key can
143
+ * ever resolve to a value here even if a future caller indexed it with an
144
+ * untrusted string directly -- belt-and-suspenders alongside the
145
+ * array-membership check above, which is what actually guards the lookup
146
+ * below.
147
147
  *
148
148
  * `ghidra.analyze`'s accepted keys carry no raw argv array and no raw
149
- * command string. Per field (corrected 34-07, CR-02 -- the previous wording
150
- * here claimed all four already flowed through a resolver, which was false
151
- * for the two script fields until this plan): `runId` is a bare name, never
152
- * a path, and flows through `resolveGhidraProject()`'s own per-run-directory
153
- * resolution; `importPath`, `preScript` and `postScript` each flow through
154
- * `resolveWorkspacePath()` -- the SAME workspace-boundary resolver
155
- * `acme.build`'s `source`/`outDir`/`includes` use -- before any of the four
156
- * ever reaches argv. `buildAnalyzeHeadlessArgv()` (ghidra-project.mts) also
157
- * independently re-checks `preScript`/`postScript` for a parent-directory
158
- * path segment, exactly as it already re-checks `projectLocation` for a
159
- * dot-prefixed segment -- so both rules hold even for a caller that
160
- * constructed these fields itself and skipped this module's own resolution
161
- * sites entirely.
149
+ * command string. Per field (corrected from an earlier version of this
150
+ * comment, which wrongly claimed all four already flowed through a
151
+ * resolver -- that was false for the two script fields until it was fixed):
152
+ * `runId` is a bare name, never a path, and flows through
153
+ * `resolveGhidraProject()`'s own per-run-directory resolution; `importPath`,
154
+ * `preScript` and `postScript` each flow through `resolveWorkspacePath()` --
155
+ * the SAME workspace-boundary resolver `acme.build`'s `source`/`outDir`/
156
+ * `includes` use -- before any of the four ever reaches argv.
157
+ * `buildAnalyzeHeadlessArgv()` (ghidra-project.mts) also independently
158
+ * re-checks `preScript`/`postScript` for a parent-directory path segment,
159
+ * exactly as it already re-checks `projectLocation` for a dot-prefixed
160
+ * segment -- so both rules hold even for a caller that constructed these
161
+ * fields itself and skipped this module's own resolution sites entirely.
162
162
  *
163
- * Phase 36, plan 36-01: `ghidra.analyze` gains `processor` (D-36-01's
164
- * promote decision) -- a REQUIRED, non-path, language-id string. It is
165
- * deliberately absent from `HOST_TOOL_PATH_ARG_KEYS` below and never flows
166
- * through `resolveWorkspacePath()`; it is validated against
167
- * `LANGUAGE_ID_PATTERN` instead (ghidra-project.mts). `ghidra.installExtension`'s
168
- * two keys: `sourceDir` (workspace-relative, path-bearing) and `moduleName`
169
- * (a non-path name validated against `RUN_ID_PATTERN`'s anchored shape,
170
- * exactly like `ghidra.analyze`'s own `runId`). */
163
+ * `ghidra.analyze` gains `processor` (promoted from optional to required
164
+ * after early testing showed a caller must always name the language) -- a
165
+ * REQUIRED, non-path, language-id string. It is deliberately absent from
166
+ * `HOST_TOOL_PATH_ARG_KEYS` below and never flows through
167
+ * `resolveWorkspacePath()`; it is validated against `LANGUAGE_ID_PATTERN`
168
+ * instead (ghidra-project.mts). `ghidra.installExtension`'s two keys:
169
+ * `sourceDir` (workspace-relative, path-bearing) and `moduleName` (a
170
+ * non-path name validated against `RUN_ID_PATTERN`'s anchored shape, exactly
171
+ * like `ghidra.analyze`'s own `runId`). */
171
172
  export const HOST_TOOL_ARG_KEYS = Object.freeze(Object.assign(Object.create(null), {
172
173
  "acme.build": Object.freeze(["source", "outDir", "format", "setpc", "defines", "includes", "noReport"]),
173
- // Phase 36, plan 36-02 (GHID-01): seven new fields close the seam-argv
174
- // surface gap 36-RESEARCH.md measured -- importRoute (required),
175
- // loaderBaseAddr, noanalysis, scriptPath, entrypointsPath, exportPath,
176
- // expectedClassificationLines.
177
- // Phase 37, plan 37-08 (AUTO-07, D-37-33): "dataRangesPath" is the ONE
178
- // new field this plan adds -- an OPTIONAL path-bearing field naming a
179
- // range file for the new DataRangeSeed.java pre-script. A run omitting
180
- // it is accepted exactly as before this plan (D-37-33's own stated
181
- // requirement: a first pass without graphics feedback must keep
182
- // working unchanged).
174
+ // Seven new fields close a measured seam-argv surface gap -- importRoute
175
+ // (required), loaderBaseAddr, noanalysis, scriptPath, entrypointsPath,
176
+ // exportPath, expectedClassificationLines.
177
+ // "dataRangesPath" is the ONE new field added after that -- an OPTIONAL
178
+ // path-bearing field naming a range file for the new DataRangeSeed.java
179
+ // pre-script. A run omitting it is accepted exactly as before this
180
+ // field existed: a first pass without graphics feedback must keep
181
+ // working unchanged.
183
182
  "ghidra.analyze": Object.freeze([
184
183
  "runId",
185
184
  "importPath",
@@ -195,40 +194,38 @@ export const HOST_TOOL_ARG_KEYS = Object.freeze(Object.assign(Object.create(null
195
194
  "expectedClassificationLines",
196
195
  "dataRangesPath",
197
196
  ]),
198
- // 34-08 (CR-01): EMPTY -- the oracle's location is host-side
199
- // configuration only (resolveOracleCommand(), below), never a wire
200
- // value. No caller-supplied value may ever select what the host
201
- // executes, even framed as merely reconfiguring an already-allowlisted
202
- // tool.
197
+ // EMPTY -- the oracle's location is host-side configuration only
198
+ // (resolveOracleCommand(), below), never a wire value. No
199
+ // caller-supplied value may ever select what the host executes, even
200
+ // framed as merely reconfiguring an already-allowlisted tool.
203
201
  "oracle.probe": Object.freeze([]),
204
202
  "oracle.run": Object.freeze(["source"]),
205
- // Phase 35, plan 35-01: frozen exactly as the plan's own Task 1 item 5
206
- // states -- five path-bearing keys plus the one enum key (`imageKind`),
207
- // never re-derived from ResolvedDxaDisassemblePaths below.
203
+ // Frozen exactly as originally specified -- five path-bearing keys plus
204
+ // the one enum key (`imageKind`), never re-derived from
205
+ // ResolvedDxaDisassemblePaths below.
208
206
  "dxa.disassemble": Object.freeze(["image", "imageKind", "entrypointsPath", "datablocksPath", "labelsPath", "outDir"]),
209
- // Phase 36, plan 36-01 (D-36-01): `sourceDir` is the vendored extension
210
- // tree; `moduleName` names the install target directory under
211
- // <GHIDRA_HOME>/Ghidra/Extensions/.
207
+ // `sourceDir` is the vendored extension tree; `moduleName` names the
208
+ // install target directory under <GHIDRA_HOME>/Ghidra/Extensions/.
212
209
  "ghidra.installExtension": Object.freeze(["sourceDir", "moduleName"]),
213
- // Phase 40, plan 40-02 (PREP-01): `image` is the `.d64` these five
214
- // capabilities read; `outDir` defaults to `dirname(imagePath)`, exactly
215
- // as `dxa.disassemble`'s own default does. `name` (entry/chain/read) is
216
- // a CBM filename or glob pattern -- never a path, never resolved
217
- // through `resolveWorkspacePath()` (see HOST_TOOL_PATH_ARG_KEYS below).
210
+ // `image` is the `.d64` these five capabilities read; `outDir` defaults
211
+ // to `dirname(imagePath)`, exactly as `dxa.disassemble`'s own default
212
+ // does. `name` (entry/chain/read) is a CBM filename or glob pattern --
213
+ // never a path, never resolved through `resolveWorkspacePath()` (see
214
+ // HOST_TOOL_PATH_ARG_KEYS below).
218
215
  "c1541.bam": Object.freeze(["image", "outDir"]),
219
216
  "c1541.dir": Object.freeze(["image", "outDir"]),
220
217
  "c1541.entry": Object.freeze(["image", "name", "outDir"]),
221
218
  "c1541.chain": Object.freeze(["image", "name", "outDir"]),
222
219
  "c1541.read": Object.freeze(["image", "name", "outDir"]),
223
- // Phase 40, plan 40-03 (PREP-02, D-24): no dialect key here or anywhere
224
- // else in this module -- the BASIC dialect is a fixed literal inside
225
- // buildHostToolArgv()'s own petcat.decode branch, never a wire field. A
226
- // caller has no way to request one, let alone a wrong one.
220
+ // No dialect key here or anywhere else in this module -- the BASIC
221
+ // dialect is a fixed literal inside buildHostToolArgv()'s own
222
+ // petcat.decode branch, never a wire field. A caller has no way to
223
+ // request one, let alone a wrong one.
227
224
  "petcat.decode": Object.freeze(["image", "outDir"]),
228
225
  }));
229
- /** 34-08 (Task 3): the answer to ONE question -- which accepted argument
230
- * keys, per tool, name a filesystem path and therefore MUST pass
231
- * `resolveWorkspacePath()` before ever reaching argv. Built with the SAME
226
+ /** The answer to ONE question -- which accepted argument keys, per tool,
227
+ * name a filesystem path and therefore MUST pass `resolveWorkspacePath()`
228
+ * before ever reaching argv. Built with the SAME
232
229
  * `Object.freeze(Object.assign(Object.create(null), ...))` idiom
233
230
  * `HOST_TOOL_ARG_KEYS` above uses. Consumed by `host-tool.test.ts`'s
234
231
  * data-driven census, never by production code -- the census is what makes
@@ -243,25 +240,24 @@ export const HOST_TOOL_ARG_KEYS = Object.freeze(Object.assign(Object.create(null
243
240
  * ghidra-project.mts), turned into a path only by `resolveGhidraProject()`
244
241
  * -- a DIFFERENT mechanism with its own guard, not `resolveWorkspacePath()`.
245
242
  * `oracle.probe`'s entry is empty because that tool accepts no arguments at
246
- * all (Task 1, CR-01).
243
+ * all.
247
244
  *
248
- * Phase 36, plan 36-01: `ghidra.analyze`'s `processor` is deliberately NOT
249
- * listed here -- it is a language-id string, not a path, and is validated
250
- * against `LANGUAGE_ID_PATTERN` instead (T-36-02). `ghidra.installExtension`'s
251
- * `sourceDir` IS path-bearing; `moduleName` is deliberately absent for the
252
- * same reason `ghidra.analyze`'s `runId` is: a validated opaque name
245
+ * `ghidra.analyze`'s `processor` is deliberately NOT listed here -- it is a
246
+ * language-id string, not a path, and is validated against
247
+ * `LANGUAGE_ID_PATTERN` instead. `ghidra.installExtension`'s `sourceDir` IS
248
+ * path-bearing; `moduleName` is deliberately absent for the same reason
249
+ * `ghidra.analyze`'s own `runId` is: a validated opaque name
253
250
  * (`RUN_ID_PATTERN`) turned into a path segment only inside
254
251
  * `runHostTool()`'s own resolution branch below, never through
255
252
  * `resolveWorkspacePath()`. */
256
253
  export const HOST_TOOL_PATH_ARG_KEYS = Object.freeze(Object.assign(Object.create(null), {
257
254
  "acme.build": Object.freeze(["source", "outDir", "includes"]),
258
- // Phase 36, plan 36-02: scriptPath/entrypointsPath/exportPath join the
259
- // pre-existing three -- each resolved through resolveWorkspacePath() in
260
- // runHostTool()'s ghidra branch, exactly like importPath/preScript/
261
- // postScript already are.
262
- // Phase 37, plan 37-08: "dataRangesPath" joins the pre-existing six --
263
- // resolved through resolveWorkspacePath() in runHostTool()'s ghidra
264
- // branch, exactly like every other script-adjacent path field.
255
+ // scriptPath/entrypointsPath/exportPath join the pre-existing three --
256
+ // each resolved through resolveWorkspacePath() in runHostTool()'s ghidra
257
+ // branch, exactly like importPath/preScript/postScript already are.
258
+ // "dataRangesPath" joins the pre-existing six -- resolved through
259
+ // resolveWorkspacePath() in runHostTool()'s ghidra branch, exactly like
260
+ // every other script-adjacent path field.
265
261
  "ghidra.analyze": Object.freeze(["importPath", "preScript", "postScript", "scriptPath", "entrypointsPath", "exportPath", "dataRangesPath"]),
266
262
  "oracle.probe": Object.freeze([]),
267
263
  "oracle.run": Object.freeze(["source"]),
@@ -270,20 +266,20 @@ export const HOST_TOOL_PATH_ARG_KEYS = Object.freeze(Object.assign(Object.create
270
266
  // classifies for this tool.
271
267
  "dxa.disassemble": Object.freeze(["image", "entrypointsPath", "datablocksPath", "labelsPath", "outDir"]),
272
268
  "ghidra.installExtension": Object.freeze(["sourceDir"]),
273
- // Phase 40, plan 40-02 (PREP-01): `image`/`outDir` are path-bearing on
274
- // all five ids; `name` (entry/chain/read) is deliberately absent here
275
- // -- it is a CBM filename/glob, not a path, and is the one key each of
276
- // those three tools' own `HOST_TOOL_ARG_KEYS_REMAINDER` entry
277
- // (host-tool.test.ts) classifies. `c1541.bam`/`c1541.dir` have no
278
- // non-path keys at all, so their own remainder entries are empty.
269
+ // `image`/`outDir` are path-bearing on all five ids; `name`
270
+ // (entry/chain/read) is deliberately absent here -- it is a CBM
271
+ // filename/glob, not a path, and is the one key each of those three
272
+ // tools' own `HOST_TOOL_ARG_KEYS_REMAINDER` entry (host-tool.test.ts)
273
+ // classifies. `c1541.bam`/`c1541.dir` have no non-path keys at all, so
274
+ // their own remainder entries are empty.
279
275
  "c1541.bam": Object.freeze(["image", "outDir"]),
280
276
  "c1541.dir": Object.freeze(["image", "outDir"]),
281
277
  "c1541.entry": Object.freeze(["image", "outDir"]),
282
278
  "c1541.chain": Object.freeze(["image", "outDir"]),
283
279
  "c1541.read": Object.freeze(["image", "outDir"]),
284
- // Phase 40, plan 40-03 (PREP-02): both of `petcat.decode`'s accepted
285
- // keys are path-bearing -- there is no non-path key at all, so its own
286
- // HOST_TOOL_ARG_KEYS_REMAINDER entry (host-tool.test.ts) is empty.
280
+ // Both of `petcat.decode`'s accepted keys are path-bearing -- there is
281
+ // no non-path key at all, so its own HOST_TOOL_ARG_KEYS_REMAINDER entry
282
+ // (host-tool.test.ts) is empty.
287
283
  "petcat.decode": Object.freeze(["image", "outDir"]),
288
284
  }));
289
285
  const HOST_TOOL_SHAPE = `an object with a "tool" field naming one of ${HOST_TOOL_IDS.map((t) => JSON.stringify(t)).join(", ")}, and an optional "args" object`;
@@ -366,11 +362,11 @@ export function normaliseHostToolRequest(raw) {
366
362
  }
367
363
  if ("includes" in argsObj) {
368
364
  const includes = argsObj.includes;
369
- // Task 1 (CR-03): an empty-string entry is refused here rather than
370
- // silently skipped or forwarded to resolveWorkspacePath() -- the same
371
- // "must be an array of strings" message, tightened to reject the one
372
- // string value that would otherwise slip through as "an array of
373
- // strings" while carrying no real path.
365
+ // An empty-string entry is refused here rather than silently skipped
366
+ // or forwarded to resolveWorkspacePath() -- the same "must be an
367
+ // array of strings" message, tightened to reject the one string
368
+ // value that would otherwise slip through as "an array of strings"
369
+ // while carrying no real path.
374
370
  if (!Array.isArray(includes) || !includes.every((i) => typeof i === "string" && i !== "")) {
375
371
  return { ok: false, message: `host_tool "acme.build" args.includes must be an array of strings; got ${describe(includes)}` };
376
372
  }
@@ -394,13 +390,12 @@ export function normaliseHostToolRequest(raw) {
394
390
  if (typeof importPathRaw !== "string" || importPathRaw === "") {
395
391
  return { ok: false, message: `host_tool "ghidra.analyze" requires a non-empty string "importPath"; got ${describe(importPathRaw)}` };
396
392
  }
397
- // Phase 36, plan 36-01 (D-36-01, T-36-02): REQUIRED, non-defaulted --
398
- // the assumption-delta decision above. Refused absent, empty,
399
- // non-string, and non-matching, each naming the field and the accepted
400
- // shape; re-validated independently inside buildAnalyzeHeadlessArgv()
401
- // (ghidra-project.mts) so the rule holds for a caller that bypassed
402
- // this narrowing entirely. Byte-exact, case-sensitive comparison --
403
- // never case-folded (must_haves.truths, 36-01-PLAN.md).
393
+ // REQUIRED, non-defaulted -- the correction stated on the interface
394
+ // above. Refused absent, empty, non-string, and non-matching, each
395
+ // naming the field and the accepted shape; re-validated independently
396
+ // inside buildAnalyzeHeadlessArgv() (ghidra-project.mts) so the rule
397
+ // holds for a caller that bypassed this narrowing entirely. Byte-exact,
398
+ // case-sensitive comparison -- never case-folded.
404
399
  const processorRaw = argsObj.processor;
405
400
  if (typeof processorRaw !== "string" || processorRaw === "" || !LANGUAGE_ID_PATTERN.test(processorRaw)) {
406
401
  return {
@@ -408,10 +403,9 @@ export function normaliseHostToolRequest(raw) {
408
403
  message: `host_tool "ghidra.analyze" requires a non-empty "processor" string matching ${LANGUAGE_ID_PATTERN.source} (a colon-separated Ghidra language id, alphanumeric-and-underscore segments, no path separator, no dot, length-capped); got ${describe(processorRaw)}`,
409
404
  };
410
405
  }
411
- // Phase 36, plan 36-02 (D-36-07): REQUIRED, non-defaulted -- exact
412
- // membership of a frozen two-member array, never a string passed
413
- // through to argv. The loader itself ("BinaryLoader") is a fixed
414
- // literal and never a wire field at all.
406
+ // REQUIRED, non-defaulted -- exact membership of a frozen two-member
407
+ // array, never a string passed through to argv. The loader itself
408
+ // ("BinaryLoader") is a fixed literal and never a wire field at all.
415
409
  const importRouteRaw = argsObj.importRoute;
416
410
  if (typeof importRouteRaw !== "string" || !GHIDRA_IMPORT_ROUTES.includes(importRouteRaw)) {
417
411
  return {
@@ -420,9 +414,8 @@ export function normaliseHostToolRequest(raw) {
420
414
  };
421
415
  }
422
416
  const importRoute = importRouteRaw;
423
- // Phase 36, plan 36-02 (D-36-07, T-36-09): loaderBaseAddr is a raw argv
424
- // token, never a path -- validated against the anchored
425
- // LOADER_BASE_ADDR_PATTERN rather than routed through
417
+ // loaderBaseAddr is a raw argv token, never a path -- validated against
418
+ // the anchored LOADER_BASE_ADDR_PATTERN rather than routed through
426
419
  // resolveWorkspacePath(). On the "flat64k" route the base is the
427
420
  // route's OWN; a differing supplied value is refused BY NAME rather
428
421
  // than silently honoured. On "prg" an absent value defaults to the
@@ -448,11 +441,11 @@ export function normaliseHostToolRequest(raw) {
448
441
  else {
449
442
  loaderBaseAddr = importRouteBaseAddr(importRoute);
450
443
  }
451
- // Phase 36, plan 36-02: a typeof boolean check, never a truthiness
452
- // coercion. Load-bearing rather than cosmetic: VolatileCarve.java's own
453
- // run() calls analyzeAll(currentProgram) itself, so omitting
454
- // -noanalysis would race Ghidra's own automatic post-preScript
455
- // analysis against the manual call.
444
+ // A typeof boolean check, never a truthiness coercion. Load-bearing
445
+ // rather than cosmetic: VolatileCarve.java's own run() calls
446
+ // analyzeAll(currentProgram) itself, so omitting -noanalysis would race
447
+ // Ghidra's own automatic post-preScript analysis against the manual
448
+ // call.
456
449
  let noanalysis;
457
450
  if ("noanalysis" in argsObj) {
458
451
  const noanalysisRaw = argsObj.noanalysis;
@@ -461,9 +454,9 @@ export function normaliseHostToolRequest(raw) {
461
454
  }
462
455
  noanalysis = noanalysisRaw;
463
456
  }
464
- // Phase 36, plan 36-02: a non-negative integer, refusing fractional,
465
- // negative, NaN and string values by name -- this field exists so
466
- // GHID-01's gate 1 can plant a deliberately wrong expectation.
457
+ // A non-negative integer, refusing fractional, negative, NaN and string
458
+ // values by name -- this field exists so a verification gate can plant
459
+ // a deliberately wrong expectation and prove the check catches it.
467
460
  let expectedClassificationLines;
468
461
  if ("expectedClassificationLines" in argsObj) {
469
462
  const linesRaw = argsObj.expectedClassificationLines;
@@ -494,9 +487,9 @@ export function normaliseHostToolRequest(raw) {
494
487
  }
495
488
  args.postScript = postScript;
496
489
  }
497
- // Phase 36, plan 36-02: path-bearing -- resolved through
498
- // resolveWorkspacePath() by runHostTool(), only validated here as a
499
- // non-empty string, mirroring preScript/postScript above.
490
+ // Path-bearing -- resolved through resolveWorkspacePath() by
491
+ // runHostTool(), only validated here as a non-empty string, mirroring
492
+ // preScript/postScript above.
500
493
  if ("scriptPath" in argsObj) {
501
494
  const scriptPath = argsObj.scriptPath;
502
495
  if (typeof scriptPath !== "string" || scriptPath === "") {
@@ -518,12 +511,12 @@ export function normaliseHostToolRequest(raw) {
518
511
  }
519
512
  args.exportPath = exportPath;
520
513
  }
521
- // Phase 37, plan 37-08 (AUTO-07): path-bearing -- resolved through
522
- // resolveWorkspacePath() by runHostTool(), only validated here as a
523
- // non-empty string, mirroring scriptPath/entrypointsPath/exportPath
524
- // above. No cross-field requirement: unlike entrypointsPath (which is
525
- // VolatileCarve.java's own positional argument and needs preScript to
526
- // be present), dataRangesPath needs no OTHER script field to be useful.
514
+ // Path-bearing -- resolved through resolveWorkspacePath() by
515
+ // runHostTool(), only validated here as a non-empty string, mirroring
516
+ // scriptPath/entrypointsPath/exportPath above. No cross-field
517
+ // requirement: unlike entrypointsPath (which is VolatileCarve.java's
518
+ // own positional argument and needs preScript to be present),
519
+ // dataRangesPath needs no OTHER script field to be useful.
527
520
  if ("dataRangesPath" in argsObj) {
528
521
  const dataRangesPath = argsObj.dataRangesPath;
529
522
  if (typeof dataRangesPath !== "string" || dataRangesPath === "") {
@@ -531,9 +524,9 @@ export function normaliseHostToolRequest(raw) {
531
524
  }
532
525
  args.dataRangesPath = dataRangesPath;
533
526
  }
534
- // Phase 36, plan 36-02: "a script argument with no script" is refused
535
- // BY NAME rather than silently dropped -- a dropped argument is how a
536
- // run reports success having asserted nothing (must_haves.prohibitions).
527
+ // "A script argument with no script" is refused BY NAME rather than
528
+ // silently dropped -- a dropped argument is how a run reports success
529
+ // having asserted nothing.
537
530
  if (args.entrypointsPath !== undefined && args.preScript === undefined) {
538
531
  return {
539
532
  ok: false,
@@ -555,10 +548,10 @@ export function normaliseHostToolRequest(raw) {
555
548
  return { ok: true, request: { tool, args } };
556
549
  }
557
550
  if (tool === "oracle.probe") {
558
- // 34-08 (CR-01): no key is accepted at all -- the unknown-key check
559
- // above already refused the retired "command" key (and any other key)
560
- // by name, since HOST_TOOL_ARG_KEYS["oracle.probe"] is now empty. No new
561
- // refusal code is needed here.
551
+ // No key is accepted at all -- the unknown-key check above already
552
+ // refused the retired "command" key (and any other key) by name, since
553
+ // HOST_TOOL_ARG_KEYS["oracle.probe"] is now empty. No new refusal code
554
+ // is needed here.
562
555
  return { ok: true, request: { tool, args: {} } };
563
556
  }
564
557
  if (tool === "oracle.run") {
@@ -574,8 +567,7 @@ export function normaliseHostToolRequest(raw) {
574
567
  return { ok: false, message: `host_tool "dxa.disassemble" requires a non-empty string "image"; got ${describe(image)}` };
575
568
  }
576
569
  const imageKindRaw = argsObj.imageKind;
577
- // The enum is exact and case-sensitive -- "PRG" and "prg" never merge
578
- // (must_haves.truths, 35-01-PLAN.md).
570
+ // The enum is exact and case-sensitive -- "PRG" and "prg" never merge.
579
571
  if (imageKindRaw !== "prg" && imageKindRaw !== "flat64k") {
580
572
  return { ok: false, message: `host_tool "dxa.disassemble" args.imageKind must be "prg" or "flat64k"; got ${describe(imageKindRaw)}` };
581
573
  }
@@ -644,12 +636,11 @@ export function normaliseHostToolRequest(raw) {
644
636
  if (typeof image !== "string" || image === "") {
645
637
  return { ok: false, message: `host_tool "${tool}" requires a non-empty string "image"; got ${describe(image)}` };
646
638
  }
647
- // T-40-02-02, D-01/D-02: `name` is a CBM filename or glob pattern --
648
- // REQUIRED, never a path -- and refused BY NAME when its first
649
- // character is a hyphen, before the child is ever spawned. The
650
- // utility's own CLI would otherwise read such a value as a flag, an
651
- // argument-injection route into a host process driven by
652
- // container-side input.
639
+ // `name` is a CBM filename or glob pattern -- REQUIRED, never a path --
640
+ // and refused BY NAME when its first character is a hyphen, before the
641
+ // child is ever spawned. The utility's own CLI would otherwise read
642
+ // such a value as a flag, an argument-injection route into a host
643
+ // process driven by container-side input.
653
644
  const name = argsObj.name;
654
645
  if (typeof name !== "string" || name === "") {
655
646
  return { ok: false, message: `host_tool "${tool}" requires a non-empty string "name"; got ${describe(name)}` };
@@ -691,11 +682,11 @@ export function normaliseHostToolRequest(raw) {
691
682
  return { ok: false, message: `normaliseHostToolRequest: no narrowing arm for tool "${tool}"` };
692
683
  }
693
684
  // ---------------------------------------------------------------------------
694
- // Workspace-relative path resolution (A-03 / T-34-03, CR-05 / 34-10). A
695
- // `host_tool` request never carries a host-absolute path -- every path
696
- // argument is workspace-relative and resolved HERE, against the broker's own
697
- // `--repo-root`, then re-checked to be inside it. This is the ONLY place a
698
- // wire-supplied path becomes a real path.
685
+ // Workspace-relative path resolution. A `host_tool` request never carries a
686
+ // host-absolute path -- every path argument is workspace-relative and
687
+ // resolved HERE, against the broker's own `--repo-root`, then re-checked to
688
+ // be inside it. This is the ONLY place a wire-supplied path becomes a real
689
+ // path.
699
690
  //
700
691
  // BOTH the workspace root and the candidate go through the SAME
701
692
  // ancestor-realpath walk (realpathOfNearestExisting(), below) before the
@@ -706,10 +697,10 @@ export function normaliseHostToolRequest(raw) {
706
697
  // walk: a workspace root that does not yet exist is a legitimate input (a
707
698
  // bare realpath would throw a raw ENOENT), and resolving only the candidate
708
699
  // side makes every in-workspace path look foreign whenever the root itself
709
- // is reached through a symlink. CR-05 (34-VERIFICATION.md gap 3) is what a
710
- // purely lexical path.resolve() + startsWith() check missed: a symlink
711
- // planted inside the workspace, pointing outside it, lexically satisfied the
712
- // prefix check while a real write through it landed outside the root.
700
+ // is reached through a symlink. A live symlink test is what a purely
701
+ // lexical path.resolve() + startsWith() check missed: a symlink planted
702
+ // inside the workspace, pointing outside it, lexically satisfied the prefix
703
+ // check while a real write through it landed outside the root.
713
704
  //
714
705
  // BEHAVIOURAL CONSEQUENCE, intended: because this returns the real path, a
715
706
  // link pointing INSIDE the workspace is FOLLOWED and the request is
@@ -727,13 +718,13 @@ export function normaliseHostToolRequest(raw) {
727
718
  // only in normalisation form are two distinct paths here (the same residual
728
719
  // anno-confinement.test.ts records for the same comparison).
729
720
  //
730
- // A-16 (docs/phase34-host-tool-seam-decisions.md): because the return value
731
- // is now the REAL path, on a host whose workspace root is itself reached
732
- // through a symlink the response `path` need not match any member of
733
- // hostRootCandidates() (containerpath.ts), and containerPath() throws
734
- // rather than passing an untranslatable path through --
735
- // HOST_WORKSPACE_PATH naming the real root is the pre-existing mitigation.
736
- // This is a recorded limit, not a widened hostpath.ts consumer set.
721
+ // A recorded residual: because the return value is now the REAL path, on a
722
+ // host whose workspace root is itself reached through a symlink the
723
+ // response `path` need not match any member of hostRootCandidates()
724
+ // (containerpath.ts), and containerPath() throws rather than passing an
725
+ // untranslatable path through -- HOST_WORKSPACE_PATH naming the real root
726
+ // is the pre-existing mitigation. This is a recorded limit, not a widened
727
+ // hostpath.ts consumer set.
737
728
  // ---------------------------------------------------------------------------
738
729
  /**
739
730
  * The maximum number of DANGLING-symlink hops `realpathOfNearestExisting`
@@ -882,17 +873,15 @@ export function resolveWorkspacePath(repoRoot, relative) {
882
873
  if (!walkedCandidate.ok) {
883
874
  return { ok: false, message: walkedCandidate.message };
884
875
  }
885
- // Phase 40, plan 40-02 (Rule 1 bug, discovered against this plan's own
886
- // literal verify command): when the workspace root walks to the
887
- // filesystem root itself (`walkedRoot.path === sep`, e.g. "/"), appending
888
- // `sep` a second time produces "//" -- a prefix no real absolute path
889
- // ever starts with (`resolvePath()`/`realpathSync()` always normalise to
890
- // a single leading separator), so EVERY candidate under root "/" was
891
- // wrongly refused as "escaping" a root that in fact contains it. A root
892
- // this broad is a legitimate input -- c1541.mjs's own commonAncestorDir()
893
- // (mirroring acme.mjs's) collapses to "/" whenever a committed fixture
894
- // inside the repo and a scratch --out-dir outside it share no smaller
895
- // ancestor, exactly this plan's own Task 1 verify command.
876
+ // A bug fix: when the workspace root walks to the filesystem root itself
877
+ // (`walkedRoot.path === sep`, e.g. "/"), appending `sep` a second time
878
+ // produces "//" -- a prefix no real absolute path ever starts with
879
+ // (`resolvePath()`/`realpathSync()` always normalise to a single leading
880
+ // separator), so EVERY candidate under root "/" was wrongly refused as
881
+ // "escaping" a root that in fact contains it. A root this broad is a
882
+ // legitimate input -- c1541.mjs's own commonAncestorDir() (mirroring
883
+ // acme.mjs's) collapses to "/" whenever a committed fixture inside the
884
+ // repo and a scratch --out-dir outside it share no smaller ancestor.
896
885
  const requiredPrefix = walkedRoot.path === sep ? walkedRoot.path : walkedRoot.path + sep;
897
886
  if (walkedCandidate.path !== walkedRoot.path && !walkedCandidate.path.startsWith(requiredPrefix)) {
898
887
  return {
@@ -902,12 +891,12 @@ export function resolveWorkspacePath(repoRoot, relative) {
902
891
  }
903
892
  return { ok: true, path: walkedCandidate.path };
904
893
  }
905
- /** Deterministic: the same typed request and the same resolved paths yield a
906
- * byte-identical argv array on two successive calls -- no randomness, no
894
+ /** Deterministic: the same typed request and the same resolved paths yield
895
+ * a byte-identical argv array on two successive calls -- no randomness, no
907
896
  * timestamp, no environment-dependent ordering. `log` is OPTIONAL and used
908
- * ONLY by the c1541.dir branch (Phase 40) to report a PATH-fallback binary
909
- * resolution (T-40-02-04, D-16) -- every pre-existing branch ignores it,
910
- * exactly as they already ignore any parameter they do not need. */
897
+ * ONLY by the c1541.dir branch to report a PATH-fallback binary resolution
898
+ * -- every pre-existing branch ignores it, exactly as they already ignore
899
+ * any parameter they do not need. */
911
900
  export function buildHostToolArgv(request, resolved, log) {
912
901
  if (request.tool === "acme.build") {
913
902
  const { args } = request;
@@ -942,7 +931,7 @@ export function buildHostToolArgv(request, resolved, log) {
942
931
  argv.push("-r", `${stem}.rep`);
943
932
  for (const define of args.defines ?? [])
944
933
  argv.push(`-D${define}`);
945
- // Task 1 (CR-03): reads ONLY from resolved.includePaths -- never from
934
+ // Reads ONLY from resolved.includePaths -- never from
946
935
  // request.args.includes -- so argv never carries a raw wire string for
947
936
  // this field. Defensively defaults to [] so a caller that omits
948
937
  // includePaths entirely still yields a valid, empty-include argv rather
@@ -980,16 +969,15 @@ export function buildHostToolArgv(request, resolved, log) {
980
969
  message: `host_tool "ghidra.analyze" refuses: GHIDRA_HOME's resolved launcher does not exist on disk (${ghidraPath})`,
981
970
  };
982
971
  }
983
- // Phase 36, plan 36-02 (D-36-01, Task 2): the checked, NON-MATERIALISING
984
- // language preflight -- refuses by name, before any child process is
985
- // spawned, when the requested processor is not declared by any .ldefs
986
- // Ghidra would load, or is declared but its slafile does not exist on
987
- // disk. This is what makes a language that cannot load a named refusal
988
- // instead of a green run on whatever .sla happens to be in place
989
- // (OPC-04 criterion 1). The preflight CHECKS and NEVER FIXES: it must
990
- // never create a directory, copy a file, invoke support/sleigh, or
991
- // fall back to another language -- doing so would mask exactly the
992
- // failure criterion 1 exists to catch.
972
+ // The checked, NON-MATERIALISING language preflight -- refuses by
973
+ // name, before any child process is spawned, when the requested
974
+ // processor is not declared by any .ldefs Ghidra would load, or is
975
+ // declared but its slafile does not exist on disk. This is what makes a
976
+ // language that cannot load a named refusal instead of a green run on
977
+ // whatever .sla happens to be in place. The preflight CHECKS and NEVER
978
+ // FIXES: it must never create a directory, copy a file, invoke
979
+ // support/sleigh, or fall back to another language -- doing so would
980
+ // mask exactly the failure this check exists to catch.
993
981
  const installedLanguages = installedLanguageIds(ghidraHome);
994
982
  const requestedProcessor = request.args.processor;
995
983
  const matchedLanguage = installedLanguages.find((lang) => lang.id === requestedProcessor);
@@ -1011,18 +999,16 @@ export function buildHostToolArgv(request, resolved, log) {
1011
999
  }
1012
1000
  // Argv construction and the dot-segment re-check both live in
1013
1001
  // ghidra-project.mts's buildAnalyzeHeadlessArgv() -- never re-derived
1014
- // here (A-06).
1015
- // Task 2 (CR-02) / 36-02: reads ONLY from resolved.preScriptPath/
1016
- // postScriptPath/scriptPathResolved/entrypointsPathResolved/
1017
- // exportPathResolved -- never from request.args's own path-shaped
1018
- // fields -- so argv never carries a raw, unresolved wire string for any
1019
- // of them.
1020
- // Phase 36, plan 36-01/36-02: `processor`/`loaderBaseAddr`/`noanalysis`/
1021
- // `expectedClassificationLines` come straight from request.args -- each
1022
- // is a validated non-path value (language id, hex string, boolean,
1023
- // integer), never a path, so none flows through resolveWorkspacePath()
1024
- // and none appears in `resolved` (ResolvedGhidraAnalyzePaths carries
1025
- // paths only).
1002
+ // here.
1003
+ // Reads ONLY from resolved.preScriptPath/postScriptPath/
1004
+ // scriptPathResolved/entrypointsPathResolved/exportPathResolved -- never
1005
+ // from request.args's own path-shaped fields -- so argv never carries a
1006
+ // raw, unresolved wire string for any of them.
1007
+ // `processor`/`loaderBaseAddr`/`noanalysis`/`expectedClassificationLines`
1008
+ // come straight from request.args -- each is a validated non-path value
1009
+ // (language id, hex string, boolean, integer), never a path, so none
1010
+ // flows through resolveWorkspacePath() and none appears in `resolved`
1011
+ // (ResolvedGhidraAnalyzePaths carries paths only).
1026
1012
  const argvInput = {
1027
1013
  projectLocation,
1028
1014
  projectName,
@@ -1044,25 +1030,24 @@ export function buildHostToolArgv(request, resolved, log) {
1044
1030
  argvInput.exportPath = exportPathResolved;
1045
1031
  if (request.args.expectedClassificationLines !== undefined)
1046
1032
  argvInput.expectedClassificationLines = request.args.expectedClassificationLines;
1047
- // Phase 37, plan 37-08 (AUTO-07): reads ONLY from
1048
- // resolved.dataRangesPathResolved -- never from request.args.dataRangesPath
1049
- // -- so argv never carries a raw, unresolved wire string for this field.
1033
+ // Reads ONLY from resolved.dataRangesPathResolved -- never from
1034
+ // request.args.dataRangesPath -- so argv never carries a raw,
1035
+ // unresolved wire string for this field.
1050
1036
  if (dataRangesPathResolved !== undefined)
1051
1037
  argvInput.dataRangesPath = dataRangesPathResolved;
1052
1038
  const built = buildAnalyzeHeadlessArgv(argvInput);
1053
1039
  if (!built.ok)
1054
1040
  return { ok: false, message: built.message };
1055
- // Phase 36, plan 36-01 (D-36-05): outputs[0] is ALWAYS the run log for
1056
- // ghidra.analyze -- a SIBLING of the reserved project directory
1057
- // (never a child of it), because -deleteProject operates INSIDE
1058
- // projectLocation. runHostTool()'s ghidra.analyze branch below writes
1059
- // the child's stdout followed by its stderr here, before the digest
1060
- // loop runs (MEASURED: analyzeHeadless's "Using Language/Compiler:"
1061
- // line arrives on stdout).
1041
+ // outputs[0] is ALWAYS the run log for ghidra.analyze -- a SIBLING of
1042
+ // the reserved project directory (never a child of it), because
1043
+ // -deleteProject operates INSIDE projectLocation. runHostTool()'s
1044
+ // ghidra.analyze branch below writes the child's stdout followed by its
1045
+ // stderr here, before the digest loop runs (MEASURED:
1046
+ // analyzeHeadless's "Using Language/Compiler:" line arrives on stdout).
1062
1047
  const runLogPath = join(dirname(projectLocation), `${projectName}.ghidra-run.log`);
1063
- // Phase 36, plan 36-02: when exportPath is present, it is a SECOND
1064
- // outputs[] entry -- digested by the existing digestOutputFile() loop
1065
- // with no new digest code. outputs[0] stays the run log unconditionally.
1048
+ // When exportPath is present, it is a SECOND outputs[] entry --
1049
+ // digested by the existing digestOutputFile() loop with no new digest
1050
+ // code. outputs[0] stays the run log unconditionally.
1066
1051
  const outputs = exportPathResolved !== undefined ? [runLogPath, exportPathResolved] : [runLogPath];
1067
1052
  return { ok: true, toolPath: ghidraPath, argv: built.argv, outputs };
1068
1053
  }
@@ -1144,15 +1129,15 @@ export function buildHostToolArgv(request, resolved, log) {
1144
1129
  request.tool === "c1541.chain" ||
1145
1130
  request.tool === "c1541.read") {
1146
1131
  const { imagePath, outDirPath } = resolved;
1147
- // T-40-02-04, D-13, D-15: resolved as a SIBLING of whichever x64sc
1148
- // backend-detect.mts already resolved -- never a bare-name spawn, which
1149
- // a host carrying both a stock and a fork build would silently answer
1150
- // with whichever build's directory happens to sort first on $PATH
1151
- // (MEASURED live on this project's own dev host, see the import
1152
- // comment above). `resolvedBackend()` with no `supervisorDir` never
1153
- // touches the on-disk cache; it still memoises in-process, which is
1154
- // what keeps a long-running broker's SECOND call here free -- see the
1155
- // import comment's own memoisation posture.
1132
+ // Resolved as a SIBLING of whichever x64sc backend-detect.mts already
1133
+ // resolved -- never a bare-name spawn, which a host carrying both a
1134
+ // stock and a fork build would silently answer with whichever build's
1135
+ // directory happens to sort first on $PATH (MEASURED live on this
1136
+ // project's own dev host, see the import comment above).
1137
+ // `resolvedBackend()` with no `supervisorDir` never touches the on-disk
1138
+ // cache; it still memoises in-process, which is what keeps a
1139
+ // long-running broker's SECOND call here free -- see the import
1140
+ // comment's own memoisation posture.
1156
1141
  const c1541Found = findSiblingBinary("c1541", resolvedBackend().binPath, log);
1157
1142
  if (c1541Found.path === null) {
1158
1143
  return {
@@ -1172,7 +1157,7 @@ export function buildHostToolArgv(request, resolved, log) {
1172
1157
  // committed fixture, fixtures/c1541/README.md); the seam captures
1173
1158
  // stdout and writes it to a single outputs[] path (TOOLS_WHOSE_OUTPUT_IS_STDOUT
1174
1159
  // above), then digests the FILE -- never c1541's own exit status, which
1175
- // is 0 even on a genuine failure (D-11, MEASURED: "Error - Cannot open
1160
+ // is 0 even on a genuine failure (MEASURED: "Error - Cannot open
1176
1161
  // file ..." exits 0) and is therefore never the pass/fail signal for a
1177
1162
  // listing. `-read` is the one exception: the child writes the output
1178
1163
  // file itself, so its argv passes the produced host path as its own
@@ -1209,8 +1194,8 @@ export function buildHostToolArgv(request, resolved, log) {
1209
1194
  }
1210
1195
  // request.tool === "c1541.read": the single-file byte-extraction route,
1211
1196
  // mirroring extractEntry(image, entryName), the now-deleted MCP-side
1212
- // pure-parse module's own signature (Phase 40 plan 40-06), one-for-one.
1213
- // The produced host path is the child's OWN output argument -- c1541
1197
+ // pure-parse module's own signature, one-for-one. The produced host
1198
+ // path is the child's OWN output argument -- c1541
1214
1199
  // writes it directly, so this tool is deliberately absent from
1215
1200
  // TOOLS_WHOSE_OUTPUT_IS_STDOUT and the existing digest loop picks the
1216
1201
  // file up unchanged.
@@ -1219,9 +1204,9 @@ export function buildHostToolArgv(request, resolved, log) {
1219
1204
  }
1220
1205
  if (request.tool === "petcat.decode") {
1221
1206
  const { imagePath, outDirPath } = resolved;
1222
- // D-13/D-15, same mechanism c1541.* already use above: resolved as a
1223
- // SIBLING of whichever x64sc backend-detect.mts already resolved, never
1224
- // a bare-name spawn, with a logged $PATH-fallback warning.
1207
+ // Resolved as a SIBLING of whichever x64sc backend-detect.mts already
1208
+ // resolved, never a bare-name spawn, with a logged $PATH-fallback
1209
+ // warning -- the same mechanism c1541.* already use above.
1225
1210
  const petcatFound = findSiblingBinary("petcat", resolvedBackend().binPath, log);
1226
1211
  if (petcatFound.path === null) {
1227
1212
  return {
@@ -1230,19 +1215,19 @@ export function buildHostToolArgv(request, resolved, log) {
1230
1215
  };
1231
1216
  }
1232
1217
  const petcatPath = petcatFound.path;
1233
- // D-24: the BASIC dialect is a FIXED literal here, server-side -- "-2"
1218
+ // The BASIC dialect is a FIXED literal here, server-side -- "-2"
1234
1219
  // (BASIC V2.0, every stock C64's own dialect), first in argv, ahead of
1235
1220
  // the resolved image path LAST. There is no wire field that selects it
1236
1221
  // (HOST_TOOL_ARG_KEYS["petcat.decode"] carries no such key), nothing
1237
1222
  // validates it, and no caller can request a different one.
1238
1223
  const argv = ["-2", imagePath];
1239
- // A-03-style: petcat has no output-file option for a plain decode --
1240
- // every listing line is printed to its own stdout (MEASURED, this
1241
- // plan's own scratch runs against both committed fixtures, see
1242
- // fixtures/petcat/README.md). The seam captures stdout and writes it to
1243
- // this single outputs[] path, then digests the FILE -- never petcat's
1244
- // own exit status, which is 0 even on garbage input (D-11, MEASURED)
1245
- // and therefore never the pass/fail signal for a listing.
1224
+ // petcat has no output-file option for a plain decode -- every
1225
+ // listing line is printed to its own stdout (MEASURED, a scratch run
1226
+ // against both committed fixtures, see fixtures/petcat/README.md). The
1227
+ // seam captures stdout and writes it to this single outputs[] path,
1228
+ // then digests the FILE -- never petcat's own exit status, which is 0
1229
+ // even on garbage input (MEASURED) and therefore never the pass/fail
1230
+ // signal for a listing.
1246
1231
  const imageStem = basename(imagePath).replace(/\.[^./]+$/, "");
1247
1232
  const listingPath = join(outDirPath, `${imageStem}.bas.txt`);
1248
1233
  return { ok: true, toolPath: petcatPath, argv, outputs: [listingPath] };
@@ -1250,93 +1235,90 @@ export function buildHostToolArgv(request, resolved, log) {
1250
1235
  return { ok: false, message: `buildHostToolArgv: no argv builder for tool "${request.tool}"` };
1251
1236
  }
1252
1237
  // ---------------------------------------------------------------------------
1253
- // Async child-process invocation and result digest (SEAM-02, SEAM-03,
1254
- // T-34-02, T-34-05, T-34-06). NEVER `spawnSync` -- broker-kill.mts's
1255
- // uncaughtException/unhandledRejection handlers kill the ENTIRE VICE pool on
1256
- // any unhandled throw in this process, and a synchronous spawn for a
1257
- // multi-second tool run would block the single-threaded event loop for its
1258
- // whole duration, starving acquires, the launching -> ready promotion sweep
1259
- // (plan 41-05 retires the warm floor this comment used to name alongside
1260
- // it), and monitor claims.
1238
+ // Async child-process invocation and result digest. NEVER `spawnSync` --
1239
+ // broker-kill.mts's uncaughtException/unhandledRejection handlers kill the
1240
+ // ENTIRE VICE pool on any unhandled throw in this process, and a
1241
+ // synchronous spawn for a multi-second tool run would block the
1242
+ // single-threaded event loop for its whole duration, starving acquires,
1243
+ // the launching -> ready promotion sweep (the warm floor this comment used
1244
+ // to name alongside it was later retired), and monitor claims.
1261
1245
  // ---------------------------------------------------------------------------
1262
1246
  /** Fallback per-invocation timeout for a tool id absent from
1263
- * HOST_TOOL_TIMEOUT_MS below -- unreachable today, since every HOST_TOOL_IDS
1264
- * member has an explicit table entry, but this constant stays exported and
1265
- * consulted as the honest bottom of the resolver's fallback chain. It is
1266
- * also the value acme.build/oracle.probe/oracle.run's own table entries
1267
- * hold today (20s, the same value packer-finding.mjs's own
1268
- * ORACLE_TIMEOUT_MS convention already used) -- no longer the ceiling for
1269
- * EVERY invocation (34-09, CR-04): a single default governing every tool is
1270
- * exactly how CR-04 happened -- a number chosen for a stateless assembler
1271
- * silently governed a JVM. */
1247
+ * HOST_TOOL_TIMEOUT_MS below -- unreachable today, since every
1248
+ * HOST_TOOL_IDS member has an explicit table entry, but this constant
1249
+ * stays exported and consulted as the honest bottom of the resolver's
1250
+ * fallback chain. It is also the value acme.build/oracle.probe/oracle.run's
1251
+ * own table entries hold today (20s, the same value packer-finding.mjs's
1252
+ * own ORACLE_TIMEOUT_MS convention already used) -- no longer the ceiling
1253
+ * for EVERY invocation: a single default governing every tool is exactly
1254
+ * how a real incident happened -- a number chosen for a stateless
1255
+ * assembler silently governed a JVM. */
1272
1256
  export const DEFAULT_HOST_TOOL_TIMEOUT_MS = 20_000;
1273
- /** 34-09 (CR-04): the per-tool SERVER-side budget table, built with the SAME
1257
+ /** The per-tool SERVER-side budget table, built with the SAME
1274
1258
  * `Object.freeze(Object.assign(Object.create(null), ...))` idiom
1275
1259
  * HOST_TOOL_ARG_KEYS uses, with an entry for EVERY HOST_TOOL_IDS member --
1276
- * completeness enforced by host-tool.test.ts's own completeness case, never
1277
- * assumed silently. `acme.build`, `oracle.probe` and `oracle.run` keep the
1278
- * value DEFAULT_HOST_TOOL_TIMEOUT_MS already held (20_000ms) -- none of
1279
- * their measured costs approach the fixed ceiling. `ghidra.analyze` gets
1280
- * 600_000ms (10 minutes), justified from this project's own recorded
1260
+ * completeness enforced by host-tool.test.ts's own completeness case,
1261
+ * never assumed silently. `acme.build`, `oracle.probe` and `oracle.run`
1262
+ * keep the value DEFAULT_HOST_TOOL_TIMEOUT_MS already held (20_000ms) --
1263
+ * none of their measured costs approach the fixed ceiling. `ghidra.analyze`
1264
+ * gets 600_000ms (10 minutes), justified from this project's own recorded
1281
1265
  * numbers rather than a round guess: the documented JVM startup range is
1282
- * 12.6-17.4s (docs/phase34-host-tool-seam-decisions.md Part 1), this
1283
- * phase's own transcript measured 12407ms and 11160ms for a *refusal* alone,
1284
- * and the same decision record states a real analysis run takes multiple
1285
- * minutes -- 10 minutes clears startup plus a realistic analysis budget
1286
- * with headroom, while staying a finite, stated ceiling: raising a budget
1287
- * must never mean removing the kill-on-expiry bound
1288
- * (must_haves.prohibitions) -- spawnHostTool()'s timer below still kills and
1289
- * reports a refusal on expiry, unchanged. */
1266
+ * 12.6-17.4s, live testing measured 12407ms and 11160ms for a *refusal*
1267
+ * alone, and the same measurement record states a real analysis run takes
1268
+ * multiple minutes -- 10 minutes clears startup plus a realistic analysis
1269
+ * budget with headroom, while staying a finite, stated ceiling: raising a
1270
+ * budget must never mean removing the kill-on-expiry bound --
1271
+ * spawnHostTool()'s timer below still kills and reports a refusal on
1272
+ * expiry, unchanged. */
1290
1273
  export const HOST_TOOL_TIMEOUT_MS = Object.freeze(Object.assign(Object.create(null), {
1291
1274
  "acme.build": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1292
1275
  "ghidra.analyze": 600_000,
1293
1276
  "oracle.probe": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1294
1277
  "oracle.run": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1295
- // Phase 35, plan 35-01 (A-05): DEFAULT_HOST_TOOL_TIMEOUT_MS, justified
1296
- // from a measurement rather than a round guess -- the pinned dxa
1297
- // disassembles a full 65,536-byte image in 21ms wall-clock (MEASURED),
1298
- // a 950x headroom against this 20s ceiling. host-tool-client.ts's
1299
- // request-deadline table gains NO entry for this tool, because
1300
- // DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
1301
- // value -- the cross-seam ordering test stays satisfied by construction.
1302
- "dxa.disassemble": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1303
- // Phase 36, plan 36-01: DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a
1304
- // measurement rather than a round guess -- `support/sleigh` compiled
1305
- // this extension's whole vendored tree in 1763ms wall-clock (MEASURED,
1306
- // this plan's own scratch run), an ~11x headroom against this 20s
1278
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a measurement rather
1279
+ // than a round guess -- the pinned dxa disassembles a full 65,536-byte
1280
+ // image in 21ms wall-clock (MEASURED), a 950x headroom against this 20s
1307
1281
  // ceiling. host-tool-client.ts's request-deadline table gains NO entry
1308
- // for this tool, for the same reason dxa.disassemble's own comment
1309
- // above states: DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already
1310
- // exceeds this value.
1282
+ // for this tool, because DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000)
1283
+ // already exceeds this value -- the cross-seam ordering test stays
1284
+ // satisfied by construction.
1285
+ "dxa.disassemble": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1286
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a measurement rather
1287
+ // than a round guess -- `support/sleigh` compiled this extension's
1288
+ // whole vendored tree in 1763ms wall-clock (MEASURED, a scratch run),
1289
+ // an ~11x headroom against this 20s ceiling. host-tool-client.ts's
1290
+ // request-deadline table gains NO entry for this tool, for the same
1291
+ // reason dxa.disassemble's own comment above states:
1292
+ // DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
1293
+ // value.
1311
1294
  "ghidra.installExtension": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1312
- // Phase 40, plan 40-02 (Task 1 -- the tracer): DEFAULT_HOST_TOOL_TIMEOUT_MS,
1313
- // justified from a measurement rather than a round guess -- `c1541
1314
- // -attach fixtures/c1541/synthetic.d64 -dir` completed in 15ms
1315
- // wall-clock (MEASURED, this plan's own scratch run against the
1316
- // committed fixture), a >1300x headroom against this 20s ceiling.
1295
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a measurement rather
1296
+ // than a round guess -- `c1541 -attach fixtures/c1541/synthetic.d64
1297
+ // -dir` completed in 15ms wall-clock (MEASURED, a scratch run against
1298
+ // the committed fixture), a >1300x headroom against this 20s ceiling.
1317
1299
  // host-tool-client.ts's request-deadline table gains NO entry for this
1318
1300
  // tool, for the same reason dxa.disassemble's own comment above states:
1319
1301
  // DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
1320
1302
  // value.
1321
1303
  "c1541.dir": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1322
- // Phase 40, plan 40-02 (Task 2): DEFAULT_HOST_TOOL_TIMEOUT_MS for all
1323
- // four, justified from a measurement rather than a round guess -- each
1324
- // of `c1541 -attach fixtures/c1541/synthetic.d64 -bam`, `-entry
1325
- // basicstub`, `-chain basicstub` and `-read basicstub <out>` completed
1326
- // in 14-16ms wall-clock (MEASURED, this plan's own scratch run against
1327
- // the committed fixture), a >1200x headroom against this 20s ceiling.
1328
- // host-tool-client.ts's request-deadline table gains NO entry for any
1329
- // of these, for the same reason c1541.dir's own comment above states.
1304
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS for all four, justified from a
1305
+ // measurement rather than a round guess -- each of `c1541 -attach
1306
+ // fixtures/c1541/synthetic.d64 -bam`, `-entry basicstub`, `-chain
1307
+ // basicstub` and `-read basicstub <out>` completed in 14-16ms
1308
+ // wall-clock (MEASURED, a scratch run against the committed fixture),
1309
+ // a >1200x headroom against this 20s ceiling. host-tool-client.ts's
1310
+ // request-deadline table gains NO entry for any of these, for the same
1311
+ // reason c1541.dir's own comment above states.
1330
1312
  "c1541.bam": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1331
1313
  "c1541.entry": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1332
1314
  "c1541.chain": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1333
1315
  "c1541.read": DEFAULT_HOST_TOOL_TIMEOUT_MS,
1334
- // Phase 40, plan 40-03: DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a
1335
- // measurement rather than a round guess -- `petcat -2` completed in
1336
- // 1-2ms wall-clock against both committed fixtures (MEASURED, this
1337
- // plan's own scratch run), a >10000x headroom against this 20s ceiling.
1338
- // host-tool-client.ts's request-deadline table gains NO entry for this
1339
- // tool, for the same reason c1541.dir's own comment above states:
1316
+ // DEFAULT_HOST_TOOL_TIMEOUT_MS, justified from a measurement rather
1317
+ // than a round guess -- `petcat -2` completed in 1-2ms wall-clock
1318
+ // against both committed fixtures (MEASURED, a scratch run), a
1319
+ // >10000x headroom against this 20s ceiling. host-tool-client.ts's
1320
+ // request-deadline table gains NO entry for this tool, for the same
1321
+ // reason c1541.dir's own comment above states:
1340
1322
  // DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS (30_000) already exceeds this
1341
1323
  // value.
1342
1324
  "petcat.decode": DEFAULT_HOST_TOOL_TIMEOUT_MS,
@@ -1362,63 +1344,63 @@ const STDERR_TAIL_CAP_BYTES = 64 * 1024;
1362
1344
  * exporting the number for its own parser and its own tests -- not a
1363
1345
  * duplicated maintenance burden, the same measured constant on both sides. */
1364
1346
  const ORACLE_STDOUT_CAP_BYTES = 64 * 1024;
1365
- /** WR-03 (40-REVIEW.md): the hard ceiling spawnHostTool()'s own stdout/
1366
- * stderr accumulation enforces, independent of the DEFAULT_HOST_TOOL_TIMEOUT_MS
1367
- * wall-clock kill below -- previously the ONLY bound on a runaway child was
1368
- * the timeout, so a pathological process could grow an unbounded in-memory
1369
- * string for its full allotted budget. c1541.chain/c1541.bam are run
1370
- * directly against untrusted, possibly-corrupt disk images (this project's
1371
- * own audit tooling exists specifically to detect fabricated/cyclic
1372
- * directory structures), and c1541 has no documented guard of its own
1373
- * against a cyclic DATA sector chain.
1347
+ /** The hard ceiling spawnHostTool()'s own stdout/stderr accumulation
1348
+ * enforces, independent of the DEFAULT_HOST_TOOL_TIMEOUT_MS wall-clock kill
1349
+ * below -- previously the ONLY bound on a runaway child was the timeout,
1350
+ * so a pathological process could grow an unbounded in-memory string for
1351
+ * its full allotted budget. c1541.chain/c1541.bam are run directly against
1352
+ * untrusted, possibly-corrupt disk images (this project's own audit
1353
+ * tooling exists specifically to detect fabricated/cyclic directory
1354
+ * structures), and c1541 has no documented guard of its own against a
1355
+ * cyclic DATA sector chain.
1374
1356
  *
1375
1357
  * Deliberately NOT set to STDOUT_CLASSIFY_CAP_BYTES (64KiB) or "a slightly
1376
- * larger" ceiling close to it, despite that being this finding's own literal
1377
- * suggestion: TOOLS_WHOSE_OUTPUT_IS_STDOUT tools (dxa.disassemble, all four
1378
- * stdout-shaped c1541.* ids, petcat.decode) write the FULL captured stdout
1379
- * verbatim to their declared output file below (the writeFileSync() call
1380
- * right after the spawn) -- a full dxa.disassemble listing for a real
1381
- * 64KB-image fixture already measures well past 64KiB of text, so a cap
1382
- * anywhere near that size would silently truncate a legitimate disassembly
1383
- * into a corrupt, incomplete listing every time it ran, not just on a
1384
- * malicious input. This ceiling is sized purely as a runaway-memory guard
1385
- * against a pathological/looping child, far above any legitimate output
1386
- * this seam produces today. */
1358
+ * larger" ceiling close to it, even though a smaller cap was the initial
1359
+ * suggestion: TOOLS_WHOSE_OUTPUT_IS_STDOUT tools (dxa.disassemble, all
1360
+ * four stdout-shaped c1541.* ids, petcat.decode) write the FULL captured
1361
+ * stdout verbatim to their declared output file below (the
1362
+ * writeFileSync() call right after the spawn) -- a full dxa.disassemble
1363
+ * listing for a real 64KB-image fixture already measures well past 64KiB
1364
+ * of text, so a cap anywhere near that size would silently truncate a
1365
+ * legitimate disassembly into a corrupt, incomplete listing every time it
1366
+ * ran, not just on a malicious input. This ceiling is sized purely as a
1367
+ * runaway-memory guard against a pathological/looping child, far above any
1368
+ * legitimate output this seam produces today. */
1387
1369
  const SPAWN_ACCUMULATION_HARD_CAP_BYTES = 64 * 1024 * 1024;
1388
- /** Phase 40, plan 40-02: which tool ids write NO output file of their own --
1389
- * their "output" IS the captured stdout, so runHostTool() turns it into a
1390
- * file itself before the digest loop runs (see the usage site below).
1391
- * Replaces the condition that used to name only "dxa.disassemble" directly
1392
- * -- a future addition is one entry in this frozen set, never a
1393
- * near-duplicate `if` branch. `c1541.read` is deliberately ABSENT -- its
1394
- * argv passes the produced host path as the child's own output argument
1395
- * (`-read <name> <outputPath>`), so the child writes that file itself and
1396
- * the existing digest loop picks it up unchanged. */
1370
+ /** Which tool ids write NO output file of their own -- their "output" IS
1371
+ * the captured stdout, so runHostTool() turns it into a file itself before
1372
+ * the digest loop runs (see the usage site below). Replaces the condition
1373
+ * that used to name only "dxa.disassemble" directly -- a future addition
1374
+ * is one entry in this frozen set, never a near-duplicate `if` branch.
1375
+ * `c1541.read` is deliberately ABSENT -- its argv passes the produced host
1376
+ * path as the child's own output argument (`-read <name> <outputPath>`),
1377
+ * so the child writes that file itself and the existing digest loop picks
1378
+ * it up unchanged. */
1397
1379
  const TOOLS_WHOSE_OUTPUT_IS_STDOUT = new Set([
1398
1380
  "dxa.disassemble",
1399
1381
  "c1541.bam",
1400
1382
  "c1541.dir",
1401
1383
  "c1541.entry",
1402
1384
  "c1541.chain",
1403
- // Phase 40, plan 40-03: petcat.decode has no output-file option for a
1404
- // plain decode -- every listing line is printed to its own stdout,
1405
- // exactly like dxa.disassemble/c1541.* above.
1385
+ // petcat.decode has no output-file option for a plain decode -- every
1386
+ // listing line is printed to its own stdout, exactly like
1387
+ // dxa.disassemble/c1541.* above.
1406
1388
  "petcat.decode",
1407
1389
  ]);
1408
- /** Phase 40, plan 40-02 (Task 2): the byte cap this module's c1541.bam/
1409
- * c1541.entry/c1541.chain classifiers apply to the captured text BEFORE
1410
- * testing it against a declared shape -- spawnHostTool()'s own stdout
1411
- * accumulation has no explicit bound today, and a sector chain
1412
- * (c1541.chain) is the first disk-image-driven input that could make it
1413
- * large. Same value and the same tail/cap convention (tailBytes(), keep
1414
- * the END, not the start) STDERR_TAIL_CAP_BYTES below already applies. */
1390
+ /** The byte cap this module's c1541.bam/c1541.entry/c1541.chain
1391
+ * classifiers apply to the captured text BEFORE testing it against a
1392
+ * declared shape -- spawnHostTool()'s own stdout accumulation has no
1393
+ * explicit bound today, and a sector chain (c1541.chain) is the first
1394
+ * disk-image-driven input that could make it large. Same value and the
1395
+ * same tail/cap convention (tailBytes(), keep the END, not the start)
1396
+ * STDERR_TAIL_CAP_BYTES below already applies. */
1415
1397
  const STDOUT_CLASSIFY_CAP_BYTES = 64 * 1024;
1416
1398
  /** Total `Readonly<Record<HostToolId, HostToolOutputClassifier | null>>` --
1417
1399
  * built with the SAME `Object.freeze(Object.assign(Object.create(null),
1418
1400
  * ...))` idiom HOST_TOOL_ARG_KEYS uses. Pre-existing ids map to `null`,
1419
1401
  * meaning "no declared shape, behaviour unchanged" -- NEVER absent, so a
1420
1402
  * tool id never falls through to an implicit success. `host-tool.test.ts`'s
1421
- * completeness case (Task 3) asserts every HOST_TOOL_IDS member has an own
1403
+ * completeness case asserts every HOST_TOOL_IDS member has an own
1422
1404
  * property here. */
1423
1405
  export const HOST_TOOL_OUTPUT_CLASSIFIERS = Object.freeze(Object.assign(Object.create(null), {
1424
1406
  "acme.build": null,
@@ -1427,38 +1409,38 @@ export const HOST_TOOL_OUTPUT_CLASSIFIERS = Object.freeze(Object.assign(Object.c
1427
1409
  "oracle.run": null,
1428
1410
  "dxa.disassemble": null,
1429
1411
  "ghidra.installExtension": null,
1430
- // Declared shape (D-09, MEASURED against the committed fixture): a
1412
+ // Declared shape (MEASURED against the committed fixture): a
1431
1413
  // `<N> blocks free` trailer. MEASURED also: c1541 prints an "OPENCBM:
1432
1414
  // opening dynamic library libopencbm.so failed!" complaint to stdout on
1433
1415
  // EVERY call on this host -- a negative stderr oracle would be wrong
1434
1416
  // here, since the complaint lands on stdout, not stderr, and is
1435
1417
  // unrelated to whether the listing itself succeeded.
1436
1418
  "c1541.dir": ((ctx) => classifyC1541DirOutput(ctx.stdout)),
1437
- // Declared shape (D-09, MEASURED against the committed fixture): at
1438
- // least one per-sector allocation row -- a digit-prefixed line
1439
- // followed by a run of `*`/`.` characters.
1419
+ // Declared shape (MEASURED against the committed fixture): at least
1420
+ // one per-sector allocation row -- a digit-prefixed line followed by
1421
+ // a run of `*`/`.` characters.
1440
1422
  "c1541.bam": ((ctx) => classifyC1541BamOutput(ctx.stdout)),
1441
- // Declared shape (D-09, MEASURED against the committed fixture): a
1423
+ // Declared shape (MEASURED against the committed fixture): a
1442
1424
  // `T/S: <t>/<s>, <n> blocks` line.
1443
1425
  "c1541.entry": ((ctx) => classifyC1541EntryOutput(ctx.stdout)),
1444
- // Declared shape (D-09, MEASURED against the committed fixture): at
1445
- // least one `(track,sector) ->` arrow pair. MEASURED: a single-sector
1446
- // file's chain output does NOT repeat a second (track,sector) tuple on
1447
- // the right of the arrow -- the LAST hop prints only the byte count
1448
- // used in the final sector, a plain integer -- so this classifier
1449
- // matches on "at least one (t,s) followed by an arrow", not on a
1426
+ // Declared shape (MEASURED against the committed fixture): at least
1427
+ // one `(track,sector) ->` arrow pair. MEASURED: a single-sector file's
1428
+ // chain output does NOT repeat a second (track,sector) tuple on the
1429
+ // right of the arrow -- the LAST hop prints only the byte count used
1430
+ // in the final sector, a plain integer -- so this classifier matches
1431
+ // on "at least one (t,s) followed by an arrow", not on a
1450
1432
  // tuple-on-both-sides shape (see fixtures/c1541/README.md).
1451
1433
  "c1541.chain": ((ctx) => classifyC1541ChainOutput(ctx.stdout)),
1452
- // Declared shape (D-09): `results` contains exactly one entry whose
1434
+ // Declared shape: `results` contains exactly one entry whose
1453
1435
  // `byteLength` is greater than zero -- reads `results`, not captured
1454
1436
  // text, since c1541.read is absent from TOOLS_WHOSE_OUTPUT_IS_STDOUT
1455
1437
  // (the child writes its own output file).
1456
1438
  "c1541.read": ((ctx) => classifyC1541ReadOutput(ctx.results)),
1457
- // Declared shape (D-09, MEASURED against both committed fixtures): the
1439
+ // Declared shape (MEASURED against both committed fixtures): the
1458
1440
  // leading banner line petcat -2 prints for a recognised BASIC program,
1459
- // `;<path> ==<hex>==`. MEASURED also: petcat exits 0 on garbage input
1460
- // (D-11) and its output for a truly non-BASIC file carries no such
1461
- // banner at all -- confirmed live against 64 random bytes this plan.
1441
+ // `;<path> ==<hex>==`. MEASURED also: petcat exits 0 on garbage input,
1442
+ // and its output for a truly non-BASIC file carries no such banner at
1443
+ // all -- confirmed live against 64 random bytes.
1462
1444
  "petcat.decode": ((ctx) => classifyPetcatDecodeOutput(ctx.stdout)),
1463
1445
  }));
1464
1446
  /** The declared success shape for c1541.dir's captured stdout: at least one
@@ -1525,8 +1507,8 @@ export function classifyC1541ReadOutput(results) {
1525
1507
  * Absence of it means the file was not recognised as a BASIC program at
1526
1508
  * all -- MEASURED against 64 random bytes, whose captured output carries a
1527
1509
  * leading `;<path> ` but never the `==<hex>==` pair that follows it for a
1528
- * real BASIC program. petcat exits 0 either way (D-11) -- this classifier,
1529
- * not the exit code, is what decides success here. */
1510
+ * real BASIC program. petcat exits 0 either way -- this classifier, not
1511
+ * the exit code, is what decides success here. */
1530
1512
  export function classifyPetcatDecodeOutput(stdout) {
1531
1513
  if (/;\S+\s+==[0-9a-fA-F]+==/.test(stdout))
1532
1514
  return { ok: true };
@@ -1535,18 +1517,17 @@ export function classifyPetcatDecodeOutput(stdout) {
1535
1517
  reason: `petcat.decode: captured stdout carries no ";<path> ==<hex>==" banner -- the file was not recognised as a BASIC program -- got: ${describe(tailBytes(stdout, 512))}`,
1536
1518
  };
1537
1519
  }
1538
- /** Phase 40, plan 40-03 (PREP-02, D-21, D-22): parses `petcat -2`'s own
1539
- * detokenized BASIC listing for the program's own machine-code handover
1540
- * instruction (`SYS`). Pure and total -- never throws -- reading ONLY the
1541
- * classifier-accepted captured stdout, called host-side immediately after
1542
- * the classifier above accepts. Three cases, ALL successes (D-21) --
1543
- * `ok: false` is reserved for the classifier's own shape refusal above,
1544
- * never for an unresolved SYS argument:
1520
+ /** Parses `petcat -2`'s own detokenized BASIC listing for the program's own
1521
+ * machine-code handover instruction (`SYS`). Pure and total -- never
1522
+ * throws -- reading ONLY the classifier-accepted captured stdout, called
1523
+ * host-side immediately after the classifier above accepts. Three cases,
1524
+ * ALL successes -- `ok: false` is reserved for the classifier's own shape
1525
+ * refusal above, never for an unresolved SYS argument:
1545
1526
  * - an all-decimal-digit argument resolves to a numeric entry point,
1546
1527
  * named by the BASIC line it came from (the literal fast path);
1547
1528
  * - anything else is a named decline quoting the unresolved expression
1548
1529
  * verbatim, so a reader sees exactly what could not be resolved (the
1549
- * computed case, D-23's own fixture);
1530
+ * computed case, whose own fixture exercises it);
1550
1531
  * - no SYS token at all is a named decline saying so.
1551
1532
  * Matches the FIRST BASIC line whose statement (immediately after the line
1552
1533
  * number) is the `sys` keyword -- petcat's own detokenized output always
@@ -1561,7 +1542,7 @@ export function derivePetcatEntrypoint(detokenizedText) {
1561
1542
  const basicLine = m[1];
1562
1543
  const argument = m[2];
1563
1544
  if (/^\d+$/.test(argument)) {
1564
- // WR-02 (40-REVIEW.md): an all-decimal-digit SYS argument used to be
1545
+ // A fix for a real bug: an all-decimal-digit SYS argument used to be
1565
1546
  // accepted as a literal entry point with no upper-bound check --
1566
1547
  // `Number()` converts an arbitrarily long digit string (with silent
1567
1548
  // precision loss past 2^53) and a BASIC program can legally contain
@@ -1597,8 +1578,8 @@ export function derivePetcatEntrypoint(detokenizedText) {
1597
1578
  * from a produced-but-empty file). */
1598
1579
  function digestOutputFile(path) {
1599
1580
  try {
1600
- // WR-03: byteLength must describe the SAME bytes sha256 was computed
1601
- // over -- derived from the buffer actually read, never from a separate
1581
+ // byteLength must describe the SAME bytes sha256 was computed over --
1582
+ // derived from the buffer actually read, never from a separate
1602
1583
  // statSync() call, which could observe a different byte string if the
1603
1584
  // file is written to between the two reads.
1604
1585
  const contents = readFileSync(path);
@@ -1617,21 +1598,22 @@ function tailBytes(text, capBytes) {
1617
1598
  return text;
1618
1599
  return buf.subarray(buf.length - capBytes).toString("utf8");
1619
1600
  }
1620
- /** Spawns `toolPath` with `argv` (an ARRAY, never a shell string; the command
1621
- * interpreter is never enabled) and resolves -- NEVER rejects -- once the
1622
- * child exits, errors, or is killed on timeout expiry. This is the ONE spawn
1623
- * call in this module -- oracle.probe/oracle.run (34-04, SEAM-05) reuse it
1624
- * rather than adding a second. `env` defaults to the broker process's own
1625
- * environment (`spawn()`'s own default) when omitted; acme.build overrides it
1626
- * to inject a probed `ACME` library directory (see `findAcmeLib()` below).
1627
- * `stdout` is captured (not just `stderr`) because oracle.run's contract is
1628
- * "the oracle's stdout", not a file digest -- acme.build/ghidra.analyze
1629
- * simply ignore the field, exactly as they ignored stdout before it was
1630
- * piped (ACME writes nothing to stdout; verified empirically this phase).
1631
- * `cwd` defaults to the broker process's own working directory (`spawn()`'s
1632
- * own default) when omitted -- exactly `env`'s existing default shape; only
1633
- * `acme.build` passes one (Phase 47, plan 47-01), and every other tool's
1634
- * spawn is therefore byte-identical to before this parameter existed. */
1601
+ /** Spawns `toolPath` with `argv` (an ARRAY, never a shell string; the
1602
+ * command interpreter is never enabled) and resolves -- NEVER rejects --
1603
+ * once the child exits, errors, or is killed on timeout expiry. This is the
1604
+ * ONE spawn call in this module -- oracle.probe/oracle.run reuse it rather
1605
+ * than adding a second. `env` defaults to the broker process's own
1606
+ * environment (`spawn()`'s own default) when omitted; acme.build overrides
1607
+ * it to inject a probed `ACME` library directory (see `findAcmeLib()`
1608
+ * below). `stdout` is captured (not just `stderr`) because oracle.run's
1609
+ * contract is "the oracle's stdout", not a file digest -- acme.build/
1610
+ * ghidra.analyze simply ignore the field, exactly as they ignored stdout
1611
+ * before it was piped (ACME writes nothing to stdout; verified
1612
+ * empirically). `cwd` defaults to the broker process's own working
1613
+ * directory (`spawn()`'s own default) when omitted -- exactly `env`'s
1614
+ * existing default shape; only `acme.build` passes one, and every other
1615
+ * tool's spawn is therefore byte-identical to before this parameter
1616
+ * existed. */
1635
1617
  function spawnHostTool(toolPath, argv, timeoutMs, env, cwd) {
1636
1618
  return new Promise((resolvePromise) => {
1637
1619
  let settled = false;
@@ -1658,17 +1640,17 @@ function spawnHostTool(toolPath, argv, timeoutMs, env, cwd) {
1658
1640
  }, timeoutMs);
1659
1641
  if (typeof timer.unref === "function")
1660
1642
  timer.unref();
1661
- // WR-03: stop appending once the hard ceiling is reached, rather than
1662
- // capping via tailBytes()'s "keep the end" convention used elsewhere in
1663
- // this module -- unlike stderrTail/the classifier window (diagnostics
1664
- // only), this accumulated string doubles as the literal file content for
1643
+ // Stop appending once the hard ceiling is reached, rather than capping
1644
+ // via tailBytes()'s "keep the end" convention used elsewhere in this
1645
+ // module -- unlike stderrTail/the classifier window (diagnostics only),
1646
+ // this accumulated string doubles as the literal file content for
1665
1647
  // TOOLS_WHOSE_OUTPUT_IS_STDOUT tools, so preserving the HEAD (the
1666
1648
  // already-received, in-order prefix) rather than an arbitrary tail
1667
1649
  // fragment keeps a capped run's written output internally coherent (a
1668
1650
  // truncated-but-ordered listing) instead of discarding its beginning.
1669
1651
  // The ceiling itself is set far above any legitimate output this seam
1670
- // produces (see SPAWN_ACCUMULATION_HARD_CAP_BYTES above), so this branch
1671
- // is never taken on a normal, successful run.
1652
+ // produces (see SPAWN_ACCUMULATION_HARD_CAP_BYTES above), so this
1653
+ // branch is never taken on a normal, successful run.
1672
1654
  child.stdout?.on("data", (chunk) => {
1673
1655
  if (stdout.length < SPAWN_ACCUMULATION_HARD_CAP_BYTES) {
1674
1656
  stdout += chunk.toString("utf8");
@@ -1696,31 +1678,31 @@ function spawnHostTool(toolPath, argv, timeoutMs, env, cwd) {
1696
1678
  });
1697
1679
  }
1698
1680
  // ---------------------------------------------------------------------------
1699
- // The ACME library probe (34-04, SEAM-05). Moved server-side from
1700
- // acme.mjs's own findAcmeLib(): the project owner's rule of 2026-08-28 is
1701
- // that a container has no PATH to a host binary, and these five candidates
1702
- // are HOST paths -- so probing them belongs on the host side of the seam,
1703
- // not in the container-side skill script. Behaviourally identical to the
1704
- // removed client-side function: same candidate order, same marker file, same
1705
- // "first candidate whose marker exists wins" rule.
1681
+ // The ACME library probe. Moved server-side from acme.mjs's own
1682
+ // findAcmeLib(): the project owner's rule is that a container has no PATH
1683
+ // to a host binary, and these five candidates are HOST paths -- so probing
1684
+ // them belongs on the host side of the seam, not in the container-side
1685
+ // skill script. Behaviourally identical to the removed client-side
1686
+ // function: same candidate order, same marker file, same "first candidate
1687
+ // whose marker exists wins" rule.
1706
1688
  // ---------------------------------------------------------------------------
1707
1689
  /** The marker file used to validate a candidate ACME library directory --
1708
1690
  * the layout fact `acme.mjs`'s own troubleshooting hint names. */
1709
1691
  const ACME_LIB_MARKER = join("cbm", "c64", "vic.a");
1710
1692
  // ---------------------------------------------------------------------------
1711
- // The vendored dxa binary probe (Phase 35, plan 35-01, A-01). This module
1712
- // ships two ways: as unbuilt source (src/mcp/vice/host-tool.mts, HERE ==
1713
- // src/mcp/vice/) and as the compiled artifact this project actually runs
1693
+ // The vendored dxa binary probe. This module ships two ways: as unbuilt
1694
+ // source (src/mcp/vice/host-tool.mts, HERE == src/mcp/vice/) and as the
1695
+ // compiled artifact this project actually runs
1714
1696
  // (src/mcp/vice/resources/host-tool.mjs, HERE == src/mcp/vice/resources/).
1715
1697
  // ghidra-project.mjs's own sibling-ness to host-tool.mjs survives that move
1716
1698
  // because BOTH are compiled into resources/ together (build.ts's
1717
1699
  // HOST_BOUND_ARTIFACTS). vendor/dxa/dxa does NOT survive it -- it is a real
1718
1700
  // binary, never copied anywhere by build.ts, always at
1719
- // src/mcp/vice/vendor/dxa/dxa. So "vendor/dxa/dxa relative to import.meta.url"
1720
- // means two DIFFERENT candidate locations depending on which form of this
1721
- // module is executing: same-directory for the unbuilt source, one level up
1722
- // for the compiled artifact. Mirrors findAcmeLib()'s own "candidate list,
1723
- // first existing wins" idiom, immediately below.
1701
+ // src/mcp/vice/vendor/dxa/dxa. So "vendor/dxa/dxa relative to
1702
+ // import.meta.url" means two DIFFERENT candidate locations depending on
1703
+ // which form of this module is executing: same-directory for the unbuilt
1704
+ // source, one level up for the compiled artifact. Mirrors findAcmeLib()'s
1705
+ // own "candidate list, first existing wins" idiom, immediately below.
1724
1706
  // ---------------------------------------------------------------------------
1725
1707
  export function findDxaBinary(here) {
1726
1708
  const tried = [join(here, "vendor", "dxa", "dxa"), join(here, "..", "vendor", "dxa", "dxa")];
@@ -1747,29 +1729,28 @@ function findAcmeLib() {
1747
1729
  return { path: null, tried };
1748
1730
  }
1749
1731
  // ---------------------------------------------------------------------------
1750
- // The c1541/petcat sibling-binary probe (Phase 40, plan 40-02, T-40-02-04,
1751
- // D-13, D-15). Follows findDxaBinary()'s own candidate-list idiom: try the
1752
- // most-trustworthy candidate first, fall back only when it does not exist,
1753
- // and return every candidate tried so a refusal can name them all. Unlike
1754
- // findDxaBinary() (a FIXED, project-vendored path) and findAcmeLib() (a
1755
- // FIXED list of well-known host install locations), this probe's first
1756
- // candidate is COMPUTED per call, from whichever x64sc backend-detect.mts
1757
- // already resolved -- see the resolvedBackend() import comment above for
1758
- // why that call is cheap here. No version probe (D-14): the ROADMAP Notes
1759
- // bullet asking for one was overruled by the project owner on 2026-09-08
1760
- // and must not be re-added.
1732
+ // The c1541/petcat sibling-binary probe. Follows findDxaBinary()'s own
1733
+ // candidate-list idiom: try the most-trustworthy candidate first, fall back
1734
+ // only when it does not exist, and return every candidate tried so a
1735
+ // refusal can name them all. Unlike findDxaBinary() (a FIXED,
1736
+ // project-vendored path) and findAcmeLib() (a FIXED list of well-known
1737
+ // host install locations), this probe's first candidate is COMPUTED per
1738
+ // call, from whichever x64sc backend-detect.mts already resolved -- see
1739
+ // the resolvedBackend() import comment above for why that call is cheap
1740
+ // here. No version probe: deliberately withdrawn by the project owner --
1741
+ // this stays a name-and-location probe only and must not gain one back.
1761
1742
  // ---------------------------------------------------------------------------
1762
- /** Memoised per binary name for the process lifetime (T-40-02-04's own
1763
- * instruction) -- mirrors backend-detect.mts's own stated posture of
1764
- * resolving once per process, never re-probing per call. A `null` (not
1765
- * found) answer is memoised too: a transient host misconfiguration that
1766
- * resolves differently mid-process is not a case this module has ever
1767
- * handled for any of its other binary probes (findDxaBinary()/
1768
- * findAcmeLib() are also called fresh per buildHostToolArgv() invocation
1769
- * but read a fixed, unchanging candidate set -- this probe's OWN
1770
- * per-binary-name memo exists because its first candidate is computed from
1771
- * a resolvedBackend() call that is itself memoised, so re-deriving it per
1772
- * call would just re-walk $PATH for no new information). */
1743
+ /** Memoised per binary name for the process lifetime -- mirrors
1744
+ * backend-detect.mts's own stated posture of resolving once per process,
1745
+ * never re-probing per call. A `null` (not found) answer is memoised too: a
1746
+ * transient host misconfiguration that resolves differently mid-process is
1747
+ * not a case this module has ever handled for any of its other binary
1748
+ * probes (findDxaBinary()/findAcmeLib() are also called fresh per
1749
+ * buildHostToolArgv() invocation but read a fixed, unchanging candidate set
1750
+ * -- this probe's OWN per-binary-name memo exists because its first
1751
+ * candidate is computed from a resolvedBackend() call that is itself
1752
+ * memoised, so re-deriving it per call would just re-walk $PATH for no new
1753
+ * information). */
1773
1754
  const siblingBinaryMemo = new Map();
1774
1755
  function findSiblingBinary(binaryName, resolvedX64scPath, log) {
1775
1756
  const memoised = siblingBinaryMemo.get(binaryName);
@@ -1787,7 +1768,7 @@ function findSiblingBinary(binaryName, resolvedX64scPath, log) {
1787
1768
  // Fallback: a $PATH walk (mirrors defaultResolveBinPath()'s own algorithm,
1788
1769
  // backend-detect.mts), logging a warning naming the resolved x64sc path,
1789
1770
  // the PATH match, and that this MAY be a DIFFERENT VICE build than the
1790
- // emulator -- never a silent PATH fallback (D-15).
1771
+ // emulator -- never a silent PATH fallback.
1791
1772
  const pathEnv = process.env.PATH ?? "";
1792
1773
  for (const dir of pathEnv.split(":")) {
1793
1774
  if (!dir)
@@ -1807,29 +1788,28 @@ function findSiblingBinary(binaryName, resolvedX64scPath, log) {
1807
1788
  return result;
1808
1789
  }
1809
1790
  /** Narrows, resolves, builds argv, then spawns the child ASYNCHRONOUSLY.
1810
- * NOTHING throws out of this function -- every failure path (refusal, launch
1811
- * error, timeout, non-zero exit, unreadable output) resolves to a response
1812
- * object, because broker-kill.mts's uncaughtException/unhandledRejection
1813
- * handlers kill the whole VICE pool on an unhandled throw in this process.
1814
- * Emits exactly one `log()` line per ATTEMPTED invocation (i.e. once argv
1815
- * construction succeeded and a child was actually spawned) naming the tool
1816
- * id, the exit status, the elapsed milliseconds and (34-09, CR-04) the
1817
- * budget that was actually applied (`timeout_ms=<n>`, from
1818
- * hostToolTimeoutMs()) -- so which budget governed a run is observable off
1819
- * the log line rather than inferred (A-02). A request refused before a
1820
- * child is ever spawned emits no log line -- there is no invocation to
1821
- * record. */
1791
+ * NOTHING throws out of this function -- every failure path (refusal,
1792
+ * launch error, timeout, non-zero exit, unreadable output) resolves to a
1793
+ * response object, because broker-kill.mts's uncaughtException/
1794
+ * unhandledRejection handlers kill the whole VICE pool on an unhandled
1795
+ * throw in this process. Emits exactly one `log()` line per ATTEMPTED
1796
+ * invocation (i.e. once argv construction succeeded and a child was
1797
+ * actually spawned) naming the tool id, the exit status, the elapsed
1798
+ * milliseconds and the budget that was actually applied
1799
+ * (`timeout_ms=<n>`, from hostToolTimeoutMs()) -- so which budget governed
1800
+ * a run is observable off the log line rather than inferred. A request
1801
+ * refused before a child is ever spawned emits no log line -- there is no
1802
+ * invocation to record. */
1822
1803
  export async function runHostTool(raw, deps) {
1823
1804
  const narrowed = normaliseHostToolRequest(raw);
1824
1805
  if (!narrowed.ok)
1825
1806
  return { ok: false, message: narrowed.message };
1826
1807
  const { request } = narrowed;
1827
- // Phase 34, plan 34-04 (SEAM-05): oracle.probe/oracle.run do not fit the
1828
- // "spawn a tool that writes files, then digest them" shape below -- their
1829
- // contract is the SPAWNED PROCESS'S OWN stdout (a version banner, or the
1830
- // oracle's unpacked-output text), not a produced-file digest. Handled as
1831
- // their own branch, reusing spawnHostTool() (the one spawn call) rather
1832
- // than adding a second.
1808
+ // oracle.probe/oracle.run do not fit the "spawn a tool that writes files,
1809
+ // then digest them" shape below -- their contract is the SPAWNED
1810
+ // PROCESS'S OWN stdout (a version banner, or the oracle's unpacked-output
1811
+ // text), not a produced-file digest. Handled as their own branch, reusing
1812
+ // spawnHostTool() (the one spawn call) rather than adding a second.
1833
1813
  if (request.tool === "oracle.probe")
1834
1814
  return runOracleProbe(deps);
1835
1815
  if (request.tool === "oracle.run")
@@ -1837,13 +1817,13 @@ export async function runHostTool(raw, deps) {
1837
1817
  const repoRootAbs = resolvePath(deps.repoRoot);
1838
1818
  let built;
1839
1819
  let acmeLib = null;
1840
- // WR-01: resolveGhidraProject() (below, in the ghidra.analyze branch)
1841
- // RESERVES the run directory (creates it on disk) before buildHostToolArgv()'s
1842
- // own GHIDRA_HOME/launcher/language preflight checks ever run -- those checks
1820
+ // resolveGhidraProject() (below, in the ghidra.analyze branch) RESERVES
1821
+ // the run directory (creates it on disk) before buildHostToolArgv()'s own
1822
+ // GHIDRA_HOME/launcher/language preflight checks ever run -- those checks
1843
1823
  // can still fail for a completely ordinary, fixable reason (unset
1844
1824
  // GHIDRA_HOME, processor not yet installed). Recorded here so the shared
1845
- // `!built.ok` check below can clean up the orphaned reservation rather than
1846
- // burning the runId permanently.
1825
+ // `!built.ok` check below can clean up the orphaned reservation rather
1826
+ // than burning the runId permanently.
1847
1827
  let ghidraReservedProjectLocation;
1848
1828
  if (request.tool === "acme.build") {
1849
1829
  const sourceResolved = resolveWorkspacePath(repoRootAbs, request.args.source);
@@ -1859,13 +1839,13 @@ export async function runHostTool(raw, deps) {
1859
1839
  else {
1860
1840
  outDirPath = dirname(sourceResolved.path);
1861
1841
  }
1862
- // Task 1 (CR-03): every `includes` entry resolved through the SAME
1842
+ // Every `includes` entry resolved through the SAME
1863
1843
  // resolveWorkspacePath() site source/outDir just used. The FIRST
1864
1844
  // refusal returns unchanged -- the whole request fails, the offending
1865
1845
  // entry is never dropped and the remaining entries are never resolved
1866
- // (no partial-success degradation, T-34-33). An absent or empty
1867
- // `includes` yields an empty array, which buildHostToolArgv() emits as
1868
- // no -I flags at all.
1846
+ // (no partial-success degradation). An absent or empty `includes`
1847
+ // yields an empty array, which buildHostToolArgv() emits as no -I
1848
+ // flags at all.
1869
1849
  const includePaths = [];
1870
1850
  for (const entry of request.args.includes ?? []) {
1871
1851
  const includeResolved = resolveWorkspacePath(repoRootAbs, entry);
@@ -1877,23 +1857,22 @@ export async function runHostTool(raw, deps) {
1877
1857
  acmeLib = findAcmeLib();
1878
1858
  }
1879
1859
  else if (request.tool === "ghidra.analyze") {
1880
- // Phase 35, plan 35-01: converted from the previous `if (acme.build) …
1881
- // else (ghidra.analyze)` shape into an explicit per-tool branch -- the
1882
- // `else`'s own comment claiming ghidra.analyze was the only remaining
1883
- // member stopped being true the moment dxa.disassemble (below) was
1884
- // added; leaving the implicit shape would have routed a
1885
- // dxa.disassemble request into Ghidra's own resolver. `importPath` is
1886
- // workspace-relative, resolved through the SAME resolveWorkspacePath()
1887
- // site acme.build's `source` uses; the project location itself comes
1888
- // from ghidra-project.mts's resolveGhidraProject() -- never computed
1889
- // here (A-06).
1860
+ // Converted from a previous `if (acme.build) … else (ghidra.analyze)`
1861
+ // shape into an explicit per-tool branch -- the `else`'s own comment
1862
+ // claiming ghidra.analyze was the only remaining member stopped being
1863
+ // true the moment dxa.disassemble (below) was added; leaving the
1864
+ // implicit shape would have routed a dxa.disassemble request into
1865
+ // Ghidra's own resolver. `importPath` is workspace-relative, resolved
1866
+ // through the SAME resolveWorkspacePath() site acme.build's `source`
1867
+ // uses; the project location itself comes from ghidra-project.mts's
1868
+ // resolveGhidraProject() -- never computed here.
1890
1869
  const importResolved = resolveWorkspacePath(repoRootAbs, request.args.importPath);
1891
1870
  if (!importResolved.ok)
1892
1871
  return { ok: false, message: importResolved.message };
1893
- // Task 2 (CR-02): preScript/postScript resolved through the SAME
1894
- // resolveWorkspacePath() site, BEFORE resolveGhidraProject()'s own
1895
- // directory RESERVATION below -- a refusal here must never leave a
1896
- // reserved-but-unused run directory behind.
1872
+ // preScript/postScript resolved through the SAME resolveWorkspacePath()
1873
+ // site, BEFORE resolveGhidraProject()'s own directory RESERVATION below
1874
+ // -- a refusal here must never leave a reserved-but-unused run
1875
+ // directory behind.
1897
1876
  let preScriptPath;
1898
1877
  if (request.args.preScript !== undefined) {
1899
1878
  const preScriptResolved = resolveWorkspacePath(repoRootAbs, request.args.preScript);
@@ -1908,11 +1887,11 @@ export async function runHostTool(raw, deps) {
1908
1887
  return { ok: false, message: postScriptResolved.message };
1909
1888
  postScriptPath = postScriptResolved.path;
1910
1889
  }
1911
- // Phase 36, plan 36-02 (Task 1): scriptPath/entrypointsPath/exportPath
1912
- // resolved through the SAME resolveWorkspacePath() site, BEFORE
1913
- // resolveGhidraProject()'s own directory RESERVATION below -- a
1914
- // refusal here must never leave a reserved-but-unused run directory
1915
- // behind, exactly as preScript/postScript already are.
1890
+ // scriptPath/entrypointsPath/exportPath resolved through the SAME
1891
+ // resolveWorkspacePath() site, BEFORE resolveGhidraProject()'s own
1892
+ // directory RESERVATION below -- a refusal here must never leave a
1893
+ // reserved-but-unused run directory behind, exactly as preScript/
1894
+ // postScript already are.
1916
1895
  let scriptPathResolved;
1917
1896
  if (request.args.scriptPath !== undefined) {
1918
1897
  const scriptPathResult = resolveWorkspacePath(repoRootAbs, request.args.scriptPath);
@@ -1934,10 +1913,10 @@ export async function runHostTool(raw, deps) {
1934
1913
  return { ok: false, message: exportPathResult.message };
1935
1914
  exportPathResolved = exportPathResult.path;
1936
1915
  }
1937
- // Phase 37, plan 37-08 (AUTO-07): resolved through the SAME site, BEFORE
1938
- // resolveGhidraProject()'s own directory RESERVATION below -- a refusal
1939
- // here must never leave a reserved-but-unused run directory behind,
1940
- // exactly as every other script-adjacent path field above.
1916
+ // Resolved through the SAME site, BEFORE resolveGhidraProject()'s own
1917
+ // directory RESERVATION below -- a refusal here must never leave a
1918
+ // reserved-but-unused run directory behind, exactly as every other
1919
+ // script-adjacent path field above.
1941
1920
  let dataRangesPathResolved;
1942
1921
  if (request.args.dataRangesPath !== undefined) {
1943
1922
  const dataRangesPathResult = resolveWorkspacePath(repoRootAbs, request.args.dataRangesPath);
@@ -2010,7 +1989,7 @@ export async function runHostTool(raw, deps) {
2010
1989
  });
2011
1990
  }
2012
1991
  else if (request.tool === "ghidra.installExtension") {
2013
- // (36-01, D-36-01). `sourceDir`
1992
+ // `sourceDir`
2014
1993
  // resolved through the SAME resolveWorkspacePath() site every other
2015
1994
  // tool's path argument uses. The materialisation side effects (create
2016
1995
  // the install directory, copy the vendored tree, copy the three stock
@@ -2022,8 +2001,8 @@ export async function runHostTool(raw, deps) {
2022
2001
  const sourceDirResolved = resolveWorkspacePath(repoRootAbs, request.args.sourceDir);
2023
2002
  if (!sourceDirResolved.ok)
2024
2003
  return { ok: false, message: sourceDirResolved.message };
2025
- // WR-02: `sourceDir` is otherwise accepted as ANY workspace-relative
2026
- // directory and copied wholesale (via cpSync below) into
2004
+ // `sourceDir` is otherwise accepted as ANY workspace-relative directory
2005
+ // and copied wholesale (via cpSync below) into
2027
2006
  // `<GHIDRA_HOME>/Ghidra/Extensions/<moduleName>/` -- a shared, host-wide
2028
2007
  // location outside this project's own workspace. Refuse by name unless
2029
2008
  // it resolves to exactly this project's own vendored extension tree,
@@ -2079,16 +2058,15 @@ export async function runHostTool(raw, deps) {
2079
2058
  built = buildHostToolArgv(request, { sourceDirPath: sourceDirResolved.path, moduleName: request.args.moduleName });
2080
2059
  }
2081
2060
  else {
2082
- // request.tool is one of the five c1541.* ids (Phase 40, plan 40-02) or
2083
- // petcat.decode (Phase 40, plan 40-03) -- every remaining id resolves
2084
- // the SAME two fields, so this one branch covers all six. `image`
2085
- // resolved through the SAME resolveWorkspacePath() site every other
2086
- // tool's path argument uses; `outDir` defaults to dirname(imagePath)
2087
- // exactly as dxa.disassemble's own default does. `name` (c1541.entry/
2088
- // chain/read only) is NOT resolved here -- it is a validated, non-path
2089
- // CBM filename/glob, read straight from request.args by
2090
- // buildHostToolArgv() (mirrors ghidra.analyze's own processor/runId
2091
- // split); petcat.decode has no such field at all.
2061
+ // request.tool is one of the five c1541.* ids or petcat.decode -- every
2062
+ // remaining id resolves the SAME two fields, so this one branch covers
2063
+ // all six. `image` resolved through the SAME resolveWorkspacePath()
2064
+ // site every other tool's path argument uses; `outDir` defaults to
2065
+ // dirname(imagePath) exactly as dxa.disassemble's own default does.
2066
+ // `name` (c1541.entry/chain/read only) is NOT resolved here -- it is a
2067
+ // validated, non-path CBM filename/glob, read straight from
2068
+ // request.args by buildHostToolArgv() (mirrors ghidra.analyze's own
2069
+ // processor/runId split); petcat.decode has no such field at all.
2092
2070
  const imageResolved = resolveWorkspacePath(repoRootAbs, request.args.image);
2093
2071
  if (!imageResolved.ok)
2094
2072
  return { ok: false, message: imageResolved.message };
@@ -2105,13 +2083,14 @@ export async function runHostTool(raw, deps) {
2105
2083
  built = buildHostToolArgv(request, { imagePath: imageResolved.path, outDirPath }, deps.log);
2106
2084
  }
2107
2085
  if (!built.ok) {
2108
- // WR-01: buildHostToolArgv()'s own GHIDRA_HOME/launcher/language preflight
2086
+ // buildHostToolArgv()'s own GHIDRA_HOME/launcher/language preflight
2109
2087
  // checks can still fail here even though resolveGhidraProject() already
2110
- // reserved (created) the run directory above -- clean it up, best-effort,
2111
- // so a caller who retries the same runId after fixing the underlying
2112
- // problem (setting GHIDRA_HOME, running ghidra.installExtension) gets a
2113
- // fresh reservation instead of resolveGhidraProject()'s unrelated
2114
- // "refuses to reuse an existing run directory" refusal.
2088
+ // reserved (created) the run directory above -- clean it up,
2089
+ // best-effort, so a caller who retries the same runId after fixing the
2090
+ // underlying problem (setting GHIDRA_HOME, running
2091
+ // ghidra.installExtension) gets a fresh reservation instead of
2092
+ // resolveGhidraProject()'s unrelated "refuses to reuse an existing run
2093
+ // directory" refusal.
2115
2094
  if (ghidraReservedProjectLocation !== undefined) {
2116
2095
  try {
2117
2096
  rmSync(ghidraReservedProjectLocation, { recursive: true, force: true });
@@ -2131,8 +2110,8 @@ export async function runHostTool(raw, deps) {
2131
2110
  // when no candidate matched, which spawnHostTool() treats identically to
2132
2111
  // "no override" (inherits the broker's own environment unchanged).
2133
2112
  const spawnEnv = acmeLib?.path ? { ...process.env, ACME: acmeLib.path } : undefined;
2134
- // WR-01 (40-REVIEW.md): c1541.read's output file is written by the CHILD
2135
- // process itself (`-read <name> <outputPath>`), never pre-cleared before
2113
+ // c1541.read's output file is written by the CHILD process itself
2114
+ // (`-read <name> <outputPath>`), never pre-cleared before
2136
2115
  // this module's own spawn -- so a colliding slug (two different CBM names
2137
2116
  // that agree on their first 32 alphanumeric characters) could leave a
2138
2117
  // PRIOR successful read's bytes at `outputPath`, and a later, genuinely
@@ -2161,24 +2140,23 @@ export async function runHostTool(raw, deps) {
2161
2140
  deps.log?.(`host_tool tool=${request.tool} exit=timeout elapsed_ms=${elapsedMs} timeout_ms=${timeoutMs} bin=${built.toolPath}`);
2162
2141
  return { ok: false, message: `runHostTool: "${request.tool}" timed out after ${timeoutMs}ms and was killed` };
2163
2142
  }
2164
- // Phase 40, plan 40-02 (D-16): `bin=` names the RESOLVED absolute binary
2165
- // path that answered this call -- for every tool, not only c1541.* --
2166
- // `built.toolPath` is already the resolved path every branch above
2167
- // produces, so this is a pure addition to an existing field, never a new
2168
- // resolution. A transcript read in isolation can now say which build
2169
- // answered, which matters most for a host carrying two VICE builds
2170
- // (T-40-02-04, MEASURED live on this project's own dev host).
2143
+ // `bin=` names the RESOLVED absolute binary path that answered this call
2144
+ // -- for every tool, not only c1541.* -- `built.toolPath` is already the
2145
+ // resolved path every branch above produces, so this is a pure addition
2146
+ // to an existing field, never a new resolution. A transcript read in
2147
+ // isolation can now say which build answered, which matters most for a
2148
+ // host carrying two VICE builds (MEASURED live on this project's own dev
2149
+ // host).
2171
2150
  deps.log?.(`host_tool tool=${request.tool} exit=${spawnResult.exitCode ?? "null"} elapsed_ms=${elapsedMs} timeout_ms=${timeoutMs} bin=${built.toolPath}`);
2172
- // dxa.disassemble (A-03): dxa has NO output-file option -- every listing
2173
- // line is fprintf(stdout, ...) (vendor/dxa/dump.c). c1541.dir (Phase 40,
2174
- // plan 40-02, Task 1 -- the tracer): c1541 -dir has no output-file option
2175
- // either -- every listing line is printed to its own stdout (MEASURED
2176
- // against the committed fixture, fixtures/c1541/README.md). Every other
2177
- // tool's outputs[] entries are already real files the child process wrote
2178
- // itself; TOOLS_WHOSE_OUTPUT_IS_STDOUT names the ones whose "output" IS
2179
- // the captured stdout, so this is the one place that stdout is turned
2180
- // into a file before the digest loop below ever runs. No second spawn
2181
- // call is added.
2151
+ // dxa.disassemble: dxa has NO output-file option -- every listing line is
2152
+ // fprintf(stdout, ...) (vendor/dxa/dump.c). c1541.dir: c1541 -dir has no
2153
+ // output-file option either -- every listing line is printed to its own
2154
+ // stdout (MEASURED against the committed fixture, fixtures/c1541/
2155
+ // README.md). Every other tool's outputs[] entries are already real
2156
+ // files the child process wrote itself; TOOLS_WHOSE_OUTPUT_IS_STDOUT
2157
+ // names the ones whose "output" IS the captured stdout, so this is the
2158
+ // one place that stdout is turned into a file before the digest loop
2159
+ // below ever runs. No second spawn call is added.
2182
2160
  if (TOOLS_WHOSE_OUTPUT_IS_STDOUT.has(request.tool) && built.outputs.length > 0) {
2183
2161
  try {
2184
2162
  writeFileSync(built.outputs[0], spawnResult.stdout, "utf8");
@@ -2190,12 +2168,12 @@ export async function runHostTool(raw, deps) {
2190
2168
  // never-throw discipline.
2191
2169
  }
2192
2170
  }
2193
- // Phase 36, plan 36-01 (D-36-05): ghidra.analyze's outputs[0] is ALWAYS
2194
- // the run log. `HostToolClientResult` carries no stdout field at all, so
2195
- // this is the ONE place the run log becomes reachable from the container
2196
- // side. MEASURED against real Ghidra 12.1.3: analyzeHeadless's own
2197
- // "Using Language/Compiler:" line arrives on STDOUT; stderr is appended
2198
- // after it so no line can be lost.
2171
+ // ghidra.analyze's outputs[0] is ALWAYS the run log.
2172
+ // `HostToolClientResult` carries no stdout field at all, so this is the
2173
+ // ONE place the run log becomes reachable from the container side.
2174
+ // MEASURED against real Ghidra 12.1.3: analyzeHeadless's own "Using
2175
+ // Language/Compiler:" line arrives on STDOUT; stderr is appended after
2176
+ // it so no line can be lost.
2199
2177
  if (request.tool === "ghidra.analyze" && built.outputs.length > 0) {
2200
2178
  try {
2201
2179
  writeFileSync(built.outputs[0], `${spawnResult.stdout}${spawnResult.stderr}`, "utf8");
@@ -2213,13 +2191,13 @@ export async function runHostTool(raw, deps) {
2213
2191
  if (digested)
2214
2192
  results.push(digested);
2215
2193
  }
2216
- // Phase 40, plan 40-02 (D-09, D-10): the classifier table entry for this
2217
- // tool id, run immediately after the digest loop and before the success
2218
- // envelope is constructed. Pre-existing ids map to `null` -- "no declared
2219
- // shape, behaviour unchanged" -- so this is a no-op for every tool that
2220
- // predates this plan. Absence of the declared success shape IS the
2221
- // failure; exit status stays recorded in the log line only and is never
2222
- // consulted here (D-11).
2194
+ // The classifier table entry for this tool id, run immediately after the
2195
+ // digest loop and before the success envelope is constructed.
2196
+ // Pre-existing ids map to `null` -- "no declared shape, behaviour
2197
+ // unchanged" -- so this is a no-op for every tool that predates this
2198
+ // mechanism. Absence of the declared success shape IS the failure; exit
2199
+ // status stays recorded in the log line only and is never consulted
2200
+ // here.
2223
2201
  const classifier = HOST_TOOL_OUTPUT_CLASSIFIERS[request.tool];
2224
2202
  if (classifier) {
2225
2203
  const verdict = classifier({ stdout: spawnResult.stdout, stderr: spawnResult.stderr, results });
@@ -2227,13 +2205,13 @@ export async function runHostTool(raw, deps) {
2227
2205
  return { ok: false, message: `host_tool "${request.tool}" refuses: ${verdict.reason}` };
2228
2206
  }
2229
2207
  }
2230
- // Phase 40, plan 40-03 (D-21, D-22): petcat.decode's handover verdict,
2231
- // computed HOST-SIDE immediately after the classifier above accepts and
2232
- // before the response envelope below is constructed. All three cases
2233
- // (literal/computed/no-SYS-token) are successes (D-21) -- the classifier's
2234
- // own shape refusal above is the only `ok: false` this tool ever reports;
2235
- // conflating the two would make PREP-04's failure oracle and PREP-02's
2236
- // decline indistinguishable.
2208
+ // petcat.decode's handover verdict, computed HOST-SIDE immediately after
2209
+ // the classifier above accepts and before the response envelope below is
2210
+ // constructed. All three cases (literal/computed/no-SYS-token) are
2211
+ // successes -- the classifier's own shape refusal above is the only
2212
+ // `ok: false` this tool ever reports; conflating the two would make a
2213
+ // genuine tool failure and a merely-unresolved SYS argument
2214
+ // indistinguishable.
2237
2215
  const petcatVerdict = request.tool === "petcat.decode" ? derivePetcatEntrypoint(spawnResult.stdout) : null;
2238
2216
  // acme.build only: ACME's own "for <...> includes..." complaint names no
2239
2217
  // directory it tried -- append a note line (in the plain, non-MSVC shape
@@ -2246,11 +2224,11 @@ export async function runHostTool(raw, deps) {
2246
2224
  if (acmeLib && /ACME.*environment variable/i.test(stderrText)) {
2247
2225
  stderrText += `\nfor <...> includes, set $ACME to the directory holding ${ACME_LIB_MARKER} (looked in: ${acmeLib.tried.join(", ")})`;
2248
2226
  }
2249
- // Phase 40, plan 40-03: the two verdict fields attach ONLY to
2250
- // petcat.decode's own response, never to the shared envelope below -- every
2251
- // other tool id's response key set is byte-for-byte what it was before
2252
- // this plan (dxa-seam.test.ts's own exact-key-set assertion is the
2253
- // committed guard on that).
2227
+ // The two verdict fields attach ONLY to petcat.decode's own response,
2228
+ // never to the shared envelope below -- every other tool id's response
2229
+ // key set is byte-for-byte what it was before this field existed
2230
+ // (dxa-seam.test.ts's own exact-key-set assertion is the committed guard
2231
+ // on that).
2254
2232
  if (request.tool === "petcat.decode") {
2255
2233
  // Non-null by construction: petcatVerdict was computed from THIS SAME
2256
2234
  // `request.tool === "petcat.decode"` check above; TypeScript cannot
@@ -2276,17 +2254,17 @@ export async function runHostTool(raw, deps) {
2276
2254
  };
2277
2255
  }
2278
2256
  // ---------------------------------------------------------------------------
2279
- // oracle.probe / oracle.run (34-04, SEAM-05). Migrated from
2280
- // packer-finding.mjs's own probeUnp64()/runUnp64(): everything about the
2281
- // BINARY (locating it, the version-banner probe, the scratch output
2282
- // location, the argument array, the runtime bound) lives here now; the
2283
- // script keeps everything about the FINDING (the name parser, the accepted
2284
- // character set, the caps, the packedness threshold, the never-throw return
2285
- // shapes). Response shapes are NOT the generic `{ ok, tool, exitStatus,
2286
- // results, stderrTail }` envelope above -- they mirror packer-finding.mjs's
2287
- // OWN pre-existing `{ available, command, version, reason }` /
2288
- // `{ ok, stdout, reason }` contracts directly, so the migrated client-side
2289
- // functions can return the seam's response with no field renaming.
2257
+ // oracle.probe / oracle.run. Migrated from packer-finding.mjs's own
2258
+ // probeUnp64()/runUnp64(): everything about the BINARY (locating it, the
2259
+ // version-banner probe, the scratch output location, the argument array,
2260
+ // the runtime bound) lives here now; the script keeps everything about the
2261
+ // FINDING (the name parser, the accepted character set, the caps, the
2262
+ // packedness threshold, the never-throw return shapes). Response shapes
2263
+ // are NOT the generic `{ ok, tool, exitStatus, results, stderrTail }`
2264
+ // envelope above -- they mirror packer-finding.mjs's OWN pre-existing
2265
+ // `{ available, command, version, reason }` / `{ ok, stdout, reason }`
2266
+ // contracts directly, so the migrated client-side functions can return the
2267
+ // seam's response with no field renaming.
2290
2268
  // ---------------------------------------------------------------------------
2291
2269
  /** Default command name when no host-side configuration is present -- the
2292
2270
  * same default packer-finding.mjs's own (removed) DEFAULT_ORACLE_COMMAND
@@ -2298,20 +2276,19 @@ const DEFAULT_ORACLE_COMMAND = "unp64";
2298
2276
  * container-side hint naming one of these two variables always describes
2299
2277
  * where this host-side resolver actually looked. */
2300
2278
  const ORACLE_ENV_VARS = Object.freeze(["UNP64", "UNP64_PATH"]);
2301
- /** 34-08 (CR-01): THE ONE PLACE the oracle binary's location is decided,
2302
- * consulted by BOTH `runOracleProbe()` and `runOracleRun()` -- mirrors
2303
- * `findAcmeLib()` above, which `34-04` already moved host-side for exactly
2304
- * this reason: the container has no PATH to a host binary, so probing host
2305
- * locations belongs on the host side of the seam. Reads the BROKER
2306
- * PROCESS'S OWN environment -- never a wire value, because
2307
- * `HOST_TOOL_ARG_KEYS["oracle.probe"]` accepts no keys at all. When a
2308
- * variable is set, two checks apply in order: the configured path's base
2309
- * name must equal `DEFAULT_ORACLE_COMMAND` (the review's own suggested
2310
- * check, kept as a second layer over the wire-key removal), and the path
2311
- * must exist on disk. Each refusal reason names WHICH variable was set and
2312
- * NEVER interpolates the configured value (T-19-18, carried forward from
2313
- * `34-04`). With no variable set, answers the bare `DEFAULT_ORACLE_COMMAND`
2314
- * -- the existing search-path behaviour, unchanged. */
2279
+ /** THE ONE PLACE the oracle binary's location is decided, consulted by
2280
+ * BOTH `runOracleProbe()` and `runOracleRun()` -- mirrors `findAcmeLib()`
2281
+ * above, moved host-side for exactly this reason: the container has no
2282
+ * PATH to a host binary, so probing host locations belongs on the host
2283
+ * side of the seam. Reads the BROKER PROCESS'S OWN environment -- never a
2284
+ * wire value, because `HOST_TOOL_ARG_KEYS["oracle.probe"]` accepts no keys
2285
+ * at all. When a variable is set, two checks apply in order: the
2286
+ * configured path's base name must equal `DEFAULT_ORACLE_COMMAND` (a
2287
+ * second layer over the wire-key removal), and the path must exist on
2288
+ * disk. Each refusal reason names WHICH variable was set and NEVER
2289
+ * interpolates the configured value. With no variable set, answers the
2290
+ * bare `DEFAULT_ORACLE_COMMAND` -- the existing search-path behaviour,
2291
+ * unchanged. */
2315
2292
  function resolveOracleCommand() {
2316
2293
  for (const varName of ORACLE_ENV_VARS) {
2317
2294
  const raw = process.env[varName];
@@ -2391,12 +2368,11 @@ async function runOracleRun(args, deps) {
2391
2368
  if (!existsSync(sourceResolved.path)) {
2392
2369
  return { ok: false, tool: "oracle.run", stdout: "", reason: "the input file does not exist" };
2393
2370
  }
2394
- // 34-08 (CR-01): the SAME resolver oracle.probe consults -- never a bare
2371
+ // The SAME resolver oracle.probe consults -- never a bare
2395
2372
  // DEFAULT_ORACLE_COMMAND argument at the spawn site below. Before this
2396
- // change a host-side configured oracle was honoured by the probe and
2373
+ // fix a host-side configured oracle was honoured by the probe and
2397
2374
  // silently ignored by the run, so a working probe could be followed by a
2398
- // failing run; resolving here closes that gap as a real defect fix, not
2399
- // merely a mechanical follow-on from Task 1's wire-key removal.
2375
+ // failing run; resolving here closes that gap as a real defect fix.
2400
2376
  const resolvedCommand = resolveOracleCommand();
2401
2377
  if (!resolvedCommand.ok) {
2402
2378
  deps.log?.(`host_tool tool=oracle.run exit=absent_configured_path`);
@@ -2408,38 +2384,36 @@ async function runOracleRun(args, deps) {
2408
2384
  // function returns, mirroring packer-finding.mjs's own (removed)
2409
2385
  // "removed before this function returns" property (T-19-24).
2410
2386
  //
2411
- // MOVED 2026-09-08 (D-33): this used to be `<repoRoot>/tools/oracle-runs/...`.
2412
- // It is the SEVENTH writer this consolidation re-points -- found by grep
2413
- // during planning, not one of the folded todo's own six-writer list, and
2414
- // moved alongside them for the same reason: it now lives under
2415
- // `runs/oracle` beneath the single tool-written root `repo-root.ts`'s
2416
- // `toolsDir()` owns. This module is host-bound (compiled by build.ts) and
2417
- // must not import the container-side repo-root.ts, so the two segments are
2418
- // joined directly here -- ".c64-re-tools" and "runs"/"oracle" must stay
2419
- // equal to `join(toolsDir(), "runs", "oracle")`, the same convention
2387
+ // MOVED 2026-09-08: this used to be `<repoRoot>/tools/oracle-runs/...`.
2388
+ // This scratch directory is one of several writers consolidated onto a
2389
+ // single tool-written root -- it now lives under `runs/oracle` beneath
2390
+ // the root `repo-root.ts`'s `toolsDir()` owns. This module is host-bound
2391
+ // (compiled by build.ts) and must not import the container-side
2392
+ // repo-root.ts, so the two segments are joined directly here --
2393
+ // ".c64-re-tools" and "runs"/"oracle" must stay equal to
2394
+ // `join(toolsDir(), "runs", "oracle")`, the same convention
2420
2395
  // install-resources.ts's installTargetDir() uses.
2421
2396
  //
2422
- // CORRECTED 2026-09-08 (gap `G-40-1`; see
2423
- // .planning/notes/ghidra-dot-path-check-semantics.md): this used to also
2424
- // name ghidra-project.mts's runs root as following "the same convention",
2425
- // full stop. That is now true of the PHYSICAL location -- both this
2426
- // directory and the Ghidra runs root land under the same
2427
- // `.c64-re-tools/runs/<subdir>` shape -- but it is NOT true of how the
2428
- // location is REACHED. This scratch directory is joined DIRECTLY, exactly
2429
- // as written above. The Ghidra runs root is joined the same way
2430
- // internally (`ghidraRunsRealRoot()`), but Ghidra itself is never handed
2431
- // that direct path -- it is handed a path through
2432
- // `ghidraRunsRoot()`'s non-dotted ALIAS HANDLE (`<repoRoot>/c64-re-tools`,
2433
- // a symlink to `.c64-re-tools`), because Ghidra's own project-location
2434
- // check refuses a dot-prefixed segment in the path it is handed, while
2435
- // this scratch directory's caller (this project's own oracle spawn) has no
2436
- // such refusal and is handed the direct path unchanged.
2397
+ // CORRECTED 2026-09-08: this used to also describe ghidra-project.mts's
2398
+ // runs root as following "the same convention", full stop. That is now
2399
+ // true of the PHYSICAL location -- both this directory and the Ghidra
2400
+ // runs root land under the same `.c64-re-tools/runs/<subdir>` shape --
2401
+ // but it is NOT true of how the location is REACHED. This scratch
2402
+ // directory is joined DIRECTLY, exactly as written above. The Ghidra
2403
+ // runs root is joined the same way internally (`ghidraRunsRealRoot()`),
2404
+ // but Ghidra itself is never handed that direct path -- it is handed a
2405
+ // path through `ghidraRunsRoot()`'s non-dotted ALIAS HANDLE
2406
+ // (`<repoRoot>/c64-re-tools`, a symlink to `.c64-re-tools`), because
2407
+ // Ghidra's own project-location check refuses a dot-prefixed segment in
2408
+ // the path it is handed, while this scratch directory's caller (this
2409
+ // project's own oracle spawn) has no such refusal and is handed the
2410
+ // direct path unchanged.
2437
2411
  const scratchDir = join(repoRootAbs, ".c64-re-tools", "runs", "oracle", `run-${Date.now()}-${Math.random().toString(36).slice(2)}`);
2438
2412
  try {
2439
- // WR-03 hole 1 (D-26): scratch-directory creation moved INSIDE this try
2440
- // block -- a full disk or an unwritable parent now resolves to the
2441
- // function's existing refusal shape instead of throwing synchronously
2442
- // out of runHostTool(), which sits outside any try/catch of its own.
2413
+ // A fix: scratch-directory creation moved INSIDE this try block -- a
2414
+ // full disk or an unwritable parent now resolves to the function's
2415
+ // existing refusal shape instead of throwing synchronously out of
2416
+ // runHostTool(), which sits outside any try/catch of its own.
2443
2417
  mkdirSync(scratchDir, { recursive: true });
2444
2418
  const scratchOut = join(scratchDir, "unpacked.out");
2445
2419
  const timeoutMs = hostToolTimeoutMs("oracle.run", deps.timeoutMs);
@@ -2462,11 +2436,10 @@ async function runOracleRun(args, deps) {
2462
2436
  return { ok: true, tool: "oracle.run", stdout, reason: null };
2463
2437
  }
2464
2438
  catch (err) {
2465
- // WR-03 hole 1 (D-26): the only synchronous throw this block can produce
2466
- // is mkdirSync() above (a full disk or an unwritable scratch parent) --
2467
- // resolved here to the function's own refusal shape, naming the
2468
- // directory, rather than propagating out of runHostTool()'s never-throw
2469
- // boundary.
2439
+ // The only synchronous throw this block can produce is mkdirSync()
2440
+ // above (a full disk or an unwritable scratch parent) -- resolved here
2441
+ // to the function's own refusal shape, naming the directory, rather
2442
+ // than propagating out of runHostTool()'s never-throw boundary.
2470
2443
  const message = err instanceof Error ? err.message : String(err);
2471
2444
  return { ok: false, tool: "oracle.run", stdout: "", reason: `could not create the oracle scratch directory ${scratchDir}: ${message}` };
2472
2445
  }
@@ -2483,11 +2456,10 @@ async function runOracleRun(args, deps) {
2483
2456
  }
2484
2457
  // ---------------------------------------------------------------------------
2485
2458
  // CLI entry point (guarded on being the process entry point, the
2486
- // check-npm-packages.mjs:159 IS_ENTRY_POINT idiom). Plan 34-04 needs this for
2487
- // the host-local route (no broker in the loop); wired now so this tracer
2488
- // proves it. `node resources/host-tool.mjs run --repo-root <path> --request
2489
- // <json>` prints the response as one JSON line on stdout and exits non-zero
2490
- // on a refusal.
2459
+ // check-npm-packages.mjs:159 IS_ENTRY_POINT idiom). Needed for the
2460
+ // host-local route (no broker in the loop). `node resources/host-tool.mjs
2461
+ // run --repo-root <path> --request <json>` prints the response as one JSON
2462
+ // line on stdout and exits non-zero on a refusal.
2491
2463
  // ---------------------------------------------------------------------------
2492
2464
  function parseCliArgs(argv) {
2493
2465
  let repoRoot;
@@ -2525,29 +2497,29 @@ if (IS_ENTRY_POINT) {
2525
2497
  catch {
2526
2498
  raw = null;
2527
2499
  }
2528
- // TEST-ONLY escape hatch for the WR-03 hole 2 regression case
2529
- // (host-tool.test.ts): every fs call reachable from runHostTool()'s
2530
- // real business logic is deliberately guarded (T-19-18's own
2531
- // discipline), so there is no organic wire input that makes the real
2532
- // function reject its promise today -- proving that is a GOOD thing,
2533
- // not a gap, but it also means the CLI's own `.catch()` below has no
2500
+ // TEST-ONLY escape hatch for the CLI's own promise-rejection
2501
+ // handling (host-tool.test.ts): every fs call reachable from
2502
+ // runHostTool()'s real business logic is deliberately guarded, so
2503
+ // there is no organic wire input that makes the real function reject
2504
+ // its promise today -- proving that is a GOOD thing, not a gap, but
2505
+ // it also means the CLI's own `.catch()` below has no
2534
2506
  // naturally-reachable trigger to regression-test against. This reads
2535
2507
  // the BROKER PROCESS'S OWN environment, never a wire value, mirroring
2536
2508
  // `resolveOracleCommand()`'s own "broker env, never wire" convention
2537
- // above -- a caller can never reach this by shaping `--request`. Unset
2538
- // in every real invocation; only host-tool.test.ts's own spawned
2539
- // subprocess ever sets it.
2509
+ // above -- a caller can never reach this by shaping `--request`.
2510
+ // Unset in every real invocation; only host-tool.test.ts's own
2511
+ // spawned subprocess ever sets it.
2540
2512
  const runHostToolOrForcedRejectForTest = process.env.HOST_TOOL_TEST_FORCE_CLI_REJECT === "1"
2541
- ? Promise.reject(new Error("HOST_TOOL_TEST_FORCE_CLI_REJECT: simulated runHostTool() rejection for WR-03 hole 2 regression testing"))
2513
+ ? Promise.reject(new Error("HOST_TOOL_TEST_FORCE_CLI_REJECT: simulated runHostTool() rejection for CLI never-throw regression testing"))
2542
2514
  : runHostTool(raw, { repoRoot });
2543
2515
  runHostToolOrForcedRejectForTest
2544
2516
  .then((response) => {
2545
2517
  process.stdout.write(`${JSON.stringify(response)}\n`);
2546
2518
  process.exitCode = response.ok ? 0 : 1;
2547
2519
  })
2548
- // WR-03 hole 2 (D-26): a rejection from runHostTool() used to become
2549
- // an unhandled rejection with NO stdout at all -- surfacing to the
2550
- // caller as the opaque "host-tool.mjs produced no output on stdout",
2520
+ // A rejection from runHostTool() used to become an unhandled
2521
+ // rejection with NO stdout at all, surfacing to the caller as the
2522
+ // opaque "host-tool.mjs produced no output on stdout",
2551
2523
  // indistinguishable from a hang. Mirrors host-tool-client.ts's own
2552
2524
  // never-reject CLI entry point field-for-field: same envelope shape
2553
2525
  // ({ ok: false, message }), same stdout-not-stderr destination, same