@henols/vice-mcp 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
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.
7
+ //
8
+ // This module makes NO network call of any kind, and never will -- the
9
+ // file-writing remit this phase adds expands, the transport remit does not
10
+ // (T-01.3-SC's package-legitimacy gate has nothing in scope here either: no
11
+ // import beyond node:fs/node:crypto/node:path and this directory's own
12
+ // repo-root.ts).
13
+ //
14
+ // Filename safety (T-01.3-07): incidentRecordPath() below builds the
15
+ // filename ONLY from a UTC timestamp, an integer port and an integer epoch.
16
+ // No caller-supplied string -- specifically, never the caller's own
17
+ // "reason" -- ever reaches a path. A non-integer port or epoch is coerced
18
+ // to the literal "unknown" rather than passed through, so a malformed
19
+ // caller value degrades the filename's specificity, never its safety.
20
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
21
+ import { randomUUID } from "node:crypto";
22
+ import { join, resolve } from "node:path";
23
+
24
+ import { repoRoot } from "./repo-root.ts";
25
+
26
+ export const INCIDENT_RECORD_VERSION = 1;
27
+
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. */
34
+ export function incidentsDir(): string {
35
+ if (process.env.VICE_INCIDENTS_DIR) return resolve(process.env.VICE_INCIDENTS_DIR);
36
+ return join(repoRoot(), ".planning", "incidents");
37
+ }
38
+
39
+ function sanitiseUtcTimestamp(at: Date | string | number): string {
40
+ const d = at instanceof Date ? at : new Date(at);
41
+ const base = Number.isNaN(d.getTime()) ? new Date() : d;
42
+ // Strip every non-digit from the ISO string ("2026-08-02T14:30:00.123Z" ->
43
+ // "20260802143000123") -- a UTC-compact timestamp with no punctuation a
44
+ // filesystem could ever object to.
45
+ return base.toISOString().replace(/[^0-9]/g, "");
46
+ }
47
+
48
+ function sanitiseInt(value: unknown): number | "unknown" {
49
+ // null/undefined/"" all coerce to 0 (or NaN) under a bare Number(), which
50
+ // would silently misreport "no port/epoch known" as the real port 0 --
51
+ // excluded FIRST, before the coercion, rather than trusting Number()'s
52
+ // own permissiveness here.
53
+ if (value === null || value === undefined || value === "") return "unknown";
54
+ const n = Number(value);
55
+ return Number.isInteger(n) ? n : "unknown";
56
+ }
57
+
58
+ /** Options shared by incidentAssetStem()/incidentAssetPath()/
59
+ * incidentRecordPath() below: `port`/`epoch` are typed `unknown` rather
60
+ * than `number` because they arrive as whatever the recycle protocol
61
+ * happened to capture -- sanitiseInt() above is what turns a malformed or
62
+ * missing value into the literal "unknown" rather than a bad path. */
63
+ export interface IncidentAssetStemOptions {
64
+ at?: Date | string | number;
65
+ port?: unknown;
66
+ epoch?: unknown;
67
+ }
68
+
69
+ export interface IncidentAssetPathOptions extends IncidentAssetStemOptions {
70
+ ext?: string;
71
+ }
72
+
73
+ /** The `<UTC compact timestamp>-port<port>-epoch<epoch>` stem shared by an
74
+ * incident record and every sibling asset (the screenshot, plan 01.3-03) --
75
+ * the ONLY three inputs that ever reach it, each coerced independently and
76
+ * with no caller-supplied string (the "reason" field) ever consulted
77
+ * (T-01.3-07). Single source of truth for `incidentAssetPath()` and
78
+ * `incidentRecordPath()` below, so a screenshot and its record can never
79
+ * drift onto two different naming rules. */
80
+ export function incidentAssetStem({ at = new Date(), port, epoch }: IncidentAssetStemOptions = {}): string {
81
+ const ts = sanitiseUtcTimestamp(at);
82
+ const p = sanitiseInt(port);
83
+ const e = sanitiseInt(epoch);
84
+ return `${ts}-port${p}-epoch${e}`;
85
+ }
86
+
87
+ /** `<incidentsDir>/<stem>.<ext>` -- the general form `incidentRecordPath()`
88
+ * specialises to `.md`. Plan 01.3-03's `gatherWedgeEvidence()` calls this
89
+ * directly with `ext: "png"` so the screenshot lands beside the record it
90
+ * will be named from, sharing the identical stem. */
91
+ export function incidentAssetPath({ at = new Date(), port, epoch, ext = "md" }: IncidentAssetPathOptions = {}): string {
92
+ return join(incidentsDir(), `${incidentAssetStem({ at, port, epoch })}.${ext}`);
93
+ }
94
+
95
+ /** Builds `<UTC compact timestamp>-port<port>-epoch<epoch>.md`. */
96
+ export function incidentRecordPath({ at = new Date(), port, epoch }: IncidentAssetStemOptions = {}): string {
97
+ return incidentAssetPath({ at, port, epoch, ext: "md" });
98
+ }
99
+
100
+ function yamlScalar(value: unknown): string {
101
+ if (value === null || value === undefined) return "null";
102
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
103
+ // A conservative single-quoted YAML scalar: doubling an embedded single
104
+ // quote is the one escape single-quoted style needs, and it never
105
+ // interprets backslashes or newlines specially -- nothing in a caller's
106
+ // reason string can break out of the frontmatter block this way.
107
+ return `'${String(value).replace(/'/g, "''")}'`;
108
+ }
109
+
110
+ // ------------------------------------------------------- evidence rendering
111
+ //
112
+ // Plan 01.3-03 (criterion 4): the full evidence set `gatherWedgeEvidence()`
113
+ // (vice-proxy.mjs) assembles, one fixed-order item per line so two records
114
+ // diff cleanly. Each item is `{ available: true, value }` or
115
+ // `{ available: false, reason }` -- captureStep()'s own contract -- and an
116
+ // unavailable item renders as an EXPLICIT unavailable line, never a silent
117
+ // omission (must_have 3): a failed capture and an omitted one must never
118
+ // look the same in the file. "snapshot" (task 2) is simply absent from
119
+ // `evidence` on a record written before that capture existed; absence is
120
+ // treated as "not this record's concern", not as a failure of its own.
121
+
122
+ /** One evidence item, as captureStep() (vice-proxy.mjs) produces it. */
123
+ export interface EvidenceItem {
124
+ available: boolean;
125
+ value?: unknown;
126
+ reason?: string;
127
+ }
128
+
129
+ /** The full, fixed-order evidence set a record MAY carry -- every key is
130
+ * optional because a record written before a given capture existed simply
131
+ * omits it (see renderEvidenceSection()'s own `undefined` skip below). */
132
+ export interface IncidentEvidence {
133
+ bracket?: EvidenceItem;
134
+ registers?: EvidenceItem;
135
+ checkpoints?: EvidenceItem;
136
+ irqHandler?: EvidenceItem;
137
+ screenshot?: EvidenceItem;
138
+ snapshot?: EvidenceItem;
139
+ }
140
+
141
+ const EVIDENCE_ITEM_ORDER: { key: keyof IncidentEvidence; label: string }[] = [
142
+ { key: "bracket", label: "cycle bracket" },
143
+ { key: "registers", label: "program counter / register snapshot" },
144
+ { key: "checkpoints", label: "armed checkpoints" },
145
+ { key: "irqHandler", label: "resolved live IRQ handler" },
146
+ { key: "screenshot", label: "screenshot" },
147
+ { key: "snapshot", label: "pre-kill snapshot attempt" },
148
+ ];
149
+
150
+ function formatEvidenceValue(key: keyof IncidentEvidence, value: unknown): string {
151
+ switch (key) {
152
+ case "bracket": {
153
+ const v = value as { cycles?: unknown; elapsedMs?: unknown } | undefined;
154
+ return `${v && v.cycles} cycles retired in ~${v && v.elapsedMs}ms`;
155
+ }
156
+ case "registers": {
157
+ const v = value as { PC?: unknown } | undefined;
158
+ const pc =
159
+ v && typeof v.PC === "number" ? `$${v.PC.toString(16).toUpperCase().padStart(4, "0")}` : "unknown";
160
+ return `PC ${pc} (full snapshot: ${JSON.stringify(value)})`;
161
+ }
162
+ case "checkpoints": {
163
+ const list = Array.isArray(value)
164
+ ? (value as { checkpoint_num: unknown; address: unknown; flag: unknown; enabled: unknown }[])
165
+ : [];
166
+ if (list.length === 0) return "none armed";
167
+ return list
168
+ .map((c) => `#${c.checkpoint_num} ${c.address} (${c.flag}, ${c.enabled ? "enabled" : "disabled"})`)
169
+ .join("; ");
170
+ }
171
+ case "irqHandler": {
172
+ const v = value as { explanation?: unknown } | undefined;
173
+ return v && v.explanation ? String(v.explanation) : JSON.stringify(value);
174
+ }
175
+ case "screenshot":
176
+ return `saved to ${value}`;
177
+ // Deliberately NEVER "verified" or "saved" wording (T-01.3-11): the
178
+ // snapshot capability takes a NAME resolved inside the host emulator's
179
+ // own directory, so nothing container-side can confirm a file actually
180
+ // landed -- the record can only say the ATTEMPT was accepted.
181
+ case "snapshot": {
182
+ const v = value as { name?: unknown } | undefined;
183
+ return `accepted (name: ${v && v.name}) -- a name resolved host-side, never independently verified as written`;
184
+ }
185
+ default:
186
+ return JSON.stringify(value);
187
+ }
188
+ }
189
+
190
+ function renderEvidenceSection(evidence: IncidentEvidence | null | undefined): string {
191
+ if (!evidence) return "(no evidence captured)";
192
+ const lines: string[] = [];
193
+ for (const { key, label } of EVIDENCE_ITEM_ORDER) {
194
+ const item = evidence[key];
195
+ if (item === undefined) continue; // this record's own plan/task never wires this item in -- not a gap
196
+ if (item && item.available === true) {
197
+ lines.push(`- ${label}: ${formatEvidenceValue(key, item.value)}`);
198
+ } else {
199
+ const reason = item && item.reason ? item.reason : "no reason recorded";
200
+ lines.push(`- ${label}: unavailable (${reason})`);
201
+ }
202
+ }
203
+ return lines.length > 0 ? lines.join("\n") : "(no evidence captured)";
204
+ }
205
+
206
+ /** `evidence_complete` (frontmatter): true only when every item THIS record
207
+ * actually attempted came back available -- "so a later grep can find the
208
+ * records that captured everything without reading each one" (task 1). An
209
+ * item this record's own plan/task never wires in (undefined) does not
210
+ * count against completeness; at least one real item must be present for
211
+ * "complete" to mean anything. */
212
+ function isEvidenceComplete(evidence: IncidentEvidence | null | undefined): boolean {
213
+ if (!evidence) return false;
214
+ let sawAny = false;
215
+ for (const { key } of EVIDENCE_ITEM_ORDER) {
216
+ const item = evidence[key];
217
+ if (item === undefined) continue;
218
+ sawAny = true;
219
+ if (item.available !== true) return false;
220
+ }
221
+ return sawAny;
222
+ }
223
+
224
+ /** The full set of fields renderIncidentRecord()/writeIncidentRecord()/
225
+ * finaliseIncidentRecord() pass around. Most fields are typed `unknown`
226
+ * rather than a narrower scalar type because this module's own contract is
227
+ * to render WHATEVER it is handed via yamlScalar()/template-literal
228
+ * stringification, never to validate it -- narrowing these to `string` or
229
+ * `number` would be a type claim this module's own runtime behaviour does
230
+ * not make good on. */
231
+ export interface IncidentRecordInput {
232
+ version?: unknown;
233
+ at?: unknown;
234
+ port?: unknown;
235
+ epoch_before?: unknown;
236
+ epoch_after?: unknown;
237
+ outcome?: unknown;
238
+ kill_stage?: unknown;
239
+ session_id?: unknown;
240
+ reason?: unknown;
241
+ evidence?: IncidentEvidence | null;
242
+ evidence_section?: string | null;
243
+ evidence_complete?: unknown;
244
+ }
245
+
246
+ /** Renders the incident record as markdown: a parseable YAML frontmatter
247
+ * block carrying every field the recycle protocol produces, then a prose
248
+ * body with the caller's own reason quoted verbatim (T-01.3-07's mitigation
249
+ * is the FILENAME, not the body -- the body is free to carry anything).
250
+ *
251
+ * `evidence` (structured, from gatherWedgeEvidence()) drives the initial
252
+ * render. `evidence_section`/`evidence_complete` (raw strings/boolean) are
253
+ * how finaliseIncidentRecord() re-renders WITHOUT structured evidence in
254
+ * hand: it extracts the already-rendered evidence text and the already-
255
+ * parsed completeness flag from the existing file and carries both forward
256
+ * verbatim, so finalising a record (outcome/kill_stage/epoch_after only)
257
+ * can never silently drop the evidence captured before the kill.
258
+ */
259
+ export function renderIncidentRecord(record: IncidentRecordInput = {}): string {
260
+ const {
261
+ version = INCIDENT_RECORD_VERSION,
262
+ at = new Date().toISOString(),
263
+ port = null,
264
+ epoch_before = null,
265
+ epoch_after = null,
266
+ outcome = "pending",
267
+ kill_stage = null,
268
+ session_id = null,
269
+ reason = "",
270
+ evidence = null,
271
+ evidence_section = null,
272
+ evidence_complete = null,
273
+ } = record;
274
+
275
+ const evidenceComplete = evidence_complete !== null ? Boolean(evidence_complete) : isEvidenceComplete(evidence);
276
+ const evidenceSectionText = evidence_section !== null ? evidence_section : renderEvidenceSection(evidence);
277
+
278
+ const frontmatter = [
279
+ "---",
280
+ `version: ${yamlScalar(version)}`,
281
+ `at: ${yamlScalar(at)}`,
282
+ `port: ${yamlScalar(port)}`,
283
+ `epoch_before: ${yamlScalar(epoch_before)}`,
284
+ `epoch_after: ${yamlScalar(epoch_after)}`,
285
+ `outcome: ${yamlScalar(outcome)}`,
286
+ `kill_stage: ${yamlScalar(kill_stage)}`,
287
+ `session_id: ${yamlScalar(session_id)}`,
288
+ `evidence_complete: ${yamlScalar(evidenceComplete)}`,
289
+ "---",
290
+ ].join("\n");
291
+
292
+ const reasonText = reason && String(reason).trim().length > 0 ? String(reason) : "(no reason recorded)";
293
+
294
+ const body = [
295
+ "",
296
+ "## Why this record exists",
297
+ "",
298
+ reasonText,
299
+ "",
300
+ "## Pre-kill evidence",
301
+ "",
302
+ `- port: ${port === null || port === undefined ? "unknown" : port}`,
303
+ `- epoch before recycle: ${epoch_before === null || epoch_before === undefined ? "unknown" : epoch_before}`,
304
+ "",
305
+ "## Evidence",
306
+ "",
307
+ evidenceSectionText,
308
+ "",
309
+ "## Outcome",
310
+ "",
311
+ `- outcome: ${outcome}`,
312
+ `- kill stage: ${kill_stage === null || kill_stage === undefined ? "(not yet known)" : kill_stage}`,
313
+ `- epoch after recycle: ${epoch_after === null || epoch_after === undefined ? "(not yet known)" : epoch_after}`,
314
+ "",
315
+ ].join("\n");
316
+
317
+ return `${frontmatter}\n${body}`;
318
+ }
319
+
320
+ // Tmp sibling created empty -> mode tightened to owner-read-write BEFORE any
321
+ // content lands -> content written -> renamed over the destination, the
322
+ // same shape vice-broker.mts's writeBrokerRecord() and install-resources.ts's
323
+ // manifest writer use (01.6.1-PATTERNS.md's "Atomic write" pattern). Ported
324
+ // here rather than left at the plain tmp-then-rename shape the retiring
325
+ // bash broker's write_json_atomic() (formerly resources/vice-broker.sh,
326
+ // deleted plan 11 -- its own mode-0600 shape survives independently in
327
+ // vice-broker.mts's writeBrokerRecord() and broker-epoch.mts's
328
+ // writeEpochRecord()) and writeJsonAtomic() (vice-broker-client.mjs) use:
329
+ // this module's own threat register entry (T-01.6.1-08) requires the
330
+ // mode-restriction step specifically, since a record can carry register/
331
+ // screenshot-path evidence and briefly sat world-readable at the default
332
+ // umask between write and rename otherwise. The rename itself is still what
333
+ // makes the write atomic (a crash between write and rename leaves at most
334
+ // one stray, uniquely-named temp file, never a half-written record observed
335
+ // mid-write); the chmod is what stops that same window being world-readable.
336
+ function writeAtomic(path: string, content: string): string {
337
+ mkdirSync(incidentsDir(), { recursive: true });
338
+ const tmp = join(incidentsDir(), `.tmp-${process.pid}-${randomUUID()}`);
339
+ writeFileSync(tmp, "");
340
+ chmodSync(tmp, 0o600);
341
+ writeFileSync(tmp, content);
342
+ renameSync(tmp, path);
343
+ return path;
344
+ }
345
+
346
+ /** Writes a NEW incident record, never clobbering an existing file at the
347
+ * same computed path: a second recycle in the same second, on the same
348
+ * port and epoch, appends "-2", "-3", ... rather than overwriting the
349
+ * first record. Returns the absolute path actually written. */
350
+ export function writeIncidentRecord(record: IncidentRecordInput = {}): string {
351
+ mkdirSync(incidentsDir(), { recursive: true });
352
+ // `record.at`, when supplied, is always an ISO timestamp string in every
353
+ // caller across this tree (this module's own contract; see the default
354
+ // above) -- narrowed here rather than left `unknown`, the same scoped-cast
355
+ // idiom this codebase uses at `(e as Error).message`.
356
+ const at = (record.at as string | undefined) || new Date().toISOString();
357
+ const basePath = incidentRecordPath({ at, port: record.port, epoch: record.epoch_before });
358
+ let path = basePath;
359
+ let suffix = 2;
360
+ while (existsSync(path)) {
361
+ path = basePath.replace(/\.md$/, `-${suffix}.md`);
362
+ suffix += 1;
363
+ }
364
+ const content = renderIncidentRecord({ ...record, at });
365
+ writeAtomic(path, content);
366
+ return path;
367
+ }
368
+
369
+ /** A DELIBERATELY minimal frontmatter reader -- this module never depends on
370
+ * a YAML parser; it only needs to read back the handful of scalar fields it
371
+ * itself wrote, in the exact single-quoted shape yamlScalar() emits above.
372
+ * `reason`/`evidence_section` are typed explicitly since every code path
373
+ * below either sets or leaves them at their initial value; every other
374
+ * frontmatter key is read back dynamically by name and so falls to the
375
+ * index signature. */
376
+ interface ParsedFrontmatter {
377
+ reason: string;
378
+ evidence_section?: string;
379
+ [key: string]: unknown;
380
+ }
381
+
382
+ function parseFrontmatterLoose(text: string): ParsedFrontmatter {
383
+ const out: ParsedFrontmatter = { reason: "" };
384
+ const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
385
+ if (fmMatch) {
386
+ for (const line of fmMatch[1].split("\n")) {
387
+ const m = line.match(/^([a-z_]+):\s*(.*)$/);
388
+ if (!m) continue;
389
+ const [, key, rawValue] = m;
390
+ let value: unknown = rawValue;
391
+ if (value === "null") value = null;
392
+ else if (value === "true") value = true;
393
+ else if (value === "false") value = false;
394
+ else if (/^-?\d+$/.test(rawValue)) value = Number(rawValue);
395
+ else if (rawValue.startsWith("'") && rawValue.endsWith("'")) value = rawValue.slice(1, -1).replace(/''/g, "'");
396
+ out[key] = value;
397
+ }
398
+ }
399
+ const reasonMatch = text.match(/## Why this record exists\n\n([\s\S]*?)\n\n## Pre-kill evidence/);
400
+ if (reasonMatch) out.reason = reasonMatch[1] === "(no reason recorded)" ? "" : reasonMatch[1];
401
+
402
+ // Carried forward VERBATIM by finaliseIncidentRecord() -- the raw already-
403
+ // rendered evidence text, not re-derived from structured evidence (which
404
+ // finalise never has in hand; only writeIncidentRecord()'s initial call
405
+ // does). This is what stops finalising a record from silently dropping
406
+ // the evidence captured before the kill.
407
+ const evidenceMatch = text.match(/## Evidence\n\n([\s\S]*?)\n\n## Outcome/);
408
+ if (evidenceMatch) out.evidence_section = evidenceMatch[1];
409
+
410
+ return out;
411
+ }
412
+
413
+ export interface FinaliseIncidentRecordOptions {
414
+ outcome?: unknown;
415
+ kill_stage?: unknown;
416
+ epoch_after?: unknown;
417
+ }
418
+
419
+ /** Re-renders an already-written record with its outcome fields filled in,
420
+ * through the same atomic write shape -- the record is never left saying
421
+ * an outcome is still pending once a caller knows better. */
422
+ export function finaliseIncidentRecord(
423
+ path: string,
424
+ { outcome, kill_stage, epoch_after }: FinaliseIncidentRecordOptions = {}
425
+ ): string {
426
+ let existing = "";
427
+ try {
428
+ existing = readFileSync(path, "utf8");
429
+ } catch {
430
+ existing = "";
431
+ }
432
+ const parsed = parseFrontmatterLoose(existing);
433
+ const merged: IncidentRecordInput = {
434
+ ...parsed,
435
+ outcome: outcome !== undefined ? outcome : parsed.outcome,
436
+ kill_stage: kill_stage !== undefined ? kill_stage : parsed.kill_stage,
437
+ epoch_after: epoch_after !== undefined ? epoch_after : parsed.epoch_after,
438
+ };
439
+ const content = renderIncidentRecord(merged);
440
+ writeAtomic(path, content);
441
+ return path;
442
+ }