@deeeed/metamask-harness 0.62.0 → 0.64.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,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.64.0 - 2026-09-25
6
+
7
+ ### Added
8
+
9
+ - Preflight behavioral proof bindings with `run --proof`, including `--plan`, before launching QA recipes. Executed outcomes and evidence still require runtime validation.
10
+ - Read source-development Extension pending approval IDs and types with `metamask.wallet.read_state scope=pending_approvals`, without mutating requests or exposing payloads.
11
+
12
+ ### Fixed
13
+
14
+ - Gate all Extension source launch paths on current dependencies and reject bundles compiled against stale or changed installed resolutions. Successful watch and LavaMoat builds record their dependency baseline; verified release artifacts retain their independent launch path.
15
+
16
+ ### Documentation
17
+
18
+ - Clarify how reusable recipes support daily domain and release QA through the scope-owning skill.
19
+
20
+ ## 0.63.0 - 2026-09-23
21
+
22
+ ### Added
23
+
24
+ - Show the task's acceptance criteria and recorded verdicts in `status`, `status --watch`, and `status --task`, using the Farmslot handoff and acceptance ledger.
25
+ - Show the active preparation step and elapsed time in `status --watch` and `status --task` even when the agent buffers preparation output.
26
+
27
+ ### Fixed
28
+
29
+ - Allow 30 seconds for iOS simulator discovery so a cold CoreSimulator startup does not fail at five seconds.
30
+
5
31
  ## 0.62.0 - 2026-09-22
6
32
 
7
33
  ### Added
package/README.md CHANGED
@@ -10,6 +10,21 @@ and runtime paths are detected automatically.
10
10
  The generic graph engine and evidence schemas live in [Farmslot](https://farmslot.io) packages.
11
11
  `mm-harness` owns MetaMask runtime control and domain capabilities.
12
12
 
13
+ ## Daily and release QA
14
+
15
+ The same recipes can validate yesterday's domain changes together or a release
16
+ that is behind `main`. The `mms-recipe-qa` skill freezes the change scope and
17
+ criteria; the team library supplies smoke and reusable journeys; this CLI runs
18
+ them on the selected app and retains assertions, traces and captures.
19
+
20
+ Use the installed skill's `references/domain-qa.md` for window/range inputs and
21
+ report policy. Scope selection and scheduling are outside the harness. Discover
22
+ recipes through `run --list` and `run <recipe> --describe`, then follow the
23
+ locked version's `help --json` for composition and drift diagnosis. Adapt a
24
+ recipe to the tested release without changing the release or weakening its
25
+ criteria. An internal action that needs development instrumentation cannot
26
+ prove behavior on an unmodified release artifact.
27
+
13
28
  ## Getting started from zero
14
29
 
15
30
  No checkouts yet? `mm-harness setup-base` clones the MetaMask product repos into one
@@ -4,11 +4,13 @@ set -euo pipefail
4
4
 
5
5
  TARGET="$PWD"
6
6
  RUNTIME_DIR="temp/recipe/runtime"
7
+ RUNNER_BIN=""
7
8
  while [ "$#" -gt 0 ]; do
8
9
  case "$1" in
9
10
  --target) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; TARGET="$2"; shift 2 ;;
10
11
  --runtime-dir) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; RUNTIME_DIR="$2"; shift 2 ;;
11
- -h|--help) echo "Usage: build-lavamoat.sh [--target <metamask-extension>] [--runtime-dir <relative path>]"; exit 0 ;;
12
+ --runner-bin) [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; RUNNER_BIN="$2"; shift 2 ;;
13
+ -h|--help) echo "Usage: build-lavamoat.sh [--target <metamask-extension>] [--runtime-dir <relative path>] [--runner-bin <path>]"; exit 0 ;;
12
14
  *) echo "Unknown arg: $1" >&2; exit 2 ;;
13
15
  esac
14
16
  done
@@ -55,4 +57,7 @@ test -f dist/chrome/manifest.json || {
55
57
  echo "build-lavamoat: build completed without dist/chrome/manifest.json" >&2
56
58
  exit 1
57
59
  }
60
+ if [ -n "$RUNNER_BIN" ]; then
61
+ "$RUNNER_BIN" runtime-decision --adapter extension --target "$TARGET" --record-baseline --json >/dev/null
62
+ fi
58
63
  echo "[recipe-harness] non-test LavaMoat build completed"
@@ -308,7 +308,7 @@ NODE
308
308
  prepare_parts+=("bash ${quoted_start_watch} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
309
309
  fi
310
310
  if $BUILD_LAVAMOAT; then
311
- prepare_parts+=("bash ${quoted_build_lavamoat} --target ${quoted_target} --runtime-dir ${quoted_runtime_dir}")
311
+ prepare_parts+=("bash ${quoted_build_lavamoat} --target ${quoted_target} --runtime-dir ${quoted_runtime_dir} --runner-bin ${quoted_runner}")
312
312
  fi
313
313
  if ! $EXTERNAL_ARTIFACT; then
314
314
  prepare_parts+=("node ${quoted_check_infura} --target ${quoted_target} --runtime-dist ${quoted_dist}")
@@ -12,7 +12,7 @@
12
12
  # --runtime-dir <rel> (default from adapters/shared path-defaults)
13
13
  # --watcher-port <port> (default env WATCHER_PORT/RECIPE_WATCHER_PORT,
14
14
  # runtime json, or slot suffix e.g. metamask-extension-1 => 9011)
15
- # --runner-bin <path> (optional; records deps/cache baseline best-effort)
15
+ # --runner-bin <path> (optional; records deps/cache baseline)
16
16
  # --summary <file> (optional; writes the standard summary.json shape)
17
17
  #
18
18
  # Outputs:
@@ -352,7 +352,7 @@ if ! write_canonical_watch_pid; then
352
352
  fi
353
353
  echo '[recipe-harness] yarn start compiled successfully'
354
354
  if [ -n "$RUNNER_BIN" ]; then
355
- "$RUNNER_BIN" runtime-decision --adapter extension --target . --record-baseline --json >/dev/null 2>&1 || true
355
+ "$RUNNER_BIN" runtime-decision --adapter extension --target . --record-baseline --json >/dev/null
356
356
  echo '[recipe-harness] recorded webpack cache baseline (runtime-decision --record-baseline)'
357
357
  fi
358
358
  status=pass
@@ -56,6 +56,13 @@ function webpackFingerprint(target) {
56
56
  for (const dir of WEBPACK_RECURSIVE_INPUTS) walk(hash, target, dir);
57
57
  return { gitHead: git(target, ["rev-parse", "HEAD"]) ?? "unknown", fingerprint: hash.digest("hex") };
58
58
  }
59
+ function installedDepsFingerprint(target) {
60
+ const hash = crypto.createHash("sha256");
61
+ for (const rel of INSTALL_MARKERS) {
62
+ if (fs.existsSync(path.join(target, rel))) addFile(hash, target, rel);
63
+ }
64
+ return hash.digest("hex");
65
+ }
59
66
  function stateDir(target) {
60
67
  return recipeHarnessPath(target, "extension", "decision-state");
61
68
  }
@@ -328,6 +335,10 @@ function distCheck(target) {
328
335
  } catch {
329
336
  return { status: "no-build" };
330
337
  }
338
+ const baseline = readBaseline(target, "webpack-cache-state.json");
339
+ if (!baseline || !("installedDepsFingerprint" in baseline) || baseline?.depsCurrent === false || typeof baseline?.installedDepsFingerprint === "string" && baseline.installedDepsFingerprint !== installedDepsFingerprint(target)) {
340
+ return { status: "stale", reason: "dependencies-changed" };
341
+ }
331
342
  const description = typeof manifest.description === "string" ? manifest.description : "";
332
343
  const match = /from git id:\s*([0-9a-f]{7,40})/iu.exec(description);
333
344
  const head = (git(target, ["rev-parse", "HEAD"]) ?? "").toLowerCase();
@@ -363,7 +374,11 @@ async function cdpCheck(target, cdpPort, pageMode) {
363
374
  }
364
375
  function recordWebpackBaseline(target) {
365
376
  const cache = webpackFingerprint(target);
366
- writeBaseline(target, "webpack-cache-state.json", cache);
377
+ writeBaseline(target, "webpack-cache-state.json", {
378
+ ...cache,
379
+ depsCurrent: depsCheck(target).status === "current",
380
+ installedDepsFingerprint: installedDepsFingerprint(target)
381
+ });
367
382
  }
368
383
  async function decideExtensionReadiness(target, options = {}) {
369
384
  const resolved = path.resolve(target);
@@ -424,7 +439,7 @@ async function decideExtensionReadiness(target, options = {}) {
424
439
  when: dist.status === "stale",
425
440
  decision: "build",
426
441
  reasonCode: "dist-stale",
427
- reasons: [dist.reason === "uncommitted-source" ? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.` : `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
442
+ reasons: [dist.reason === "uncommitted-source" ? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.` : dist.reason === "dependencies-changed" ? "Dependency installation changed or was not current at compilation; rebuild." : `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
428
443
  actions: buildAction(resolved, false)
429
444
  },
430
445
  {
@@ -25,7 +25,7 @@ const SPEC = {
25
25
  { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/reset/generate)", args: ["sync", "set", "reset", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
26
26
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--matrix", "--categories", "--category", "--action", "--library"] },
27
27
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
28
- { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
28
+ { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device", "--plan", "--proof"] },
29
29
  { name: "execution-template", desc: "Discover and materialize shared agent checklists", args: ["list", "materialize", "lint", "new"], flags: ["--dir", "--domain-dir", "--project-worker", "--project-name", "--package-templates", "--package-id", "--flow", "--run-mode", "--platform", "--domain", "--id", "--provenance", "--include-shadowed", "--no-include-shadowed", "--title", "--force", "--json"] },
30
30
  { name: "recipe-quality", desc: "Build canonical quality or request optional recipe planning/advice", args: ["build", "plan", "advise", "advisor-status"], flags: ["--input", "--output", "--recipe", "--task", "--evidence", "--params", "--adapter", "--library", "--advisor", "--advisor-model", "--advisor-stages", "--json"] },
31
31
  { name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
@@ -254,6 +254,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
254
254
  options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, RUN_FUNDING, {
255
255
  "--list": bool(),
256
256
  "--plan": bool(),
257
+ "--proof": bool(),
257
258
  "--describe": bool(),
258
259
  "--hud": value(["auto", "show", "hide"])
259
260
  }),
@@ -52,6 +52,7 @@ function extensionDepsBlock(target) {
52
52
  return null;
53
53
  }
54
54
  async function installExtensionDeps(target) {
55
+ stopExtensionWatcher(target);
55
56
  const installScript = path.join(runnerDir, "adapters/shared/install-repo-deps.sh");
56
57
  const result = await spawnScriptStreaming(installScript, ["--target", target], target);
57
58
  if (result.status === 0) recordDepsBaseline(target);
@@ -91,6 +92,16 @@ async function launchExtension(target, tier, wantWatch, displayMode = "fullscree
91
92
  Next: rerun runtime-launch with the same artifact source.`
92
93
  };
93
94
  }
95
+ if (artifact.status === "none") {
96
+ const depsBlock = extensionDepsBlock(target);
97
+ if (depsBlock) {
98
+ return {
99
+ status: EXIT.infra,
100
+ output: `EXTENSION_DEPENDENCIES_REQUIRED: ${depsBlock.message}
101
+ Next: ${depsBlock.userAction}`
102
+ };
103
+ }
104
+ }
94
105
  if (!reusable) {
95
106
  const block = extensionProductConfigBlock(target);
96
107
  if (block) {
@@ -105,7 +116,7 @@ Next: ${block.userAction}`
105
116
  restoreRequestedPorts();
106
117
  if (wantWatch) {
107
118
  const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
108
- const watchArgs = ["--target", target];
119
+ const watchArgs = ["--target", target, "--runner-bin", path.join(runnerDir, "bin/mm-harness")];
109
120
  if (process.env.WATCHER_PORT) watchArgs.push("--watcher-port", process.env.WATCHER_PORT);
110
121
  console.error(colorHumanMessage(`\u2192 extension watch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`));
111
122
  return spawnScriptStreaming(startWatchSh, watchArgs, target);
@@ -28,6 +28,7 @@ function parseArgs(argv, command) {
28
28
  "preserveProfile",
29
29
  "record",
30
30
  "plan",
31
+ "proof",
31
32
  "list",
32
33
  "describe",
33
34
  "raw",
@@ -7,7 +7,7 @@ import {
7
7
  import { color, stripAnsi } from "../cli-color.js";
8
8
  import { runnerProvenance } from "../doctor.js";
9
9
  import { detectAdapter } from "../harness.js";
10
- import { harnessExecutable, manifestPath, recipeRuntimeDir } from "../paths.js";
10
+ import { harnessExecutable, manifestPath, PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "../paths.js";
11
11
  import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
12
12
  import { EXIT } from "./shared.js";
13
13
  import { formatDuration } from "./status-watch.js";
@@ -336,13 +336,32 @@ async function handlePrepare(argv) {
336
336
  const remoteFlagArgs = remoteFlag === void 0 ? [] : ["--remote-flag", remoteFlag];
337
337
  let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
338
338
  const recordPath = path.join(artifactsDir, READINESS_RECORD);
339
+ const progressPath = path.join(artifactsDir, PREPARE_PROGRESS_ARTIFACT);
339
340
  const chain = stepChain(platform);
340
341
  const view = json ? void 0 : createStepView(chain, stepHints(chain, lastRunDurations(recordPath)));
341
342
  view?.begin();
342
343
  const steps = [];
344
+ let activeStep;
345
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
346
+ const writeProgress = () => {
347
+ fs.mkdirSync(path.dirname(progressPath), { recursive: true });
348
+ const temporaryPath = `${progressPath}.${process.pid}.tmp`;
349
+ fs.writeFileSync(temporaryPath, `${JSON.stringify({
350
+ schemaVersion: 1,
351
+ startedAt,
352
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
353
+ total: chain.length,
354
+ steps: steps.filter(({ id }) => chain.includes(id)).map(({ id, status, durationMs }) => ({ id, status, durationMs })),
355
+ ...activeStep ? { active: activeStep } : {}
356
+ })}
357
+ `);
358
+ fs.renameSync(temporaryPath, progressPath);
359
+ };
343
360
  const failed = () => steps.some((step) => step.status === "fail");
344
361
  const addStep = (step) => {
345
362
  steps.push(step);
363
+ activeStep = void 0;
364
+ writeProgress();
346
365
  view?.finish(step);
347
366
  return step;
348
367
  };
@@ -352,10 +371,17 @@ async function handlePrepare(argv) {
352
371
  }
353
372
  const step = await runStep(executable, target, artifactsDir, id, args, {
354
373
  json,
355
- onSpawn: (startedAt) => view?.start(id, startedAt)
374
+ onSpawn: (stepStartedAt) => {
375
+ activeStep = { id, startedAt: new Date(stepStartedAt).toISOString() };
376
+ writeProgress();
377
+ view?.start(id, stepStartedAt);
378
+ }
356
379
  });
357
380
  return addStep(step);
358
381
  };
382
+ const heartbeat = setInterval(() => {
383
+ if (activeStep) writeProgress();
384
+ }, 1e4);
359
385
  try {
360
386
  await run("doctor", ["doctor", "--fix", "--json", "--adapter", platform, ...targetArgs]);
361
387
  await run("status", ["status", "--json", ...targetArgs]);
@@ -384,6 +410,7 @@ async function handlePrepare(argv) {
384
410
  }
385
411
  await run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
386
412
  } finally {
413
+ clearInterval(heartbeat);
387
414
  view?.end();
388
415
  }
389
416
  const provenance = runnerProvenance(manifestPath(platform));
@@ -404,6 +431,7 @@ async function handlePrepare(argv) {
404
431
  fs.mkdirSync(artifactsDir, { recursive: true });
405
432
  fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}
406
433
  `);
434
+ fs.rmSync(progressPath, { force: true });
407
435
  if (json) {
408
436
  console.log(JSON.stringify(record, null, 2));
409
437
  } else {
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { mkdtemp } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { validateRuntimeProofPlan } from "../runtime-proof.js";
5
6
  import {
6
7
  ensureOverlay,
7
8
  newHealState,
@@ -46,6 +47,7 @@ import { EXIT } from "./shared.js";
46
47
  import {
47
48
  actionManifestPathOption,
48
49
  optionString,
50
+ optionFlag,
49
51
  optionStrings,
50
52
  isRecord,
51
53
  shellQuoteArg,
@@ -563,7 +565,7 @@ function restoreEnv(key, value) {
563
565
  if (value === void 0) delete process.env[key];
564
566
  else process.env[key] = value;
565
567
  }
566
- async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySources, rootRef, params) {
568
+ async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySources, rootRef, params, proof = false) {
567
569
  const protocol = await importRecipeProtocol();
568
570
  let externalRecipeIds;
569
571
  let libraryResolution;
@@ -641,6 +643,12 @@ async function validateRecipeAdapterAware(adapter, recipe, manifest, librarySour
641
643
  }
642
644
  };
643
645
  validateDependencyInstances(recipe, params ?? {});
646
+ if (proof && !findings.some((finding) => finding.severity === "error")) {
647
+ findings.push(...validateRuntimeProofPlan(
648
+ recipe,
649
+ new Map([...dependencies.recipes].map(([ref, dependency]) => [ref, dependency.document]))
650
+ ));
651
+ }
644
652
  } catch (error) {
645
653
  const resolutionError = error instanceof harness.RecipeResolutionError ? error : void 0;
646
654
  if (!resolutionError) throw error;
@@ -919,7 +927,8 @@ async function validateRunRecipeStatic(recipeArg, adapter, options, params = {})
919
927
  manifest,
920
928
  librarySources,
921
929
  resolved.ref,
922
- effectiveParams
930
+ effectiveParams,
931
+ optionFlag(options, "proof")
923
932
  ) : {
924
933
  status: "invalid",
925
934
  findings: [],
@@ -613,6 +613,12 @@ async function handleRunPlan(recipeArg, params, options, stream) {
613
613
  status: manifestOk && schemaValid ? "ok" : "error",
614
614
  detail: "recipe document schema + action existence/platform vs the adapter manifest"
615
615
  },
616
+ ...optionFlag(options, "proof") ? [{
617
+ step: "validate.proofBindings",
618
+ confidence: "static",
619
+ status: findings.some((finding) => finding.code.startsWith("proof.")) ? "error" : errorCount ? "planned" : "ok",
620
+ detail: "checks possible behavioral assertion bindings only; runtime order, branches, results and proof artifact indexing remain unverified"
621
+ }] : [],
616
622
  {
617
623
  step: "fixture.file",
618
624
  confidence: "static",
@@ -10,6 +10,8 @@ import {
10
10
  writeMobileReleaseArtifactState
11
11
  } from "../adapters/mobile/release-artifact-state.js";
12
12
  import { recipeHarnessPath } from "../paths.js";
13
+ import { extensionDepsBlock } from "./launch/extension.js";
14
+ import { isExtensionDistStale } from "../adapters/extension/runtime-decision.js";
13
15
  import {
14
16
  adapterOption,
15
17
  isRecord,
@@ -152,6 +154,23 @@ async function handleRuntimeLaunch({ options }) {
152
154
  printRuntimeLaunchReport(report2, optionFlag(options, "json"));
153
155
  return 1;
154
156
  }
157
+ if (!artifact) {
158
+ const depsBlock = extensionDepsBlock(target);
159
+ const staleDist = mode === "existing-dist" && isExtensionDistStale(target);
160
+ if (depsBlock || staleDist) {
161
+ const report2 = runtimeLaunchReport("fail", {
162
+ adapter,
163
+ target,
164
+ cdpPort,
165
+ artifactsDir,
166
+ reason: depsBlock ? "extension_dependencies_required" : "extension_dist_stale",
167
+ fix: depsBlock?.userAction ?? `mm-harness launch --adapter extension --target ${shellQuote(target)}`,
168
+ command
169
+ });
170
+ printRuntimeLaunchReport(report2, optionFlag(options, "json"));
171
+ return 1;
172
+ }
173
+ }
155
174
  fs.mkdirSync(artifactsDir, { recursive: true });
156
175
  const jsonMode = optionFlag(options, "json");
157
176
  if (!jsonMode) {
@@ -103,9 +103,13 @@ function renderTaskView(view, title, explicitTask = false) {
103
103
  const contextLine = joinParts([
104
104
  view.libraries.length > 0 && `libraries ${out("info", view.libraries.map(libraryLabel).join(" "))}`,
105
105
  showTask && view.fixture && `fixture ${out("info", path.basename(view.fixture.fixture ?? "unknown"))} ${statusWord(out, view.fixture.status)}${view.fixture.walletReset ? out("warn", " reset") : ""}`,
106
- showTask && view.sandbox?.ready !== void 0 && `sandbox ${view.sandbox.ready ? out("ok", "ready") : out("warn", "not ready")}`
106
+ showTask && (view.prepare ? `sandbox ${out("info", "preparing")}` : view.sandbox?.ready !== void 0 && `sandbox ${view.sandbox.ready ? out("ok", "ready") : out("warn", "not ready")}`)
107
107
  ]);
108
108
  if (contextLine) lines.push(contextLine);
109
+ if (showTask && view.acceptance?.length) lines.push("", ...renderAcceptanceLines(view));
110
+ if (showTask && view.prepare) {
111
+ lines.push(`prepare ${out("info", view.prepare.step)} \xB7 ${view.prepare.completed}/${view.prepare.total} steps \xB7 ${formatDuration(view.prepare.elapsedMs)} elapsed`);
112
+ }
109
113
  const last = view.lastCommand;
110
114
  if (showTask && last) {
111
115
  lines.push(joinParts([
@@ -148,6 +152,19 @@ function renderTaskView(view, title, explicitTask = false) {
148
152
  }
149
153
  return lines;
150
154
  }
155
+ function renderAcceptanceLines(view) {
156
+ if (!view.acceptance?.length) return [];
157
+ const out = (style, text) => color(style, text, { stream: process.stdout });
158
+ const proven = view.acceptance.filter((row) => row.status?.verdict === "proven").length;
159
+ return [
160
+ `acceptance criteria ${proven}/${view.acceptance.length} proven`,
161
+ ...view.acceptance.map((row) => {
162
+ const verdict = row.status?.verdict ?? "no verdict";
163
+ const style = verdict === "proven" ? "ok" : verdict === "weak" ? "warn" : verdict === "no verdict" ? "dim" : "err";
164
+ return ` ${row.id} [${out(style, verdict)}] ${row.text}`;
165
+ })
166
+ ];
167
+ }
151
168
  function idleReason(view) {
152
169
  if (!view.taskDir) return "no task directory under temp/tasks";
153
170
  const { reason, idleMs, branchCarriesTicket } = view.activity;
@@ -161,15 +178,33 @@ function idleReason(view) {
161
178
  }
162
179
  function changedLines(previous, next) {
163
180
  if (!previous || previous.taskDir !== next.taskDir) {
164
- return [joinParts([
165
- path.basename(next.checkout.target),
166
- next.taskDir ? path.basename(next.taskDir) : "no-task-dir",
167
- `status=${next.signal?.status ?? "unknown"}`,
168
- `step=${next.signal?.step ? stepLabel(next.signal.step) : "none"}`
169
- ], " ")];
181
+ return [
182
+ joinParts([
183
+ path.basename(next.checkout.target),
184
+ next.taskDir ? path.basename(next.taskDir) : "no-task-dir",
185
+ `status=${next.signal?.status ?? "unknown"}`,
186
+ `step=${next.signal?.step ? stepLabel(next.signal.step) : "none"}`
187
+ ], " "),
188
+ ...(next.acceptance ?? []).map((row) => `${row.id} ${row.status?.verdict ?? "no verdict"} ${row.text}`),
189
+ ...next.prepare ? [`prepare ${next.prepare.step} started (${next.prepare.completed}/${next.prepare.total} steps)`] : []
190
+ ];
170
191
  }
171
192
  const lines = [];
193
+ for (const step of next.prepare?.steps ?? []) {
194
+ if (!previous.prepare?.steps.some((prior) => prior.id === step.id && prior.status === step.status)) {
195
+ lines.push(`prepare ${step.id} ${step.status} (${formatDuration(step.durationMs)})`);
196
+ }
197
+ }
198
+ if (next.prepare && (previous.prepare?.step !== next.prepare.step || previous.prepare.startedAt !== next.prepare.startedAt)) {
199
+ lines.push(`prepare ${next.prepare.step} started (${next.prepare.completed}/${next.prepare.total} steps)`);
200
+ }
172
201
  const before = new Map(previous.checklist.map((row) => [row.number, row.checked]));
202
+ const beforeAcceptance = new Map(previous.acceptance?.map((row) => [row.id, row.status?.verdict]));
203
+ for (const row of next.acceptance ?? []) {
204
+ if (beforeAcceptance.get(row.id) !== row.status?.verdict || !beforeAcceptance.has(row.id)) {
205
+ lines.push(`${row.id} ${row.status?.verdict ?? "no verdict"} ${row.text}`);
206
+ }
207
+ }
173
208
  for (const row of next.checklist) {
174
209
  if (!row.checked || before.get(row.number) === true) continue;
175
210
  lines.push(`checked ${row.number} ${row.label}${row.durationMs !== void 0 ? ` (${formatDuration(row.durationMs)})` : ""}`);
@@ -254,6 +289,7 @@ export {
254
289
  changedLines,
255
290
  formatDuration,
256
291
  handleStatusTaskView,
292
+ renderAcceptanceLines,
257
293
  renderTaskView,
258
294
  runStatusWatch
259
295
  };
@@ -20,7 +20,8 @@ import {
20
20
  renderMobileLiveBlock
21
21
  } from "./mobile-device-view.js";
22
22
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
23
- import { handleStatusTaskView } from "./status-watch.js";
23
+ import { handleStatusTaskView, renderAcceptanceLines } from "./status-watch.js";
24
+ import { collectTaskView, findTaskDir } from "../task-view.js";
24
25
  import { optionFlag, optionString, targetPath } from "./parse-args.js";
25
26
  async function handleStatus({ options }) {
26
27
  const target = targetPath(options);
@@ -106,6 +107,12 @@ async function handleStatus({ options }) {
106
107
  const out = (style, text) => color(style, text, { stream: process.stdout });
107
108
  console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
108
109
  console.log(renderCheckoutView(view));
110
+ if (!fast) {
111
+ const taskView = collectTaskView(target, findTaskDir(target), Date.now());
112
+ if (taskView.activity.ongoing && taskView.acceptance?.length) {
113
+ console.log(renderAcceptanceLines(taskView).join("\n"));
114
+ }
115
+ }
109
116
  if (adapter !== "mobile") renderFeatureFlagLine(staticFeatureFlags, out);
110
117
  if (deviceView) renderMobileDeviceList(deviceView, out);
111
118
  if (!fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
package/dist/devices.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
3
+ const IOS_DEVICE_DISCOVERY_TIMEOUT_MS = 3e4;
3
4
  function reportProbeFailure(errors, platform, error) {
4
5
  const code = error?.code;
5
6
  if (code === "ENOENT") return;
6
- const reason = code === "ETIMEDOUT" ? "timed out after 5 seconds" : "failed";
7
+ const timeoutSeconds = platform === "ios" ? IOS_DEVICE_DISCOVERY_TIMEOUT_MS / 1e3 : 5;
8
+ const reason = code === "ETIMEDOUT" ? `timed out after ${timeoutSeconds} seconds` : "failed";
7
9
  const probeCode = code ?? error?.status;
8
10
  errors.push({
9
11
  platform,
@@ -61,7 +63,7 @@ function listIosSimulators(scope, errors) {
61
63
  try {
62
64
  output = execFileSync("xcrun", ["simctl", "list", "devices", scope, "-j"], {
63
65
  encoding: "utf8",
64
- timeout: 5e3,
66
+ timeout: IOS_DEVICE_DISCOVERY_TIMEOUT_MS,
65
67
  stdio: ["ignore", "pipe", "ignore"]
66
68
  });
67
69
  } catch (error) {
@@ -192,8 +192,10 @@ Example:
192
192
  The task view reads task files only \u2014 no probe, no launch, no write \u2014 and shows
193
193
  the same facts whether a skill or a control plane started the run: slot and
194
194
  device isolation, template and flow, fixture and sandbox readiness, then the
195
- checklist with the time each row took and how long the current row has been
196
- running. --task without --watch prints it once. With --json, --watch emits one
195
+ checklist with the time each row took, how long the current row has been
196
+ running, and acceptance criteria with recorded verdicts. The home dashboard
197
+ also shows criteria for an ongoing task. --task without --watch prints it once.
198
+ With --json, --watch emits one
197
199
  object per change (newline-delimited) instead of redrawing.
198
200
 
199
201
  The checkout view (also under "view" in --json, and printed by doctor) is what
@@ -462,6 +464,7 @@ Discovery:
462
464
  --list List complete recipes accepted by run for this adapter; no <recipe> needed
463
465
  --describe Show one recipe's parameters, composed actions/recipes, and runnable command
464
466
  --plan Validate + print execution plan, touching nothing. Exit 5 if invalid.
467
+ --proof Preflight behavioral proof bindings before planning/execution; runtime evidence is still required.
465
468
 
466
469
  Common overrides:
467
470
  --device <udid|serial|name> Mobile only: target this device (sets IOS_SIMULATOR / ADB_SERIAL and updates agentic-runtime.json). Without it, >1 connected mobile device fails fast and lists them. Do not shut down other devices to force selection.
package/dist/paths.js CHANGED
@@ -5,6 +5,7 @@ const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
5
5
  const pathDefaults = readPathDefaults();
6
6
  const DEFAULT_RECIPE_RUNTIME_DIR = pathDefaults.recipeRuntimeDir;
7
7
  const DEFAULT_RECIPE_HARNESS_ROOT = pathDefaults.recipeHarnessRoot;
8
+ const PREPARE_PROGRESS_ARTIFACT = "prepare/progress.json";
8
9
  function recipeRuntimeDir() {
9
10
  return validateRelativeRecipePath("RECIPE_RUNTIME_DIR", process.env.RECIPE_RUNTIME_DIR || DEFAULT_RECIPE_RUNTIME_DIR);
10
11
  }
@@ -171,6 +172,7 @@ function isMissingPackageError(error, packageName) {
171
172
  export {
172
173
  DEFAULT_RECIPE_HARNESS_ROOT,
173
174
  DEFAULT_RECIPE_RUNTIME_DIR,
175
+ PREPARE_PROGRESS_ARTIFACT,
174
176
  assertAdapter,
175
177
  extensionIdPath,
176
178
  harnessExecutable,
@@ -9,23 +9,8 @@ function validateRuntimeProof(bundle, smokeTarget) {
9
9
  assert(entries.length && entries.every((entry) => entry.ok === true), "Runtime trace contains failed or unknown actions");
10
10
  assert(entries.some((entry) => entry.action === "end" && entry.status === "pass" && !String(entry.nodeId ?? entry.id).includes("/")), "Runtime recipe did not reach its passing end");
11
11
  const documents = new Map(recipeResolution.dependencies.map((dependency) => [normalizeRecipeRef(dependency.ref), resolvedRecipes[dependency.digest]]));
12
- const nodes = /* @__PURE__ */ new Map();
13
- function collect(document, prefix = "", stack = /* @__PURE__ */ new Set()) {
14
- for (const [id, node] of Object.entries(document.workflow.nodes)) {
15
- nodes.set(prefix + id, node);
16
- if (node.action === "call" && typeof node.ref === "string") {
17
- const ref = normalizeRecipeRef(node.ref);
18
- assert(!stack.has(ref), "Recursive recipe proof");
19
- const nested = documents.get(ref);
20
- assert(nested, `Missing resolved recipe ${ref}`);
21
- collect(nested, `${prefix}${id}/`, new Set(stack).add(ref));
22
- }
23
- }
24
- }
25
- collect(recipe);
12
+ const nodes = collectProofNodes(recipe, documents);
26
13
  const executed = new Map(entries.map((entry, index) => [entry.nodeId ?? entry.id, { entry, index }]));
27
- const assertions = /* @__PURE__ */ new Set(["assert_output", "assert_json", "ui.wait_for"]);
28
- const controls = /* @__PURE__ */ new Set(["end", "wait", "manual", "switch", "call", "index_artifacts", "assert_exit_code", "assert_file", "branch", "repeat", "set", "log"]);
29
14
  function proves(id) {
30
15
  const node = nodes.get(id);
31
16
  const current = executed.get(id);
@@ -33,13 +18,12 @@ function validateRuntimeProof(bundle, smokeTarget) {
33
18
  if (node.action === "call") return [...nodes.keys()].some((child) => child.startsWith(`${id}/`) && proves(child));
34
19
  if (!assertions.has(node.action) || !current.entry.output || typeof current.entry.output !== "object") return false;
35
20
  if (node.action === "assert_output") {
36
- const prefix = id.includes("/") ? id.slice(0, id.lastIndexOf("/") + 1) : "";
37
- const source = prefix + String(node.source ?? node.node ?? "");
21
+ const source = assertionSource(id, node);
38
22
  const prior = executed.get(source);
39
23
  const action = nodes.get(source)?.action;
40
- return Boolean(prior && prior.index < current.index && action && action === prior.entry.action && !controls.has(action) && !assertions.has(action));
24
+ return Boolean(prior && prior.index < current.index && action && action === prior.entry.action && isObservation(action));
41
25
  }
42
- if (node.action === "ui.wait_for") return entries.slice(0, current.index).some((entry) => ["ui.press", "ui.key_press", "ui.set_input", "ui.swipe", "ui.drag", "ui.long_press"].includes(entry.action));
26
+ if (node.action === "ui.wait_for") return entries.slice(0, current.index).some((entry) => inputs.has(entry.action));
43
27
  return typeof node.path === "string" && manifest.artifacts.some((artifact) => artifact.path === node.path && artifact.category === "proof");
44
28
  }
45
29
  assert(recipe.proofTargets?.length, "Runtime recipe has no behavioral proof targets");
@@ -49,6 +33,58 @@ function validateRuntimeProof(bundle, smokeTarget) {
49
33
  }
50
34
  if (smokeTarget) assert(recipe.proofTargets.some((target) => target.id === smokeTarget), "Smoke target is absent from recipe");
51
35
  }
36
+ const assertions = /* @__PURE__ */ new Set(["assert_output", "assert_json", "ui.wait_for"]);
37
+ const controls = /* @__PURE__ */ new Set(["end", "wait", "manual", "switch", "call", "index_artifacts", "assert_exit_code", "assert_file", "branch", "repeat", "set", "log"]);
38
+ const inputs = /* @__PURE__ */ new Set(["ui.press", "ui.key_press", "ui.set_input", "ui.swipe", "ui.drag", "ui.long_press"]);
39
+ function isObservation(action) {
40
+ return !controls.has(action) && !assertions.has(action);
41
+ }
42
+ function assertionSource(id, node) {
43
+ const prefix = id.includes("/") ? id.slice(0, id.lastIndexOf("/") + 1) : "";
44
+ return prefix + String(node.source ?? node.node ?? "");
45
+ }
46
+ function collectProofNodes(recipe, documents) {
47
+ const nodes = /* @__PURE__ */ new Map();
48
+ function collect(document, prefix = "", stack = /* @__PURE__ */ new Set()) {
49
+ for (const [id, node] of Object.entries(document.workflow.nodes)) {
50
+ nodes.set(prefix + id, node);
51
+ if (node.action === "call" && typeof node.ref === "string") {
52
+ const ref = normalizeRecipeRef(node.ref);
53
+ assert(!stack.has(ref), "Recursive recipe proof");
54
+ const nested = documents.get(ref);
55
+ assert(nested, `Missing resolved recipe ${ref}`);
56
+ collect(nested, `${prefix}${id}/`, new Set(stack).add(ref));
57
+ }
58
+ }
59
+ }
60
+ collect(recipe);
61
+ return nodes;
62
+ }
63
+ function validateRuntimeProofPlan(recipe, documents = /* @__PURE__ */ new Map()) {
64
+ const finding = (code, path, message) => ({ severity: "error", code, path, message });
65
+ if (!recipe.proofTargets?.length) return [finding("proof.targets_missing", "proofTargets", "Behavioral proof requires at least one proof target.")];
66
+ const nodes = collectProofNodes(recipe, documents);
67
+ const hasInput = [...nodes.values()].some((node) => inputs.has(node.action));
68
+ function eligible(id) {
69
+ const node = nodes.get(id);
70
+ if (node.action === "call") return [...nodes.keys()].some((child) => child.startsWith(`${id}/`) && eligible(child));
71
+ if (node.action === "assert_output") {
72
+ const source = nodes.get(assertionSource(id, node));
73
+ return Boolean(source && isObservation(source.action));
74
+ }
75
+ if (node.action === "ui.wait_for") return hasInput;
76
+ return node.action === "assert_json" && typeof node.path === "string";
77
+ }
78
+ return recipe.proofTargets.flatMap((target, index) => {
79
+ const linked = [...nodes].filter(([, node]) => node.proves?.includes(target.id));
80
+ return linked.some(([id]) => eligible(id)) ? [] : [finding(
81
+ "proof.no_runtime_assertion",
82
+ `proofTargets[${index}]`,
83
+ `Proof target ${target.id} has no potentially eligible runtime assertion. Bind proves to assert_output over an observation, ui.wait_for with an input action, assert_json over a proof artifact, or a call containing one. Runtime order, outcomes and artifact indexing remain unverified.`
84
+ )];
85
+ });
86
+ }
52
87
  export {
53
- validateRuntimeProof
88
+ validateRuntimeProof,
89
+ validateRuntimeProofPlan
54
90
  };
package/dist/task-view.js CHANGED
@@ -2,6 +2,12 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { HANDOFF_INPUT, latestTaskDir } from "@farmslot/agent-runtime";
5
+ import {
6
+ ACCEPTANCE_STATUS_ARTIFACT,
7
+ acceptanceCriteriaView,
8
+ acceptanceCriterionId,
9
+ validateAcceptanceStatusLedger
10
+ } from "@farmslot/protocol/contracts/acceptance";
5
11
  import {
6
12
  checklistStepName,
7
13
  deriveChecklistStepDurations,
@@ -17,7 +23,7 @@ import {
17
23
  } from "@farmslot/protocol";
18
24
  import { readCommandJournal } from "./command-journal.js";
19
25
  import { resolveRuntimeContextPath } from "./harness.js";
20
- import { recipeRuntimeDir } from "./paths.js";
26
+ import { PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "./paths.js";
21
27
  import { parseLibraryPathEnv } from "./review/knowledge.js";
22
28
  const CHECKLIST_LABEL_LIMIT = 70;
23
29
  const TASK_SILENT_AFTER_MS = 10 * 60 * 1e3;
@@ -42,8 +48,22 @@ function collectTaskView(target, taskDir, now) {
42
48
  const checklist = readChecklistRows(path.join(taskDir, INTERACTIVE_CHECKLIST_MARKDOWN));
43
49
  const template = asObject(handoff?.executionTemplate);
44
50
  const task = asObject(handoff?.task);
51
+ const rawCriteria = task?.acceptanceCriteria;
52
+ if (rawCriteria !== void 0 && !Array.isArray(rawCriteria)) {
53
+ throw new Error(`${path.join(taskDir, HANDOFF_INPUT)}: task.acceptanceCriteria must be an array.`);
54
+ }
55
+ const criteria = rawCriteria?.map((text, index) => ({
56
+ id: acceptanceCriterionId(index),
57
+ text: String(text)
58
+ })).filter((criterion) => criterion.text.trim().length > 0) ?? [];
59
+ const rawLedger = readJsonObject(path.join(taskDir, ACCEPTANCE_STATUS_ARTIFACT));
60
+ if (rawLedger) {
61
+ const issues = validateAcceptanceStatusLedger(rawLedger);
62
+ if (issues.length > 0) throw new Error(`${ACCEPTANCE_STATUS_ARTIFACT}: ${issues.join("; ")}`);
63
+ }
45
64
  const startedAtIso = pickString(handoff, "startedAt");
46
65
  const startedAt = parseTime(startedAtIso);
66
+ const prepare = readPrepareProgress(taskDir, now, startedAt, parseTime(pickString(sandbox, "recordedAt")));
47
67
  const timing = applyTiming(checklist, signal, startedAtIso, now);
48
68
  const ticket = pickString(task, "ticket");
49
69
  const journal = readLastCommand(target, now);
@@ -56,7 +76,8 @@ function collectTaskView(target, taskDir, now) {
56
76
  startedAt,
57
77
  parseTime(pickString(signal, "timestamp")),
58
78
  ...readMarkEvents(signal).map((event) => Date.parse(event.checkedAt)),
59
- commandCounts ? commandAt : void 0
79
+ commandCounts ? commandAt : void 0,
80
+ prepare ? parseTime(prepare.updatedAt) : void 0
60
81
  ],
61
82
  branch: base.checkout.branch,
62
83
  ticket,
@@ -79,6 +100,7 @@ function collectTaskView(target, taskDir, now) {
79
100
  title: pickString(task, "title"),
80
101
  ticket
81
102
  }),
103
+ ...criteria.length > 0 || rawLedger ? { acceptance: acceptanceCriteriaView(criteria, rawLedger ? rawLedger : null) } : {},
82
104
  fixture: section({
83
105
  fixture: pickString(fixtures, "fixture"),
84
106
  canonicalFixture: pickString(fixtures, "canonicalFixture"),
@@ -86,6 +108,7 @@ function collectTaskView(target, taskDir, now) {
86
108
  walletReset: pickBoolean(fixtures, "walletReset")
87
109
  }),
88
110
  sandbox: section({ ready: pickBoolean(sandbox, "ready") }),
111
+ ...prepare ? { prepare } : {},
89
112
  checklist,
90
113
  subtasks: readSubtasks(taskDir),
91
114
  signal: section({
@@ -99,6 +122,26 @@ function collectTaskView(target, taskDir, now) {
99
122
  activity
100
123
  };
101
124
  }
125
+ function readPrepareProgress(taskDir, now, taskStartedAt, recordedAt) {
126
+ const progress = readJsonObject(path.join(taskDir, "artifacts", PREPARE_PROGRESS_ARTIFACT));
127
+ const active = asObject(progress?.active);
128
+ const step = pickString(active, "id");
129
+ const startedAt = pickString(active, "startedAt");
130
+ const stepStartedAt = parseTime(startedAt);
131
+ const runStartedAt = parseTime(pickString(progress, "startedAt"));
132
+ const updatedAtIso = pickString(progress, "updatedAt");
133
+ const updatedAt = parseTime(updatedAtIso);
134
+ const total = pickNumber(progress, "total");
135
+ if (!step || !["doctor", "status", "launch", "fixtures", "verify"].includes(step) || stepStartedAt === void 0 || runStartedAt === void 0 || updatedAt === void 0 || now - updatedAt > 3e4 || total === void 0 || total < 1 || taskStartedAt !== void 0 && runStartedAt < taskStartedAt || recordedAt !== void 0 && recordedAt >= runStartedAt) return void 0;
136
+ const steps = Array.isArray(progress?.steps) ? progress.steps.flatMap((value) => {
137
+ const item = asObject(value);
138
+ const id = pickString(item, "id");
139
+ const status = pickString(item, "status");
140
+ const durationMs = pickNumber(item, "durationMs");
141
+ return id && ["doctor", "status", "launch", "fixtures", "verify"].includes(id) && status && ["pass", "fail", "skipped"].includes(status) && durationMs !== void 0 ? [{ id, status, durationMs }] : [];
142
+ }) : [];
143
+ return { step, startedAt, updatedAt: updatedAtIso, elapsedMs: Math.max(0, now - stepStartedAt), completed: steps.length, total, steps };
144
+ }
102
145
  function readLastCommand(target, now) {
103
146
  const { record } = readCommandJournal(target);
104
147
  if (!record) return void 0;
@@ -118,6 +118,24 @@ current manifests do not expose the required durable capability.
118
118
  | Bridge tracking and details | Completed supported bridge route | Read-only Swap/Bridge evidence exists, but Extension cannot create the terminal bridge transaction | **MISSING** for a complete Extension bridge journey |
119
119
  | Android multi-network visibility | Imported wallet with holdings on multiple chains | Native Assets read reports aggregate visible holdings without Metro or a development bridge | **AVAILABLE** |
120
120
 
121
+ ## Pending approval observation
122
+
123
+ On source-dev Extension runtimes, `metamask.wallet.read_state` with
124
+ `scope: "pending_approvals"` reads `stateHooks.store.getState()` once. It returns
125
+ only `state.pendingApprovals`, an ID-sorted array of `{ id, type }`, and
126
+ `state.pendingApprovalCount`. It neither approves nor rejects requests and does
127
+ not navigate, reload, or call the background controller. Missing or malformed
128
+ approval state fails the read instead of reporting an empty queue.
129
+
130
+ Capture the IDs before entering a confirmation flow and again after its explicit
131
+ UI teardown. Check that newly created IDs are gone and pre-existing IDs remain.
132
+ A count alone or Back navigation does not prove cleanup. This observes pending
133
+ approvals only, not transaction settlement or other controller queues.
134
+
135
+ This scope requires the development store hook. It is unsupported on official
136
+ Extension artifacts without that hook and on Mobile. Do not patch a release or
137
+ mutate controller state to make the read available.
138
+
121
139
  ## Platform scope
122
140
 
123
141
  The 135-case source inventory targets Extension. It must not be relabeled as an
@@ -1,8 +1,16 @@
1
- // Reads wallet identity, onboarding, and network state from state hooks alongside the password-input marker.
1
+ // Reads wallet state or pending approval identities without changing requests.
2
2
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
3
3
  import { readWalletStateExpression } from './state.mjs';
4
4
 
5
5
  runAdapter((input) => withExtensionPage(input, async (page) => {
6
- const state = await page.evaluate(readWalletStateExpression(), { awaitPromise: true });
7
- return { action: input.action, source: 'extension-stateHooks-clean-state', state, redacted: true, proofPath: 'extension-wallet-state' };
6
+ const scope = input.node?.scope ?? 'wallet';
7
+ const state = await page.evaluate(readWalletStateExpression(scope), { awaitPromise: true });
8
+ return {
9
+ action: input.action,
10
+ source: scope === 'pending_approvals' ? 'extension-stateHooks-store' : 'extension-stateHooks-clean-state',
11
+ scope,
12
+ state,
13
+ redacted: true,
14
+ proofPath: 'extension-wallet-state',
15
+ };
8
16
  }));
@@ -22,7 +22,31 @@ const walletStatePreamble = `
22
22
  };
23
23
  `;
24
24
 
25
- export function readWalletStateExpression() {
25
+ export function readWalletStateExpression(scope = 'wallet') {
26
+ if (scope === 'pending_approvals') {
27
+ return `(() => {
28
+ const hooks = globalThis.stateHooks;
29
+ if (typeof hooks?.store?.getState !== 'function') {
30
+ throw new Error('Pending approval reads require source-dev stateHooks.store.getState; official artifacts are unsupported.');
31
+ }
32
+ const root = hooks.store.getState();
33
+ const pending = (root?.metamask || root)?.pendingApprovals;
34
+ if (!pending || typeof pending !== 'object' || Array.isArray(pending)) {
35
+ throw new Error('Pending approval state is unavailable; an empty queue cannot be inferred.');
36
+ }
37
+ const pendingApprovals = Object.entries(pending).map(([id, approval]) => {
38
+ if (!id || !approval || approval.id !== id ||
39
+ typeof approval.type !== 'string' || !approval.type) {
40
+ throw new Error('Pending approval state contains an invalid request identity or type.');
41
+ }
42
+ return { id, type: approval.type };
43
+ }).sort((left, right) => left.id.localeCompare(right.id));
44
+ return { pendingApprovals, pendingApprovalCount: pendingApprovals.length };
45
+ })()`;
46
+ }
47
+ if (scope !== 'wallet') {
48
+ throw new Error('metamask.wallet.read_state scope must be wallet or pending_approvals.');
49
+ }
26
50
  return `(async () => {
27
51
  ${walletStatePreamble}
28
52
  const { metamask, internal } = await readWalletState();
@@ -1753,10 +1753,16 @@
1753
1753
  "execution_capabilities": []
1754
1754
  },
1755
1755
  "metamask.wallet.read_state": {
1756
- "description": "Read the redacted wallet state (selected account, route, and device) from the running app.",
1756
+ "description": "Read redacted wallet state, or pending approval IDs/types/count from the source-dev store without modifying requests. Pending approvals are unsupported on official artifacts without the dev store.",
1757
1757
  "schema": {
1758
1758
  "type": "object",
1759
- "properties": {},
1759
+ "properties": {
1760
+ "scope": {
1761
+ "type": "string",
1762
+ "enum": ["wallet", "pending_approvals"],
1763
+ "default": "wallet"
1764
+ }
1765
+ },
1760
1766
  "additionalProperties": false
1761
1767
  },
1762
1768
  "examples": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.62.0",
3
+ "version": "0.64.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"