@deeeed/metamask-harness 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.16.0 - 2026-07-14
6
+
7
+ ### Added
8
+
9
+ - Mobile and Extension recipe runs now write bounded, redacted, non-blocking side findings to `diagnostics.json`: Mobile combines its in-app issue buffer with run-scoped log bytes, while Extension owns a CDP console subscription for the run. Core remains N/A because it is headless.
10
+ - `actions --categories` now lists compact category counts, and `actions --category <name>` returns a bounded action/schema view so agents can discover capabilities without loading the complete adapter catalog.
11
+
5
12
  ## 0.15.2 - 2026-07-13
6
13
 
7
14
  ### Fixed
package/README.md CHANGED
@@ -50,6 +50,13 @@ mm-harness launch ios # mobile: ios | android; extension: jus
50
50
  mm-harness run recipe.json --adapter mobile --artifacts-dir /tmp/recipe-artifacts --json
51
51
  ```
52
52
 
53
+ Mobile and Extension runs also write `diagnostics.json` from application events
54
+ emitted during that run. Mobile combines its bounded in-app issue buffer with
55
+ run-scoped log bytes; Extension owns a CDP console subscription for the run.
56
+ These redacted warnings/errors are non-blocking side findings: they help spot
57
+ adjacent bugs without claiming the recipe or current change caused them. Core
58
+ is headless, so this is N/A there.
59
+
53
60
  Outputs: `summary.json`, `trace.json`, screenshots, logs, and an artifact
54
61
  manifest.
55
62
 
@@ -69,7 +76,8 @@ windows, wallet state, health, ports, parallel).
69
76
 
70
77
  ```bash
71
78
  # Capabilities (compose recipes from the vocabulary + flow library)
72
- mm-harness actions --adapter mobile --json # the action vocabulary
79
+ mm-harness actions --adapter mobile --categories --json # compact discovery
80
+ mm-harness actions --adapter mobile --category ui --json # bounded vocabulary
73
81
  mm-harness actions --adapter mobile --raw # raw action-manifest dump
74
82
  mm-harness call unlock --adapter extension # run one action via the real engine path
75
83
  mm-harness flows --json # reusable library flows, with provenance
@@ -15,7 +15,7 @@ const SPEC = {
15
15
  { name: "logs", aliases: ["tail"], desc: "Compact build events or full log", flags: ["--full", "-f", "--window", "--events", "--source", "--json"] },
16
16
  { name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
17
17
  { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
18
- { name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
18
+ { name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--categories", "--category", "--action"] },
19
19
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
20
20
  { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
21
21
  { name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
@@ -174,6 +174,8 @@ async function handleCall(argv) {
174
174
  summaryPath: result.summaryPath,
175
175
  tracePath: result.tracePath,
176
176
  artifactManifestPath: result.artifactManifestPath,
177
+ ...result.diagnosticsPath ? { diagnosticsPath: result.diagnosticsPath } : {},
178
+ ...result.sideFindings ? { sideFindings: result.sideFindings } : {},
177
179
  ...callOutput !== void 0 ? { output: callOutput } : {},
178
180
  recovered: state.recovered,
179
181
  mutations: state.mutations,
@@ -187,12 +189,15 @@ async function handleCall(argv) {
187
189
  const rendered = callOutput !== void 0 ? `
188
190
  Result:
189
191
  ${formatCallOutput(callOutput)}` : "";
192
+ const sideFindingTotal = result.sideFindings?.counts.total ?? 0;
190
193
  const out = (style, text) => color(style, text, { stream: process.stdout });
194
+ const sideFindings = sideFindingTotal > 0 ? `${out("label", "Side findings:")} REVIEW ${sideFindingTotal} distinct application warning/error event(s); see ${out("path", result.diagnosticsPath ?? "diagnostics.json")} (non-blocking)
195
+ ` : "";
191
196
  console.log(
192
197
  `${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
193
198
  ${out("label", "Result:")}
194
199
  ${formatCallOutput(callOutput)}` : ""}
195
- ${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
200
+ ` + sideFindings + `${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
196
201
  );
197
202
  }
198
203
  return result.status === "pass" ? EXIT.ok : EXIT.runtime;
@@ -22,13 +22,42 @@ async function handleActions({ options }) {
22
22
  await validateManifest(manifest);
23
23
  const json = optionFlag(options, "json");
24
24
  const action = optionString(options, "action");
25
+ const category = optionString(options, "category")?.toLowerCase();
26
+ const categoriesOnly = optionFlag(options, "categories");
25
27
  const all = describeManifestActions(manifest);
26
- const actions = action ? fuzzyResolveActions(all, action) : all;
28
+ const categories = summarizeActionCategories(all);
29
+ if (categoriesOnly && (action || category)) {
30
+ const message = "--categories cannot be combined with --action or --category.";
31
+ if (json) {
32
+ console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, error: { code: "ACTION_FILTER_CONFLICT", message } }, null, 2));
33
+ } else {
34
+ console.error(`\u2717 mm-harness actions: ${message}`);
35
+ }
36
+ return EXIT.usage;
37
+ }
38
+ if (categoriesOnly) {
39
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, categories }, null, 2));
40
+ else for (const entry of categories) console.log(`${entry.name} (${entry.count})`);
41
+ return EXIT.ok;
42
+ }
43
+ const categoryActions = category ? all.filter((entry) => entry.category === category) : all;
44
+ if (category && categoryActions.length === 0) {
45
+ const message = `no action category matches "${category}" for the ${adapter} adapter.`;
46
+ const userAction = `mm-harness actions --adapter ${adapter} --categories`;
47
+ if (json) {
48
+ console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, category, availableCategories: categories, error: { code: "ACTION_CATEGORY_UNKNOWN", message, userAction } }, null, 2));
49
+ } else {
50
+ console.error(`\u2717 mm-harness actions: ${message}
51
+ Next: ${userAction}`);
52
+ }
53
+ return EXIT.usage;
54
+ }
55
+ const actions = action ? fuzzyResolveActions(categoryActions, action) : categoryActions;
27
56
  if (action && actions.length === 0) {
28
57
  const message = `no action matches "${action}" for the ${adapter} adapter.`;
29
58
  const userAction = `mm-harness actions --adapter ${adapter} # list the vocabulary`;
30
59
  if (json) {
31
- console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, action, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
60
+ console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, action, category, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
32
61
  } else {
33
62
  console.error(`\u2717 mm-harness actions: ${message}
34
63
  Next: ${userAction}`);
@@ -36,7 +65,7 @@ async function handleActions({ options }) {
36
65
  return EXIT.usage;
37
66
  }
38
67
  if (json) {
39
- console.log(JSON.stringify({ adapter, actions }, null, 2));
68
+ console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, category, actions }, null, 2));
40
69
  } else {
41
70
  for (const entry of actions) {
42
71
  const fields = entry.fields.length ? ` fields=${entry.fields.join(",")}` : "";
@@ -53,6 +82,11 @@ function fuzzyResolveActions(entries, query) {
53
82
  if (exactSegment.length > 0) return exactSegment;
54
83
  return entries.filter((e) => finalSegment(e.name).includes(query));
55
84
  }
85
+ function summarizeActionCategories(actions) {
86
+ const counts = /* @__PURE__ */ new Map();
87
+ for (const action of actions) counts.set(action.category, (counts.get(action.category) ?? 0) + 1);
88
+ return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((left, right) => left.name.localeCompare(right.name));
89
+ }
56
90
  function describeManifestActions(manifest) {
57
91
  const manifestRecord = isRecord(manifest) ? manifest : {};
58
92
  const metadata = isRecord(manifestRecord.action_metadata) ? manifestRecord.action_metadata : {};
@@ -80,15 +114,22 @@ function describeManifestAction(name, kind, metadata) {
80
114
  return {
81
115
  name,
82
116
  kind,
117
+ category: actionCategory(name),
83
118
  description: typeof record.description === "string" ? record.description : "",
84
119
  fields: properties,
85
120
  schema,
86
121
  examples: record.examples
87
122
  };
88
123
  }
124
+ function actionCategory(name) {
125
+ const segments = name.split(".");
126
+ if (segments[0] === "metamask" && segments.length > 2) return segments[1] ?? "metamask";
127
+ return segments[0] || "other";
128
+ }
89
129
  export {
90
130
  describeManifestActions,
91
131
  fuzzyResolveActions,
92
132
  handleActions,
93
- handleManifest
133
+ handleManifest,
134
+ summarizeActionCategories
94
135
  };
@@ -25,6 +25,7 @@ function parseArgs(argv, command) {
25
25
  "plan",
26
26
  "list",
27
27
  "raw",
28
+ "categories",
28
29
  "fix",
29
30
  "force",
30
31
  "resolveOnly",
@@ -20,6 +20,11 @@ import {
20
20
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
21
21
  import { listRecipeFiles } from "../recipe-files.js";
22
22
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
23
+ import {
24
+ beginRunDiagnostics,
25
+ finishRunDiagnostics,
26
+ stopRunDiagnostics
27
+ } from "../run-diagnostics.js";
23
28
  import { EXIT } from "./shared.js";
24
29
  import {
25
30
  actionManifestPathOption,
@@ -49,6 +54,7 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
49
54
  try {
50
55
  await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
51
56
  const absoluteArtifactsDir = path.resolve(artifactsDir);
57
+ const diagnosticBaseline = await beginRunDiagnostics(adapter, projectRoot);
52
58
  const recordVideo = runtimeOptions.recordVideo ?? false;
53
59
  const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
54
60
  const recording = useFramedExtensionRecording ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
@@ -80,8 +86,9 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
80
86
  }
81
87
  }
82
88
  await stopRecipeRecording(recording, result);
83
- return result;
89
+ return finishRunDiagnostics(diagnosticBaseline, result);
84
90
  } finally {
91
+ stopRunDiagnostics(diagnosticBaseline);
85
92
  await stopRecipeRecording(recording);
86
93
  }
87
94
  } finally {
@@ -25,10 +25,19 @@ function renderRunReport(summary, entries) {
25
25
  "",
26
26
  `Status: ${String(summary.status ?? "unknown")}`,
27
27
  `Duration: ${formatDuration(Number(summary.durationMs ?? 0))}`,
28
- `Nodes: ${Number(summary.passed ?? 0)}/${Number(summary.total ?? entries.length)} passed`,
29
- "",
30
- "## Steps"
28
+ `Nodes: ${Number(summary.passed ?? 0)}/${Number(summary.total ?? entries.length)} passed`
31
29
  ];
30
+ const sideFindings = isRecord(summary.sideFindings) ? summary.sideFindings : void 0;
31
+ const sideFindingCounts = sideFindings && isRecord(sideFindings.counts) ? sideFindings.counts : void 0;
32
+ const sideFindingTotal = Number(sideFindingCounts?.total ?? 0);
33
+ if (sideFindingTotal > 0) {
34
+ lines.push(
35
+ "",
36
+ "## Side findings",
37
+ `- REVIEW ${sideFindingTotal} distinct application warning/error event(s); see diagnostics.json (non-blocking)`
38
+ );
39
+ }
40
+ lines.push("", "## Steps");
32
41
  for (const entry of entries) {
33
42
  if (!isRecord(entry)) continue;
34
43
  const mark = entry.ok === false ? "FAIL" : "PASS";
@@ -76,6 +76,8 @@ Example:
76
76
  List the action vocabulary + field schemas for the checkout adapter.
77
77
 
78
78
  --action <name> Describe one action; fuzzy-resolves like call (short or full name)
79
+ --categories List compact action categories and counts
80
+ --category <name> List only one category (for example ui, wallet, or perps)
79
81
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
80
82
  --target <path> Checkout path (default: cwd)
81
83
  --raw Dump raw action registry JSON
@@ -83,6 +85,8 @@ Example:
83
85
 
84
86
  Example:
85
87
  mm-harness actions --adapter mobile
88
+ mm-harness actions --adapter mobile --categories --json
89
+ mm-harness actions --adapter mobile --category ui --json
86
90
  mm-harness actions --adapter mobile --action assert_orders
87
91
  mm-harness actions --adapter extension --raw`
88
92
  },
@@ -0,0 +1,271 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { getAdapterSurface } from "./adapters/surface.js";
6
+ import { runnerDir } from "./paths.js";
7
+ const MAX_CAPTURE_BYTES = 512 * 1024;
8
+ const MAX_FINDINGS = 20;
9
+ const MAX_PREVIEW_CHARS = 320;
10
+ async function beginRunDiagnostics(adapter, projectRoot) {
11
+ const source = getAdapterSurface(adapter).appLogSource(projectRoot);
12
+ if (!source) return null;
13
+ const mobileIssueBuffer = adapter === "mobile" && armMobileIssueBuffer(projectRoot) ? { projectRoot } : void 0;
14
+ const extensionConsoleCapture = adapter === "extension" ? await startExtensionConsoleCapture(source, projectRoot) : void 0;
15
+ const stat = safeStat(source.path);
16
+ return {
17
+ source,
18
+ offset: stat?.size ?? 0,
19
+ ...stat ? { inode: stat.ino } : {},
20
+ ...mobileIssueBuffer ? { mobileIssueBuffer } : {},
21
+ ...extensionConsoleCapture ? { extensionConsoleCapture } : {}
22
+ };
23
+ }
24
+ function finishRunDiagnostics(baseline, result) {
25
+ if (!baseline) return result;
26
+ try {
27
+ stopRunDiagnostics(baseline);
28
+ const bufferedIssues = baseline.mobileIssueBuffer ? collectMobileIssueBuffer(baseline.mobileIssueBuffer.projectRoot) : void 0;
29
+ const diagnostics = collectRunDiagnostics(baseline, bufferedIssues);
30
+ const artifactsDir = path.dirname(result.summaryPath);
31
+ const diagnosticsPath = path.join(artifactsDir, "diagnostics.json");
32
+ fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}
33
+ `);
34
+ indexDiagnosticArtifact(result.artifactManifestPath);
35
+ indexDiagnosticSummary(result.summaryPath, diagnostics);
36
+ return {
37
+ ...result,
38
+ diagnosticsPath,
39
+ sideFindings: {
40
+ status: diagnostics.status,
41
+ note: diagnostics.note,
42
+ counts: diagnostics.counts
43
+ }
44
+ };
45
+ } catch (error) {
46
+ const detail = error instanceof Error ? error.message : String(error);
47
+ console.warn(`WARN: run diagnostics were unavailable: ${detail}`);
48
+ return {
49
+ ...result,
50
+ sideFindings: {
51
+ status: "unavailable",
52
+ note: "Run diagnostics could not be collected; the recipe result is unchanged.",
53
+ counts: { total: 0, warning: 0, error: 0, exception: 0 }
54
+ }
55
+ };
56
+ }
57
+ }
58
+ function stopRunDiagnostics(baseline) {
59
+ const capture = baseline?.extensionConsoleCapture;
60
+ if (!capture || capture.exitCode !== null || capture.signalCode !== null) return;
61
+ capture.kill("SIGTERM");
62
+ }
63
+ function collectRunDiagnostics(baseline, bufferedIssues) {
64
+ const stat = safeStat(baseline.source.path);
65
+ const source = {
66
+ label: baseline.source.label,
67
+ path: baseline.source.path,
68
+ startOffset: baseline.offset,
69
+ endOffset: stat?.size ?? baseline.offset,
70
+ bytesRead: 0,
71
+ truncated: false,
72
+ inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected"
73
+ };
74
+ let text = "";
75
+ if (stat) {
76
+ const sameFile = baseline.inode === void 0 || baseline.inode === stat.ino;
77
+ const startOffset = sameFile && stat.size >= baseline.offset ? baseline.offset : 0;
78
+ const available = Math.max(0, stat.size - startOffset);
79
+ const bytesToRead = Math.min(available, MAX_CAPTURE_BYTES);
80
+ source.startOffset = startOffset;
81
+ source.endOffset = stat.size;
82
+ source.bytesRead = bytesToRead;
83
+ source.truncated = available > MAX_CAPTURE_BYTES;
84
+ if (bytesToRead > 0) text = readSlice(baseline.source.path, startOffset, bytesToRead);
85
+ }
86
+ const allFindings = dedupeFindings(
87
+ [
88
+ ...text.split(/\r?\n/u).map(classifyLine).filter((finding) => finding !== null),
89
+ ...(bufferedIssues ?? []).map(classifyBufferedIssue).filter((finding) => finding !== null)
90
+ ]
91
+ );
92
+ const findings = allFindings.slice(0, MAX_FINDINGS);
93
+ const counts = countFindings(allFindings);
94
+ const status = counts.total > 0 ? "review" : stat || bufferedIssues !== void 0 && bufferedIssues !== null ? "clean" : "unavailable";
95
+ const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
96
+ return {
97
+ schemaVersion: 1,
98
+ scope: "recipe-run-application",
99
+ status,
100
+ nonBlocking: true,
101
+ note,
102
+ source,
103
+ counts,
104
+ findings
105
+ };
106
+ }
107
+ function classifyLine(line) {
108
+ const trimmed = line.trim();
109
+ if (!trimmed) return null;
110
+ let level = null;
111
+ if (/\bEXCEPTION\s{2,}|\b(?:Uncaught|UnhandledPromiseRejection|FATAL EXCEPTION)\b/iu.test(trimmed)) {
112
+ level = "exception";
113
+ } else if (/^(?:ERROR\s{2,}|\[error\]\s*)/iu.test(trimmed) || /\]\s+ERROR\s{2,}/u.test(trimmed)) {
114
+ level = "error";
115
+ } else if (/^(?:WARN(?:ING)?\s{2,}|\[warn(?:ing)?\]\s*)/iu.test(trimmed) || /\]\s+WARN(?:ING)?\s{2,}/u.test(trimmed)) {
116
+ level = "warning";
117
+ }
118
+ if (!level) return null;
119
+ return makeFinding(level, trimmed);
120
+ }
121
+ function classifyBufferedIssue(value) {
122
+ if (!isRecord(value) || typeof value.text !== "string") return null;
123
+ const rawLevel = String(value.level ?? "").toLowerCase();
124
+ const level = rawLevel === "warn" || rawLevel === "warning" ? "warning" : rawLevel === "error" ? "error" : rawLevel === "exception" ? "exception" : null;
125
+ return level ? makeFinding(level, value.text) : null;
126
+ }
127
+ function makeFinding(level, text) {
128
+ const preview = redactPreview(text.trim());
129
+ const identity = preview.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?\b/gu, "[TIME]").replace(/\[(?:sw|page:[^\]]+|console:[^\]]+)\]/gu, "[APP]").replace(/^(?:(?:WARN(?:ING)?|ERROR|EXCEPTION|\[TIME\]|\[APP\])\s*)+/u, "");
130
+ return {
131
+ level,
132
+ fingerprint: createHash("sha256").update(`${level}|${identity}`).digest("hex").slice(0, 12),
133
+ preview
134
+ };
135
+ }
136
+ function armMobileIssueBuffer(projectRoot) {
137
+ const armed = runMobileIssueCommand(projectRoot, "issues-arm");
138
+ if (!armed) return false;
139
+ return runMobileIssueCommand(projectRoot, "issues-collect") !== null;
140
+ }
141
+ function collectMobileIssueBuffer(projectRoot) {
142
+ const result = runMobileIssueCommand(projectRoot, "issues-collect");
143
+ return isRecord(result) && Array.isArray(result.entries) ? result.entries : null;
144
+ }
145
+ function runMobileIssueCommand(projectRoot, command) {
146
+ const bridge = path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
147
+ const result = spawnSync(process.execPath, [bridge, command], {
148
+ cwd: projectRoot,
149
+ env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
150
+ encoding: "utf8",
151
+ timeout: 1e4
152
+ });
153
+ if (result.status !== 0) return null;
154
+ try {
155
+ return JSON.parse(result.stdout.trim());
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+ async function startExtensionConsoleCapture(source, projectRoot) {
161
+ const cdpPort = process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
162
+ if (!cdpPort) return void 0;
163
+ fs.mkdirSync(path.dirname(source.path), { recursive: true });
164
+ const before = safeStat(source.path)?.size ?? 0;
165
+ const script = path.join(runnerDir, "adapters", "extension", "console-tail.mjs");
166
+ const child = spawn(process.execPath, [script, "--cdp-port", cdpPort, "--log", source.path], {
167
+ cwd: projectRoot,
168
+ stdio: "ignore"
169
+ });
170
+ const attached = await waitForExtensionAttachment(source.path, before, child, 3e3);
171
+ if (attached) {
172
+ await delay(50);
173
+ return child;
174
+ }
175
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
176
+ return void 0;
177
+ }
178
+ async function waitForExtensionAttachment(logPath, offset, child, timeoutMs) {
179
+ const deadline = Date.now() + timeoutMs;
180
+ while (Date.now() < deadline && child.exitCode === null && child.signalCode === null) {
181
+ const stat = safeStat(logPath);
182
+ if (stat && stat.size > offset) {
183
+ const appended = readSlice(logPath, offset, Math.min(stat.size - offset, 64 * 1024));
184
+ if (appended.includes("[attached]")) return true;
185
+ }
186
+ await delay(50);
187
+ }
188
+ return false;
189
+ }
190
+ function delay(ms) {
191
+ return new Promise((resolve) => setTimeout(resolve, ms));
192
+ }
193
+ function redactPreview(value) {
194
+ return value.replace(/\b(Bearer)\s+\S+/giu, "$1 [REDACTED]").replace(/\b(password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/giu, "$1=[REDACTED]").replace(/\b(?:0x)?[a-f0-9]{64,}\b/giu, "[REDACTED_HEX]").replace(/(https?:\/\/[^\s?]+)\?\S+/giu, "$1?[REDACTED_QUERY]").slice(0, MAX_PREVIEW_CHARS);
195
+ }
196
+ function dedupeFindings(findings) {
197
+ const byFingerprint = /* @__PURE__ */ new Map();
198
+ for (const finding of findings) {
199
+ const existing = byFingerprint.get(finding.fingerprint);
200
+ if (existing) existing.count += 1;
201
+ else byFingerprint.set(finding.fingerprint, { ...finding, count: 1 });
202
+ }
203
+ return [...byFingerprint.values()];
204
+ }
205
+ function countFindings(findings) {
206
+ const counts = { total: findings.length, warning: 0, error: 0, exception: 0 };
207
+ for (const finding of findings) counts[finding.level] += 1;
208
+ return counts;
209
+ }
210
+ function readSlice(filePath, offset, length) {
211
+ const fd = fs.openSync(filePath, "r");
212
+ try {
213
+ const buffer = Buffer.alloc(length);
214
+ const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
215
+ return buffer.subarray(0, bytesRead).toString("utf8");
216
+ } finally {
217
+ fs.closeSync(fd);
218
+ }
219
+ }
220
+ function safeStat(filePath) {
221
+ try {
222
+ return fs.statSync(filePath);
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+ function indexDiagnosticArtifact(manifestPath) {
228
+ const manifest = readJsonRecord(manifestPath);
229
+ if (!manifest) return;
230
+ const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
231
+ manifest.artifacts = [
232
+ ...artifacts.filter((artifact) => !isRecord(artifact) || artifact.path !== "diagnostics.json"),
233
+ {
234
+ path: "diagnostics.json",
235
+ type: "json",
236
+ label: "Run-scoped application diagnostics",
237
+ category: "diagnostic"
238
+ }
239
+ ];
240
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
241
+ `);
242
+ }
243
+ function indexDiagnosticSummary(summaryPath, diagnostics) {
244
+ const summary = readJsonRecord(summaryPath);
245
+ if (!summary) return;
246
+ summary.sideFindings = {
247
+ status: diagnostics.status,
248
+ nonBlocking: true,
249
+ counts: diagnostics.counts,
250
+ diagnosticsPath: "diagnostics.json"
251
+ };
252
+ fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}
253
+ `);
254
+ }
255
+ function readJsonRecord(filePath) {
256
+ try {
257
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
258
+ return isRecord(value) ? value : null;
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+ function isRecord(value) {
264
+ return typeof value === "object" && value !== null && !Array.isArray(value);
265
+ }
266
+ export {
267
+ beginRunDiagnostics,
268
+ collectRunDiagnostics,
269
+ finishRunDiagnostics,
270
+ stopRunDiagnostics
271
+ };
package/docs/CLI-SPEC.md CHANGED
@@ -366,6 +366,8 @@ Installs the cached Runway iOS dev client onto a prepared mobile slot. It does n
366
366
 
367
367
  **Validates first (adapter-aware):** Before touching any device, `run` validates the recipe: action existence in the adapter's manifest, platform support for each action, and fixture preconditions. Validation errors exit 5 with a structured error list. `--plan` stops here — prints the plan and exits, no device touched. Without `--plan`, validation failures are fatal before any execution begins.
368
368
 
369
+ **Run-scoped diagnostics:** Mobile combines its bounded in-app issue buffer with application-log bytes appended while the recipe executes; Extension owns a CDP console subscription for the run. The bounded, redacted result is written to `diagnostics.json`, indexed as a diagnostic artifact, and summarized as non-blocking `sideFindings`; it never changes recipe pass/fail and does not claim causality. Core is headless, so this is N/A.
370
+
369
371
  > **Wave-2 status:** adapter-aware validation (`validateRecipeDocument` +
370
372
  > `validateRecipeWithManifest`, shared helper `validateRecipeAdapterAware`) is
371
373
  > wired for **`run --plan`** and **`call`** at the pinned deps (`@farmslot/protocol`
@@ -559,9 +561,9 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
559
561
 
560
562
  ### `actions` (ROUTES-NOW → DISCOVER verb)
561
563
 
562
- **Synopsis:** `mm-harness actions --adapter <p> [--json] [--action <name>] [--kind official|custom]`
564
+ **Synopsis:** `mm-harness actions --adapter <p> [--json] [--categories | --category <name> | --action <name>]`
563
565
 
564
- **PRIMARY (agent):** `mm-harness actions --adapter mobile --json`
566
+ **PRIMARY (agent):** `mm-harness actions --adapter mobile --categories --json`, then one `--category` or `--action` query.
565
567
 
566
568
  **`--json` output shape** (grounded — `actions --adapter core --json` confirmed):
567
569
  ```json
@@ -571,6 +573,7 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
571
573
  {
572
574
  "name": "metamask.perps.read_positions",
573
575
  "kind": "custom",
576
+ "category": "perps",
574
577
  "description": "Read live Perps positions...",
575
578
  "fields": ["account", "action", "market", "markets", "mode", "selector", "side", "symbol", "symbols", "timeout_ms"],
576
579
  "examples": [{ "node": { "action": "metamask.perps.read_positions", "account": "...", "symbol": "BTC" } }]
@@ -578,7 +581,7 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
578
581
  ]
579
582
  }
580
583
  ```
581
- `kind` is `"official"` (engine built-ins) or `"custom"` (MetaMask adapter actions). `fields` lists every accepted parameter name. `examples[].node` is a copy-pasteable recipe node.
584
+ `kind` is `"official"` (engine built-ins) or `"custom"` (MetaMask adapter actions). `category` is derived from the durable action namespace (`ui.*` → `ui`, `metamask.perps.*` → `perps`). `fields` lists every accepted parameter name. `examples[].node` is a copy-pasteable recipe node.
582
585
 
583
586
  **Human form:** `mm-harness actions --adapter mobile` — one line per action: `<name> (<kind>) <description> fields=<f1,f2,...>`.
584
587
 
@@ -586,12 +589,13 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
586
589
  |---|---|---|---|---|---|
587
590
  | `--adapter <p>` | mobile\|extension\|core | auto-detect | `RECIPE_HARNESS_PLATFORM` | both | Target adapter · [DEFAULT-GAP] required today |
588
591
  | `--action <name>` | string | all | — | agent | Filter to one action; full schema + all examples |
589
- | `--kind <k>` | official\|custom | all | — | agent | Filter by action kind |
592
+ | `--categories` | bool | false | — | agent | Return only sorted category names and action counts |
593
+ | `--category <name>` | string | all | — | agent | Return actions from one namespace-derived category |
590
594
  | `--action-manifest <path>` | path | bundled | — | agent | Override manifest |
591
595
  | `--raw` | bool | false | — | agent | Dump the underlying action manifest JSON (protocol version, registry version, all entries in raw registry format — same output as `manifest --json` today; replaces the retired `manifest` verb) |
592
596
  | `--json` | bool | false | — | **agent PRIMARY** | Full schema + fields + examples per action |
593
597
 
594
- **[DISCOVERY-GAP]:** No `--action <name>` single-action filter today (all or nothing). No `--kind` filter. No keyword search across descriptions or fields.
598
+ **[DISCOVERY-GAP]:** No keyword search across descriptions or fields. Flow discovery remains separate.
595
599
  **Exit:** 0 / non-zero on engine error.
596
600
  **Maps-to:** A:`actions` (ROUTES-NOW); C/D:`actions` (ABSORB-LATER).
597
601
 
@@ -189,14 +189,14 @@ The composition loop: `actions --json` → `call <action>` (try one via real eng
189
189
  | | |
190
190
  |---|---|
191
191
  | **Human form** | `mm-harness actions --adapter mobile` · `mm-harness call unlock` · `mm-harness flows` |
192
- | **Agent PRIMARY** | `mm-harness actions --adapter mobile --json` `{ adapter, actions: [{ name, kind, description, fields, examples }] }` |
192
+ | **Agent PRIMARY** | `mm-harness actions --adapter mobile --categories --json`, then `--category <name>` or one focused `--action <name>` schema |
193
193
  | | `mm-harness call metamask.perps.read_positions --arg symbol=BTC --adapter core --json` → `{ action, resolvedAction, args, trace, evidence, recovered, mutations, exitCode, schemaVersion }` (same shape as `run --json`, one-node subset) |
194
194
  | | `mm-harness flows --json` → `{ sources, flows: [{ ref, source, file, description?, requiredParams?, shadows?, lastVerified? }] }` |
195
195
  | **Plan before run** | `mm-harness run recipe.json --plan --json` → `{ plan[], validation: { status, findings }, schemaVersion }` (adapter-aware; exit 5 on errors) |
196
196
 
197
197
  | Today (real) | → mm-harness | Status |
198
198
  |---|---|---|
199
- | `metamask-recipe actions --adapter …` | `mm-harness actions --adapter …` | ROUTES-NOW · [DEFAULT-GAP] `--adapter` required · [DISCOVERY-GAP] no `--action <name>` filter, no `--kind` filter, no keyword search |
199
+ | `metamask-recipe actions --adapter …` | `mm-harness actions --adapter …` | ROUTES-NOW · auto-detects in a checkout · bounded category/action views shipped · [DISCOVERY-GAP] no keyword search |
200
200
  | `metamask-recipe flows list\|promote` | `mm-harness flows [\|promote]` | ROUTES-NOW · `flows` = browse the reusable flow library (compose, don't rewrite); `promote` publishes a proven flow up a tier · bare `flows` = list · [DISCOVERY-GAP] no keyword filter, no per-flow body fetch, no platform filter |
201
201
  | `mm-recipe`/`mme-recipe` `actions`/`doctor` (auto adapter) | same `mm-harness` verbs | ABSORB-LATER — porcelain supplies missing adapter default |
202
202
  | hook layer `run-action app.unlock` | `mm-harness call unlock` (fuzzy: `unlock` → `metamask.wallet.unlock`) — **one-node recipe via real engine path** | REAL (wave 2) · ambiguous → exit 2 listing candidates |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"