@deeeed/metamask-harness 0.51.9 → 0.53.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,36 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.53.0 - 2026-09-17
6
+
7
+ ### Added
8
+
9
+ - `doctor` and `status` print one checkout view, also carried under `view` in their `--json`: the runtime-context fields that keep this checkout off other slots' ports and devices (mobile adds `watcherPort`, `metroPort`, `devServerPort`, `simulator`, `simulatorUdid`; extension adds `devServerPort`), the recipe libraries this machine resolves with their revision and recipe/checklist counts, and the wallet fixture the checkout last applied. Values are an allowlist — no fixture body, key, or phrase is ever rendered.
10
+ - `prepare --clear-metro` passes the flag to its launch step, so the stale-Metro recovery `doctor` teaches is reachable through the sanctioned prepare path. Nothing clears the transform cache on its own; the flag stays an explicit operator decision, and it is refused on extension and core.
11
+ - `status --watch` shows a live read-only view of the task a checkout is working through: slot and device isolation, template and flow, fixture and sandbox readiness, and the checklist with the time each row took and how long the current row has been running. `--task <dir>` selects a task directory instead of the most recently marked one under `temp/tasks/`, and prints the view once without `--watch`. It reads task files only, so watching never probes a device or disturbs the run. Piped output logs one line per change; `--json` emits the view model, one object per change while watching. When nothing is in progress it says `no ongoing task detected` with the reason (finished, silent, or no task yet) and whether the checked-out branch carries the task's ticket, and it keeps waiting for a new task instead of exiting on an old finished one. Rows are grouped by checklist phase.
12
+
13
+ ### Changed
14
+
15
+ - The base review, which every `review checklist` starts with in any repo and domain, gains a signal-over-noise rule: comments that say why and stay short, no ticket keys or tool mentions in source, no leftover scaffolding, swallowed errors, one-caller abstractions or padded tests and PR text.
16
+ - `task init` no longer requires `--run-mode`. It defaults to `autonomous`; the distinction belongs to a control plane, and an engineer running a skill steers by hand.
17
+ - Every library this machine resolves — engineer config, a sibling checkout, the fetch cache — now reaches recipe, action, and template discovery, not only the ones `RECIPE_LIBRARY_PATH` names. An engineer who never exported the variable sees the same recipes as an orchestrator that injects it. Entries already in the variable keep their precedence and order, the personal library still applies, and nothing is fetched.
18
+ - A runtime-context field overridden from the environment now names the variable that actually supplied the value instead of the canonical one.
19
+
20
+ ### Fixed
21
+
22
+ - `check diff` runs a changed `<name>.<layer>.test.*` file under `jest.config.<layer>.*` when the repo has one. MetaMask Mobile keeps view and integration tests out of its default Jest config, so a task that changed a view test got "No tests found" and a failed gate.
23
+ - `mm-harness update` and `update --check` no longer report `REGISTRY_UNREACHABLE` inside a checkout whose `.npmrc` sets `offline = true`. The registry lookup and the global install run from a neutral directory, so a project can no longer answer for a global install.
24
+
25
+ ## 0.52.0 - 2026-09-17
26
+
27
+ ### Added
28
+
29
+ - Export portable runtime proof validation so standalone QA requires executed assertions and rejects passing packages without behavioral proof.
30
+
31
+ ### Changed
32
+
33
+ - Align tutorials with explicit workflow invocation, static review, recipe-qa scopes and caller-owned publication/cleanup; refresh shared execution-template examples.
34
+
5
35
  ## 0.51.9 - 2026-09-16
6
36
 
7
37
  ### Changed
package/README.md CHANGED
@@ -189,6 +189,11 @@ mm-harness run --list
189
189
  Shared libraries hold durable actions and composable recipes. Task acceptance
190
190
  criteria remain task-local. See [Recipes](docs/RECIPES.md).
191
191
 
192
+ Exporting is not required: a library registered with `mm-harness config set
193
+ libraries.<name> <path>`, or checked out beside the product checkout, reaches
194
+ `run`, `actions`, and template discovery too. `mm-harness status` lists what this
195
+ machine resolves, with each location's revision and recipe count.
196
+
192
197
  Teams maintain their library checkouts. Update them before starting a task;
193
198
  the harness records local revisions and recipe digests but does not fetch or
194
199
  check for upstream updates. Keep the selected library unchanged during a run.
@@ -262,3 +267,17 @@ mm-harness --version
262
267
  ```
263
268
 
264
269
  Unset `MM_HARNESS_BIN` to return to the published installation.
270
+
271
+
272
+ ### Workflow entry points and completion
273
+
274
+ Explicitly invoke `mms-recipe-dev`, `mms-recipe-fixbug`, `mms-recipe-qa` or the team's static review skill. `mms-recipe-cook` selects or resumes their shared Markdown checklist. Matching descriptions or changed paths never activates these workflows.
275
+
276
+ Static Perps review uses a generated source checklist without installing or launching the app harness. `recipe-qa` freezes a PR, range or daily change window and requires runtime smoke plus selected recipe proof. Official release validation uses its named release profile and proof lane.
277
+
278
+ TASK.md holds the request; CHECKLIST.md is the sole step list. The frozen terminal contract and task-local mark validate completion. Publication, retained sessions and workspace cleanup belong to the caller. Farmslot handles them through its control plane; direct invocation returns the outcome to the engineer. See the [task-directory guide](site/task-dir.html) and [QA tutorial](site/tutorials/v5.html).
279
+
280
+
281
+ ### Portable runtime proof
282
+
283
+ `validateRuntimeProof(bundle, smokeTarget)` validates a retained Recipe v1 artifact package, requiring executed behavioral assertions for its declared targets. A passing command or end node alone is insufficient. QA skills use this exported function through their locked harness installation; no gateway, farm profile or run ID is required.
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { color } from "./cli-color.js";
4
+ import { renderRuntimeContext, runtimeContextSummary } from "./doctor.js";
5
+ import { recipeRuntimeDir } from "./paths.js";
6
+ import {
7
+ configPath,
8
+ declaredLibraryPathEnv,
9
+ discoverLibraries,
10
+ readConfig
11
+ } from "./review/knowledge.js";
12
+ const NO_LIBRARIES = "none registered. Learn how: docs/RECIPES.md, or run: mm-harness config set libraries.<name> <path>";
13
+ const FIXTURE_REPORT = path.join("prepare", "fixtures.json");
14
+ function collectCheckoutView(target, adapter) {
15
+ const declared = declaredLibraryPathEnv();
16
+ const libraries = discoverLibraries({
17
+ target,
18
+ config: readConfig(configPath()),
19
+ ...declared ? { env: { ...process.env, RECIPE_LIBRARY_PATH: declared.value } } : {}
20
+ }).map((library) => ({
21
+ name: library.name,
22
+ root: library.root,
23
+ source: library.source,
24
+ revision: library.revision ?? null,
25
+ recipes: countFiles(library.root, "recipes", ".json"),
26
+ checklists: countFiles(library.root, "checklists", ".md")
27
+ }));
28
+ const fixtures = readFixtureView(target);
29
+ return {
30
+ isolation: runtimeContextSummary(target, adapter),
31
+ libraries,
32
+ ...fixtures ? { fixtures } : {}
33
+ };
34
+ }
35
+ function renderCheckoutView(view) {
36
+ const out = (style, text) => color(style, text, { stream: process.stdout });
37
+ const lines = [renderRuntimeContext(view.isolation)];
38
+ if (view.libraries.length === 0) {
39
+ lines.push(`${out("label", "libraries:")} ${out("dim", NO_LIBRARIES)}`);
40
+ } else {
41
+ lines.push(`${out("label", "libraries:")} ${view.libraries.length}`);
42
+ for (const library of view.libraries) {
43
+ const pin = library.revision ? `${library.source} ${library.revision}` : library.source;
44
+ lines.push(
45
+ ` ${library.name.padEnd(22)} ${out("ok", library.root)} ${out("accent", `[${pin}]`)} ` + out("dim", `\u2014 ${library.recipes} recipes, ${library.checklists} checklists`)
46
+ );
47
+ }
48
+ }
49
+ if (view.fixtures) {
50
+ lines.push(`${out("label", "fixtures:")}`);
51
+ for (const [key, value] of Object.entries(view.fixtures)) {
52
+ const isSet = value !== null && value !== "";
53
+ lines.push(` ${key.padEnd(22)} ${isSet ? out("ok", String(value)) : out("dim", "(unset)")}`);
54
+ }
55
+ }
56
+ return lines.join("\n");
57
+ }
58
+ function countFiles(root, directory, extension) {
59
+ const base = path.join(root, directory);
60
+ if (!isDirectory(base)) return 0;
61
+ return fs.readdirSync(base, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(extension)).length;
62
+ }
63
+ function readFixtureView(target) {
64
+ const newest = fixtureReports(target).map((file) => ({ file, modified: modifiedAt(file) })).filter((entry) => entry.modified !== void 0).sort((left, right) => right.modified - left.modified)[0];
65
+ if (!newest) return void 0;
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(fs.readFileSync(newest.file, "utf8"));
69
+ } catch {
70
+ return { fixture: null, canonicalFixture: null, status: "unreadable", walletReset: null };
71
+ }
72
+ const record = parsed ?? {};
73
+ const text = (key) => typeof record[key] === "string" ? record[key] : null;
74
+ return {
75
+ fixture: text("fixture"),
76
+ canonicalFixture: text("canonicalFixture"),
77
+ status: text("status"),
78
+ walletReset: typeof record.walletReset === "boolean" ? record.walletReset : null
79
+ };
80
+ }
81
+ function fixtureReports(target) {
82
+ const reports = [path.join(target, recipeRuntimeDir(), FIXTURE_REPORT)];
83
+ const tasksRoot = path.join(target, "temp", "tasks");
84
+ for (const flow of directoryNames(tasksRoot)) {
85
+ for (const task of directoryNames(path.join(tasksRoot, flow))) {
86
+ reports.push(path.join(tasksRoot, flow, task, "artifacts", FIXTURE_REPORT));
87
+ }
88
+ }
89
+ return reports;
90
+ }
91
+ function directoryNames(root) {
92
+ if (!isDirectory(root)) return [];
93
+ return fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
94
+ }
95
+ function isDirectory(candidate) {
96
+ try {
97
+ return fs.statSync(candidate).isDirectory();
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ function modifiedAt(file) {
103
+ try {
104
+ return fs.statSync(file).mtimeMs;
105
+ } catch {
106
+ return void 0;
107
+ }
108
+ }
109
+ export {
110
+ collectCheckoutView,
111
+ renderCheckoutView
112
+ };
@@ -15,7 +15,7 @@ const SPEC = {
15
15
  { name: "config", desc: "Per-engineer locations: libraries.<name>, references.<adapter>", args: ["list", "path", "get", "set", "unset"], flags: ["--json"] },
16
16
  { name: "tutorial", desc: "Open the visual recipe tutorial", flags: ["--json", "--no-open"] },
17
17
  { name: "setup-base", desc: "Bootstrap numbered product checkouts", flags: ["--dir", "--counts", "--only", "--dry-run", "--force", "--json", "--show-config", "--reset-config", "--skip-harness-update"] },
18
- { name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast"] },
18
+ { name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast", "--watch", "--task"] },
19
19
  { name: "ports", desc: "Slot ports and runtime paths", flags: ["--json"] },
20
20
  { name: "up", desc: "Decide + run minimum work to reach ready", flags: ["--json", "--dry-run"] },
21
21
  { name: "sync", desc: "Refresh harness + canonicalize wallet fixture", flags: ["--json"] },
@@ -112,7 +112,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
112
112
  aliases: ["health", "home"],
113
113
  options: options(HELP, JSON, TARGET, DEVICE, {
114
114
  "--fast": bool(),
115
- "--all-devices": bool()
115
+ "--all-devices": bool(),
116
+ "--watch": bool(),
117
+ "--task": value()
116
118
  })
117
119
  },
118
120
  checklist: {
@@ -147,10 +149,11 @@ const PUBLIC_COMMAND_CONTRACTS = {
147
149
  minimumPositionals: 2
148
150
  },
149
151
  prepare: {
150
- usage: "mm-harness prepare [--target <checkout>] [--platform mobile|extension|core] [--mobile-platform ios|android] [--artifacts-dir <dir>] [--json]",
152
+ usage: "mm-harness prepare [--target <checkout>] [--platform mobile|extension|core] [--mobile-platform ios|android] [--clear-metro] [--artifacts-dir <dir>] [--json]",
151
153
  options: options(HELP, JSON, TARGET, {
152
154
  "--platform": value(["mobile", "extension", "core"]),
153
155
  "--mobile-platform": value(["ios", "android"]),
156
+ "--clear-metro": bool(),
154
157
  "--artifacts-dir": value()
155
158
  })
156
159
  },
@@ -312,17 +312,18 @@ function runToolCheck(input) {
312
312
  }
313
313
  function runJestChecks(input) {
314
314
  if (input.adapter !== "core" || input.files.length === 0) {
315
- return [
316
- runToolCheck({
317
- id: "jest",
318
- label: "Changed test files",
315
+ const groups2 = groupByJestConfig(input.target, input.files);
316
+ return groups2.map(
317
+ (group) => runToolCheck({
318
+ id: groups2.length === 1 || !group.layer ? "jest" : `jest-${safeId(group.layer)}`,
319
+ label: group.layer ? `Changed ${group.layer} test files` : "Changed test files",
319
320
  target: input.target,
320
321
  artifactsDir: input.artifactsDir,
321
- files: input.files,
322
+ files: group.files,
322
323
  command: executable(input.target, "jest"),
323
- args: ["--no-coverage"]
324
+ args: group.config ? ["--no-coverage", "-c", group.config] : ["--no-coverage"]
324
325
  })
325
- ];
326
+ );
326
327
  }
327
328
  const groups = /* @__PURE__ */ new Map();
328
329
  const rootFiles = [];
@@ -367,6 +368,18 @@ function runJestChecks(input) {
367
368
  }
368
369
  return results;
369
370
  }
371
+ function groupByJestConfig(target, files) {
372
+ const groups = /* @__PURE__ */ new Map();
373
+ for (const file of files) {
374
+ const layer = /\.([a-z][a-z0-9-]*)\.test\.[cm]?[jt]sx?$/u.exec(path.basename(file))?.[1];
375
+ const config = layer ? ["js", "cjs", "mjs", "ts"].map((ext) => `jest.config.${layer}.${ext}`).find((name) => fs.existsSync(path.join(target, name))) : void 0;
376
+ const key = config ?? "";
377
+ const group = groups.get(key) ?? { ...config ? { layer, config } : {}, files: [] };
378
+ group.files.push(file);
379
+ groups.set(key, group);
380
+ }
381
+ return [...groups.values()].sort((a, b) => (a.config ?? "").localeCompare(b.config ?? ""));
382
+ }
370
383
  function resolveWorkspace(target, file) {
371
384
  const root = path.resolve(target);
372
385
  let current = path.dirname(path.resolve(root, file));
@@ -555,5 +568,6 @@ function failEnvelope(input) {
555
568
  return exitCode;
556
569
  }
557
570
  export {
571
+ groupByJestConfig,
558
572
  handleCheck
559
573
  };
@@ -3,7 +3,8 @@ import { randomUUID } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { color } from "../cli-color.js";
6
- import { createDoctorReport, fixtureSummary, renderRuntimeContext, requiredDoctorCheckSummary } from "../doctor.js";
6
+ import { collectCheckoutView, renderCheckoutView } from "../checkout-view.js";
7
+ import { createDoctorReport, fixtureSummary, requiredDoctorCheckSummary } from "../doctor.js";
7
8
  import { detectAdapter, resolveRuntimeContextPath } from "../harness.js";
8
9
  import { assertAdapter, runnerDir } from "../paths.js";
9
10
  import { getAdapterSurface } from "../adapters/surface.js";
@@ -139,6 +140,7 @@ async function handleDoctor({ options }) {
139
140
  actionManifestPath,
140
141
  mobilePlatform
141
142
  );
143
+ const view = collectCheckoutView(target, adapter);
142
144
  const missingRequiredTool = result.checks.find(
143
145
  (check) => check.required && check.status === "fail" && check.id.startsWith("device-tool-") && check.userAction
144
146
  );
@@ -209,7 +211,7 @@ async function handleDoctor({ options }) {
209
211
  )?.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
210
212
  const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
211
213
  const next = doctorError?.userAction ?? discovery.deviceDiscoveryErrors?.[0]?.userAction ?? (runtime?.decision === "ready" ? void 0 : runtime?.nextAction);
212
- if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices, ...discovery, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
214
+ if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, view, orphanMetros, capture, devices, additionalReachableDevices, ...discovery, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
213
215
  else {
214
216
  const out = (style, text) => color(style, text, { stream: process.stdout });
215
217
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
@@ -230,7 +232,7 @@ async function handleDoctor({ options }) {
230
232
  } else if (runtimeProbeError) {
231
233
  console.log(`${out("err", "runtime probe failed:")} ${out("dim", runtimeProbeError)}`);
232
234
  }
233
- console.log(renderRuntimeContext(result.runtimeContext));
235
+ console.log(renderCheckoutView(view));
234
236
  if (adapter === "mobile") {
235
237
  const perps = mobilePerpsEnvironment(target);
236
238
  console.log(
@@ -35,11 +35,13 @@ function parseArgs(argv, command) {
35
35
  "categories",
36
36
  "fix",
37
37
  "force",
38
+ "clearMetro",
38
39
  "resolveOnly",
39
40
  "expectLive",
40
41
  "printReady",
41
42
  "fast",
42
43
  "allDevices",
44
+ "watch",
43
45
  "help",
44
46
  "noOpen"
45
47
  ]);
@@ -9,7 +9,7 @@ import { detectAdapter } from "../harness.js";
9
9
  import { harnessExecutable, manifestPath, recipeRuntimeDir } from "../paths.js";
10
10
  import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
11
11
  import { EXIT } from "./shared.js";
12
- const USAGE = "mm-harness prepare [--target <checkout>] [--platform mobile|extension|core] [--mobile-platform ios|android] [--artifacts-dir <dir>] [--json]";
12
+ const USAGE = "mm-harness prepare [--target <checkout>] [--platform mobile|extension|core] [--mobile-platform ios|android] [--clear-metro] [--artifacts-dir <dir>] [--json]";
13
13
  const REPORT_DIR = "prepare";
14
14
  const SKIPPED_AFTER_FAILURE = "previous step failed";
15
15
  function reportPathFor(id) {
@@ -166,6 +166,11 @@ async function handlePrepare(argv) {
166
166
  );
167
167
  const executable = harnessExecutable();
168
168
  const targetArgs = ["--target", target];
169
+ const clearMetro = optionFlag(options, "clearMetro");
170
+ if (clearMetro && platform !== "mobile") {
171
+ throw usageError(`--clear-metro is mobile only; this checkout is ${platform}
172
+ Next: ${USAGE}`);
173
+ }
169
174
  let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
170
175
  const steps = [];
171
176
  const failed = () => steps.some((step) => step.status === "fail");
@@ -189,7 +194,7 @@ async function handlePrepare(argv) {
189
194
  addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
190
195
  } else if (platform === "mobile") {
191
196
  const device = mobilePlatform ? [mobilePlatform] : [];
192
- run("launch", ["launch", ...device, "--verify", "--json", ...targetArgs]);
197
+ run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], "--verify", "--json", ...targetArgs]);
193
198
  run("fixtures", [
194
199
  "fixtures",
195
200
  "set",
@@ -0,0 +1,216 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { color } from "../cli-color.js";
4
+ import {
5
+ CHECKLIST_LABEL_LIMIT,
6
+ collectTaskView,
7
+ findTaskDir,
8
+ isTerminalStatus,
9
+ TASK_SILENT_AFTER_MS
10
+ } from "../task-view.js";
11
+ import { EXIT } from "./shared.js";
12
+ const REFRESH_MS = 2e3;
13
+ const CLEAR_SCREEN = "\x1B[2J\x1B[H";
14
+ const HIDE_CURSOR = "\x1B[?25l";
15
+ const SHOW_CURSOR = "\x1B[?25h";
16
+ async function handleStatusTaskView(options) {
17
+ const deps = {
18
+ now: () => Date.now(),
19
+ wait: (ms) => new Promise((resolve) => {
20
+ setTimeout(resolve, ms);
21
+ }),
22
+ write: (text) => process.stdout.write(text),
23
+ isTty: Boolean(process.stdout.isTTY)
24
+ };
25
+ if (!options.watch || !deps.isTty) return runStatusWatch(options, deps);
26
+ const restore = () => deps.write(SHOW_CURSOR);
27
+ const interrupt = () => {
28
+ restore();
29
+ process.exit(EXIT.ok);
30
+ };
31
+ process.on("SIGINT", interrupt);
32
+ deps.write(HIDE_CURSOR);
33
+ try {
34
+ return await runStatusWatch(options, deps);
35
+ } finally {
36
+ process.off("SIGINT", interrupt);
37
+ restore();
38
+ }
39
+ }
40
+ async function runStatusWatch(options, deps) {
41
+ const title = `mm-harness status ${options.watch ? "--watch" : "--task"}`;
42
+ let previous;
43
+ let sawInProgress = false;
44
+ for (; ; ) {
45
+ const taskDir = options.taskDir ?? findTaskDir(options.target);
46
+ const view = collectTaskView(options.target, taskDir, deps.now());
47
+ const changes = changedLines(previous, view);
48
+ if (options.json && !options.watch) {
49
+ deps.write(`${JSON.stringify(view, null, 2)}
50
+ `);
51
+ } else if (options.json) {
52
+ if (changes.length > 0) deps.write(`${JSON.stringify(view)}
53
+ `);
54
+ } else if (options.watch && !deps.isTty) {
55
+ for (const line of changes) deps.write(`${line}
56
+ `);
57
+ } else {
58
+ deps.write(`${deps.isTty && options.watch ? CLEAR_SCREEN : ""}${renderTaskView(view, title, options.taskDir !== void 0).join("\n")}
59
+ `);
60
+ }
61
+ previous = view;
62
+ const terminal = isTerminalStatus(view.signal?.status);
63
+ if (view.activity.ongoing) sawInProgress = true;
64
+ if (!options.watch || terminal && (sawInProgress || options.taskDir !== void 0)) return EXIT.ok;
65
+ await deps.wait(REFRESH_MS);
66
+ }
67
+ }
68
+ function renderTaskView(view, title, explicitTask = false) {
69
+ const out = (style, text) => color(style, text, { stream: process.stdout });
70
+ const showTask = view.activity.ongoing || explicitTask;
71
+ const lines = [joinParts([
72
+ out("label", title),
73
+ out("bold", path.basename(view.checkout.target)),
74
+ view.checkout.branch && out("info", view.checkout.branch),
75
+ view.checkout.head && out("dim", `@${view.checkout.head}`),
76
+ showTask && view.elapsedMs !== void 0 && out("dim", `${formatDuration(view.elapsedMs)} elapsed`)
77
+ ], " ")];
78
+ const isolation = view.isolation;
79
+ const device = isolation && joinParts([isolation.platform, isolation.simulator, shortUdid(isolation.simulatorUdid)], " ");
80
+ const devServer = isolation?.metroPort ?? isolation?.watcherPort ?? isolation?.devServerPort;
81
+ const runtimeLine = joinParts([
82
+ isolation?.slotId && `slot ${out("ok", isolation.slotId)}`,
83
+ device && out("ok", device),
84
+ devServer !== void 0 && `metro ${out("ok", String(devServer))}`,
85
+ isolation?.cdpPort !== void 0 && `cdp ${out("ok", String(isolation.cdpPort))}`,
86
+ isolation?.extensionId && `extension ${out("ok", isolation.extensionId)}`,
87
+ showTask && view.harness?.version && `harness ${out("info", view.harness.version)}${view.harness.sourceKind ? out("dim", ` (${view.harness.sourceKind})`) : ""}`
88
+ ]);
89
+ if (runtimeLine) lines.push(runtimeLine);
90
+ const template = showTask ? view.template : void 0;
91
+ const templateLine = joinParts([
92
+ template?.id && `template ${out("accent", template.id)}${template.sourceId ? out("dim", ` (${template.sourceId})`) : ""}`,
93
+ template?.flow && `flow ${out("info", template.flow)}`,
94
+ template?.domain && `domain ${out("info", template.domain)}`,
95
+ template?.surface && `via ${out("info", template.surface)}`
96
+ ]);
97
+ if (templateLine) lines.push(templateLine);
98
+ const taskLine = joinParts([
99
+ template?.ticket && out("bold", template.ticket),
100
+ template?.title && out("info", template.title.trim())
101
+ ], " ");
102
+ if (taskLine) lines.push(`${out("label", "task")} ${taskLine}`);
103
+ const contextLine = joinParts([
104
+ view.libraries.length > 0 && `libraries ${out("info", view.libraries.map(libraryLabel).join(" "))}`,
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")}`
107
+ ]);
108
+ if (contextLine) lines.push(contextLine);
109
+ lines.push("");
110
+ if (!view.activity.ongoing) {
111
+ lines.push(out("warn", "no ongoing task detected"));
112
+ lines.push(out("dim", idleReason(view)));
113
+ if (!view.taskDir || !explicitTask) return lines;
114
+ lines.push("");
115
+ }
116
+ let phase;
117
+ for (const row of view.checklist) {
118
+ if (row.phase && row.phase !== phase) {
119
+ const inPhase = view.checklist.filter((other) => other.phase === row.phase);
120
+ const done = inPhase.filter((other) => other.checked).length;
121
+ lines.push(out("dim", ` ${row.phase} ${done}/${inPhase.length}`));
122
+ }
123
+ phase = row.phase;
124
+ lines.push(renderRow(row, view.currentStep, out));
125
+ }
126
+ const signal = view.signal;
127
+ if (signal) {
128
+ const age = view.checklist.find((row) => row.runningMs !== void 0)?.runningMs;
129
+ lines.push("");
130
+ lines.push(joinParts([
131
+ `signal ${statusWord(out, signal.status)}`,
132
+ // SIGNAL.json records the last row ticked, not the row in progress.
133
+ signal.step && `last ticked ${out("info", stepLabel(signal.step))}`,
134
+ // A cancelled agent leaves status "running"; the age is the only honest hint.
135
+ age !== void 0 && `no update for ${out(age > TASK_SILENT_AFTER_MS ? "warn" : "dim", formatDuration(age))}`
136
+ ]));
137
+ }
138
+ return lines;
139
+ }
140
+ function idleReason(view) {
141
+ if (!view.taskDir) return "no task directory under temp/tasks";
142
+ const { reason, idleMs, branchCarriesTicket } = view.activity;
143
+ if (reason === "no-task") return `${path.basename(view.taskDir)} holds no task files yet`;
144
+ const what = reason === "finished" ? view.signal?.status ?? "finished" : "silent";
145
+ return joinParts([
146
+ `last task ${path.basename(view.taskDir)}: ${what}`,
147
+ idleMs !== void 0 && `last update ${formatDuration(idleMs)} ago`,
148
+ branchCarriesTicket === false && `checkout is on ${view.checkout.branch}, which does not carry ${view.template?.ticket}`
149
+ ], ", ");
150
+ }
151
+ function changedLines(previous, next) {
152
+ if (!previous || previous.taskDir !== next.taskDir) {
153
+ return [joinParts([
154
+ path.basename(next.checkout.target),
155
+ next.taskDir ? path.basename(next.taskDir) : "no-task-dir",
156
+ `status=${next.signal?.status ?? "unknown"}`,
157
+ `step=${next.signal?.step ? stepLabel(next.signal.step) : "none"}`
158
+ ], " ")];
159
+ }
160
+ const lines = [];
161
+ const before = new Map(previous.checklist.map((row) => [row.number, row.checked]));
162
+ for (const row of next.checklist) {
163
+ if (!row.checked || before.get(row.number) === true) continue;
164
+ lines.push(`checked ${row.number} ${row.label}${row.durationMs !== void 0 ? ` (${formatDuration(row.durationMs)})` : ""}`);
165
+ }
166
+ if (previous.signal?.step !== next.signal?.step && next.signal?.step) {
167
+ lines.push(`step ${stepLabel(next.signal.step)}`);
168
+ }
169
+ if (previous.signal?.status !== next.signal?.status) {
170
+ lines.push(`status ${next.signal?.status ?? "unknown"}`);
171
+ }
172
+ return lines;
173
+ }
174
+ function formatDuration(ms) {
175
+ const total = Math.max(0, Math.round(ms / 1e3));
176
+ const seconds = String(total % 60).padStart(2, "0");
177
+ const minutes = Math.floor(total / 60) % 60;
178
+ const hours = Math.floor(total / 3600);
179
+ if (hours === 0) return `${minutes}:${seconds}`;
180
+ return `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`;
181
+ }
182
+ function renderRow(row, currentStep, out) {
183
+ const marker = row.checked ? out("ok", "\u2713") : row.number === currentStep ? out("accent", "\u25B6") : out("dim", "\xB7");
184
+ const timing = row.durationMs !== void 0 ? formatDuration(row.durationMs) : row.runningMs !== void 0 ? `${formatDuration(row.runningMs)} \u2026` : "";
185
+ const number = String(row.number).padStart(2);
186
+ const label = timing ? row.label.padEnd(CHECKLIST_LABEL_LIMIT) : row.label;
187
+ const text = row.checked ? out("dim", label) : out("bold", label);
188
+ return ` ${marker} ${out("dim", number)} ${text}${timing ? ` ${out("dim", timing)}` : ""}`;
189
+ }
190
+ function libraryLabel(library) {
191
+ const home = os.homedir();
192
+ const location = home && library.path.startsWith(`${home}/`) ? `~${library.path.slice(home.length)}` : library.path;
193
+ return library.name ? `${library.name}=${location}` : location;
194
+ }
195
+ function statusWord(out, status) {
196
+ if (!status) return out("dim", "unknown");
197
+ const style = ["pass", "ready", "complete", "no-change"].includes(status) ? "ok" : ["running", "started"].includes(status) ? "accent" : "err";
198
+ return out(style, status);
199
+ }
200
+ function stepLabel(step) {
201
+ return /^\s*(\d+)\./u.exec(step)?.[1] ?? step.trim();
202
+ }
203
+ function shortUdid(udid) {
204
+ if (!udid) return void 0;
205
+ return `(${udid.split("-")[0]})`;
206
+ }
207
+ function joinParts(parts, separator = " \xB7 ") {
208
+ return parts.filter((part) => Boolean(part)).join(separator);
209
+ }
210
+ export {
211
+ changedLines,
212
+ formatDuration,
213
+ handleStatusTaskView,
214
+ renderTaskView,
215
+ runStatusWatch
216
+ };
@@ -1,4 +1,7 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { color } from "../cli-color.js";
4
+ import { collectCheckoutView, renderCheckoutView } from "../checkout-view.js";
2
5
  import { detectAdapter } from "../harness.js";
3
6
  import { assertAdapter } from "../paths.js";
4
7
  import { getAdapterSurface } from "../adapters/surface.js";
@@ -15,10 +18,20 @@ import {
15
18
  renderMobileLiveBlock
16
19
  } from "./mobile-device-view.js";
17
20
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
18
- import { optionFlag, targetPath } from "./parse-args.js";
21
+ import { handleStatusTaskView } from "./status-watch.js";
22
+ import { optionFlag, optionString, targetPath } from "./parse-args.js";
19
23
  async function handleStatus({ options }) {
20
24
  const target = targetPath(options);
21
25
  const json = optionFlag(options, "json");
26
+ const watch = optionFlag(options, "watch");
27
+ const taskDir = optionString(options, "task");
28
+ if (watch || taskDir) {
29
+ const resolvedTask = taskDir ? path.resolve(taskDir) : void 0;
30
+ if (resolvedTask && !(fs.existsSync(resolvedTask) && fs.statSync(resolvedTask).isDirectory())) {
31
+ return usageOut(json, "status", `--task is not a directory: ${resolvedTask}`, "Pass a task directory under temp/tasks, or omit --task to follow the newest one.");
32
+ }
33
+ return handleStatusTaskView({ target, taskDir: resolvedTask, json, watch });
34
+ }
22
35
  const fast = optionFlag(options, "fast");
23
36
  const allDevices = optionFlag(options, "allDevices");
24
37
  const adapter = detectAdapter(target);
@@ -26,6 +39,7 @@ async function handleStatus({ options }) {
26
39
  return usageOut(json, "status", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
27
40
  }
28
41
  assertAdapter(adapter);
42
+ const view = collectCheckoutView(target, adapter);
29
43
  const surface = getAdapterSurface(adapter);
30
44
  surface.resolveSlotPorts(target);
31
45
  if (adapter === "mobile") {
@@ -50,6 +64,7 @@ async function handleStatus({ options }) {
50
64
  command: "status",
51
65
  adapter,
52
66
  target,
67
+ view,
53
68
  devices: liveView.devicesWithLive,
54
69
  ...discovery,
55
70
  ...liveView.additionalReachableDevices.length > 0 ? { additionalReachableDevices: liveView.additionalReachableDevices } : {},
@@ -69,6 +84,7 @@ async function handleStatus({ options }) {
69
84
  command: "status",
70
85
  adapter,
71
86
  target,
87
+ view,
72
88
  devices,
73
89
  ...discovery,
74
90
  ...runtime ? { runtime } : {},
@@ -83,6 +99,7 @@ async function handleStatus({ options }) {
83
99
  }
84
100
  const out = (style, text) => color(style, text, { stream: process.stdout });
85
101
  console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
102
+ console.log(renderCheckoutView(view));
86
103
  if (deviceView) renderMobileDeviceList(deviceView, out);
87
104
  if (!fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
88
105
  const liveView = await mobileDeviceLiveView(target, deviceView);
@@ -4,13 +4,14 @@ import { detectAdapter } from "../harness.js";
4
4
  import { harnessExecutable } from "../paths.js";
5
5
  import { EXIT } from "./shared.js";
6
6
  const require2 = createRequire(import.meta.url);
7
- const USAGE = "mm-harness task init <task-dir> --flow f --run-mode m --template id --title t [options]";
7
+ const USAGE = "mm-harness task init <task-dir> --flow f --template id --title t [options]";
8
8
  function hasOption(argv, name) {
9
9
  return argv.some((arg) => arg === name || arg.startsWith(`${name}=`));
10
10
  }
11
11
  function metamaskDefaults(argv) {
12
12
  const defaults = [];
13
13
  if (!hasOption(argv, "--surface")) defaults.push("--surface", "skill");
14
+ if (!hasOption(argv, "--run-mode")) defaults.push("--run-mode", "autonomous");
14
15
  if (!hasOption(argv, "--mark-command")) {
15
16
  defaults.push("--mark-command", `${harnessExecutable()} checklist mark`);
16
17
  }
@@ -8,6 +8,9 @@ const PACKAGE_NAME = "@deeeed/metamask-harness";
8
8
  const NUDGE_INTERVAL_MS = 24 * 60 * 60 * 1e3;
9
9
  const NUDGE_FETCH_TIMEOUT_MS = 2e3;
10
10
  const REGISTRY_DIST_TAGS_ENDPOINT = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE_NAME)}/dist-tags`;
11
+ function registryCwd() {
12
+ return os.homedir();
13
+ }
11
14
  function currentVersion() {
12
15
  try {
13
16
  const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, "package.json"), "utf8"));
@@ -38,6 +41,7 @@ function isNewer(candidate, base) {
38
41
  }
39
42
  function fetchLatest(timeoutMs) {
40
43
  const result = spawnSync("npm", ["view", PACKAGE_NAME, "dist-tags.latest"], {
44
+ cwd: registryCwd(),
41
45
  encoding: "utf8",
42
46
  timeout: timeoutMs,
43
47
  stdio: ["ignore", "pipe", "pipe"]
@@ -119,6 +123,7 @@ async function handleUpdate(argv) {
119
123
  }
120
124
  const INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
121
125
  const install = spawnSync("npm", ["i", "-g", `${PACKAGE_NAME}@latest`], {
126
+ cwd: registryCwd(),
122
127
  encoding: "utf8",
123
128
  timeout: INSTALL_TIMEOUT_MS,
124
129
  stdio: ["ignore", "pipe", "pipe"]
@@ -227,5 +232,6 @@ export {
227
232
  isNewer,
228
233
  maybeNudge,
229
234
  nudgeDisabled,
230
- nudgeLine
235
+ nudgeLine,
236
+ registryCwd
231
237
  };
package/dist/doctor.js CHANGED
@@ -95,8 +95,14 @@ function fixtureFileSummary(file, adapter, reportedPath = file) {
95
95
  return { status: "invalid", path: reportedPath, error: error instanceof Error ? error.message : String(error) };
96
96
  }
97
97
  }
98
+ const DEV_SERVER_PORT_ENV = ["WATCHER_PORT", "METRO_PORT", "RECIPE_WATCHER_PORT"];
98
99
  const RUNTIME_CONTEXT_FIELDS = [
99
100
  { key: "slotId", envVars: ["RECIPE_SLOT_ID"], envVar: "RECIPE_SLOT_ID", customize: "doctor --fix creates local identity; Farmslot may override", adapters: ["mobile", "extension", "core"] },
101
+ { key: "watcherPort", envVars: DEV_SERVER_PORT_ENV, envVar: "WATCHER_PORT", customize: "doctor --fix claims one; --watcher-port overrides", adapters: ["mobile"] },
102
+ { key: "metroPort", envVars: DEV_SERVER_PORT_ENV, envVar: "WATCHER_PORT", customize: "mirrors watcherPort; --metro-port is its alias", adapters: ["mobile"] },
103
+ { key: "devServerPort", envVars: DEV_SERVER_PORT_ENV, envVar: "WATCHER_PORT", customize: "mirrors watcherPort for the adapter dev server", adapters: ["mobile", "extension"] },
104
+ { key: "simulator", envVars: ["IOS_SIMULATOR"], envVar: "IOS_SIMULATOR", customize: "--device pins the matched simulator into the context", adapters: ["mobile"] },
105
+ { key: "simulatorUdid", envVars: ["SIM_UDID"], envVar: "SIM_UDID", customize: "--device pins the matched udid into the context", adapters: ["mobile"] },
100
106
  { key: "extensionId", envVars: ["RECIPE_HARNESS_EXTENSION_ID"], envVar: "RECIPE_HARNESS_EXTENSION_ID", customize: "resolved from the built extension", adapters: ["extension"] },
101
107
  { key: "cdpPort", envVars: ["RECIPE_CDP_PORT", "CDP_PORT"], envVar: "CDP_PORT", customize: "doctor --fix claims one; --cdp-port overrides", adapters: ["extension"] }
102
108
  ];
@@ -108,9 +114,12 @@ function runtimeContextSummary(target, adapter) {
108
114
  const fields = {};
109
115
  const specs = adapter ? RUNTIME_CONTEXT_FIELDS.filter((spec) => spec.adapters.includes(adapter)) : RUNTIME_CONTEXT_FIELDS;
110
116
  for (const spec of specs) {
111
- const envValue = spec.envVars.map((name) => process.env[name]).find((value) => value !== void 0 && value !== "");
112
- if (envValue !== void 0) {
113
- fields[spec.key] = { value: envValue, source: "env", envVar: spec.envVar, customize: spec.customize };
117
+ const envName = spec.envVars.find((name) => {
118
+ const value = process.env[name];
119
+ return value !== void 0 && value !== "";
120
+ });
121
+ if (envName !== void 0) {
122
+ fields[spec.key] = { value: process.env[envName], source: "env", envVar: envName, customize: spec.customize };
114
123
  continue;
115
124
  }
116
125
  const fileValue = fileExists ? readRuntimeContextField(contextPath, spec.key) : void 0;
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  walletFixturePath
10
10
  } from "./paths.js";
11
11
  import { captureActiveRecipeRecordingSnapshot } from "./run-recording.js";
12
+ import { validateRuntimeProof } from "./runtime-proof.js";
12
13
  export {
13
14
  captureActiveRecipeRecordingSnapshot,
14
15
  createDoctorReport,
@@ -21,5 +22,6 @@ export {
21
22
  loadActionManifest,
22
23
  recipeHarnessPath,
23
24
  validateManifest,
25
+ validateRuntimeProof,
24
26
  walletFixturePath
25
27
  };
@@ -13,6 +13,7 @@ import { handleTutorial } from "./commands/tutorial.js";
13
13
  import { handleSetupBase } from "./commands/setup-base.js";
14
14
  import { getAdapterSurface } from "./adapters/surface.js";
15
15
  import { detectAdapter } from "./harness.js";
16
+ import { configPath, hydrateLibraryPathEnv, readConfig } from "./review/knowledge.js";
16
17
  import {
17
18
  PUBLIC_COMMAND_CONTRACTS,
18
19
  publicCommandNames,
@@ -177,9 +178,23 @@ Example:
177
178
  to run, and (mobile only) the connected devices with live app state. Aliases: health, home.
178
179
 
179
180
  --target <path> Checkout path (default: cwd)
180
- --json Machine-readable envelope { adapter, target, devices[], next }
181
+ --json Machine-readable envelope { adapter, target, view, devices[], next }
181
182
  --fast Skip all live-state probes (instant output, safe for scripts)
182
183
  --all-devices Mobile only: show every connected device instead of the slot-scoped target
184
+ --watch Live read-only view of the task this checkout is working through (refreshes every 2s, Ctrl-C to leave)
185
+ --task <dir> Task directory for the view (default: the most recently marked one under temp/tasks/)
186
+
187
+ The task view reads task files only \u2014 no probe, no launch, no write \u2014 and shows
188
+ the same facts whether a skill or a control plane started the run: slot and
189
+ device isolation, template and flow, fixture and sandbox readiness, then the
190
+ checklist with the time each row took and how long the current row has been
191
+ running. --task without --watch prints it once. With --json, --watch emits one
192
+ object per change (newline-delimited) instead of redrawing.
193
+
194
+ The checkout view (also under "view" in --json, and printed by doctor) is what
195
+ this checkout is bound to: the runtime-context fields that keep it off other
196
+ slots' ports and devices, the recipe libraries this machine resolves, and the
197
+ wallet fixture it last applied.
183
198
 
184
199
  Human output: static device list prints immediately; live-state lines append
185
200
  after the ~2s probe window (screen, wallet, account, fixture per device).
@@ -202,7 +217,8 @@ Example:
202
217
  Example:
203
218
  mm-harness status
204
219
  mm-harness status --json
205
- mm-harness status --fast`
220
+ mm-harness status --fast
221
+ mm-harness status --watch`
206
222
  },
207
223
  {
208
224
  name: "checklist",
@@ -244,6 +260,7 @@ Example:
244
260
  --target <path> Checkout path (default: cwd)
245
261
  --platform <mobile|extension|core> Force the platform (auto-detected inside a checkout)
246
262
  --mobile-platform <ios|android> Mobile target when status cannot decide it
263
+ --clear-metro Mobile only: clear Metro's transform cache in the launch step
247
264
  --artifacts-dir <dir> Where sandbox.json and the per-step reports land
248
265
  (default: <target>/temp/recipe/runtime)
249
266
  --json Print the readiness record
@@ -266,8 +283,8 @@ Example:
266
283
  mm-harness checklist mark, and inputs/ (handoff.json, worker-terminal-contract.json).
267
284
  checklist-target.json is NOT written \u2014 absent means CHECKLIST.md + SIGNAL.json.
268
285
 
269
- Applied when you do not pass them: --surface skill, --mark-command for this
270
- executable, and --platform from the detected checkout.
286
+ Applied when you do not pass them: --surface skill, --run-mode autonomous,
287
+ --mark-command for this executable, and --platform from the detected checkout.
271
288
 
272
289
  Task:
273
290
  --title <t> Task title (required)
@@ -277,7 +294,7 @@ Task:
277
294
 
278
295
  Selection (same catalog as execution-template):
279
296
  --flow <flow> Exact flow (required)
280
- --run-mode <mode> autonomous|interactive|validation (required)
297
+ --run-mode <mode> autonomous|interactive|validation (default autonomous; set by a control plane)
281
298
  --platform <platform> Exact platform
282
299
  --template <id> Exact template id (required)
283
300
  --domain <domain> Exact domain
@@ -483,8 +500,9 @@ Example:
483
500
  example: "mm-harness doctor",
484
501
  helpText: `mm-harness doctor [flags]
485
502
 
486
- Readiness check for a checkout \u2014 no app launch. Reports the fixture and runtime-context
487
- sections so there is no hunting for files.
503
+ Readiness check for a checkout \u2014 no app launch. Reports the same checkout view
504
+ status does \u2014 runtime-context isolation, resolved recipe libraries, and the last
505
+ applied wallet fixture \u2014 so there is no hunting for files.
488
506
 
489
507
  --fix Repair the overlay/runtime-context WITHOUT launching (no fixture reseed); --json adds fixed[]/failed[]
490
508
  --expect-live Exit-coded liveness gate only (human verdict on stderr). Use without --print-ready when callers need pass/fail without a Farmslot indicator line
@@ -1027,6 +1045,12 @@ const NUDGE_SKIP = ["help", "tutorial", "setup-base", "update", "completions", "
1027
1045
  if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
1028
1046
  maybeNudge();
1029
1047
  }
1048
+ function targetFromArgv(argv) {
1049
+ const index = argv.indexOf("--target");
1050
+ const separate = index !== -1 ? argv[index + 1] : void 0;
1051
+ const inline = argv.find((argument) => argument.startsWith("--target="))?.slice("--target=".length);
1052
+ return path.resolve(separate ?? inline ?? process.cwd());
1053
+ }
1030
1054
  function hasPassthroughHelp(argv) {
1031
1055
  const divider = argv.indexOf("--");
1032
1056
  if (divider === -1) return false;
@@ -1102,6 +1126,7 @@ if (!preflightBypass.has(rawArgv[0] ?? "")) {
1102
1126
  );
1103
1127
  }
1104
1128
  }
1129
+ await hydrateLibraryPathEnv({ target: targetFromArgv(rawArgv), config: readConfig(configPath()) });
1105
1130
  if (hasPassthroughHelp(rawArgv)) {
1106
1131
  process.exit(await delegate(rawArgv));
1107
1132
  }
@@ -140,9 +140,13 @@ function fetchLibrary(name, target) {
140
140
  excludeCacheDir(path.resolve(target));
141
141
  return withRevision({ name, root, source: "cache" });
142
142
  }
143
+ let declaredLibraryPath;
144
+ function declaredLibraryPathFor(env) {
145
+ return env === process.env && declaredLibraryPath ? declaredLibraryPath.value : env.RECIPE_LIBRARY_PATH;
146
+ }
143
147
  function resolveLibrary(name, options) {
144
148
  const env = options.env ?? process.env;
145
- const fromEnv = parseLibraryPathEnv(env.RECIPE_LIBRARY_PATH).find(
149
+ const fromEnv = parseLibraryPathEnv(declaredLibraryPathFor(env)).find(
146
150
  (entry) => isDir(entry.root) && (entry.name === name || entry.name === void 0 && loadOwnedPaths(entry.root)?.domain === name)
147
151
  );
148
152
  if (fromEnv) return withRevision({ name, root: fromEnv.root, source: "env" });
@@ -171,7 +175,7 @@ function discoverLibraries(context) {
171
175
  seenNames.add(libraryName);
172
176
  found.push(withRevision({ name: libraryName, root: resolved, source }));
173
177
  };
174
- for (const entry of parseLibraryPathEnv(env.RECIPE_LIBRARY_PATH)) add(entry.name, entry.root, "env");
178
+ for (const entry of parseLibraryPathEnv(declaredLibraryPathFor(env))) add(entry.name, entry.root, "env");
175
179
  for (const [name, root] of Object.entries(context.config?.libraries ?? {})) add(name, root, "config");
176
180
  const siblingNames = /* @__PURE__ */ new Set();
177
181
  for (const root of siblingRoots(context)) {
@@ -188,6 +192,30 @@ function discoverLibraries(context) {
188
192
  }
189
193
  return found;
190
194
  }
195
+ function declaredLibraryPathEnv() {
196
+ return declaredLibraryPath;
197
+ }
198
+ async function hydrateLibraryPathEnv(context) {
199
+ const env = context.env ?? process.env;
200
+ const existing = env.RECIPE_LIBRARY_PATH ?? "";
201
+ if (env === process.env) declaredLibraryPath = { value: existing || void 0 };
202
+ const declared = new Set(
203
+ parseLibraryPathEnv(existing).map(
204
+ (entry) => entry.name ?? loadOwnedPaths(entry.root)?.domain ?? path.basename(entry.root)
205
+ )
206
+ );
207
+ const discovered = discoverLibraries(context).filter(
208
+ (library) => library.source !== "env" && !declared.has(library.name)
209
+ );
210
+ if (discovered.length === 0) return;
211
+ const entries = discovered.map((library) => `${library.name}=${library.root}`);
212
+ if (!existing) {
213
+ const { personalRecipeLibraryRoot } = await import("@farmslot/recipe-harness");
214
+ const personal = personalRecipeLibraryRoot(env);
215
+ if (isDir(personal)) entries.push(`personal=${personal}`);
216
+ }
217
+ env.RECIPE_LIBRARY_PATH = existing ? `${existing}:${entries.join(":")}` : entries.join(":");
218
+ }
191
219
  function resolveReference(adapter, context) {
192
220
  const env = context.env ?? process.env;
193
221
  const fromEnv = env[referenceEnvName(adapter)];
@@ -385,7 +413,8 @@ const BASE_REVIEW_STEPS = [
385
413
  "Tests: each behavior change has a test at its best-fit layer (view, integration, unit); no mocked-away logic that hides the change under test.",
386
414
  "Safety: no secrets, no widened permissions or network surface, no logging of user data; new dependencies justified.",
387
415
  "Product wiring: i18n keys, telemetry events, feature flags, and error messages follow the repo conventions for the touched area.",
388
- "Scope and hygiene: diff is minimal for the ask, no unrelated refactors or reformatting, commit and PR title follow conventional commits."
416
+ "Scope and hygiene: diff is minimal for the ask, no unrelated refactors or reformatting, commit and PR title follow conventional commits.",
417
+ "Signal over noise: comments say why in a line or two and never restate the code; no ticket keys, PR numbers or tool mentions in source; no leftover TODOs, debug logs, commented-out code or unused helpers; no catch that swallows, no abstraction with one caller, no padded tests or PR text. Prefer deleting to rewording."
389
418
  ];
390
419
  const VERDICT_STEP = "Post the verdict: APPROVE or REQUEST CHANGES, findings ordered by severity with file:line, nits listed separately; state what was not checked and why.";
391
420
  function parityAdapter(adapter) {
@@ -489,12 +518,14 @@ export {
489
518
  changedFiles,
490
519
  classifyDomain,
491
520
  configPath,
521
+ declaredLibraryPathEnv,
492
522
  defaultBaseRef,
493
523
  discoverLibraries,
494
524
  excludeCacheDir,
495
525
  fetchLibrary,
496
526
  gitExcludeFile,
497
527
  globToRegExp,
528
+ hydrateLibraryPathEnv,
498
529
  libraryCachePath,
499
530
  loadDomainKnowledge,
500
531
  loadOwnedPaths,
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { normalizeRecipeRef, validateRecipeArtifactPackage } from "@farmslot/protocol";
3
+ function validateRuntimeProof(bundle, smokeTarget) {
4
+ const checked = validateRecipeArtifactPackage(bundle);
5
+ assert.equal(checked.status, "valid", JSON.stringify(checked.findings));
6
+ const { recipe, summary, trace, manifest, recipeResolution, resolvedRecipes } = bundle;
7
+ assert.equal(summary.status, "pass", "Runtime recipe did not pass");
8
+ const entries = Array.isArray(trace) ? trace : trace.entries;
9
+ assert(entries.length && entries.every((entry) => entry.ok === true), "Runtime trace contains failed or unknown actions");
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
+ 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);
26
+ 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
+ function proves(id) {
30
+ const node = nodes.get(id);
31
+ const current = executed.get(id);
32
+ if (!current || current.entry.action !== node.action) return false;
33
+ if (node.action === "call") return [...nodes.keys()].some((child) => child.startsWith(`${id}/`) && proves(child));
34
+ if (!assertions.has(node.action) || !current.entry.output || typeof current.entry.output !== "object") return false;
35
+ 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 ?? "");
38
+ const prior = executed.get(source);
39
+ 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));
41
+ }
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));
43
+ return typeof node.path === "string" && manifest.artifacts.some((artifact) => artifact.path === node.path && artifact.category === "proof");
44
+ }
45
+ assert(recipe.proofTargets?.length, "Runtime recipe has no behavioral proof targets");
46
+ for (const target of recipe.proofTargets) {
47
+ const linked = [...nodes].filter(([, node]) => node.proves?.includes(target.id));
48
+ assert(linked.some(([id]) => proves(id)), `Proof target ${target.id} has no executed runtime assertion`);
49
+ }
50
+ if (smokeTarget) assert(recipe.proofTargets.some((target) => target.id === smokeTarget), "Smoke target is absent from recipe");
51
+ }
52
+ export {
53
+ validateRuntimeProof
54
+ };
@@ -0,0 +1,247 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveRuntimeContextPath } from "./harness.js";
5
+ import { parseLibraryPathEnv } from "./review/knowledge.js";
6
+ const TASK_SEARCH_DEPTH = 3;
7
+ const CHECKLIST_LABEL_LIMIT = 70;
8
+ const CHECKLIST_ROW = /^\s*-\s\[([ xX])\]\s*(\d+)\.\s*(.*)$/u;
9
+ const CHECKLIST_PHASE = /^##\s+(.+?)\s*$/u;
10
+ const TASK_SILENT_AFTER_MS = 10 * 60 * 1e3;
11
+ function findTaskDir(target) {
12
+ const root = path.join(target, "temp", "tasks");
13
+ let bySignal;
14
+ let byChecklist;
15
+ const consider = (best, dir, file) => {
16
+ const stat = fs.statSync(path.join(dir, file), { throwIfNoEntry: false });
17
+ if (!stat?.isFile()) return best;
18
+ return best && best.mtimeMs >= stat.mtimeMs ? best : { dir, mtimeMs: stat.mtimeMs };
19
+ };
20
+ const visit = (dir, depth) => {
21
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
22
+ if (!entry.isDirectory()) continue;
23
+ const child = path.join(dir, entry.name);
24
+ bySignal = consider(bySignal, child, "SIGNAL.json");
25
+ byChecklist = consider(byChecklist, child, "CHECKLIST.md");
26
+ if (depth < TASK_SEARCH_DEPTH) visit(child, depth + 1);
27
+ }
28
+ };
29
+ if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) return void 0;
30
+ visit(root, 1);
31
+ return (bySignal ?? byChecklist)?.dir;
32
+ }
33
+ function collectTaskView(target, taskDir, now) {
34
+ const base = {
35
+ checkout: readCheckout(target),
36
+ isolation: readIsolation(target),
37
+ libraries: parseLibraryPathEnv(process.env.RECIPE_LIBRARY_PATH).map(
38
+ ({ name, root }) => ({ ...name ? { name } : {}, path: root })
39
+ )
40
+ };
41
+ if (!taskDir) return { ...base, checklist: [], activity: { ongoing: false, reason: "no-task" } };
42
+ const handoff = readJsonObject(path.join(taskDir, "inputs", "handoff.json"));
43
+ const provenance = readJsonObject(path.join(taskDir, "artifacts", "harness-provenance.json"));
44
+ const fixtures = readJsonObject(path.join(taskDir, "artifacts", "prepare", "fixtures.json"));
45
+ const sandbox = readJsonObject(path.join(taskDir, "artifacts", "sandbox.json"));
46
+ const signal = readJsonObject(path.join(taskDir, "SIGNAL.json"));
47
+ const checklist = readChecklistRows(path.join(taskDir, "CHECKLIST.md"));
48
+ const template = asObject(handoff?.executionTemplate);
49
+ const task = asObject(handoff?.task);
50
+ const startedAt = parseTime(pickString(handoff, "startedAt"));
51
+ const timing = applyTiming(checklist, signal, startedAt, now);
52
+ const ticket = pickString(task, "ticket");
53
+ const activity = taskActivity({
54
+ status: pickString(signal, "status"),
55
+ lastWrites: [startedAt, parseTime(pickString(signal, "timestamp")), ...readMarkTimes(signal).values()],
56
+ branch: base.checkout.branch,
57
+ ticket,
58
+ now
59
+ });
60
+ return {
61
+ ...base,
62
+ taskDir,
63
+ harness: section({
64
+ version: pickString(asObject(provenance?.package), "version"),
65
+ sourceKind: pickString(asObject(provenance?.source), "kind")
66
+ }),
67
+ template: section({
68
+ id: pickString(template, "id"),
69
+ sourceId: pickString(template, "sourceId"),
70
+ flow: pickString(handoff, "flow"),
71
+ domain: pickString(handoff, "domain"),
72
+ surface: pickString(handoff, "surface"),
73
+ title: pickString(task, "title"),
74
+ ticket
75
+ }),
76
+ fixture: section({
77
+ fixture: pickString(fixtures, "fixture"),
78
+ canonicalFixture: pickString(fixtures, "canonicalFixture"),
79
+ status: pickString(fixtures, "status"),
80
+ walletReset: pickBoolean(fixtures, "walletReset")
81
+ }),
82
+ sandbox: section({ ready: pickBoolean(sandbox, "ready") }),
83
+ checklist,
84
+ signal: section({
85
+ status: pickString(signal, "status"),
86
+ step: pickString(signal, "step"),
87
+ timestamp: pickString(signal, "timestamp"),
88
+ attemptId: pickString(signal, "attemptId")
89
+ }),
90
+ ...timing,
91
+ activity
92
+ };
93
+ }
94
+ function taskActivity(input) {
95
+ const writes = input.lastWrites.filter((value) => value !== void 0);
96
+ const idleMs = writes.length > 0 ? input.now - Math.max(...writes) : void 0;
97
+ const branchCarriesTicket = input.ticket && input.branch ? input.branch.includes(input.ticket) : void 0;
98
+ const extra = {
99
+ ...idleMs !== void 0 ? { idleMs } : {},
100
+ ...branchCarriesTicket !== void 0 ? { branchCarriesTicket } : {}
101
+ };
102
+ if (input.status === void 0 && writes.length === 0) return { ongoing: false, reason: "no-task", ...extra };
103
+ if (isTerminalStatus(input.status)) return { ongoing: false, reason: "finished", ...extra };
104
+ if (idleMs !== void 0 && idleMs > TASK_SILENT_AFTER_MS) return { ongoing: false, reason: "silent", ...extra };
105
+ return { ongoing: true, ...extra };
106
+ }
107
+ function isTerminalStatus(status) {
108
+ return status !== void 0 && status !== "running" && status !== "started";
109
+ }
110
+ function applyTiming(rows, signal, startedAt, now) {
111
+ const marks = readMarkTimes(signal);
112
+ let previousAt = startedAt;
113
+ let lastAt;
114
+ let firstAt;
115
+ for (const row of rows) {
116
+ const checkedAt = marks.get(row.number);
117
+ if (checkedAt === void 0) continue;
118
+ if (previousAt !== void 0) row.durationMs = checkedAt - previousAt;
119
+ if (firstAt === void 0) firstAt = checkedAt;
120
+ previousAt = checkedAt;
121
+ lastAt = checkedAt;
122
+ }
123
+ const current = rows.find((row) => !row.checked);
124
+ if (current && lastAt !== void 0) current.runningMs = now - lastAt;
125
+ const origin = startedAt ?? firstAt;
126
+ return {
127
+ ...current ? { currentStep: current.number } : {},
128
+ ...origin !== void 0 ? { elapsedMs: now - origin } : {}
129
+ };
130
+ }
131
+ function readMarkTimes(signal) {
132
+ const marks = /* @__PURE__ */ new Map();
133
+ const events = asObject(signal?.checklistTiming)?.events;
134
+ if (!Array.isArray(events)) return marks;
135
+ for (const entry of events) {
136
+ const event = asObject(entry);
137
+ const stepNumber = pickNumber(event, "stepNumber");
138
+ const checkedAt = parseTime(pickString(event, "checkedAt"));
139
+ if (stepNumber === void 0 || checkedAt === void 0) continue;
140
+ marks.set(stepNumber, checkedAt);
141
+ }
142
+ return marks;
143
+ }
144
+ function readChecklistRows(file) {
145
+ const text = readTextFile(file);
146
+ if (text === void 0) return [];
147
+ const rows = [];
148
+ let phase;
149
+ for (const line of text.split("\n")) {
150
+ const heading = CHECKLIST_PHASE.exec(line);
151
+ if (heading) {
152
+ phase = heading[1];
153
+ continue;
154
+ }
155
+ const match = CHECKLIST_ROW.exec(line);
156
+ if (!match) continue;
157
+ rows.push({
158
+ ...phase ? { phase } : {},
159
+ number: Number(match[2]),
160
+ label: trimLabel(match[3]),
161
+ checked: match[1].toLowerCase() === "x"
162
+ });
163
+ }
164
+ return rows;
165
+ }
166
+ function trimLabel(text) {
167
+ const plain = text.replace(/\*\*/gu, "").replace(/\s+/gu, " ").trim();
168
+ if (plain.length <= CHECKLIST_LABEL_LIMIT) return plain;
169
+ return `${plain.slice(0, CHECKLIST_LABEL_LIMIT).trimEnd()}\u2026`;
170
+ }
171
+ function readCheckout(target) {
172
+ const branch = gitRef(target, "--abbrev-ref");
173
+ const head = gitRef(target, "--short");
174
+ return { target, ...branch ? { branch } : {}, ...head ? { head } : {} };
175
+ }
176
+ function gitRef(target, mode) {
177
+ const result = spawnSync("git", ["-C", target, "rev-parse", mode, "HEAD"], { encoding: "utf8" });
178
+ if (result.status !== 0) return void 0;
179
+ return result.stdout.trim() || void 0;
180
+ }
181
+ function readIsolation(target) {
182
+ const context = readJsonObject(resolveRuntimeContextPath(target));
183
+ if (!context) return void 0;
184
+ return section({
185
+ slotId: pickString(context, "slotId"),
186
+ platform: pickString(context, "platform"),
187
+ metroPort: pickNumber(context, "metroPort"),
188
+ watcherPort: pickNumber(context, "watcherPort"),
189
+ devServerPort: pickNumber(context, "devServerPort"),
190
+ simulator: pickString(context, "simulator"),
191
+ simulatorUdid: pickString(context, "simulatorUdid"),
192
+ cdpPort: pickNumber(context, "cdpPort"),
193
+ extensionId: pickString(context, "extensionId")
194
+ });
195
+ }
196
+ function readTextFile(file) {
197
+ try {
198
+ return fs.readFileSync(file, "utf8");
199
+ } catch (error) {
200
+ if (error.code === "ENOENT") return void 0;
201
+ throw error;
202
+ }
203
+ }
204
+ function readJsonObject(file) {
205
+ const raw = readTextFile(file);
206
+ if (raw === void 0) return void 0;
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(raw);
210
+ } catch (error) {
211
+ throw new Error(`${file} is not valid JSON: ${error.message}`);
212
+ }
213
+ const object = asObject(parsed);
214
+ if (!object) throw new Error(`${file} must contain a JSON object.`);
215
+ return object;
216
+ }
217
+ function asObject(value) {
218
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
219
+ return value;
220
+ }
221
+ function pickString(source, key) {
222
+ const value = source?.[key];
223
+ return typeof value === "string" && value !== "" ? value : void 0;
224
+ }
225
+ function pickNumber(source, key) {
226
+ const value = source?.[key];
227
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
228
+ }
229
+ function pickBoolean(source, key) {
230
+ const value = source?.[key];
231
+ return typeof value === "boolean" ? value : void 0;
232
+ }
233
+ function parseTime(value) {
234
+ if (value === void 0) return void 0;
235
+ const parsed = Date.parse(value);
236
+ return Number.isFinite(parsed) ? parsed : void 0;
237
+ }
238
+ function section(value) {
239
+ return Object.values(value).some((entry) => entry !== void 0) ? value : void 0;
240
+ }
241
+ export {
242
+ CHECKLIST_LABEL_LIMIT,
243
+ TASK_SILENT_AFTER_MS,
244
+ collectTaskView,
245
+ findTaskDir,
246
+ isTerminalStatus
247
+ };
package/docs/RECIPES.md CHANGED
@@ -450,6 +450,12 @@ mm-harness run onboarding.smoke --describe
450
450
  mm-harness run onboarding.smoke --plan
451
451
  ```
452
452
 
453
+ A library registered once with `mm-harness config set libraries.wallet <path>`,
454
+ or checked out beside the product checkout, is reachable without exporting
455
+ anything: every location the harness resolves reaches `run`, `actions`, and
456
+ template discovery, and `mm-harness status` lists them. Exported entries keep
457
+ their precedence and order in front of those.
458
+
453
459
  Use `--library wallet=/path/to/library` for one command. The alias is the
454
460
  source name; without one, the directory name is used. Resolution follows the
455
461
  explicit library order, then bundled MetaMask. Adapter-specific variants are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.51.9",
3
+ "version": "0.53.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -391,7 +391,7 @@ async function checkBehavior(browserEndpoint, port, pages) {
391
391
  const expected = [
392
392
  ['npm i -g @deeeed/metamask-harness@latest', copied.setup],
393
393
  ['yarn skills', copied.setup],
394
- ['/mms-recipe-pr-qa-review', copied.work.join('\n')],
394
+ ['/mms-recipe-qa', copied.work.join('\n')],
395
395
  ['/mms-recipe-cook', copied.work.join('\n')],
396
396
  ['/mms-recipe-performance', copied.work.join('\n')],
397
397
  ['/mms-recipe-evidence', copied.work.join('\n')],
@@ -425,6 +425,32 @@ async function checkBehavior(browserEndpoint, port, pages) {
425
425
 
426
426
  // The walkthrough and its saved progress live one click deeper.
427
427
  await withPage(browserEndpoint, `${base}how-it-works.html`, async (page) => {
428
+ const previews = await page.evaluate(String.raw`
429
+ (async () => {
430
+ document.querySelector('[data-map-key="template"]').click();
431
+ const checked = [];
432
+ for (const button of document.querySelectorAll('[data-template-example]')) {
433
+ button.click();
434
+ const dialog = document.querySelector('#template-preview');
435
+ for (let attempt = 0; attempt < 100; attempt++) {
436
+ if (!dialog.querySelector('.template-preview-controls').hidden) break;
437
+ await new Promise(resolve => setTimeout(resolve, 20));
438
+ }
439
+ const meta = dialog.querySelector('#template-preview-meta').textContent;
440
+ const raw = dialog.querySelector('#template-preview-raw').textContent;
441
+ const source = dialog.querySelector('#template-preview-source');
442
+ if (!dialog.open || !meta.includes(button.dataset.templateExample) || !raw.includes('- [ ]') || source.hidden || !/\/blob\/[a-f0-9]{40}\//.test(source.href)) {
443
+ throw new Error('Invalid canonical template preview: ' + button.dataset.templateExample);
444
+ }
445
+ checked.push(button.dataset.templateExample);
446
+ dialog.querySelector('[data-template-close]').click();
447
+ }
448
+ return checked;
449
+ })()
450
+ `);
451
+ if (!previews.includes('review-pr/static-perps') || !previews.includes('qa/pr.extension')) {
452
+ throw new Error('Static review and QA previews must both be available');
453
+ }
428
454
  const progress = await page.evaluate(String.raw`
429
455
  (() => {
430
456
  const box = document.querySelector('.step-check');