@henols/vice-mcp 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env node
2
+ // host-tool-client.ts
3
+ //
4
+ // Phase 34, plan 34-01 (SEAM-01..SEAM-03, tracer): the CONTAINER-side half of
5
+ // the host-tool execution seam. Mirrors vice-broker-client.ts's
6
+ // acquireOverControlPlane() shape exactly: read broker.json ONCE, resolve the
7
+ // dial target, open ONE connection, write ONE JSON line, await ONE response
8
+ // line, then close -- session-less and short-lived, no lease held. That is
9
+ // SEAM-01's "consumes no emulator lease" satisfied by construction: this
10
+ // module never writes an `acquire` line, never holds a socket open past one
11
+ // request/response pair, and the broker's own host_tool dispatch branch
12
+ // (broker-control.mts) never calls any of the seven VICE callbacks for it.
13
+ //
14
+ // TWO ROUTES, chosen by isInsideContainer() -- the project's ONE container
15
+ // detector -- and NEVER by whether a broker happens to be reachable:
16
+ // - inside a container, the ONLY route is the control op
17
+ // (hostToolOverControlPlane(), below), dialled at the broker's control
18
+ // port exactly like acquireOverControlPlane() already is;
19
+ // - on the host, the ONLY route is a direct `spawn` of
20
+ // `resources/host-tool.mjs` under `process.execPath`, carrying the same
21
+ // typed request on argv.
22
+ // WHY THE HOST ROUTE EXISTS, AND WHY IT IS NOT A SECOND EXECUTOR:
23
+ // `.github/workflows/ci.yml` invokes `acme.mjs build` on a flat GitHub
24
+ // Actions runner with no container and no broker -- removing the host route
25
+ // would DELETE a working route rather than migrate it. Both routes reach the
26
+ // SAME executor module (host-tool.mts, compiled to resources/host-tool.mjs),
27
+ // the same allowlist, the same argv construction and the same digest -- there
28
+ // is exactly one place a binary is ever spawned; this file only ever decides
29
+ // HOW to reach it.
30
+ //
31
+ // Every response `path` is translated through containerPath()
32
+ // (containerpath.ts) before it is handed back to a caller -- never through
33
+ // hostpath.ts, which this file must NEVER import (A-03): a `host_tool`
34
+ // request never carries a host-absolute path in the first place (every path
35
+ // argument is workspace-relative, resolved server-side by
36
+ // host-tool.mts's resolveWorkspacePath()), so only the RESULT direction
37
+ // (host -> container) ever needs translation here, and containerpath.ts is
38
+ // already a declared consumer of hostpath.ts's own closed set
39
+ // (hostpath-consumers.test.ts) -- reaching it through containerpath.ts keeps
40
+ // this new family off that five-member list entirely, by construction.
41
+ //
42
+ // Do not write the tokens `binPath`, `viceBin`, `VICE_BIN` or `x64sc`
43
+ // anywhere in this file -- it ships (package.json `files[]`) and is scanned
44
+ // by spawn-seam.test.ts, which would misclassify a bare identifier match as
45
+ // an emulator spawn site.
46
+ import { readFileSync } from "node:fs";
47
+ import { spawn } from "node:child_process";
48
+ import { connect } from "node:net";
49
+ import { dirname, join, resolve as resolvePath } from "node:path";
50
+ import { fileURLToPath } from "node:url";
51
+
52
+ import { brokerRootDir, brokerJsonPath, newRequestId, resolveControlTarget, CONTROL_CONNECT_TIMEOUT_MS } from "./vice-broker-client.ts";
53
+ import { containerPath } from "./containerpath.ts";
54
+ import { isInsideContainer } from "./container-guard.mts";
55
+ import { repoRoot } from "./repo-root.ts";
56
+
57
+ const HERE = dirname(fileURLToPath(import.meta.url));
58
+
59
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not an
60
+ * array. A local copy of vice-broker-client.ts's own isPlainObject(), which
61
+ * is not exported -- this file's own untrusted-input parsing needs the exact
62
+ * same shape check, not a re-export of an internal helper. */
63
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
66
+
67
+ /** Reads and parses `path` as JSON, returning `null` (never throwing) on any
68
+ * failure -- absent file, unreadable, malformed JSON, or a non-object
69
+ * result. Mirrors vice-broker-client.ts's own never-throw posture toward
70
+ * broker.json. */
71
+ function readJsonMaybe(path: string): Record<string, unknown> | null {
72
+ try {
73
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
74
+ return isPlainObject(parsed) ? parsed : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export interface HostToolFileResult {
81
+ path: string;
82
+ sha256: string;
83
+ byteLength: number;
84
+ }
85
+
86
+ /** 34-09 (CR-04): the per-tool CLIENT-side request-deadline table, declared
87
+ * in THIS file (never in vice-broker-client.ts, whose export list is pinned
88
+ * by exact set equality -- vice-broker-client.test.ts:1208 -- this file has
89
+ * no such census). Bounds the REQUEST phase only, the phase AFTER the
90
+ * connect timer below has already been cleared -- mirroring
91
+ * openBrokerControl()'s own connect-then-request split
92
+ * (vice-broker-client.ts). Every entry here MUST be strictly greater than
93
+ * host-tool.mts's own HOST_TOOL_TIMEOUT_MS entry for the SAME tool id: the
94
+ * side that owns the budget (the host-bound executor) must be the side that
95
+ * reports the verdict, or a caller sees an opaque transport timeout instead
96
+ * of the host's own diagnosable refusal. This ordering is asserted by
97
+ * host-tool.test.ts's own cross-seam ordering case, which imports BOTH
98
+ * sides and iterates every tool id -- the anti-drift mechanism for two
99
+ * numbers that deliberately live in two files (two processes, one
100
+ * container-side and one host-bound). `ghidra.analyze`'s entry (660_000ms)
101
+ * exceeds the server-side Ghidra budget (600_000ms, host-tool.mts) by 60
102
+ * seconds -- comfortably larger without being needlessly slack. */
103
+ export const HOST_TOOL_REQUEST_TIMEOUT_MS: Readonly<Record<string, number>> = Object.freeze({
104
+ "ghidra.analyze": 660_000,
105
+ });
106
+
107
+ /** Fallback request-deadline for a tool id absent from the table above --
108
+ * strictly greater than host-tool.mts's own DEFAULT_HOST_TOOL_TIMEOUT_MS
109
+ * (20_000ms), the server-side fallback for the same tools. */
110
+ export const DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS = 30_000;
111
+
112
+ /** The resolver: an exact table entry wins, else the default above. Mirrors
113
+ * host-tool.mts's own hostToolTimeoutMs() shape on the OTHER side of the
114
+ * seam -- deliberately duplicated, never imported: the two sides run in
115
+ * different processes (this file is container-side, host-tool.mts is
116
+ * host-bound), so there is nothing to import across that boundary. The
117
+ * cross-seam ordering test is what keeps the two numbers from drifting
118
+ * apart, not a shared value. */
119
+ export function hostToolRequestTimeoutMs(tool: string): number {
120
+ return HOST_TOOL_REQUEST_TIMEOUT_MS[tool] ?? DEFAULT_HOST_TOOL_REQUEST_TIMEOUT_MS;
121
+ }
122
+
123
+ /** The wire shape host-tool.mts's runHostTool() produces, mirrored here
124
+ * rather than imported as a value -- this file only ever receives this shape
125
+ * as untrusted JSON off a socket or a child process's stdout, never as a
126
+ * same-process function call. */
127
+ export type HostToolClientResult =
128
+ | { ok: true; tool: string; exitStatus: number | null; results: HostToolFileResult[]; stderrTail: string }
129
+ | { ok: false; message: string };
130
+
131
+ /** Dials the broker's control plane, sends ONE `host_tool` request line, and
132
+ * resolves with the raw (untranslated) response -- never rejects on an
133
+ * application-level refusal (`{ ok: false, message }` from host-tool.mts's
134
+ * own allowlist), only on a transport/protocol failure: unreadable
135
+ * broker.json, a connection error, a malformed response line, a
136
+ * control-plane-level `error` response (unauthorized/bad_request/internal --
137
+ * distinct from the tool's OWN refusal shape), or a timeout. `dir` defaults
138
+ * to brokerRootDir(), exactly as acquireOverControlPlane() does, so a test
139
+ * can point it at a scratch directory. */
140
+ export function hostToolOverControlPlane(
141
+ dir: string = brokerRootDir(),
142
+ tool: string,
143
+ args: Record<string, unknown>,
144
+ ): Promise<HostToolClientResult> {
145
+ return new Promise((resolvePromise, reject) => {
146
+ const broker = readJsonMaybe(brokerJsonPath(dir));
147
+ if (broker === null) {
148
+ reject(new Error("hostToolOverControlPlane: broker.json not present or unreadable"));
149
+ return;
150
+ }
151
+ const port = typeof broker.control_port === "number" ? broker.control_port : null;
152
+ const token = typeof broker.control_token === "string" ? broker.control_token : null;
153
+ if (port === null || token === null) {
154
+ reject(new Error("hostToolOverControlPlane: broker.json missing control_port/control_token"));
155
+ return;
156
+ }
157
+
158
+ const targetResult = resolveControlTarget(broker, port);
159
+ if (!targetResult.ok) {
160
+ reject(new Error(targetResult.message));
161
+ return;
162
+ }
163
+ const { host } = targetResult.target;
164
+
165
+ const socket = connect({ host, port });
166
+ let buffer = "";
167
+ let settled = false;
168
+
169
+ // 34-09 (CR-04): TWO timers, mirroring openBrokerControl()'s own
170
+ // connect-then-request split (vice-broker-client.ts). Before this plan a
171
+ // SINGLE timer bounded the CONNECT phase AND the tool's entire
172
+ // execution -- a ghidra.analyze run that legitimately outlives the
173
+ // TCP-connect budget could never complete over this route at all. The
174
+ // connect timer bounds ONLY the TCP handshake and is cleared the moment
175
+ // `connect` fires; the request-deadline timer then bounds the actual
176
+ // tool execution, sized per tool by hostToolRequestTimeoutMs() above.
177
+ let connectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
178
+ if (settled) return;
179
+ settled = true;
180
+ socket.destroy();
181
+ reject(new Error(`hostToolOverControlPlane: no connection within ${CONTROL_CONNECT_TIMEOUT_MS}ms (connect phase)`));
182
+ }, CONTROL_CONNECT_TIMEOUT_MS);
183
+ if (typeof connectTimer.unref === "function") connectTimer.unref();
184
+
185
+ let requestTimer: ReturnType<typeof setTimeout> | null = null;
186
+
187
+ socket.on("connect", () => {
188
+ if (settled) return;
189
+ // Clear the connect timer THE MOMENT the request line is written --
190
+ // exactly where openBrokerControl()'s own onConnect() clears its
191
+ // connect timer before sendAndAwaitLine()'s separate per-request
192
+ // deadline takes over.
193
+ if (connectTimer !== null) {
194
+ clearTimeout(connectTimer);
195
+ connectTimer = null;
196
+ }
197
+ const requestId = newRequestId();
198
+ socket.write(`${JSON.stringify({ op: "host_tool", id: requestId, token, tool, args })}\n`);
199
+
200
+ const requestTimeoutMs = hostToolRequestTimeoutMs(tool);
201
+ requestTimer = setTimeout(() => {
202
+ if (settled) return;
203
+ settled = true;
204
+ socket.destroy();
205
+ reject(new Error(`hostToolOverControlPlane: no response within ${requestTimeoutMs}ms (request deadline)`));
206
+ }, requestTimeoutMs);
207
+ if (typeof requestTimer.unref === "function") requestTimer.unref();
208
+ });
209
+
210
+ socket.on("data", (chunk: Buffer) => {
211
+ if (settled) return;
212
+ buffer += chunk.toString("utf8");
213
+ const newlineIdx = buffer.indexOf("\n");
214
+ if (newlineIdx === -1) return;
215
+ const line = buffer.slice(0, newlineIdx);
216
+
217
+ let parsed: unknown;
218
+ try {
219
+ parsed = JSON.parse(line);
220
+ } catch {
221
+ settled = true;
222
+ if (requestTimer !== null) clearTimeout(requestTimer);
223
+ socket.destroy();
224
+ reject(new Error("hostToolOverControlPlane: malformed response line"));
225
+ return;
226
+ }
227
+ if (!isPlainObject(parsed)) {
228
+ settled = true;
229
+ if (requestTimer !== null) clearTimeout(requestTimer);
230
+ socket.destroy();
231
+ reject(new Error("hostToolOverControlPlane: response line is not a JSON object"));
232
+ return;
233
+ }
234
+
235
+ settled = true;
236
+ if (requestTimer !== null) clearTimeout(requestTimer);
237
+ socket.destroy();
238
+
239
+ // A control-plane-level error (unauthorized/bad_request/internal) is a
240
+ // DIFFERENT failure from the tool's own `{ ok: false, message }`
241
+ // refusal -- only THIS shape rejects; the tool's own refusal resolves
242
+ // normally so a caller can inspect `message` without a try/catch.
243
+ if (parsed.kind === "error") {
244
+ reject(new Error(`hostToolOverControlPlane: ${String(parsed.code)}: ${String(parsed.message)}`));
245
+ return;
246
+ }
247
+ resolvePromise(parsed as unknown as HostToolClientResult);
248
+ });
249
+
250
+ socket.on("error", (err) => {
251
+ if (settled) return;
252
+ settled = true;
253
+ if (connectTimer !== null) clearTimeout(connectTimer);
254
+ if (requestTimer !== null) clearTimeout(requestTimer);
255
+ reject(err);
256
+ });
257
+ });
258
+ }
259
+
260
+ /** The host-local route: spawns resources/host-tool.mjs directly, under
261
+ * `process.execPath`, with the same typed request passed on argv -- the same
262
+ * executor, the same allowlist, the same argv construction, the same digest.
263
+ * Never rejects on the tool's OWN refusal (a non-zero exit still prints one
264
+ * JSON response line, which this function parses and resolves); rejects only
265
+ * on a transport failure -- the child could not be spawned, or its stdout
266
+ * did not end in a parseable JSON line. */
267
+ function hostToolOverHostRoute(repoRootPath: string, tool: string, args: Record<string, unknown>): Promise<HostToolClientResult> {
268
+ return new Promise((resolvePromise, reject) => {
269
+ const scriptPath = join(HERE, "resources", "host-tool.mjs");
270
+ const requestJson = JSON.stringify({ tool, args });
271
+ let child;
272
+ try {
273
+ child = spawn(process.execPath, [scriptPath, "run", "--repo-root", repoRootPath, "--request", requestJson], {
274
+ stdio: ["ignore", "pipe", "pipe"],
275
+ });
276
+ } catch (e) {
277
+ reject(e instanceof Error ? e : new Error(String(e)));
278
+ return;
279
+ }
280
+ let stdout = "";
281
+ let stderr = "";
282
+ child.stdout?.on("data", (chunk: Buffer) => {
283
+ stdout += chunk.toString("utf8");
284
+ });
285
+ child.stderr?.on("data", (chunk: Buffer) => {
286
+ stderr += chunk.toString("utf8");
287
+ });
288
+ child.on("error", (err) => reject(err));
289
+ child.on("close", () => {
290
+ const lines = stdout.split("\n").filter((line) => line.trim() !== "");
291
+ const lastLine = lines[lines.length - 1];
292
+ if (lastLine === undefined) {
293
+ reject(new Error(`hostToolOverHostRoute: host-tool.mjs produced no output on stdout${stderr ? ` (stderr: ${stderr})` : ""}`));
294
+ return;
295
+ }
296
+ let parsed: unknown;
297
+ try {
298
+ parsed = JSON.parse(lastLine);
299
+ } catch {
300
+ reject(new Error(`hostToolOverHostRoute: host-tool.mjs's stdout is not a parseable JSON line: ${lastLine}`));
301
+ return;
302
+ }
303
+ if (!isPlainObject(parsed)) {
304
+ reject(new Error("hostToolOverHostRoute: host-tool.mjs's response line is not a JSON object"));
305
+ return;
306
+ }
307
+ resolvePromise(parsed as unknown as HostToolClientResult);
308
+ });
309
+ });
310
+ }
311
+
312
+ // Translates every response `path` through containerPath() -- never the
313
+ // sibling host-to-container translation module this file must never import
314
+ // (A-03, see this file's own header). A refusal (`{ ok: false }`) carries no
315
+ // `path` field at all, so it passes through unchanged.
316
+ function translateHostToolResponse(response: HostToolClientResult): HostToolClientResult {
317
+ if (!response.ok) return response;
318
+ return {
319
+ ...response,
320
+ results: response.results.map((result) => ({ ...result, path: containerPath(result.path) })),
321
+ };
322
+ }
323
+
324
+ export interface RunHostToolFromContainerOptions {
325
+ /** Control-plane state directory override (test seam) -- only consulted on
326
+ * the container route; ignored on the host route. */
327
+ dir?: string;
328
+ /** Repo root override for the host route (test seam); defaults to
329
+ * repoRoot()'s own ladder. Ignored on the container route -- the broker's
330
+ * own `--repo-root` is authoritative there. */
331
+ repoRoot?: string;
332
+ }
333
+
334
+ /** The single route-selecting entry point. Transport is chosen by
335
+ * isInsideContainer() -- the project's ONE container detector -- and NOT by
336
+ * whether a broker happens to be reachable. */
337
+ export async function runHostToolFromContainer(
338
+ tool: string,
339
+ args: Record<string, unknown>,
340
+ opts: RunHostToolFromContainerOptions = {},
341
+ ): Promise<HostToolClientResult> {
342
+ if (isInsideContainer()) {
343
+ const raw = await hostToolOverControlPlane(opts.dir ?? brokerRootDir(), tool, args);
344
+ return translateHostToolResponse(raw);
345
+ }
346
+ // Host route (34-04, SEAM-05): host and container coordinates are the
347
+ // SAME filesystem here -- there is no container to translate across --
348
+ // so the raw host-absolute paths in the response are already correct for
349
+ // the calling process. Skip containerPath() entirely rather than call it
350
+ // unconditionally: containerPath() only resolves a path that falls under
351
+ // THIS project's own workspace root (hostRootCandidates()), which a
352
+ // legitimate host-route invocation need not be under at all -- e.g. a
353
+ // build rooted at a scratch directory outside the repo (this project's own
354
+ // skill-acme-build-cli.test.ts, and CI's RUNNER_TEMP-rooted scaffold
355
+ // check, both build entirely outside the repo tree).
356
+ return hostToolOverHostRoute(opts.repoRoot ?? repoRoot({ from: HERE }), tool, args);
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // CLI entry point (Phase 34, plan 34-04, SEAM-05).
361
+ //
362
+ // The migrated skill scripts (acme.mjs, packer-finding.mjs) live in a
363
+ // DIFFERENT npm package than this file (@henols/c64-re-tools vs.
364
+ // @henols/vice-mcp), so a static `import` of runHostToolFromContainer()
365
+ // resolves on neither npm-installer distribution route -- exactly the
366
+ // cross-package constraint vsf-slice.mjs's own header already records for a
367
+ // different module. Those scripts instead LOCATE this file on disk (via
368
+ // mcp-module.mjs's resolveMcpModule() ladder) and invoke it as a subprocess:
369
+ // `process.execPath <resolved-path> run --tool <id> --args <json> [--repo-root <path>]`.
370
+ // This is the interpreter already running the calling script, spawned on an
371
+ // in-tree module -- not an external host binary, and not a second executor:
372
+ // it is the SAME route selection (isInsideContainer()) and the SAME
373
+ // runHostToolFromContainer() this file already exposes as a value import for
374
+ // same-package (src/mcp/vice/**) callers.
375
+ //
376
+ // Prints exactly ONE line of JSON on stdout (the translated
377
+ // HostToolClientResult) and exits 0 when it carries `ok: true`, 1 otherwise
378
+ // -- including a transport-level failure (rejected promise), which is
379
+ // reported in the SAME `{ ok: false, message }` shape a tool's own refusal
380
+ // uses, so a caller never needs to distinguish "the seam refused" from "the
381
+ // seam was unreachable" by inspecting anything but `ok`/`message`.
382
+ function parseRunCliArgs(argv: string[]): { tool?: string; args?: string; repoRoot?: string } {
383
+ let tool: string | undefined;
384
+ let args: string | undefined;
385
+ let repoRoot: string | undefined;
386
+ for (let i = 0; i < argv.length; i++) {
387
+ if (argv[i] === "--tool") {
388
+ tool = argv[++i];
389
+ } else if (argv[i] === "--args") {
390
+ args = argv[++i];
391
+ } else if (argv[i] === "--repo-root") {
392
+ repoRoot = argv[++i];
393
+ }
394
+ }
395
+ return { tool, args, repoRoot };
396
+ }
397
+
398
+ const IS_ENTRY_POINT = process.argv[1] !== undefined && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url);
399
+
400
+ if (IS_ENTRY_POINT) {
401
+ const USAGE = "usage: host-tool-client.ts run --tool <tool> --args <json> [--repo-root <path>]\n";
402
+ const [, , cliCommand, ...cliRest] = process.argv;
403
+ if (cliCommand !== "run") {
404
+ process.stderr.write(USAGE);
405
+ process.exitCode = 1;
406
+ } else {
407
+ const { tool, args, repoRoot: repoRootArg } = parseRunCliArgs(cliRest);
408
+ if (!tool || args === undefined) {
409
+ process.stderr.write(USAGE);
410
+ process.exitCode = 1;
411
+ } else {
412
+ let parsedArgs: unknown;
413
+ try {
414
+ parsedArgs = JSON.parse(args);
415
+ } catch {
416
+ parsedArgs = null;
417
+ }
418
+ const argsObj = isPlainObject(parsedArgs) ? parsedArgs : {};
419
+ runHostToolFromContainer(tool, argsObj, repoRootArg ? { repoRoot: repoRootArg } : {})
420
+ .then((response) => {
421
+ process.stdout.write(`${JSON.stringify(response)}\n`);
422
+ process.exitCode = response.ok ? 0 : 1;
423
+ })
424
+ .catch((err: unknown) => {
425
+ process.stdout.write(`${JSON.stringify({ ok: false, message: err instanceof Error ? err.message : String(err) })}\n`);
426
+ process.exitCode = 1;
427
+ });
428
+ }
429
+ }
430
+ }
@@ -1,9 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  // Records a vice_recycle incident to disk BEFORE anything is killed (D-17,
3
- // plan 01.3-01). This is the FIRST repo-tracked file any mcp__vice__* tool
4
- // has ever written -- see .planning/incidents/README.md for why the path is
5
- // committed rather than living under the gitignored .vice-supervisor/ tree
6
- // every other module in this directory reads/writes through.
3
+ // plan 01.3-01).
4
+ //
5
+ // MOVED 2026-09-08 (D-33, clean break): this directory used to live at
6
+ // `<repoRoot>/.planning/incidents`, repo-tracked and never gitignored, with
7
+ // its own README.md inside the GSD planning tree explaining why. That was
8
+ // the wrong place for product output to land -- writing incident records
9
+ // (which can carry screenshots and snapshot metadata) into a consumer's GSD
10
+ // planning tree was the named reason for the move. It now lives under the
11
+ // single tool-written root `repo-root.ts`'s `toolsDir()` owns, alongside the
12
+ // other five writers that root consolidates, and is gitignored along with
13
+ // the rest of that root. There is no fallback to the old location and no
14
+ // migration shim -- a pre-existing `.planning/incidents/` tree is left on
15
+ // disk, unread, for the user to delete by hand. Never write incident records
16
+ // back into the consumer's planning tree.
7
17
  //
8
18
  // This module makes NO network call of any kind, and never will -- the
9
19
  // file-writing remit this phase adds expands, the transport remit does not
@@ -21,19 +31,20 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
21
31
  import { randomUUID } from "node:crypto";
22
32
  import { join, resolve } from "node:path";
23
33
 
24
- import { repoRoot } from "./repo-root.ts";
34
+ import { toolsDir } from "./repo-root.ts";
25
35
 
26
36
  export const INCIDENT_RECORD_VERSION = 1;
27
37
 
28
- /** `<repoRoot>/.planning/incidents` -- repo-tracked, never gitignored.
29
- * `VICE_INCIDENTS_DIR` overrides the resolved location when set, mirroring
30
- * vice-broker-client.mjs's own `VICE_POOL_DIR` override -- the seam this
31
- * module's own test suite uses to write against a disposable temp
32
- * directory instead of the real, permanent `.planning/incidents/` every
33
- * production caller resolves to. */
38
+ /** `<toolsDir>/incidents` -- gitignored, a subdirectory of the single
39
+ * tool-written root `repo-root.ts`'s `toolsDir()` owns (D-33, moved
40
+ * 2026-09-08). `VICE_INCIDENTS_DIR` overrides the resolved location when
41
+ * set, mirroring vice-broker-client.mjs's own `VICE_POOL_DIR` override --
42
+ * the seam this module's own test suite uses to write against a disposable
43
+ * temp directory instead of the real, permanent `<toolsDir>/incidents/`
44
+ * every production caller resolves to. */
34
45
  export function incidentsDir(): string {
35
46
  if (process.env.VICE_INCIDENTS_DIR) return resolve(process.env.VICE_INCIDENTS_DIR);
36
- return join(repoRoot(), ".planning", "incidents");
47
+ return join(toolsDir(), "incidents");
37
48
  }
38
49
 
39
50
  function sanitiseUtcTimestamp(at: Date | string | number): string {
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // Deploys this skill's resources/ (the host-side shell launchers) into
3
- // <repo>/tools/ the first time any skill .mjs entry point runs, so a copy of
4
- // this skill directory alone is sufficient -- nobody has to remember to also
5
- // copy three shell scripts from somewhere else (D-1, quick-260730-q4b).
3
+ // <repo>/.c64-re-tools/bin/ the first time any skill .mjs entry point runs,
4
+ // so a copy of this skill directory alone is sufficient -- nobody has to
5
+ // remember to also copy three shell scripts from somewhere else (D-1,
6
+ // quick-260730-q4b). Deploy target moved from `<repo>/tools/` to
7
+ // `<repo>/.c64-re-tools/bin/` on 2026-09-08 (D-33) -- see installTargetDir()
8
+ // below.
6
9
  //
7
10
  // HOSTING CHOICE (D-3): this check lives in a DEDICATED module, triggered
8
11
  // from repo-root.mjs, for two reasons. First, repo-root.mjs is a pure path
@@ -87,9 +90,15 @@ export type ResourceStatus = "missing" | "present" | "diverged";
87
90
  export const RESOURCES_DIR = join(HERE, "resources");
88
91
 
89
92
  /** Where resources/ gets deployed to, for a given repo root. Always
90
- * `<root>/tools` -- the host's existing muscle-memory location. */
93
+ * `<root>/.c64-re-tools/bin` -- moved 2026-09-08 (D-33) under the single
94
+ * tool-written root every other writer in this codebase now resolves
95
+ * through `repo-root.ts`'s `toolsDir()`. This function cannot import
96
+ * `toolsDir()` itself (see this file's header: importing repo-root.ts here
97
+ * would be a module cycle), so it joins the two segments directly --
98
+ * `".c64-re-tools"` and `"bin"` must stay equal to `join(toolsDir(root),
99
+ * "bin")` by convention, not by shared code. */
91
100
  export function installTargetDir(root: string): string {
92
- return join(root, "tools");
101
+ return join(root, ".c64-re-tools", "bin");
93
102
  }
94
103
 
95
104
  /** Recursive walk of RESOURCES_DIR, returning the relative path (posix-style,
@@ -173,7 +182,9 @@ export function hostLaunchInstructions(root: string): string {
173
182
  `vice-mcp-selector: deployed host launcher scripts to ${installTargetDir(root)}`,
174
183
  "vice-mcp-selector: for MCP-mediated access (mcp__vice__* tools), start the on-demand broker from the HOST workspace, e.g.:",
175
184
  ` ${displayPath}`,
176
- "vice-mcp-selector: the broker launches a boot-fresh instance per session on demand, supervises it, and respawns a crashed one with backoff, while keeping a warm floor of spare instances ready.",
185
+ // Plan 41-05 (folded todo): the warm floor is retired -- the broker
186
+ // launches strictly on demand now, with no speculative pre-warming.
187
+ "vice-mcp-selector: the broker launches a boot-fresh instance strictly on demand, on the first request, supervises it, and respawns a crashed one with backoff.",
177
188
  "vice-mcp-selector: it cannot run inside the container -- the container guard refuses with exit 2.",
178
189
  "vice-mcp-selector: if it refuses when it should not, run it with --check-container for the full per-signal diagnostic.",
179
190
  "vice-mcp-selector: press Ctrl-C to stop it -- SIGINT/SIGTERM are handled and it shuts down cleanly.",
@@ -263,13 +274,18 @@ function isSafeManifestCandidate(entry: unknown, targetDir: string): entry is st
263
274
  * step"; a retired executable would otherwise linger on the host forever).
264
275
  *
265
276
  * The candidate set is EXACTLY `readDeployManifest(root)` minus the current
266
- * `resourceEntries()` -- never a directory walk of `installTargetDir(root)`,
267
- * which is a MIXED directory also holding tracked reverse-engineering
268
- * tooling (d64-parse.mjs, diff-images.mjs, watch-loads.mjs,
269
- * recovery-schema.mjs, releases.mjs and their tests). A file present in the
270
- * target but ABSENT from the manifest is therefore left untouched no matter
271
- * what it is: the prune can only ever reach a path it recorded having placed
272
- * there itself (T-01.6-11).
277
+ * `resourceEntries()` -- never a directory walk of `installTargetDir(root)`.
278
+ * Before the 2026-09-08 `.c64-re-tools/` consolidation (D-33),
279
+ * `installTargetDir(root)` was `<root>/tools`, a MIXED directory also
280
+ * holding tracked reverse-engineering tooling (diff-images.mjs,
281
+ * watch-loads.mjs, recovery-schema.mjs, releases.mjs and
282
+ * their tests) -- this manifest-only candidate set is what kept the prune
283
+ * from ever reaching those tracked files. The deploy target has since moved
284
+ * to `<root>/.c64-re-tools/bin`, no longer shared with that tracked tooling,
285
+ * but the same manifest-only discipline is kept unconditionally: a file
286
+ * present in the target but ABSENT from the manifest is left untouched no
287
+ * matter what it is, so the prune can only ever reach a path it recorded
288
+ * having placed there itself (T-01.6-11).
273
289
  *
274
290
  * Every candidate is validated by isSafeManifestCandidate() BEFORE any
275
291
  * unlink is attempted; a rejected candidate is pushed to `skipped` (nothing