@deeeed/metamask-harness 0.16.0 → 0.17.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/dist/harness.js CHANGED
@@ -50,6 +50,12 @@ function failureHint(adapter, action) {
50
50
  const helper = adapter === "mobile" ? "mm-harness launch ios # or launch android" : "mm-harness launch";
51
51
  return `Read the error above for the specific cause. To (re)start the runtime, run: ${helper}`;
52
52
  }
53
+ function successNext(adapter, action, target) {
54
+ const base = `--adapter ${adapter} --target ${shellQuote(target)}`;
55
+ if (action === "install") return `mm-harness verify ${base}`;
56
+ if (action === "cleanup") return `mm-harness install ${base}`;
57
+ return `mm-harness status --target ${shellQuote(target)} --json`;
58
+ }
53
59
  function hasArg(args, needle) {
54
60
  return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
55
61
  }
@@ -347,7 +353,8 @@ async function handleMobileLive(target, forwardArgs, json, autoDetected) {
347
353
  )
348
354
  );
349
355
  } else if (exitCode === 0) {
350
- console.error(`\u2713 live mobile passed (${elapsed}s)`);
356
+ console.error(`\u2713 live mobile passed (${elapsed}s)
357
+ Next: ${successNext("mobile", "live", target)}`);
351
358
  } else {
352
359
  console.error(
353
360
  `\u2717 live mobile failed (exit ${exitCode}, ${elapsed}s)
@@ -492,7 +499,8 @@ async function handleHarness(argv) {
492
499
  )
493
500
  );
494
501
  } else if (exitCode === 0) {
495
- console.error(`\u2713 ${harnessAction} ${adapter} passed (${seconds}s)`);
502
+ console.error(`\u2713 ${harnessAction} ${adapter} passed (${seconds}s)
503
+ Next: ${successNext(adapter, harnessAction, target)}`);
496
504
  } else {
497
505
  console.error(`\u2717 ${harnessAction} ${adapter} failed (exit ${exitCode}, ${seconds}s)
498
506
  ${failureHint(adapter, harnessAction)}`);
@@ -522,8 +530,10 @@ async function handleRunwayInstall(adapter, target, forward, json) {
522
530
  resolveOnly: hasArg(forward, "--resolve-only"),
523
531
  rerunCommand
524
532
  });
533
+ const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? "ios")} --adapter mobile --target ${shellQuote(target)}` : void 0;
534
+ const output = next ? { ...result, next } : result;
525
535
  if (json) {
526
- console.log(JSON.stringify(result, null, 2));
536
+ console.log(JSON.stringify(output, null, 2));
527
537
  } else if (result.status === "pass") {
528
538
  const cache = typeof result.cache === "object" && result.cache ? result.cache : void 0;
529
539
  const simulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
@@ -534,6 +544,7 @@ async function handleRunwayInstall(adapter, target, forward, json) {
534
544
  const action = result.skipped ? "already provisioned" : "installed Runway app";
535
545
  console.error(`\u2713 ${action} for ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} cache=${cache?.status ?? "skip"}`);
536
546
  }
547
+ if (next) console.error(` Next: ${next}`);
537
548
  } else {
538
549
  console.error(`\u2717 mm-harness install --runway: ${result.error?.message ?? "runway install failed"}
539
550
  Next: ${result.error?.userAction ?? rerunCommand}`);
@@ -578,7 +589,8 @@ function harnessSummary(action, adapter, target, status, exitCode, autoDetected,
578
589
  // Error contract: every --json failure carries a stable machine code + human
579
590
  // message (CLI-SPEC.md §5.1). userAction is included when present so callers
580
591
  // can surface the reachable escape without parsing the human message.
581
- ...status === "fail" && error ? { error } : {}
592
+ ...status === "fail" && error ? { error } : {},
593
+ ...status === "pass" && adapter ? { next: successNext(adapter, action, target) } : {}
582
594
  });
583
595
  }
584
596
  export {
@@ -0,0 +1,57 @@
1
+ class JsonStreamWriter {
2
+ enabled;
3
+ command;
4
+ completed = false;
5
+ writeLine;
6
+ constructor(command, enabled, output = process.stdout) {
7
+ this.command = command;
8
+ this.enabled = enabled;
9
+ const write = output.write.bind(output);
10
+ this.writeLine = (line) => {
11
+ write(`${line}
12
+ `);
13
+ };
14
+ }
15
+ isolateStdout() {
16
+ if (!this.enabled) return () => void 0;
17
+ const original = process.stdout.write;
18
+ const redirect = ((chunk, encoding, callback) => process.stderr.write(chunk, encoding, callback));
19
+ process.stdout.write = redirect;
20
+ return () => {
21
+ if (process.stdout.write === redirect) process.stdout.write = original;
22
+ };
23
+ }
24
+ emit(event, fields = {}) {
25
+ if (!this.enabled || this.completed) return;
26
+ this.writeLine(JSON.stringify({
27
+ schemaVersion: 1,
28
+ command: this.command,
29
+ event,
30
+ ...fields,
31
+ ts: (/* @__PURE__ */ new Date()).toISOString()
32
+ }));
33
+ }
34
+ phase(phase, fields = {}) {
35
+ this.emit("phase", { phase, ...fields });
36
+ }
37
+ node(nodeId, action, status) {
38
+ this.emit("node", { nodeId, action, status });
39
+ }
40
+ mutation(mutation) {
41
+ this.emit("mutation", { mutation });
42
+ }
43
+ recovery(code) {
44
+ this.emit("recovery", { code });
45
+ }
46
+ error(error) {
47
+ this.emit("error", { error });
48
+ }
49
+ complete(status, exitCode, fields = {}) {
50
+ if (!this.enabled || this.completed) return;
51
+ this.emit("complete", { status, exitCode, ...fields });
52
+ this.completed = true;
53
+ }
54
+ }
55
+ export {
56
+ JsonStreamWriter
57
+ };
@@ -4,10 +4,17 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Command } from "commander";
6
6
  import { color } from "./cli-color.js";
7
+ import { withCommandJournal } from "./command-journal.js";
8
+ import { JsonStreamWriter } from "./json-stream.js";
7
9
  import { handleUpdate, maybeNudge } from "./commands/update.js";
8
10
  import { handleCallHelp } from "./commands/call.js";
9
11
  import { getAdapterSurface } from "./adapters/surface.js";
10
12
  import { detectAdapter } from "./harness.js";
13
+ import {
14
+ PUBLIC_COMMAND_CONTRACTS,
15
+ publicCommandNames,
16
+ validatePublicInvocation
17
+ } from "./command-contract.js";
11
18
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
19
  globalThis.__MM_HARNESS_WRAPPER__ = true;
13
20
  const { main: recipeMain } = await import("./cli.js");
@@ -71,10 +78,11 @@ Example:
71
78
  name: "actions",
72
79
  summary: "List the action vocabulary + field schemas (--raw dumps the raw action registry JSON).",
73
80
  example: "mm-harness actions --adapter mobile",
74
- helpText: `mm-harness actions [flags]
81
+ helpText: `mm-harness actions [query] [flags]
75
82
 
76
83
  List the action vocabulary + field schemas for the checkout adapter.
77
84
 
85
+ query Search names, categories, fields, and descriptions (typo-tolerant)
78
86
  --action <name> Describe one action; fuzzy-resolves like call (short or full name)
79
87
  --categories List compact action categories and counts
80
88
  --category <name> List only one category (for example ui, wallet, or perps)
@@ -85,6 +93,7 @@ Example:
85
93
 
86
94
  Example:
87
95
  mm-harness actions --adapter mobile
96
+ mm-harness actions positions --adapter mobile
88
97
  mm-harness actions --adapter mobile --categories --json
89
98
  mm-harness actions --adapter mobile --category ui --json
90
99
  mm-harness actions --adapter mobile --action assert_orders
@@ -176,12 +185,29 @@ Example:
176
185
  --library <name=path> Add/override a recipe-library source (repeatable)
177
186
  --heal <off|infra-only|auto> Healing policy (default: infra-only); auto-ensures the overlay
178
187
  --json Machine-readable output
188
+ --json-stream Line-flushed JSONL progress + terminal event
179
189
  --record-video=full-run Record a video of the run
180
190
 
181
191
  Example:
182
192
  mm-harness run recipe.json --plan --adapter mobile
183
193
  mm-harness run recipe.json --adapter extension --artifacts-dir ./out`
184
194
  },
195
+ {
196
+ name: "last",
197
+ summary: "Show the last significant command, verdict, timestamps, and evidence paths for this checkout.",
198
+ example: "mm-harness last --json",
199
+ helpText: `mm-harness last [flags]
200
+
201
+ Read the atomic per-checkout resumability journal. Discovery commands do not
202
+ replace it, and an interrupted process remains recorded as verdict=running.
203
+
204
+ --target <path> Checkout path (default: cwd)
205
+ --runtime-dir <dir> Runtime dir containing last-command.json
206
+ --json Machine-readable envelope
207
+
208
+ Example:
209
+ mm-harness last --json`
210
+ },
185
211
  {
186
212
  name: "doctor",
187
213
  summary: "Readiness check for a checkout \u2014 no app launch. --fix repairs the overlay/runtime without launching.",
@@ -379,6 +405,7 @@ Example:
379
405
  --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
380
406
  --target <path> Checkout path (default: cwd)
381
407
  --json Machine-readable summary (recovered[] / mutations[] / phase)
408
+ --json-stream Line-flushed JSONL progress + terminal event
382
409
 
383
410
  Example:
384
411
  mm-harness launch ios
@@ -477,6 +504,7 @@ Example:
477
504
  --cdp-port <port> finalize: CDP port of the running extension
478
505
  --extension-dir <path> finalize: loaded extension dist (e.g. dist/chrome)
479
506
  --extension-id-file <path> finalize: optional file to read/write the resolved extension id
507
+ --action-manifest <path> Extension set: override the wallet action manifest
480
508
  --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
481
509
  --target <path> Checkout path (default: cwd)
482
510
  --device <udid|serial|name> Mobile only: target this device for sync/set
@@ -491,6 +519,28 @@ Example:
491
519
  mm-harness fixtures finalize --fixture wallet-fixture.json --state fixture-state.json --cdp-port 6661 --extension-dir dist/chrome`
492
520
  }
493
521
  ];
522
+ const PUBLIC_COMMAND_EXAMPLES = Object.fromEntries(
523
+ REAL.map((command) => [command.name, command.example])
524
+ );
525
+ function assertPublicContractMatchesSurface() {
526
+ const registered = REAL.map((command) => command.name).sort();
527
+ const contracted = publicCommandNames().sort();
528
+ if (registered.join("\n") !== contracted.join("\n")) {
529
+ throw new Error(
530
+ `public command contract drift: registered=[${registered.join(", ")}], contracted=[${contracted.join(", ")}]`
531
+ );
532
+ }
533
+ for (const command of REAL) {
534
+ const registeredAliases = [...command.aliases ?? []].sort();
535
+ const contractedAliases = [...PUBLIC_COMMAND_CONTRACTS[command.name]?.aliases ?? []].sort();
536
+ if (registeredAliases.join("\n") !== contractedAliases.join("\n")) {
537
+ throw new Error(
538
+ `public command alias contract drift for ${command.name}: registered=[${registeredAliases.join(", ")}], contracted=[${contractedAliases.join(", ")}]`
539
+ );
540
+ }
541
+ }
542
+ }
543
+ assertPublicContractMatchesSurface();
494
544
  const RETIRED = [
495
545
  {
496
546
  name: "live",
@@ -520,7 +570,7 @@ const HELP_GROUPS = [
520
570
  {
521
571
  title: "PROVE",
522
572
  blurb: "run recipes and inspect readiness",
523
- commands: ["run", "doctor", "check", "checklist", "recipe-quality"]
573
+ commands: ["run", "last", "doctor", "check", "checklist", "recipe-quality"]
524
574
  },
525
575
  {
526
576
  title: "RUNTIME OVERLAY",
@@ -643,7 +693,7 @@ for (const command of REAL) {
643
693
  process.exit(await handleUpdate(rawArgv.slice(1)));
644
694
  }
645
695
  const argv = command.name === "actions" && rawArgv.includes("--raw") ? translateActionsRaw(rawArgv) : rawArgv;
646
- process.exit(await delegate(argv));
696
+ process.exit(await withCommandJournal(command.name, rawArgv, () => delegate(argv)));
647
697
  });
648
698
  }
649
699
  const HIDDEN = [
@@ -696,10 +746,67 @@ function isCallActionHelp(argv) {
696
746
  const scope = divider === -1 ? argv : argv.slice(0, divider);
697
747
  return scope.includes("--help") || scope.includes("-h");
698
748
  }
749
+ function jsonRequestedBeforePassthrough(argv) {
750
+ const divider = argv.indexOf("--");
751
+ const scope = divider === -1 ? argv : argv.slice(0, divider);
752
+ return scope.some((argument) => argument === "--json" || argument.startsWith("--json="));
753
+ }
754
+ function jsonStreamRequestedBeforePassthrough(argv) {
755
+ const divider = argv.indexOf("--");
756
+ const scope = divider === -1 ? argv : argv.slice(0, divider);
757
+ return scope.includes("--json-stream");
758
+ }
759
+ function emitUsageError(error, json, jsonStream) {
760
+ if (jsonStream) {
761
+ const stream = new JsonStreamWriter(error.command, true);
762
+ stream.error({ code: error.code, message: error.message, userAction: error.userAction });
763
+ stream.complete("fail", 2);
764
+ } else if (json) {
765
+ process.stdout.write(
766
+ `${JSON.stringify(
767
+ {
768
+ schemaVersion: 1,
769
+ command: error.command,
770
+ status: "fail",
771
+ error: {
772
+ code: error.code,
773
+ message: error.message,
774
+ userAction: error.userAction
775
+ },
776
+ exitCode: 2
777
+ },
778
+ null,
779
+ 2
780
+ )}
781
+ `
782
+ );
783
+ } else {
784
+ const scope = error.command === "mm-harness" ? "" : ` ${error.command}`;
785
+ process.stderr.write(`\u2717 mm-harness${scope}: ${error.message}
786
+ Next: ${error.userAction}
787
+ `);
788
+ }
789
+ process.exit(2);
790
+ }
699
791
  if (rawArgv.length === 0) {
700
792
  process.stdout.write(groupedHelp());
701
793
  process.exit(0);
702
794
  }
795
+ const preflightBypass = /* @__PURE__ */ new Set([
796
+ ...HIDDEN,
797
+ "completions",
798
+ ...RETIRED.map((command) => command.name)
799
+ ]);
800
+ if (!preflightBypass.has(rawArgv[0] ?? "")) {
801
+ const usageError = validatePublicInvocation(rawArgv, PUBLIC_COMMAND_EXAMPLES);
802
+ if (usageError) {
803
+ emitUsageError(
804
+ usageError,
805
+ jsonRequestedBeforePassthrough(rawArgv),
806
+ jsonStreamRequestedBeforePassthrough(rawArgv)
807
+ );
808
+ }
809
+ }
703
810
  if (hasPassthroughHelp(rawArgv)) {
704
811
  process.exit(await delegate(rawArgv));
705
812
  }
package/dist/runner.js CHANGED
@@ -58,7 +58,7 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
58
58
  const logger = options.quietStdout ? new console.Console(process.stderr) : console;
59
59
  return createRecipeRunner({
60
60
  actionManifest,
61
- adapters: [...core, ...ui, ...lifecycle, ...custom],
61
+ adapters: withActionProgress([...core, ...ui, ...lifecycle, ...custom], options.onActionEvent),
62
62
  logger,
63
63
  recording: {
64
64
  targetProvider: createMetaMaskRecordingTargetProvider(adapter)
@@ -76,6 +76,36 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
76
76
  }
77
77
  });
78
78
  }
79
+ function withActionProgress(adapters, onActionEvent) {
80
+ if (!onActionEvent) return adapters;
81
+ return adapters.map((entry) => ({
82
+ ...entry,
83
+ async execute(node, context) {
84
+ const event = { nodeId: context.nodeId, action: entry.action };
85
+ const reportProgress = !isAutomaticHudProgress(entry.action, node, context.nodeId);
86
+ if (reportProgress) onActionEvent({ ...event, status: "running" });
87
+ try {
88
+ const result = await entry.execute(node, context);
89
+ if (reportProgress) {
90
+ onActionEvent({ ...event, status: result.status === "fail" ? "failed" : "passed" });
91
+ }
92
+ return result;
93
+ } catch (error) {
94
+ if (reportProgress) onActionEvent({ ...event, status: "failed" });
95
+ throw error;
96
+ }
97
+ }
98
+ }));
99
+ }
100
+ function isAutomaticHudProgress(action, node, nodeId) {
101
+ if (action !== "app.hud" || node === null || typeof node !== "object" || Array.isArray(node)) {
102
+ return false;
103
+ }
104
+ const payload = node;
105
+ if (typeof payload.action_name === "string") return true;
106
+ const progress = payload.progress;
107
+ return nodeId === "recipe-complete" && progress !== null && typeof progress === "object" && !Array.isArray(progress) && progress.complete === true;
108
+ }
79
109
  function mobileSourceAwareLifecycleAdapters(adapter, lifecycle) {
80
110
  if (adapter !== "mobile") return lifecycle;
81
111
  return lifecycle.map((entry) => ({
@@ -120,5 +150,6 @@ export {
120
150
  createMetaMaskExtensionRunner,
121
151
  createMetaMaskMobileRunner,
122
152
  createMetaMaskRunner,
153
+ isAutomaticHudProgress,
123
154
  mobileSourceAwareLifecycleAdapters
124
155
  };
@@ -0,0 +1,32 @@
1
+ <!-- Generated by scripts/generate-cli-ergonomics-audit.mjs. Do not edit by hand. -->
2
+ # CLI ergonomics audit
3
+
4
+ This matrix tracks the public command surface. “Covered” means the shared preflight contract and
5
+ `tests/contract/cli-teaching-errors.test.sh` exercise the behavior without dispatching runtime work.
6
+ Runtime recovery is claimed only where command-specific failures return a stable `userAction`.
7
+ Success hints are intentionally limited to lifecycle transitions with one truthful next command;
8
+ discovery, read-only, and terminal evidence commands do not invent one. ID prefix support is N/A
9
+ unless a command displays a shortened ID that another command accepts.
10
+
11
+ | Command | Bad flag | First feedback | `--json` recovery | Success next step | Displayed ID prefix |
12
+ |---|---|---|---|---|---|
13
+ | `status` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | structured `next` | N/A: command displays no shortened ID |
14
+ | `checklist` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage errors covered; no runtime recovery claim | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
15
+ | `actions` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
16
+ | `stop` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | launch `next` when the adapter has one unambiguous launch | N/A: command displays no shortened ID |
17
+ | `call` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
18
+ | `flows` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage errors covered; no runtime recovery claim | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
19
+ | `run` | covered: `CLI_UNKNOWN_OPTION` | TTY intent before slow work; machine output clean | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
20
+ | `last` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
21
+ | `doctor` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
22
+ | `check` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
23
+ | `recipe-quality` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
24
+ | `provision` | covered: `CLI_UNKNOWN_OPTION` | TTY intent before slow work; machine output clean | usage + runtime `error.code/message/userAction` | launch `next` after install | N/A: command displays no shortened ID |
25
+ | `install` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | verify `next` | N/A: command displays no shortened ID |
26
+ | `verify` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | status `next` | N/A: command displays no shortened ID |
27
+ | `cleanup` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | install `next` | N/A: command displays no shortened ID |
28
+ | `launch` | covered: `CLI_UNKNOWN_OPTION` | TTY intent before slow work; machine output clean | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
29
+ | `logs` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
30
+ | `debug` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage errors covered; no runtime recovery claim | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
31
+ | `update` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
32
+ | `fixtures` | covered: `CLI_UNKNOWN_OPTION` | N/A: no newly introduced slow path | usage + runtime `error.code/message/userAction` | N/A: discovery, read-only, or terminal result | N/A: command displays no shortened ID |
@@ -0,0 +1,104 @@
1
+ # CLI ergonomics human QA
2
+
3
+ Use this short pass after installing a release candidate. Automated contracts remain the release gate; this checklist confirms the experience in a real shell and checkout.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ export TARGET=/absolute/path/to/a/metamask-checkout
9
+ export ADAPTER=extension # extension, mobile, or core
10
+ mm-harness --version
11
+ mm-harness doctor --adapter "$ADAPTER" --target "$TARGET" --json | jq .status
12
+ ```
13
+
14
+ Expected: the intended version is printed and `doctor` returns parseable JSON. Resolve required doctor failures before continuing.
15
+
16
+ ## 1. Teaching errors
17
+
18
+ ```bash
19
+ out="$(mm-harness actions --categoriez --adapter "$ADAPTER" --json 2>/dev/null || true)"
20
+ jq '.error | {code, message, userAction}' <<<"$out"
21
+ ```
22
+
23
+ Expected: `code` is stable, the message names the bad flag and valid options, and `userAction` suggests the closest valid invocation.
24
+
25
+ ## 2. In-tool discovery
26
+
27
+ ```bash
28
+ mm-harness actions --adapter "$ADAPTER" --categories
29
+ mm-harness actions status --adapter "$ADAPTER"
30
+ mm-harness actions --action app.status --adapter "$ADAPTER" --json | jq '{actions, relatedActions}'
31
+ ```
32
+
33
+ Expected: category output is compact, fuzzy search is bounded, and action detail includes related actions when available. A missing example action should produce a teaching error, not silent output.
34
+
35
+ ## 3. Machine-output cleanliness and recovery
36
+
37
+ ```bash
38
+ out="$(mm-harness call definitely.not.an.action --adapter "$ADAPTER" --target "$TARGET" --json 2>/dev/null || true)"
39
+ jq '{status, error}' <<<"$out"
40
+ ```
41
+
42
+ Expected: stdout is one parseable JSON document with `error.code`, `error.message`, and `error.userAction`; it contains no spinner, banner, or human-only glyph.
43
+
44
+ ## 4. Immediate human feedback
45
+
46
+ Run the platform-appropriate launch in an interactive terminal:
47
+
48
+ ```bash
49
+ # Extension
50
+ mm-harness launch --adapter extension --target "$TARGET"
51
+
52
+ # Mobile: choose the intended platform
53
+ mm-harness launch ios --target "$TARGET"
54
+ # mm-harness launch android --target "$TARGET"
55
+ ```
56
+
57
+ Expected: intent/progress appears immediately, long work continues to report progress, and the app becomes ready. Redirected or `--json` stdout stays decoration-free.
58
+
59
+ ## 5. Lifecycle next step
60
+
61
+ For Extension:
62
+
63
+ ```bash
64
+ mm-harness stop --adapter extension --target "$TARGET" --json | jq '{status, next}'
65
+ ```
66
+
67
+ Expected: `next` is a target-scoped launch command. Mobile `stop` intentionally omits `next` because Metro does not identify whether iOS or Android should be relaunched.
68
+
69
+ ## 6. Resume after interruption
70
+
71
+ ```bash
72
+ mm-harness last --target "$TARGET" --json | jq '.last | {command, args, verdict, evidencePaths, startedAt, finishedAt}'
73
+ ```
74
+
75
+ Expected: the last operation can be reconstructed without rerunning it, including its arguments, verdict, evidence paths, and timestamps.
76
+
77
+ ## 7. Recipe proof smoke
78
+
79
+ ```bash
80
+ mm-harness run --list --adapter "$ADAPTER" --json | jq '{actions: .actions[0:5], flows: .flows[0:5]}'
81
+ export RECIPE=replace-with-a-safe-recipe-name-or-path
82
+ export ARTIFACTS_DIR="$(mktemp -d)"
83
+ mm-harness run "$RECIPE" --plan --adapter "$ADAPTER" --target "$TARGET" --json | jq '{status, plan}'
84
+ mm-harness run "$RECIPE" --adapter "$ADAPTER" --target "$TARGET" --artifacts-dir "$ARTIFACTS_DIR"
85
+ ```
86
+
87
+ Expected: the plan validates before execution; the final result identifies evidence paths and `mm-harness last --json` records the run.
88
+
89
+ Optional JSONL check:
90
+
91
+ ```bash
92
+ mm-harness run "$RECIPE" --adapter "$ADAPTER" --target "$TARGET" --artifacts-dir "$ARTIFACTS_DIR-stream" --json-stream | jq -c .
93
+ ```
94
+
95
+ Expected: complete JSON objects arrive line by line while the run is active.
96
+
97
+ ## Cleanup
98
+
99
+ ```bash
100
+ [ "$ADAPTER" = core ] || mm-harness stop --adapter "$ADAPTER" --target "$TARGET"
101
+ mm-harness status --target "$TARGET" --json | jq .
102
+ ```
103
+
104
+ Record the harness version, checkout SHA, adapter/platform, failed command, exit code, and JSON envelope for any discrepancy.
package/docs/CLI-SPEC.md CHANGED
@@ -379,7 +379,7 @@ Installs the cached Runway iOS dev client onto a prepared mobile slot. It does n
379
379
  > recipe) validate fully; the execute path relies on the engine's own manifest
380
380
  > validation. Tagged in code (`runner/src/cli.ts` `validateRecipeAdapterAware`).
381
381
 
382
- **Phase state-machine:** `resolve → installhealthcheckrecovervalidate launch execute` (phase reported in `--json` as `phase`; `--json-stream` emits one event per phase transition).
382
+ **Execution phases:** `resolve → validateinstallhealthcheckexecute`, with `recover` emitted only when bounded recovery actually runs. `--json-stream` emits each transition as it occurs.
383
383
 
384
384
  **Auto-ensure overlay:** `run` checks for the runtime overlay before executing (phase: `install`). If missing: installs it inline with a one-line notice, then proceeds. **First-install overlay notice:** on the very first install for this checkout, prints `installed mm-harness overlay v<version> → <path>` to stderr in human mode; in `--json` it appears solely as `mutations[]`. Use `--heal=off` to disable all auto-install and healing.
385
385
 
@@ -389,6 +389,14 @@ Installs the cached Runway iOS dev client onto a prepared mobile slot. It does n
389
389
  **Outputs:** `summary.json`, `trace.json`, `artifact-manifest.json`, screenshots/logs under `--artifacts-dir`.
390
390
  **Maps-to:** A:`run` (ROUTES-NOW); C/D:`run` (ABSORB-LATER — supply the auto adapter+artifacts defaults the typed CLI lacks).
391
391
 
392
+ ## `last` (resumability inspect verb)
393
+
394
+ **Synopsis:** `mm-harness last [--target <checkout>] [--runtime-dir <dir>] [--json]`
395
+
396
+ Reads `last-command.json`, the atomic per-checkout journal written before and after significant runtime/proof commands. The record contains redacted args, `running|pass|fail` verdict, exit code, explicit evidence/output paths, and start/finish timestamps. A process killed between writes remains `running`, allowing an agent to inspect rather than repeat side effects. Read-only discovery commands do not replace the record, and `last` never journals itself.
397
+
398
+ The file is mode `0600`; password, credential, auth, mnemonic, private-key, seed/SRP, secret, and token values are redacted before the first write. `--json` returns `{ command:"last", target, journalPath, status, exitCode, last }`. Missing or invalid state returns `LAST_NOT_FOUND` with an exact `userAction`.
399
+
392
400
  ## `doctor` (ROUTES-NOW → PROVE inspect verb)
393
401
 
394
402
  Readiness check for a checkout without launching the app. Doctor is the single place to understand the full slot context — **no hunting for files**. It also absorbs the retired `manifest` verb's validation function: the readiness checks include manifest well-formedness (schema valid, no unknown action references) and report the manifest path + protocol version in the explain-my-setup section. It includes three grounded sub-sections:
@@ -561,9 +569,9 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
561
569
 
562
570
  ### `actions` (ROUTES-NOW → DISCOVER verb)
563
571
 
564
- **Synopsis:** `mm-harness actions --adapter <p> [--json] [--categories | --category <name> | --action <name>]`
572
+ **Synopsis:** `mm-harness actions [query] --adapter <p> [--json] [--categories | --category <name> | --action <name>]`
565
573
 
566
- **PRIMARY (agent):** `mm-harness actions --adapter mobile --categories --json`, then one `--category` or `--action` query.
574
+ **PRIMARY (agent):** search from the task vocabulary, for example `mm-harness actions positions --adapter mobile --json`, then request one full `--action` schema. Use `--categories` only when the task does not provide a useful search term.
567
575
 
568
576
  **`--json` output shape** (grounded — `actions --adapter core --json` confirmed):
569
577
  ```json
@@ -581,7 +589,7 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
581
589
  ]
582
590
  }
583
591
  ```
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.
592
+ `kind` is `"official"` (engine built-ins) or `"custom"` (MetaMask adapter actions). `category` uses manifest metadata when present, then stable generic groups for protocol primitives and durable action namespaces (`ui.*` → `ui`, `metamask.perps.*` → `perps`). `fields` lists every accepted parameter name. `examples[].node` is a copy-pasteable recipe node.
585
593
 
586
594
  **Human form:** `mm-harness actions --adapter mobile` — one line per action: `<name> (<kind>) <description> fields=<f1,f2,...>`.
587
595
 
@@ -595,7 +603,7 @@ pretty-printed fallback. `manifest` is **RETIRED**: raw protocol dump rehomes to
595
603
  | `--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) |
596
604
  | `--json` | bool | false | — | **agent PRIMARY** | Full schema + fields + examples per action |
597
605
 
598
- **[DISCOVERY-GAP]:** No keyword search across descriptions or fields. Flow discovery remains separate.
606
+ The optional positional `query` searches names, categories, fields, and descriptions with typo tolerance. A single `--action` detail also returns up to five compact `relatedActions` names. Flow discovery remains separate.
599
607
  **Exit:** 0 / non-zero on engine error.
600
608
  **Maps-to:** A:`actions` (ROUTES-NOW); C/D:`actions` (ABSORB-LATER).
601
609
 
@@ -901,6 +909,36 @@ Every failure in `--json` mode produces an `error` field at the top level:
901
909
  | `retryable` | boolean | `true` = same command may succeed on retry; `false` = agent must change inputs or escalate |
902
910
  | `userAction` | string \| null | Exact next command or manual step |
903
911
 
912
+ Successful lifecycle transitions may add a top-level `next` command when exactly
913
+ one follow-up is correct (for example install → verify or stop → relaunch).
914
+ Discovery, read-only, and terminal evidence commands intentionally omit it rather
915
+ than inventing guidance. Agents must therefore treat `next` as optional.
916
+
917
+ Public command-grammar failures happen before runtime dispatch and always exit 2.
918
+ Their machine envelope is deliberately smaller and stable: agents branch on
919
+ `error.code`, never on prose.
920
+
921
+ ```json
922
+ {
923
+ "schemaVersion": 1,
924
+ "command": "actions",
925
+ "status": "fail",
926
+ "error": {
927
+ "code": "CLI_UNKNOWN_OPTION",
928
+ "message": "unknown option '--categoriez'. Valid options for mm-harness actions: ...",
929
+ "userAction": "Did you mean '--categories' instead of '--categoriez'? Try: mm-harness actions --adapter mobile"
930
+ },
931
+ "exitCode": 2
932
+ }
933
+ ```
934
+
935
+ Stable grammar codes are `CLI_UNKNOWN_COMMAND`, `CLI_UNKNOWN_OPTION`,
936
+ `CLI_MISSING_OPTION_VALUE`, `CLI_INVALID_OPTION_VALUE`,
937
+ `CLI_MISSING_POSITIONAL`, `CLI_INVALID_POSITIONAL`,
938
+ `CLI_EXCESS_POSITIONAL`, and `CLI_UNEXPECTED_PASSTHROUGH`. Every public
939
+ command is tracked in `docs/CLI-ERGONOMICS-AUDIT.md`; private adapter leaves keep
940
+ their own grammar behind explicit `--` passthrough.
941
+
904
942
  ## §5.2 Stable recovery codes (`recovered[]`)
905
943
 
906
944
  `recovered[]` in `--json` output lists what was healed. These are stable enum values — not prose, safe to match in agent code:
@@ -941,18 +979,20 @@ Every `--json` response includes `mutations[]` listing all side effects the comm
941
979
 
942
980
  ## §5.4 Phase state-machine
943
981
 
944
- Every `--json` response includes a `phase` field reflecting the last completed phase. `--json-stream` emits one event per transition. Agents can detect exactly where a failure occurred.
982
+ Summary `--json` responses retain their command-specific `phase` field. `--json-stream` emits transitions as they occur, so an agent can distinguish active work from a stalled command.
945
983
 
946
984
  **`run` phases:**
947
985
  ```
948
- resolve → installhealthcheckrecovervalidate → launch → execute
986
+ resolve → validateinstallhealthcheck → execute
949
987
  ```
950
988
 
951
989
  **`launch` phases:**
952
990
  ```
953
- resolve → install → healthcheck → recover → launch → verify
991
+ resolve → install → healthcheck → launch → verify
954
992
  ```
955
993
 
994
+ `recover` is conditional and appears immediately before a bounded recovery attempt; `verify` appears only for `launch --verify`.
995
+
956
996
  | Phase | What happens |
957
997
  |---|---|
958
998
  | `resolve` | Recipe/target/adapter resolved; library sources loaded |
@@ -966,20 +1006,20 @@ resolve → install → healthcheck → recover → launch → verify
966
1006
 
967
1007
  ## §5.5 JSONL event stream (`--json-stream`)
968
1008
 
969
- `--json-stream` emits one JSON object per line on stdout as the command progresses. Parseable with `jq -R 'fromjson?'`. Schema is versioned via `schemaVersion`.
1009
+ `run` and `launch` accept `--json-stream`. Each progress event is written as one complete JSON object plus a newline, so a piped reader can parse it before the command finishes. Schema is versioned via `schemaVersion`; every event also carries `command` and `ts`.
1010
+
1011
+ Stream mode owns stdout: existing human or subprocess output is routed to stderr. If both `--json` and `--json-stream` are supplied, stream mode wins. The existing summary `--json` bytes are unchanged when stream mode is absent.
970
1012
 
971
1013
  ```jsonl
972
- {"schemaVersion":1,"event":"phase","phase":"resolve","ts":"2026-07-02T10:00:00.000Z"}
973
- {"schemaVersion":1,"event":"phase","phase":"install","ts":"2026-07-02T10:00:00.100Z"}
974
- {"schemaVersion":1,"event":"mutation","mutation":{"type":"file","path":"/...","action":"created"},"ts":"..."}
975
- {"schemaVersion":1,"event":"phase","phase":"validate","ts":"..."}
976
- {"schemaVersion":1,"event":"phase","phase":"execute","ts":"..."}
977
- {"schemaVersion":1,"event":"node","index":0,"action":"metamask.wallet.unlock","status":"running","ts":"..."}
978
- {"schemaVersion":1,"event":"node","index":0,"action":"metamask.wallet.unlock","status":"passed","ts":"..."}
979
- {"schemaVersion":1,"event":"complete","status":"pass","exitCode":0,"recovered":[],"mutations":[...],"ts":"..."}
1014
+ {"schemaVersion":1,"command":"run","event":"phase","phase":"resolve","ts":"2026-07-02T10:00:00.000Z"}
1015
+ {"schemaVersion":1,"command":"run","event":"phase","phase":"validate","ts":"2026-07-02T10:00:00.100Z"}
1016
+ {"schemaVersion":1,"command":"run","event":"phase","phase":"execute","ts":"..."}
1017
+ {"schemaVersion":1,"command":"run","event":"node","nodeId":"unlock","action":"metamask.wallet.unlock","status":"running","ts":"..."}
1018
+ {"schemaVersion":1,"command":"run","event":"node","nodeId":"unlock","action":"metamask.wallet.unlock","status":"passed","ts":"..."}
1019
+ {"schemaVersion":1,"command":"run","event":"complete","status":"pass","exitCode":0,"reportPath":"/.../report.md","artifactManifestPath":"/.../artifact-manifest.json","recovered":[],"mutations":[],"ts":"..."}
980
1020
  ```
981
1021
 
982
- Event types: `phase` · `node` · `mutation` · `recovery` · `error` · `complete`. The terminal `complete` event always appears (even on failure) so agents have a clean sentinel.
1022
+ Event types: `phase` · `node` · `mutation` · `recovery` · `error` · `complete`. Node events use stable recipe `nodeId` values rather than an inferred numeric order. Exactly one terminal `complete` event is last on normal success or handled failure, giving agents a clean sentinel and compact evidence paths without repeating the full run result.
983
1023
 
984
1024
  ## §5.6 Exit code taxonomy
985
1025