@deeeed/metamask-harness 0.51.6 → 0.51.7

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
@@ -1,6 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.51.7 - 2026-09-14
4
+
5
+ ### Added
6
+
7
+ - `pr-body render <task-dir>` writes `artifacts/pr-body.md`: the worker's `artifacts/pr-description.md` with the "Validation Recipe", "Validation Logs" (and "Recipe Workflow" when `artifacts/workflow.mmd` exists) sections rendered from the task artifacts with fences that cannot break, replacing any pasted by hand. The farm publication step and the Cook evidence packaging call the same command.
8
+
9
+ ### Changed
10
+
11
+ - Extension launch focus hold polls every 80 ms instead of 20 ms, and the reopen launcher honours `MM_HARNESS_FOCUS_SETTLE_MS` like the primary launcher.
4
12
 
5
13
  ## 0.51.6 - 2026-09-14
6
14
 
package/README.md CHANGED
@@ -193,6 +193,20 @@ Teams maintain their library checkouts. Update them before starting a task;
193
193
  the harness records local revisions and recipe digests but does not fetch or
194
194
  check for upstream updates. Keep the selected library unchanged during a run.
195
195
 
196
+ ## Publish a change
197
+
198
+ ```bash
199
+ # Write artifacts/pr-description.md in the repository PR template shape (prose
200
+ # only), then render the publishable body.
201
+ mm-harness pr-body render temp/tasks/dev/TAT-1 --command 'mm-harness run artifacts/recipe.json'
202
+ ```
203
+
204
+ `pr-body render` writes `artifacts/pr-body.md`: the description with the "Validation
205
+ Recipe" and "Validation Logs" sections rendered from `artifacts/recipe.json` and
206
+ `artifacts/recipe-run/report.md` (and "Recipe Workflow" from `artifacts/workflow.mmd`
207
+ when it exists), replacing any pasted by hand. The worker never pastes artifacts
208
+ into markdown, so fences cannot break.
209
+
196
210
  ## Review a change
197
211
 
198
212
  ```bash
@@ -519,7 +519,9 @@ const releaseFocusHold = () => {
519
519
  if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
520
520
  await page.bringToFront().catch(() => {});
521
521
  } else {
522
- await new Promise(r => setTimeout(r, 1000));
522
+ const settleMs = Number(process.env.MM_HARNESS_FOCUS_SETTLE_MS);
523
+ const waitMs = Number.isFinite(settleMs) && settleMs >= 0 ? settleMs : 1000;
524
+ if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));
523
525
  preserveMacFrontmost(previousFrontmost, chromiumApp);
524
526
  }
525
527
  releaseFocusHold();
@@ -408,11 +408,11 @@ function cdpListenerPids(port) {
408
408
  function terminatePids(pids) {
409
409
  const unique = [...new Set(pids)].filter((pid) => pid !== process.pid);
410
410
  for (const pid of unique) signalPid(pid, 'SIGTERM');
411
- for (let i = 0; i < 10 && unique.some(pidAlive); i += 1) {
411
+ for (let i = 0; i < 10 && unique.some(processRespondsToSignal); i += 1) {
412
412
  spawnSync('sleep', ['0.2']);
413
413
  }
414
414
  for (const pid of unique) {
415
- if (pidAlive(pid)) signalPid(pid, 'SIGKILL');
415
+ if (processRespondsToSignal(pid)) signalPid(pid, 'SIGKILL');
416
416
  }
417
417
  }
418
418
 
@@ -426,7 +426,7 @@ function signalPid(pid, signal) {
426
426
  }
427
427
  }
428
428
 
429
- function pidAlive(pid) {
429
+ function processRespondsToSignal(pid) {
430
430
  try {
431
431
  process.kill(pid, 0);
432
432
  return true;
@@ -3,7 +3,9 @@
3
3
  const { execFileSync, spawn } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
 
6
- const HOLD_INTERVAL_MS = 20;
6
+ // Window activation is not sub-20ms; 80ms keeps the hold responsive while
7
+ // spawning far fewer lsappinfo/open subprocesses per second.
8
+ const HOLD_INTERVAL_MS = 80;
7
9
  // Upper bound for one Launch Services query or `open -a`. A short bound made a
8
10
  // loaded Mac time out the first query, which silently disabled the whole hold.
9
11
  const LS_TIMEOUT_MS = 2000;
@@ -28,6 +30,7 @@ function captureMacFrontmost() {
28
30
  timeout: LS_TIMEOUT_MS,
29
31
  }));
30
32
  } catch {
33
+ // Best effort: lsappinfo missing, timed out, or the app exited; no focus to preserve.
31
34
  return null;
32
35
  }
33
36
  }
@@ -62,6 +65,7 @@ function currentFrontmostPid() {
62
65
  const pid = Number.parseInt((value.match(/"pid"=(\d+)/u) || [])[1], 10);
63
66
  return Number.isInteger(pid) && pid > 0 ? pid : null;
64
67
  } catch {
68
+ // Best effort: lsappinfo unavailable or timed out; callers treat null as unknown.
65
69
  return null;
66
70
  }
67
71
  }
@@ -76,6 +80,7 @@ function restoreMacFrontmost(target) {
76
80
  });
77
81
  return true;
78
82
  } catch {
83
+ // Best effort: `open -a` failed or timed out; the operator keeps whatever is in front.
79
84
  return false;
80
85
  }
81
86
  }
@@ -97,6 +102,7 @@ function restoreMacFrontmostProcess(pid) {
97
102
  timeout: LS_TIMEOUT_MS,
98
103
  })));
99
104
  } catch {
105
+ // Best effort: lsappinfo unavailable, timed out, or the pid has no Launch Services entry; nothing to restore.
100
106
  return false;
101
107
  }
102
108
  }
@@ -152,6 +158,7 @@ function sameBundle(left, right) {
152
158
  try {
153
159
  return fs.realpathSync(value);
154
160
  } catch {
161
+ // A path that cannot be resolved is compared as given.
155
162
  return value;
156
163
  }
157
164
  };
@@ -173,6 +180,7 @@ function bundlePathForPid(pid) {
173
180
  timeout: LS_TIMEOUT_MS,
174
181
  }))?.bundlePath ?? null;
175
182
  } catch {
183
+ // Best effort: lsappinfo unavailable, timed out, or the pid has no Launch Services entry; the hold is skipped.
176
184
  return null;
177
185
  }
178
186
  }
@@ -192,6 +200,7 @@ function parentAlive(pid) {
192
200
  process.kill(pid, 0);
193
201
  return true;
194
202
  } catch {
203
+ // ESRCH or EPERM: the parent is gone or not ours; either way the hold must end.
195
204
  return false;
196
205
  }
197
206
  }
@@ -55,6 +55,7 @@ function pidAlive(pid) {
55
55
  if (error.code === 'ESRCH') return false;
56
56
  throw error;
57
57
  }
58
+ // Requires macOS or procps ps; a ps that rejects these flags prints nothing, which is treated as dead.
58
59
  const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], {
59
60
  encoding: 'utf8',
60
61
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -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: libraries.<name>, references.<adapter>", 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"] },
package/dist/cli.js CHANGED
@@ -28,6 +28,7 @@ import { handleLast } from "./commands/last.js";
28
28
  import { handleReview } from "./commands/review.js";
29
29
  import { handleDomain } from "./commands/domain.js";
30
30
  import { handleConfig } from "./commands/config.js";
31
+ import { handlePrBody } from "./commands/pr-body.js";
31
32
  import { parseArgs, targetPath } from "./commands/parse-args.js";
32
33
  const COMMANDS = {
33
34
  actions: handleActions,
@@ -138,6 +139,7 @@ async function main(argv) {
138
139
  if (command === "review") return handleReview(argv.slice(1));
139
140
  if (command === "domain") return handleDomain(argv.slice(1));
140
141
  if (command === "config") return handleConfig(argv.slice(1));
142
+ if (command === "pr-body") return handlePrBody(argv.slice(1));
141
143
  const handler = COMMANDS[command];
142
144
  if (!handler) throw new Error(`Unknown command: ${command}`);
143
145
  return handler(parseArgs(argv.slice(1), command));
@@ -77,6 +77,14 @@ const PUBLIC_COMMAND_CONTRACTS = {
77
77
  "--base": value()
78
78
  })
79
79
  },
80
+ "pr-body": {
81
+ options: options(HELP, JSON, {
82
+ "--command": value(),
83
+ "--out": value()
84
+ }),
85
+ positionals: [{ label: "action", choices: ["render"] }, { label: "task-dir" }],
86
+ minimumPositionals: 2
87
+ },
80
88
  config: {
81
89
  options: options(HELP, JSON),
82
90
  positionals: [
@@ -0,0 +1,31 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { BODY_FILE, renderPrBody } from "../pr-body/render.js";
4
+ import { optionFlag, optionString, parseArgs, usageError } from "./parse-args.js";
5
+ const USAGE = "Usage: mm-harness pr-body render <task-dir> [--command <text>] [--out <file>] [--json]";
6
+ async function handlePrBody(argv) {
7
+ const parsed = parseArgs(argv, "pr-body");
8
+ const [action, taskArg] = parsed.positional;
9
+ if (action !== "render" || !taskArg) throw usageError(USAGE);
10
+ const taskDir = path.resolve(taskArg);
11
+ if (!fs.existsSync(path.join(taskDir, "artifacts"))) {
12
+ throw usageError(`${taskDir} has no artifacts/ directory; pass the task directory mm-harness task init wrote.`);
13
+ }
14
+ const command = optionString(parsed.options, "command");
15
+ const result = renderPrBody({ taskDir, ...command ? { command } : {} });
16
+ const out = path.resolve(optionString(parsed.options, "out") ?? path.join(taskDir, "artifacts", BODY_FILE));
17
+ fs.mkdirSync(path.dirname(out), { recursive: true });
18
+ fs.writeFileSync(out, result.body);
19
+ if (optionFlag(parsed.options, "json")) {
20
+ process.stdout.write(`${JSON.stringify({ command: "pr-body", subcommand: "render", status: "ok", taskDir, out, ...result, body: void 0 }, null, 2)}
21
+ `);
22
+ } else {
23
+ process.stdout.write(`pr body: ${out}
24
+ ${result.sections.join("\n ")}
25
+ `);
26
+ }
27
+ return 0;
28
+ }
29
+ export {
30
+ handlePrBody
31
+ };
@@ -91,6 +91,28 @@ Example:
91
91
  mm-harness domain
92
92
  mm-harness domain --json`
93
93
  },
94
+ {
95
+ name: "pr-body",
96
+ summary: "Render the publishable PR body: the authored description plus the recipe and run log sections built from artifacts.",
97
+ example: "mm-harness pr-body render temp/tasks/dev/TAT-1",
98
+ helpText: `mm-harness pr-body render <task-dir> [flags]
99
+
100
+ Reads <task-dir>/artifacts/pr-description.md (the PR body the worker wrote in
101
+ the repository PR template shape) and inserts the machine sections rendered
102
+ from artifacts: "Validation Recipe" from artifacts/recipe.json, "Validation
103
+ Logs" from artifacts/recipe-run/report.md, and "Recipe Workflow" from
104
+ artifacts/workflow.mmd when it exists. Any such section the worker pasted by
105
+ hand is replaced. The sections go before the first checklist section, else at
106
+ the end. Fences are always longer than any backtick run in the content and
107
+ close on their own line. Writes <task-dir>/artifacts/pr-body.md.
108
+
109
+ --command <text> Recipe invocation to show above the run log
110
+ --out <file> Write elsewhere than artifacts/pr-body.md
111
+ --json Machine-readable result
112
+
113
+ Example:
114
+ mm-harness pr-body render temp/tasks/dev/TAT-1 --json`
115
+ },
94
116
  {
95
117
  name: "config",
96
118
  summary: "Per-engineer machine locations for team libraries and reference checkouts.",
@@ -856,7 +878,7 @@ const HELP_GROUPS = [
856
878
  {
857
879
  title: "PROVE",
858
880
  blurb: "run recipes and inspect readiness",
859
- commands: ["run", "last", "doctor", "check", "checklist", "review", "recipe-quality"]
881
+ commands: ["run", "last", "doctor", "check", "checklist", "review", "pr-body", "recipe-quality"]
860
882
  },
861
883
  {
862
884
  title: "RUNTIME OVERLAY",
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const PROSE_FILE = "pr-description.md";
4
+ const BODY_FILE = "pr-body.md";
5
+ const RECIPE_HEADING = "## **Validation Recipe**";
6
+ const LOGS_HEADING = "## **Validation Logs**";
7
+ const WORKFLOW_HEADING = "## **Recipe Workflow**";
8
+ function headingKey(heading) {
9
+ return heading.replace(/^#+\s*/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
10
+ }
11
+ const MACHINE_KEYS = new Set([RECIPE_HEADING, LOGS_HEADING, WORKFLOW_HEADING].map(headingKey));
12
+ function levelTwoHeadingLines(lines) {
13
+ const found = [];
14
+ lines.forEach((line, index) => {
15
+ if (/^ {0,3}##(?!#)\s+\S/.test(line)) found.push(index);
16
+ });
17
+ return found;
18
+ }
19
+ function fenceFor(content) {
20
+ let longest = 0;
21
+ for (const run of content.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
22
+ return "`".repeat(Math.max(3, longest + 1));
23
+ }
24
+ function fenced(content, info = "") {
25
+ const fence = fenceFor(content);
26
+ return `${fence}${info}
27
+ ${content.replace(/\n+$/, "")}
28
+ ${fence}`;
29
+ }
30
+ function escapeHtml(text) {
31
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32
+ }
33
+ function countRecipeNodes(recipe) {
34
+ if (!recipe || typeof recipe !== "object") return 0;
35
+ const record = recipe;
36
+ if (Array.isArray(record.nodes)) return record.nodes.length;
37
+ if (Array.isArray(record.steps)) return record.steps.length;
38
+ const workflow = record.workflow;
39
+ if (!workflow || typeof workflow !== "object") return 0;
40
+ return Object.values(workflow).reduce(
41
+ (total, value) => total + (Array.isArray(value) ? value.length : 0),
42
+ 0
43
+ );
44
+ }
45
+ function recipeSection(file) {
46
+ const recipe = JSON.parse(fs.readFileSync(file, "utf8"));
47
+ const nodes = countRecipeNodes(recipe);
48
+ const record = recipe && typeof recipe === "object" ? recipe : {};
49
+ const title = typeof record.title === "string" ? record.title : typeof record.description === "string" ? record.description : "";
50
+ const summary = escapeHtml(`recipe.json (${nodes} steps${title ? ` \u2014 ${title.slice(0, 120)}` : ""})`);
51
+ return {
52
+ nodes,
53
+ body: [`<details><summary>${summary}</summary>`, "", fenced(JSON.stringify(recipe, null, 2), "json"), "</details>"].join("\n")
54
+ };
55
+ }
56
+ function logsSection(file, command) {
57
+ const report = fs.readFileSync(file, "utf8");
58
+ const status = /^Status:\s*(\S+)/m.exec(report)?.[1];
59
+ const nodes = /^Nodes:\s*(.+?)\s*$/m.exec(report)?.[1];
60
+ const summary = escapeHtml(status || nodes ? `Full output (${[nodes, status].filter(Boolean).join(", ")})` : "Full output");
61
+ const lines = [];
62
+ if (command) lines.push("Command:", "", fenced(command, "bash"), "");
63
+ lines.push(`<details><summary>${summary}</summary>`, "", fenced(report), "</details>");
64
+ return lines.join("\n");
65
+ }
66
+ function trimEnd(lines) {
67
+ let end = lines.length;
68
+ while (end > 0 && !lines[end - 1].trim()) end -= 1;
69
+ return lines.slice(0, end);
70
+ }
71
+ function renderPrBody(input) {
72
+ const artifacts = path.join(input.taskDir, "artifacts");
73
+ const prosePath = path.join(artifacts, PROSE_FILE);
74
+ if (!fs.existsSync(prosePath)) {
75
+ throw new Error(`Missing ${path.relative(input.taskDir, prosePath)}: write the PR description there first.`);
76
+ }
77
+ const recipePath = path.join(artifacts, "recipe.json");
78
+ const reportPath = path.join(artifacts, "recipe-run", "report.md");
79
+ const workflowPath = path.join(artifacts, "workflow.mmd");
80
+ const machine = [];
81
+ let recipe = null;
82
+ if (fs.existsSync(recipePath)) {
83
+ const rendered = recipeSection(recipePath);
84
+ recipe = { nodes: rendered.nodes, path: path.relative(input.taskDir, recipePath) };
85
+ machine.push([RECIPE_HEADING, rendered.body]);
86
+ } else {
87
+ machine.push([RECIPE_HEADING, "Not applicable: this task has no `artifacts/recipe.json`."]);
88
+ }
89
+ const hasReport = fs.existsSync(reportPath);
90
+ machine.push([
91
+ LOGS_HEADING,
92
+ hasReport ? logsSection(reportPath, input.command) : "Not applicable: no recipe run report under `artifacts/recipe-run/`."
93
+ ]);
94
+ const hasWorkflow = fs.existsSync(workflowPath);
95
+ if (hasWorkflow) {
96
+ machine.push([
97
+ WORKFLOW_HEADING,
98
+ ["<details><summary>workflow.mmd</summary>", "", fenced(fs.readFileSync(workflowPath, "utf8"), "mermaid"), "</details>"].join("\n")
99
+ ]);
100
+ }
101
+ const lines = fs.readFileSync(prosePath, "utf8").replace(/\r\n?/g, "\n").split("\n");
102
+ const headings = levelTwoHeadingLines(lines);
103
+ const keep = new Array(lines.length).fill(true);
104
+ let insertAt = lines.length;
105
+ headings.forEach((start, at) => {
106
+ const end = headings[at + 1] ?? lines.length;
107
+ const key = headingKey(lines[start]);
108
+ if (MACHINE_KEYS.has(key)) keep.fill(false, start, end);
109
+ else if (insertAt === lines.length && /checklist/.test(key)) insertAt = start;
110
+ });
111
+ const before = lines.slice(0, insertAt).filter((_, index) => keep[index]);
112
+ const after = lines.slice(insertAt).filter((_, index) => keep[insertAt + index]);
113
+ const generated = machine.flatMap(([heading, body2]) => [heading, "", body2, ""]);
114
+ const body = [...trimEnd(before), "", ...generated, ...after].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
115
+ return {
116
+ body,
117
+ sections: machine.map(([heading]) => heading),
118
+ recipe,
119
+ run: hasReport ? { path: path.relative(input.taskDir, reportPath) } : null,
120
+ workflow: hasWorkflow ? { path: path.relative(input.taskDir, workflowPath) } : null
121
+ };
122
+ }
123
+ export {
124
+ BODY_FILE,
125
+ LOGS_HEADING,
126
+ PROSE_FILE,
127
+ RECIPE_HEADING,
128
+ WORKFLOW_HEADING,
129
+ countRecipeNodes,
130
+ escapeHtml,
131
+ fenceFor,
132
+ fenced,
133
+ headingKey,
134
+ levelTwoHeadingLines,
135
+ renderPrBody
136
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.51.6",
3
+ "version": "0.51.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"