@deeeed/metamask-harness 0.92.20 → 0.92.22

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,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.92.22 - 2026-10-18
6
+
7
+ ### Fixed
8
+
9
+ - Mobile: the recipe HUD no longer satisfies or blocks a text-only `ui.wait_for` whose own HUD text (badge, intent or detail) contains the asserted text. Each poll hides the HUD (an earlier node's HUD included), then checks every 50 ms, for about 1 s, that the intent has left the app tree before it queries. It hides again if a late HUD update brings the HUD back. That node's HUD comes back with the next update. Such a wait reports `hud: "hidden"`. A poll that can't confirm the HUD left decides nothing. If the app itself shows the whole intent text, the wait times out and says so; reword the intent. Before, `expected: absent` never passed and `expected: visible` could pass on the HUD text alone. Waits with a `test_id` and waits that don't collide with the HUD are unchanged.
10
+
11
+ ## 0.92.21 - 2026-10-17
12
+
13
+ ### Added
14
+
15
+ - Restore `mm-harness pr-body render` as a thin command over the shared Farmslot renderer.
16
+
17
+ ### Changed
18
+
19
+ - Pin the `@farmslot` packages to the release set with `@farmslot/recipe-cli` 0.23.0 (protocol 0.54.0, recipe-runner 0.39.0, adapter-sdk 0.18.0, agent-runtime 0.29.0, adapter-web 0.10.14, adapter-node 0.1.34, adapter-rn 0.22.23).
20
+
21
+ ### Fixed
22
+
23
+ - `pr-body render` takes the Validation Recipe from the selected attempt's captured `recipe.json` (or the task's single retained package), not the editable `artifacts/recipe.json`, and names the run, runner, runner revision and recipe digest above its log.
24
+ - `pr-body render` refuses a selected attempt that fails artifact validation or lacks its recipe or report, and refuses two candidate packages when no attempt is selected; it writes nothing in either case.
25
+ - `pr-body render --out` refuses to overwrite the authored description, recipe, workflow, attempt selection or retained evidence.
26
+ - `pr-body render` refuses symlinked or oversized (over 4 MiB) inputs, converts CRLF in fenced logs to LF, and creates new output files owner-only without following a symlink.
27
+
5
28
  ## 0.92.20 - 2026-10-17
6
29
 
7
30
  ### Removed
package/README.md CHANGED
@@ -400,16 +400,16 @@ check for upstream updates. Keep the selected library unchanged during a run.
400
400
  ```bash
401
401
  # Write artifacts/pr-description.md in the repository PR template shape (prose
402
402
  # only), then render the publishable body.
403
- farmslot recipe artifacts temp/tasks/dev/TAT-1 --format markdown \
404
- --out temp/tasks/dev/TAT-1/artifacts/pr-body.md \
405
- --command 'farmslot recipe run artifacts/recipe.json'
403
+ mm-harness pr-body render temp/tasks/dev/TAT-1 --command 'mm-harness run artifacts/recipe.json'
406
404
  ```
407
405
 
408
- The Markdown renderer writes `artifacts/pr-body.md`: the description with the "Validation
406
+ `pr-body render` writes `artifacts/pr-body.md`: the description with the "Validation
409
407
  Recipe" and "Validation Logs" sections rendered from `artifacts/recipe.json` and
410
408
  `artifacts/recipe-run/report.md` (and "Recipe Workflow" from `artifacts/workflow.mmd`
411
409
  when it exists), replacing any pasted by hand. The worker never pastes artifacts
412
- into markdown, so fences cannot break.
410
+ into markdown, so fences cannot break. When a passing run recorded the task's
411
+ selected attempt, both sections come from that attempt's validated package; a package
412
+ that fails validation is refused, and nothing is written.
413
413
 
414
414
  ## Review a change
415
415
 
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { hudIntentDisplay } from "@farmslot/protocol/recipe";
3
4
  import { bridgeCommand, evalAsync, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../../../library/actions/mobile/platform/bridge.mjs";
4
5
  import { observeNativeUi } from "../../../library/actions/mobile/platform/observe-ui.mjs";
5
6
  import { nativeAgentDeviceStateDir } from "../../../library/actions/mobile/platform/native-session-name.mjs";
@@ -188,6 +189,16 @@ async function hiddenRouteOnly(input, probeInput, payload, testId, deadline) {
188
189
  const nativeVisible = visible.items.some((item) => isRecord(item) && item.test_id === testId && item.visible !== false);
189
190
  return nativeVisible ? null : { hiddenRoute: true, nativeVisible: false };
190
191
  }
192
+ const HUD_HIDE_SETTLE_MS = 1e3;
193
+ function hudIntentCollidingWith(payload, testId, expectedText) {
194
+ if (testId || !expectedText || typeof payload.intent !== "string") return null;
195
+ const intent = hudIntentDisplay(payload.intent).text;
196
+ if (!intent) return null;
197
+ const detail = typeof payload.detail === "string" ? payload.detail : "";
198
+ const needle = expectedText.toLowerCase();
199
+ const inBadge = ["run", "pass", "fail"].some((word) => `${word} 0/0`.includes(needle.replace(/\d+/gu, "0")));
200
+ return inBadge || [intent, detail].some((text) => text.toLowerCase().includes(needle)) ? intent : null;
201
+ }
191
202
  async function waitForMobileTarget(input, payload) {
192
203
  const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
193
204
  const deadline = Date.now() + timeoutMs;
@@ -197,13 +208,43 @@ async function waitForMobileTarget(input, payload) {
197
208
  ...expectedText && textMatch === "contains" ? { textContains: expectedText } : {},
198
209
  visibility
199
210
  };
211
+ const hudIntent = hudIntentCollidingWith(payload, testId, expectedText);
200
212
  const queryExpression = `(function(){
201
213
  const api = globalThis.__AGENTIC__;
202
214
  if (!api || typeof api.queryUiTarget !== 'function') {
203
215
  return Promise.resolve(JSON.stringify({ unsupported: true }));
204
216
  }
205
- return api.queryUiTarget(${JSON.stringify(query)}).then(function(result){
206
- return JSON.stringify(result);
217
+ const query = function(){
218
+ return Promise.resolve(api.queryUiTarget(${JSON.stringify(query)}));
219
+ };
220
+ const hudIntent = ${JSON.stringify(hudIntent)};
221
+ if (hudIntent === null || typeof api.hideStep !== 'function') {
222
+ return query().then(function(result){ return JSON.stringify(result); });
223
+ }
224
+ const hudShown = function(){
225
+ return Promise.resolve(api.queryUiTarget({ textContains: hudIntent, visibility: 'tree' }))
226
+ .then(function(found){ return Boolean(found && found.present); });
227
+ };
228
+ // Hide, let React commit it, then look: an earlier node's HUD can still be
229
+ // up, and the tree only drops it after the commit. Hide again while this
230
+ // node's intent is shown, since a late HUD update can land after a hide.
231
+ const settle = function(waitedMs){
232
+ api.hideStep();
233
+ return new Promise(function(resolve){ setTimeout(resolve, 50); })
234
+ .then(hudShown)
235
+ .then(function(shown){
236
+ if (!shown) return true;
237
+ if (waitedMs + 50 >= ${HUD_HIDE_SETTLE_MS}) return false;
238
+ return settle(waitedMs + 50);
239
+ });
240
+ };
241
+ return settle(0).then(function(hidden){
242
+ if (!hidden) return JSON.stringify({ hudShown: true });
243
+ return query().then(function(result){
244
+ return hudShown().then(function(shown){
245
+ return JSON.stringify(shown ? { hudShown: true } : Object.assign({}, result, { hud: 'hidden' }));
246
+ });
247
+ });
207
248
  });
208
249
  })()`;
209
250
  const textExpression = testId ? `(function(){
@@ -241,6 +282,10 @@ async function waitForMobileTarget(input, payload) {
241
282
  );
242
283
  }
243
284
  lastResult = result;
285
+ if (result.hudShown === true) {
286
+ await sleep(100);
287
+ continue;
288
+ }
244
289
  let present = result.present === true;
245
290
  let visible = result.visible === true;
246
291
  let cachedRoute = null;
@@ -280,7 +325,8 @@ async function waitForMobileTarget(input, payload) {
280
325
  visible,
281
326
  visibility,
282
327
  ...cachedRoute ?? {},
283
- ...isRecord(result.rect) ? { rect: result.rect } : {}
328
+ ...isRecord(result.rect) ? { rect: result.rect } : {},
329
+ ...typeof result.hud === "string" ? { hud: result.hud } : {}
284
330
  };
285
331
  }
286
332
  await sleep(250);
@@ -291,8 +337,9 @@ async function waitForMobileTarget(input, payload) {
291
337
  ].filter(Boolean).join(" with ");
292
338
  const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
293
339
  const bridgeReason = lastBridgeError ? `; last bridge error=${traceText(lastBridgeError)}` : "";
340
+ const hudReason = lastResult?.hudShown === true ? `; the recipe HUD showing this node's intent never left the app tree, so the text was not checked without it (if the app itself shows the intent text, reword the intent)` : "";
294
341
  throw new Error(
295
- `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}.`
342
+ `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}${hudReason}.`
296
343
  );
297
344
  }
298
345
  const MOBILE_BRIDGE_HANDLERS = {
@@ -11,6 +11,7 @@ const SPEC = {
11
11
  { name: "help", desc: "Load version-matched recipe guidance (help review composes the review guide)", args: ["review"], flags: ["--json", "--adapter", "--target", "--domain"] },
12
12
  { name: "review", desc: "Materialize a review checklist composed from the base review and a team library", args: ["checklist"], flags: ["--domain", "--since", "--base", "--out", "--adapter", "--target", "--json"] },
13
13
  { name: "domain", desc: "Which team library owns the change (declared value, else owned-paths.json)", flags: ["--domain", "--base", "--adapter", "--target", "--json"] },
14
+ { name: "pr-body", desc: "Render the publishable PR body: pr-description.md plus the recipe and run sections built from artifacts", args: ["render", "<task-dir>"], flags: ["--command", "--out", "--json"] },
14
15
  { name: "config", desc: "Per-engineer locations and optional advisor settings", args: ["list", "path", "get", "set", "unset"], flags: ["--json"] },
15
16
  { name: "tutorial", desc: "Open the visual recipe tutorial", flags: ["--json", "--no-open"] },
16
17
  { name: "setup-base", desc: "Bootstrap numbered product checkouts", flags: ["--dir", "--counts", "--only", "--dry-run", "--force", "--json", "--show-config", "--reset-config", "--skip-harness-update"] },
@@ -175,6 +175,40 @@ Example:
175
175
  })
176
176
  }
177
177
  },
178
+ {
179
+ name: "pr-body",
180
+ summary: "Render the publishable PR body: the authored description plus the recipe and run log sections built from artifacts.",
181
+ example: "mm-harness pr-body render temp/tasks/dev/TAT-1",
182
+ helpText: `mm-harness pr-body render <task-dir> [flags]
183
+
184
+ Reads <task-dir>/artifacts/pr-description.md (the PR body the worker wrote in
185
+ the repository PR template shape) and inserts the machine sections rendered
186
+ from artifacts: "Validation Recipe" from artifacts/recipe.json, "Validation
187
+ Logs" from artifacts/recipe-run/report.md, and "Recipe Workflow" from
188
+ artifacts/workflow.mmd when it exists. Any such section the worker pasted by
189
+ hand is replaced. The sections go before the first checklist section, else at
190
+ the end. Fences are always longer than any backtick run in the content and
191
+ close on their own line. Writes <task-dir>/artifacts/pr-body.md.
192
+
193
+ When a passing run recorded the task's selected attempt, both sections come
194
+ from that attempt's package, which must pass validation or nothing is written.
195
+
196
+ --command <text> Recipe invocation to show above the run log
197
+ --out <file> Write elsewhere than artifacts/pr-body.md (never over an
198
+ authored input or retained evidence)
199
+ --json Machine-readable result
200
+
201
+ Example:
202
+ mm-harness pr-body render temp/tasks/dev/TAT-1 --json`,
203
+ contract: {
204
+ options: options(HELP, JSON, {
205
+ "--command": value(),
206
+ "--out": value()
207
+ }),
208
+ positionals: [{ label: "action", choices: ["render"] }, { label: "task-dir" }],
209
+ minimumPositionals: 2
210
+ }
211
+ },
178
212
  {
179
213
  name: "config",
180
214
  summary: "Per-engineer locations and optional recipe advisor settings.",
@@ -0,0 +1,29 @@
1
+ import { optionFlag, optionString, parseArgs, renderRetainedArtifactsMarkdown, usageError } from "@farmslot/recipe-cli/harness";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const USAGE = "Usage: mm-harness pr-body render <task-dir> [--command <text>] [--out <file>] [--json]";
5
+ async function handlePrBody(argv) {
6
+ const parsed = parseArgs(argv);
7
+ const [action, taskArg] = parsed.positional;
8
+ if (action !== "render" || !taskArg) throw usageError(USAGE);
9
+ const taskDir = path.resolve(taskArg);
10
+ if (!fs.existsSync(path.join(taskDir, "artifacts"))) {
11
+ throw usageError(`${taskDir} has no artifacts/ directory; pass the task directory mm-harness task init wrote.`);
12
+ }
13
+ const command = optionString(parsed.options, "command");
14
+ const out = path.resolve(optionString(parsed.options, "out") ?? path.join(taskDir, "artifacts", "pr-body.md"));
15
+ const { result } = await renderRetainedArtifactsMarkdown({ directory: taskDir, command, out, format: "markdown" });
16
+ const { sections = [], recipe, run, workflow } = result;
17
+ if (optionFlag(parsed.options, "json")) {
18
+ process.stdout.write(`${JSON.stringify({ command: "pr-body", subcommand: "render", status: "ok", taskDir, out, sections, recipe, run, workflow }, null, 2)}
19
+ `);
20
+ } else {
21
+ process.stdout.write(`pr body: ${out}
22
+ ${sections.join("\n ")}
23
+ `);
24
+ }
25
+ return 0;
26
+ }
27
+ export {
28
+ handlePrBody
29
+ };
@@ -48,6 +48,7 @@ import { handleHelp } from "./commands/help.js";
48
48
  import { handleLogs } from "./commands/logs.js";
49
49
  import { overlayCommandOptions } from "./commands/overlay.js";
50
50
  import { handlePin } from "./commands/pin.js";
51
+ import { handlePrBody } from "./commands/pr-body.js";
51
52
  import { handlePrepare } from "./commands/prepare.js";
52
53
  import { handleProvision } from "./commands/provision.js";
53
54
  import { handleRecipeQuality } from "./commands/recipe-quality.js";
@@ -138,6 +139,7 @@ const PUBLIC_RUN = {
138
139
  help: { run: (args) => handleHelp(args, { packageRoot, harnessVersion: hostVersion() }), nudge: false },
139
140
  review: { run: handleReview },
140
141
  domain: { run: handleDomain },
142
+ "pr-body": { run: handlePrBody },
141
143
  config: { run: handleConfig },
142
144
  tutorial: { run: (args) => handleTutorial(args, { harnessVersion: hostVersion() }), exit: "now", nudge: false },
143
145
  "setup-base": { run: handleSetupBase, raw: true, exit: "now", nudge: false },
@@ -196,7 +198,7 @@ const HELP_GROUPS = [
196
198
  {
197
199
  title: "PROVE",
198
200
  blurb: "run recipes and inspect readiness",
199
- commands: ["run", "last", "doctor", "check", "checklist", "review", "recipe-quality"]
201
+ commands: ["run", "last", "doctor", "check", "checklist", "review", "pr-body", "recipe-quality"]
200
202
  },
201
203
  {
202
204
  title: "RUNTIME OVERLAY",
@@ -62,7 +62,8 @@ Callers scanned (2026-10-02):
62
62
  | Command | Caller | Consumes | G: |
63
63
  |---|---|---|---|
64
64
  | `checklist mark <taskDir> {start\|<n>\|complete [--mark-last]\|blocked --reason <s>\|no-change --reason <s>\|--help}` | `{{TASK_DIR}}/mark` shim (`mark_cmd`) | exit code; `SIGNAL.json`, `CHECKLIST.md` | `task/mark-*` |
65
- | `farmslot recipe artifacts <taskDir> --format markdown --out <file> --json` | migrated pack hooks and PR-description skills | successful exit, structured `error.message`, explicit `out`; no implicit output file | shared recipe-cli artifact contracts; retained Markdown baselines |
65
+ | `pr-body render <taskDir> [--out <file>] --json` (cwd = task dir) | MetaMask engineers and skills; the gateway `pr-body-render.ts` when a pack's `pr_body_cmd` names it | on failure `JSON.parse(stdout).error`, else stderr; then `--out`, else `artifacts/pr-body.md` | `task/pr-body-render-*` |
66
+ | `farmslot recipe artifacts <taskDir> --format markdown --out <file> --json` | farm pack hooks (`vars.pr_body_cmd`) and PR-description skills on the farm | successful exit, structured `error.message`, explicit `out`; no implicit output file | shared recipe-cli artifact contracts; retained Markdown baselines |
66
67
  | `review checklist --out <file> [--domain <d>] [--since <sha>]` | domain fixtures, review templates | the written file | `task/review-checklist` |
67
68
  | `check diff …`, `recipe-quality build …` | templates | exit code | not covered: run repo lint/test tooling |
68
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.92.20",
3
+ "version": "0.92.22",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -21,15 +21,15 @@
21
21
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
22
22
  },
23
23
  "dependencies": {
24
- "@farmslot/adapter-node": "0.1.33",
25
- "@farmslot/adapter-rn": "0.22.22",
26
- "@farmslot/adapter-sdk": "0.17.1",
27
- "@farmslot/adapter-web": "0.10.13",
28
- "@farmslot/agent-runtime": "0.28.0",
24
+ "@farmslot/adapter-node": "0.1.34",
25
+ "@farmslot/adapter-rn": "0.22.23",
26
+ "@farmslot/adapter-sdk": "0.18.0",
27
+ "@farmslot/adapter-web": "0.10.14",
28
+ "@farmslot/agent-runtime": "0.29.0",
29
29
  "@farmslot/handoff": "^0.3.1",
30
- "@farmslot/protocol": "0.53.0",
31
- "@farmslot/recipe-cli": "0.22.5",
32
- "@farmslot/recipe-runner": "0.38.4",
30
+ "@farmslot/protocol": "0.54.0",
31
+ "@farmslot/recipe-cli": "0.23.0",
32
+ "@farmslot/recipe-runner": "0.39.0",
33
33
  "agent-device": "0.19.3",
34
34
  "commander": "^12.0.0",
35
35
  "esbuild": "0.28.1",