@deeeed/metamask-harness 0.16.0 → 0.17.1

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 (119) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +90 -137
  3. package/adapters/core/cleanup.sh +0 -0
  4. package/adapters/core/inject.sh +0 -0
  5. package/adapters/extension/cleanup.mjs +0 -0
  6. package/adapters/extension/console-tail.mjs +51 -24
  7. package/adapters/extension/ensure-browser.sh +0 -0
  8. package/adapters/extension/inject.mjs +5 -9
  9. package/adapters/extension/launch-browser.cjs +0 -0
  10. package/adapters/extension/launch-webpack.cjs +83 -0
  11. package/adapters/extension/launch.sh +8 -11
  12. package/adapters/extension/live.sh +2 -0
  13. package/adapters/extension/readiness.mjs +0 -0
  14. package/adapters/extension/reattach.sh +1 -0
  15. package/adapters/extension/refresh-build.sh +0 -0
  16. package/adapters/extension/seed-fixture.sh +4 -12
  17. package/adapters/extension/sidepanel-toggle.sh +4 -1
  18. package/adapters/extension/snapshot-dist.sh +0 -0
  19. package/adapters/extension/stamp-runtime-title.cjs +49 -0
  20. package/adapters/extension/start-watch.sh +16 -4
  21. package/adapters/extension/stop-viewers.sh +1 -1
  22. package/adapters/extension/sync-webpack-dist.cjs +105 -0
  23. package/adapters/extension/verify.sh +1 -1
  24. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  25. package/adapters/manifest.json +46 -38
  26. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
  27. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  28. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  29. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  30. package/adapters/mobile/cleanup.sh +16 -176
  31. package/adapters/mobile/inject.sh +88 -671
  32. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  33. package/adapters/mobile/lib/metro-listener.sh +0 -0
  34. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  35. package/adapters/mobile/open-device.sh +0 -0
  36. package/adapters/mobile/prewarm-bundle.sh +0 -0
  37. package/adapters/mobile/start-console-forwarder.sh +70 -0
  38. package/adapters/mobile/start-metro.sh +0 -41
  39. package/adapters/mobile/stop-metro.sh +0 -0
  40. package/adapters/mobile/verify.sh +4 -61
  41. package/adapters/mobile/wait-for-bridge.sh +0 -0
  42. package/adapters/mobile/yarn-setup.sh +0 -0
  43. package/adapters/shared/activate-repo-node.sh +0 -0
  44. package/adapters/shared/activate-repo-ruby.sh +0 -0
  45. package/adapters/shared/cli-ux.sh +0 -0
  46. package/adapters/shared/ensure-runner-deps.sh +0 -0
  47. package/adapters/shared/harness-path.sh +0 -0
  48. package/adapters/shared/hash-helpers.sh +0 -0
  49. package/adapters/shared/install-repo-deps.sh +0 -0
  50. package/adapters/shared/json-field.sh +0 -0
  51. package/adapters/shared/open-debug.mjs +90 -36
  52. package/adapters/shared/open-log-window.sh +0 -0
  53. package/adapters/shared/reap-checkout-metros.sh +0 -0
  54. package/adapters/shared/resolve-slot-ports-core.mjs +0 -6
  55. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  56. package/adapters/shared/resolve-slot-ports.sh +0 -5
  57. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  58. package/adapters/shared/tmux-session.sh +0 -0
  59. package/adapters/shared/tmux-viewer.sh +0 -0
  60. package/dist/adapters/extension/console-capture.js +84 -0
  61. package/dist/adapters/extension/runtime-decision.js +14 -2
  62. package/dist/adapters/extension/surface.js +3 -2
  63. package/dist/adapters/mobile/prepare.js +22 -7
  64. package/dist/adapters/mobile/surface.js +1 -1
  65. package/dist/adapters/resolve-slot-ports.js +0 -2
  66. package/dist/adapters/slot-ports.js +8 -0
  67. package/dist/cli.js +5 -11
  68. package/dist/command-contract.js +441 -0
  69. package/dist/command-journal.js +225 -0
  70. package/dist/commands/call.js +43 -20
  71. package/dist/commands/check.js +9 -3
  72. package/dist/commands/debug.js +38 -31
  73. package/dist/commands/device-target.js +27 -12
  74. package/dist/commands/doctor.js +19 -6
  75. package/dist/commands/fixtures.js +19 -11
  76. package/dist/commands/last.js +52 -0
  77. package/dist/commands/launch/index.js +166 -59
  78. package/dist/commands/logs.js +8 -6
  79. package/dist/commands/manifest.js +114 -29
  80. package/dist/commands/parse-args.js +1 -0
  81. package/dist/commands/provision.js +10 -3
  82. package/dist/commands/run-engine.js +27 -42
  83. package/dist/commands/run-report.js +1 -1
  84. package/dist/commands/run.js +243 -41
  85. package/dist/commands/shared.js +11 -1
  86. package/dist/commands/status-probe.js +1 -1
  87. package/dist/commands/status.js +1 -1
  88. package/dist/commands/stop.js +7 -2
  89. package/dist/doctor.js +1 -4
  90. package/dist/harness.js +19 -125
  91. package/dist/json-stream.js +57 -0
  92. package/dist/live-adapter-contract.js +6 -20
  93. package/dist/mm-harness-cli.js +111 -41
  94. package/dist/paths.js +2 -5
  95. package/dist/run-diagnostics.js +35 -40
  96. package/dist/runner.js +32 -1
  97. package/dist/runtime-context.js +26 -8
  98. package/docs/CHEATSHEET.md +39 -52
  99. package/docs/CLI-ERGONOMICS-AUDIT.md +32 -0
  100. package/docs/CLI-ERGONOMICS-HUMAN-QA.md +48 -0
  101. package/docs/CODE-MAP.md +1 -1
  102. package/docs/UX-PRINCIPLES.md +2 -0
  103. package/docs/VIDEO-DEMO-VALIDATION.md +62 -0
  104. package/docs/architecture.md +65 -375
  105. package/docs/live-adapter-contract.md +3 -1
  106. package/library/actions/core/perps/_controller.mjs +10 -55
  107. package/library/manifests/mobile.action-manifest.json +1 -1
  108. package/package.json +3 -2
  109. package/scripts/completions.sh +0 -0
  110. package/scripts/install-completions.sh +0 -0
  111. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  112. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  113. package/adapters/mobile/overlay/compat/README.md +0 -28
  114. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  115. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  116. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  117. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  118. package/docs/CLI-SPEC.md +0 -1102
  119. package/docs/MENTAL-MODEL.md +0 -295
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { walletFixturePath } from "../paths.js";
4
4
  import { color } from "../cli-color.js";
5
5
  import { recipeRunning } from "../heal-bounds.js";
6
- import { checkoutBusyOut, EXIT, usageOut } from "./shared.js";
6
+ import { checkoutBusyOut, EXIT, usageOut, writeInteractiveProgress } from "./shared.js";
7
7
  import {
8
8
  optionFlag,
9
9
  optionString,
@@ -11,6 +11,8 @@ import {
11
11
  resolveAdapter,
12
12
  runtimeOptionsFromCli,
13
13
  isRecord,
14
+ shellQuote,
15
+ targetPath,
14
16
  usageError
15
17
  } from "./parse-args.js";
16
18
  import {
@@ -28,54 +30,119 @@ import { getAdapterSurface } from "../adapters/surface.js";
28
30
  import { coreDependencyBlock } from "./core-readiness.js";
29
31
  import { writeRunReport } from "./run-report.js";
30
32
  import { acquireCheckoutLock } from "../checkout-lock.js";
31
- async function handleRun({ positional, options }) {
32
- if (optionFlag(options, "list")) return handleListExecutables("run", options);
33
+ import { JsonStreamWriter } from "../json-stream.js";
34
+ import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
35
+ async function handleRun(parsed) {
36
+ const stream = new JsonStreamWriter("run", optionFlag(parsed.options, "jsonStream"));
37
+ const restoreStdout = stream.isolateStdout();
38
+ const target = targetPath(parsed.options);
39
+ try {
40
+ const exitCode = await handleRunInner(parsed, stream);
41
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
42
+ return exitCode;
43
+ } catch (error) {
44
+ const exitCode = error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : EXIT.runtime;
45
+ stream.error({
46
+ code: exitCode === EXIT.usage ? "CLI_USAGE_ERROR" : "RUN_FAILED",
47
+ message: error instanceof Error ? error.message : String(error),
48
+ userAction: `mm-harness doctor --target ${shellQuote(target)} --json`
49
+ });
50
+ stream.complete("fail", exitCode);
51
+ throw error;
52
+ } finally {
53
+ restoreStdout();
54
+ }
55
+ }
56
+ async function handleRunInner({ positional, options }, stream) {
57
+ if (optionFlag(options, "list")) {
58
+ if (stream.enabled) {
59
+ const message = "--json-stream is for recipe execution; use run --list --json for discovery.";
60
+ stream.error({ code: "JSON_STREAM_UNSUPPORTED_MODE", message, userAction: "mm-harness run --list --json" });
61
+ return EXIT.usage;
62
+ }
63
+ return handleListExecutables("run", options);
64
+ }
33
65
  const targetRecipe = positional[0];
34
66
  if (!targetRecipe) throw usageError("run requires <recipe.json>.");
35
- if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
67
+ if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options, stream);
36
68
  const { adapter, target } = resolveAdapter(options);
37
69
  const json = optionFlag(options, "json");
70
+ const jsonOutput = json && !stream.enabled;
71
+ const machine = json || stream.enabled;
72
+ stream.phase("resolve", { adapter, target, recipe: targetRecipe });
38
73
  if (!fs.existsSync(target)) {
39
- return usageOut(
40
- json,
41
- "run",
42
- `target does not exist: ${target}`,
43
- "pass --target <metamask-checkout> pointing to an existing checkout"
44
- );
74
+ const message = `target does not exist: ${target}`;
75
+ const userAction = "pass --target <metamask-checkout> pointing to an existing checkout";
76
+ if (stream.enabled) {
77
+ stream.error({ code: "TARGET_NOT_FOUND", message, userAction });
78
+ return EXIT.usage;
79
+ }
80
+ return usageOut(jsonOutput, "run", message, userAction);
45
81
  }
82
+ writeInteractiveProgress(machine, `\u2192 recipe run \u2014 validating ${targetRecipe} \xB7 ${adapter}`);
46
83
  getAdapterSurface(adapter).resolveSlotPorts(target);
47
84
  const dtResult = applyDeviceTargeting("run", adapter, options, { gate: true, rerun: "" });
48
85
  if ("code" in dtResult) {
49
- return emitRunUsageError(json, adapter, targetRecipe, dtResult.code, dtResult.message);
86
+ return emitRunUsageError(jsonOutput, stream, adapter, targetRecipe, dtResult.code, dtResult.message, dtResult.userAction);
50
87
  }
51
- if (recipeRunning(target)) return emitRunRecipeRunning(json);
88
+ if (recipeRunning(target)) return emitRunRecipeRunning(jsonOutput, stream, target);
52
89
  if (optionString(options, "artifactsDir") === void 0 && runArgLooksLikeRecipeFile(targetRecipe)) {
53
90
  requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
54
91
  }
92
+ stream.phase("validate");
55
93
  const validated = await validateRunRecipeStatic(targetRecipe, adapter, options);
56
94
  if (validated.usageError) {
57
- return emitRunUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
95
+ const userAction = runUsageRecovery(
96
+ validated.usageError.code,
97
+ validated.usageError.message,
98
+ adapter,
99
+ validated.recipeFile
100
+ );
101
+ return emitRunUsageError(
102
+ jsonOutput,
103
+ stream,
104
+ adapter,
105
+ validated.recipeFile,
106
+ validated.usageError.code,
107
+ validated.usageError.message,
108
+ userAction
109
+ );
58
110
  }
59
111
  if (validated.errorCount > 0) {
60
- return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
112
+ return emitRunValidationError(jsonOutput, stream, adapter, validated.recipeFile, validated.findings, validated.errorCount);
61
113
  }
62
114
  const depsBlock = adapter === "core" && recipeUsesCoreController(validated.recipe, validated.librarySources) ? coreDependencyBlock(target) : null;
63
115
  if (depsBlock) {
64
- return emitRunUsageError(json, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
116
+ return emitRunUsageError(jsonOutput, stream, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
65
117
  }
66
118
  const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
67
119
  const lock = acquireCheckoutLock(target, "run");
68
- if ("message" in lock) return checkoutBusyOut(json, "run", lock.message, lock.path);
120
+ if ("message" in lock) {
121
+ const userAction = `wait for the current owner, or inspect ${lock.path} if its process has exited`;
122
+ stream.error({ code: "SANDBOX_BUSY", message: lock.message, userAction });
123
+ return checkoutBusyOut(jsonOutput, "run", lock.message, lock.path);
124
+ }
69
125
  try {
70
- const prepared = await prepareHeal(adapter, target, options, json);
71
- if (typeof prepared === "number") return prepared;
126
+ const prepared = await prepareHeal(adapter, target, options, machine, {
127
+ onPhase: (phase, fields) => stream.phase(phase, fields)
128
+ });
129
+ if (typeof prepared === "number") {
130
+ stream.error({
131
+ code: "RUN_PREPARE_FAILED",
132
+ message: "runtime preparation failed; inspect stderr for the exact probe",
133
+ userAction: `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`
134
+ });
135
+ return prepared;
136
+ }
72
137
  const { state, heal } = prepared;
73
138
  const librarySources = validated.librarySources;
74
139
  const runtimeOptions = {
75
140
  ...runtimeOptionsFromCli(options),
76
141
  ...librarySources ? { librarySources } : {},
77
- stdoutIsMachineContract: json
142
+ stdoutIsMachineContract: machine,
143
+ onActionEvent: ({ nodeId, action, status }) => stream.node(nodeId, action, status)
78
144
  };
145
+ stream.phase("execute");
79
146
  const { result, violation } = await executeWithHealBounds(
80
147
  // validated.recipeFile, not the raw arg: the arg may be a library recipe NAME
81
148
  // that only the resolver knows how to turn into a file.
@@ -84,12 +151,42 @@ async function handleRun({ positional, options }) {
84
151
  target,
85
152
  heal,
86
153
  state,
87
- () => recoverRunInfra(adapter, target, json)
154
+ () => recoverRunInfra(adapter, target, machine),
155
+ (code) => stream.phase("recover", { code })
88
156
  );
89
- if (violation !== null) return emitHealViolation(json, "run", result, violation, state, adapter);
157
+ for (const mutation of state.mutations) stream.mutation(mutation);
158
+ for (const recovery of state.recovered) stream.recovery(recovery);
159
+ if (violation !== null) {
160
+ const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
161
+ stream.error({
162
+ code: violation.code,
163
+ message: violation.message,
164
+ userAction,
165
+ originalError: violation.originalError ?? null
166
+ });
167
+ return emitHealViolation(jsonOutput, "run", result, violation, state, adapter);
168
+ }
90
169
  const report = writeRunReport(result);
91
170
  const exitCode = result.status === "pass" ? EXIT.ok : EXIT.runtime;
92
- if (json) {
171
+ const failureUserAction = `mm-harness last --target ${shellQuote(target)} --json`;
172
+ if (stream.enabled) {
173
+ if (result.status === "fail") {
174
+ stream.error({
175
+ code: "RECIPE_EXECUTION_FAILED",
176
+ message: "recipe execution failed; inspect the persisted result and evidence paths",
177
+ userAction: failureUserAction
178
+ });
179
+ }
180
+ stream.complete(result.status === "pass" ? "pass" : "fail", exitCode, {
181
+ adapter,
182
+ reportPath: report.path,
183
+ summaryPath: result.summaryPath,
184
+ tracePath: result.tracePath,
185
+ artifactManifestPath: result.artifactManifestPath,
186
+ recovered: state.recovered,
187
+ mutations: state.mutations
188
+ });
189
+ } else if (json) {
93
190
  console.log(
94
191
  JSON.stringify(
95
192
  {
@@ -101,7 +198,8 @@ async function handleRun({ positional, options }) {
101
198
  recovered: state.recovered,
102
199
  mutations: state.mutations,
103
200
  reportPath: report.path,
104
- result
201
+ result,
202
+ ...result.status === "fail" ? { error: { code: "RECIPE_EXECUTION_FAILED", message: "recipe execution failed; inspect the persisted result and evidence paths", userAction: failureUserAction } } : {}
105
203
  },
106
204
  null,
107
205
  2
@@ -114,14 +212,61 @@ async function handleRun({ positional, options }) {
114
212
  console.log(out("label", "summary:"));
115
213
  for (const line of report.preview) console.log(` ${formatPreviewLine(line, out)}`);
116
214
  }
117
- console.log(`${out("label", "report:")} ${out("path", report.path)}`);
118
- console.log(`${out("label", "artifacts:")} ${out("path", result.artifactManifestPath)}`);
215
+ const artifacts = runArtifactInventory(result.artifactManifestPath);
216
+ console.log(out("label", `artifacts (${artifacts.length}):`));
217
+ for (const artifact of artifacts) {
218
+ console.log(` ${out("dim", `${artifact.label}:`)} ${out("path", artifact.absolutePath)}`);
219
+ }
220
+ if (result.status === "fail") {
221
+ console.error(` Next: ${failureUserAction}`);
222
+ }
223
+ console.log(out("label", "diagnostics:"));
224
+ const diagnostics = readRunDiagnosticsDocument(result.diagnosticsPath);
225
+ for (const line of formatRunDiagnosticsForHuman(diagnostics, adapter)) {
226
+ console.log(` ${formatDiagnosticLine(line, out)}`);
227
+ }
119
228
  }
120
229
  return exitCode;
121
230
  } finally {
122
231
  lock.release();
123
232
  }
124
233
  }
234
+ function formatDiagnosticLine(line, out) {
235
+ const match = /^(CLEAN|REVIEW|UNAVAILABLE|N\/A|WARNING|ERROR|EXCEPTION)(.*)$/u.exec(line);
236
+ if (!match) return line;
237
+ const status = match[1];
238
+ const style = status === "CLEAN" ? "ok" : status === "ERROR" || status === "EXCEPTION" ? "err" : "warn";
239
+ return `${out(style, status)}${match[2]}`;
240
+ }
241
+ function runArtifactInventory(manifestPathValue) {
242
+ const manifestPath = path.resolve(String(manifestPathValue));
243
+ const root = path.dirname(manifestPath);
244
+ let manifest;
245
+ try {
246
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
247
+ } catch {
248
+ return [{ absolutePath: manifestPath, basename: path.basename(manifestPath), label: "Artifact manifest" }];
249
+ }
250
+ const entries = isRecord(manifest) && Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
251
+ const artifacts = [];
252
+ for (const entry of entries) {
253
+ if (!isRecord(entry) || typeof entry.path !== "string" || entry.path.length === 0) continue;
254
+ const absolutePath = path.resolve(root, entry.path);
255
+ const basename = path.basename(absolutePath);
256
+ const label = typeof entry.label === "string" && entry.label.length > 0 ? entry.label : basename;
257
+ artifacts.push({ absolutePath, basename, label });
258
+ }
259
+ artifacts.push({ absolutePath: manifestPath, basename: path.basename(manifestPath), label: "Artifact manifest" });
260
+ const priority = /* @__PURE__ */ new Map([
261
+ ["diagnostics.json", 0],
262
+ ["report.md", 1],
263
+ ["summary.json", 2],
264
+ ["trace.json", 3],
265
+ ["recipe.json", 4],
266
+ ["artifact-manifest.json", 100]
267
+ ]);
268
+ return artifacts.filter((artifact, index, all) => all.findIndex((candidate) => candidate.absolutePath === artifact.absolutePath) === index).sort((left, right) => (priority.get(left.basename) ?? 50) - (priority.get(right.basename) ?? 50));
269
+ }
125
270
  function formatPreviewLine(line, out) {
126
271
  const match = /^(PASS|FAIL)\s+(.+)$/u.exec(line);
127
272
  if (!match) return line;
@@ -193,12 +338,29 @@ function loadFlowCatalogs(librarySources) {
193
338
  function actionUsesCoreController(action) {
194
339
  return typeof action === "string" && action.startsWith("metamask.perps.");
195
340
  }
196
- async function handleRunPlan(recipeArg, options) {
341
+ async function handleRunPlan(recipeArg, options, stream) {
197
342
  const json = optionFlag(options, "json");
343
+ const jsonOutput = json && !stream.enabled;
198
344
  const { adapter, target } = resolveAdapter(options);
345
+ stream.phase("resolve", { adapter, target, recipe: recipeArg });
346
+ stream.phase("validate");
199
347
  const validated = await validateRunRecipeStatic(recipeArg, adapter, options);
200
348
  if (validated.usageError) {
201
- return emitPlanUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
349
+ const userAction = runUsageRecovery(
350
+ validated.usageError.code,
351
+ validated.usageError.message,
352
+ adapter,
353
+ validated.recipeFile
354
+ );
355
+ return emitPlanUsageError(
356
+ jsonOutput,
357
+ stream,
358
+ adapter,
359
+ validated.recipeFile,
360
+ validated.usageError.code,
361
+ validated.usageError.message,
362
+ userAction
363
+ );
202
364
  }
203
365
  const { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid } = validated;
204
366
  const status = errorCount === 0 ? "pass" : "fail";
@@ -252,9 +414,27 @@ async function handleRunPlan(recipeArg, options) {
252
414
  detail: nodeCount === void 0 ? "would execute the recipe nodes" : `would execute ${nodeCount} recipe node(s)`
253
415
  }
254
416
  ];
255
- if (json) {
256
- const payload = { schemaVersion: 1, command: "run", mode: "plan", status, adapter, recipe: recipeFile, findings, plan };
257
- if (status === "fail") payload.error = { code: "RECIPE_VALIDATION_FAILED", message: `recipe validation found ${errorCount} error(s)` };
417
+ const payload = {
418
+ schemaVersion: 1,
419
+ command: "run",
420
+ mode: "plan",
421
+ status,
422
+ adapter,
423
+ recipe: recipeFile,
424
+ findings,
425
+ plan
426
+ };
427
+ if (status === "fail") {
428
+ payload.error = {
429
+ code: "RECIPE_VALIDATION_FAILED",
430
+ message: `recipe validation found ${errorCount} error(s)`,
431
+ userAction: runPlanProbe(adapter, recipeFile)
432
+ };
433
+ }
434
+ if (stream.enabled) {
435
+ if (status === "fail") stream.error(payload.error);
436
+ stream.complete(status, status === "pass" ? EXIT.ok : EXIT.validation, { mode: "plan", adapter, recipe: recipeFile, findings, plan });
437
+ } else if (json) {
258
438
  console.log(JSON.stringify(payload, null, 2));
259
439
  } else {
260
440
  console.log(`plan ${status} \u2014 ${adapter} \u2014 ${recipeFile}`);
@@ -268,52 +448,61 @@ async function handleRunPlan(recipeArg, options) {
268
448
  console.log(` ${finding.severity === "error" ? "\u2717" : "\u26A0"} ${finding.code} ${finding.path} \u2014 ${finding.message}`);
269
449
  }
270
450
  }
451
+ if (status === "fail") console.error(` Next: ${runPlanProbe(adapter, recipeFile)}`);
271
452
  }
272
453
  return status === "pass" ? EXIT.ok : EXIT.validation;
273
454
  }
274
- function emitPlanUsageError(json, adapter, recipeFile, code, message) {
455
+ function emitPlanUsageError(json, stream, adapter, recipeFile, code, message, userAction) {
456
+ stream.error({ code, message, userAction, mode: "plan", adapter, recipe: recipeFile });
275
457
  if (json) {
276
458
  console.log(
277
459
  JSON.stringify(
278
- { schemaVersion: 1, command: "run", mode: "plan", status: "fail", adapter, recipe: recipeFile, error: { code, message } },
460
+ { schemaVersion: 1, command: "run", mode: "plan", status: "fail", adapter, recipe: recipeFile, error: { code, message, userAction } },
279
461
  null,
280
462
  2
281
463
  )
282
464
  );
283
465
  } else {
284
- console.error(`\u2717 run --plan: ${message}`);
466
+ console.error(`\u2717 run --plan: ${message}
467
+ Next: ${userAction}`);
285
468
  }
286
469
  return EXIT.usage;
287
470
  }
288
471
  function runArgLooksLikeRecipeFile(value) {
289
472
  return path.isAbsolute(value) || value.includes("/") || value.includes(path.sep) || value.endsWith(".json") || fs.existsSync(path.resolve(value));
290
473
  }
291
- function emitRunRecipeRunning(json, detail) {
474
+ function emitRunRecipeRunning(json, stream, target, detail) {
292
475
  const message = detail ?? "a recipe is currently running \u2014 refusing to start while another recipe executes.";
476
+ const userAction = `inspect the checkout state with: mm-harness status --target ${shellQuote(target)} --json; retry after the active recipe finishes`;
477
+ stream.error({ code: "RECIPE_RUNNING", message, userAction, recoverable: false });
293
478
  if (json) {
294
- console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message } }, null, 2));
479
+ console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message, userAction } }, null, 2));
295
480
  } else {
296
- console.error(`\u2717 mm-harness: ${message}`);
481
+ console.error(`\u2717 mm-harness: ${message}
482
+ Next: ${userAction}`);
297
483
  }
298
484
  return EXIT.bounded;
299
485
  }
300
- function emitRunUsageError(json, adapter, recipeFile, code, message, userAction) {
486
+ function emitRunUsageError(json, stream, adapter, recipeFile, code, message, userAction) {
487
+ stream.error({ code, message, userAction });
301
488
  if (json) {
302
489
  console.log(
303
490
  JSON.stringify(
304
- { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message, ...userAction ? { userAction } : {} } },
491
+ { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message, userAction } },
305
492
  null,
306
493
  2
307
494
  )
308
495
  );
309
496
  } else {
310
497
  console.error(`\u2717 run: ${message}`);
311
- if (userAction) console.error(` Next: ${userAction}`);
498
+ console.error(` Next: ${userAction}`);
312
499
  }
313
500
  return EXIT.usage;
314
501
  }
315
- function emitRunValidationError(json, adapter, recipeFile, findings, errorCount) {
502
+ function emitRunValidationError(json, stream, adapter, recipeFile, findings, errorCount) {
316
503
  const message = `recipe validation found ${errorCount} error(s)`;
504
+ const userAction = runPlanProbe(adapter, recipeFile);
505
+ stream.error({ code: "RECIPE_VALIDATION_FAILED", message, userAction, findings });
317
506
  if (json) {
318
507
  console.log(
319
508
  JSON.stringify(
@@ -327,7 +516,7 @@ function emitRunValidationError(json, adapter, recipeFile, findings, errorCount)
327
516
  mutations: [],
328
517
  recipe: recipeFile,
329
518
  findings,
330
- error: { code: "RECIPE_VALIDATION_FAILED", message }
519
+ error: { code: "RECIPE_VALIDATION_FAILED", message, userAction }
331
520
  },
332
521
  null,
333
522
  2
@@ -338,9 +527,22 @@ function emitRunValidationError(json, adapter, recipeFile, findings, errorCount)
338
527
  for (const finding of findings) {
339
528
  if (finding.severity === "error") console.error(` ${finding.code} ${finding.path} \u2014 ${finding.message}`);
340
529
  }
530
+ console.error(` Next: ${userAction}`);
341
531
  }
342
532
  return EXIT.validation;
343
533
  }
534
+ function runPlanProbe(adapter, recipeFile) {
535
+ return `mm-harness run --plan ${shellQuote(recipeFile)} --adapter ${adapter} --json`;
536
+ }
537
+ function runUsageRecovery(code, message, adapter, recipeFile) {
538
+ const actionCommand = /This is an action, not a recipe\. Use: (mm-harness call .+)\.$/u.exec(message)?.[1];
539
+ if (actionCommand) return actionCommand;
540
+ if (code === "RECIPE_NOT_FOUND") return `mm-harness run --list --adapter ${adapter} --json`;
541
+ if (code === "RECIPE_UNPARSEABLE") {
542
+ return `fix the JSON syntax in ${shellQuote(recipeFile)}, then retry: ${runPlanProbe(adapter, recipeFile)}`;
543
+ }
544
+ return runPlanProbe(adapter, recipeFile);
545
+ }
344
546
  export {
345
547
  handleRun
346
548
  };
@@ -3,6 +3,15 @@ import path from "node:path";
3
3
  import { detectAdapter } from "../harness.js";
4
4
  import { resolveLeafInvoke, shellLeafMissing } from "../leaf-invoke.js";
5
5
  const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 };
6
+ function writeInteractiveProgress(json, message, {
7
+ stdoutIsTTY = Boolean(process.stdout.isTTY),
8
+ stream = process.stderr
9
+ } = {}) {
10
+ if (json || !stdoutIsTTY) return false;
11
+ stream.write(`${message}
12
+ `);
13
+ return true;
14
+ }
6
15
  function parseFlags(argv, booleans) {
7
16
  const positional = [];
8
17
  const options = {};
@@ -262,5 +271,6 @@ export {
262
271
  spawnScriptStreaming,
263
272
  str,
264
273
  targetOf,
265
- usageOut
274
+ usageOut,
275
+ writeInteractiveProgress
266
276
  };
@@ -34,7 +34,7 @@ function parseBridgeEntries(raw) {
34
34
  deviceName: typeof e.deviceName === "string" ? e.deviceName : "",
35
35
  platform: typeof e.platform === "string" ? e.platform : "",
36
36
  // agenticPresent is only set when the bridge explicitly sends it (false = absent).
37
- // Omitted by older bridge versions → leave undefined so caller treats as present.
37
+ // An omitted value remains unknown.
38
38
  ...typeof e.agenticPresent === "boolean" ? { agenticPresent: e.agenticPresent } : {}
39
39
  }));
40
40
  }
@@ -31,7 +31,7 @@ async function handleStatus({ options }) {
31
31
  if (adapter === "mobile") {
32
32
  const dtResult = applyDeviceTargeting("status", adapter, options, { gate: false, rerun: "" });
33
33
  if ("code" in dtResult) {
34
- return usageOut(json, "status", dtResult.message, "mm-harness status --device <id> or --all-devices");
34
+ return usageOut(json, "status", dtResult.message, dtResult.userAction);
35
35
  }
36
36
  }
37
37
  const next = surface.hints.relaunch;
@@ -5,7 +5,8 @@ import {
5
5
  parseArgs,
6
6
  optionFlag,
7
7
  optionString,
8
- resolveAdapter
8
+ resolveAdapter,
9
+ shellQuote
9
10
  } from "./parse-args.js";
10
11
  async function handleStop(argv) {
11
12
  const { options } = parseArgs(argv, "stop");
@@ -22,6 +23,8 @@ async function handleStop(argv) {
22
23
  if (stop.kind === "headless") {
23
24
  return usageOut(json, "stop", stop.message, stop.userAction);
24
25
  }
26
+ const next = adapter === "extension" ? `${surface.hints.launch} --target ${shellQuote(target)}` : void 0;
27
+ const userAction = `mm-harness status --target ${shellQuote(target)} --json`;
25
28
  if (json) {
26
29
  console.log(
27
30
  JSON.stringify(
@@ -33,7 +36,8 @@ async function handleStop(argv) {
33
36
  status: stop.status === 0 ? "pass" : "fail",
34
37
  ...stop.signalled !== void 0 ? { signalled: stop.signalled } : {},
35
38
  exitCode: stop.status,
36
- ...stop.output ? { output: stop.output } : {}
39
+ ...stop.output ? { output: stop.output } : {},
40
+ ...stop.status === 0 ? next ? { next } : {} : { error: { code: "DEV_SERVER_STOP_FAILED", message: stop.summary, userAction } }
37
41
  },
38
42
  null,
39
43
  2
@@ -43,6 +47,7 @@ async function handleStop(argv) {
43
47
  if (stop.output) process.stderr.write(`${stop.output}
44
48
  `);
45
49
  console.error(`${color(stop.status === 0 ? "ok" : "err", stop.status === 0 ? "\u2713" : "\u2717")} ${stop.summary}`);
50
+ if (stop.status !== 0 || next) console.error(` Next: ${stop.status === 0 ? next : userAction}`);
46
51
  }
47
52
  return stop.status;
48
53
  }
package/dist/doctor.js CHANGED
@@ -93,10 +93,7 @@ function fixtureFileSummary(file, adapter, reportedPath = file) {
93
93
  const RUNTIME_CONTEXT_FIELDS = [
94
94
  { key: "slotId", envVars: ["RECIPE_SLOT_ID"], envVar: "RECIPE_SLOT_ID", customize: "doctor --fix creates local identity; Farmslot may override", adapters: ["mobile", "extension", "core"] },
95
95
  { key: "extensionId", envVars: ["RECIPE_HARNESS_EXTENSION_ID"], envVar: "RECIPE_HARNESS_EXTENSION_ID", customize: "resolved from the built extension", adapters: ["extension"] },
96
- { key: "cdpPort", envVars: ["RECIPE_CDP_PORT", "CDP_PORT"], envVar: "CDP_PORT", customize: "doctor --fix claims one; --cdp-port overrides", adapters: ["extension"] },
97
- { key: "runtimeStart.approved", envVars: ["RECIPE_RUNTIME_START_APPROVED"], envVar: "RECIPE_RUNTIME_START_APPROVED", customize: "optional orchestrator-provided legacy live context", adapters: ["mobile", "extension"] },
98
- { key: "runtimeStart.command", envVars: [], envVar: null, customize: "optional orchestrator-provided legacy live context", adapters: ["mobile", "extension"] },
99
- { key: "runtimeStart.readyUrl", envVars: ["RECIPE_RUNTIME_READY_URL"], envVar: "RECIPE_RUNTIME_READY_URL", customize: "optional orchestrator-provided legacy live context", adapters: ["mobile", "extension"] }
96
+ { key: "cdpPort", envVars: ["RECIPE_CDP_PORT", "CDP_PORT"], envVar: "CDP_PORT", customize: "doctor --fix claims one; --cdp-port overrides", adapters: ["extension"] }
100
97
  ];
101
98
  function runtimeContextSummary(target, adapter) {
102
99
  const contextPath = resolveRuntimeContextPath(target);