@deeeed/metamask-harness 0.67.1 → 0.69.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.
@@ -1,9 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { OperationRecord, readOperations, processIdentity, ensureRuntimeDirectory } from "@farmslot/recipe-harness/runtime/operation";
5
+ import { latestTaskDir } from "@farmslot/agent-runtime";
4
6
  import { recipeRuntimeDir } from "./paths.js";
5
7
  const COMMAND_JOURNAL_FILE = "last-command.json";
6
8
  const JOURNALED_COMMANDS = /* @__PURE__ */ new Set([
9
+ "prepare",
7
10
  "call",
8
11
  "check",
9
12
  "checklist",
@@ -12,6 +15,7 @@ const JOURNALED_COMMANDS = /* @__PURE__ */ new Set([
12
15
  "fixtures",
13
16
  "install",
14
17
  "launch",
18
+ "runtime-launch",
15
19
  "provision",
16
20
  "recipe-quality",
17
21
  "run",
@@ -25,6 +29,8 @@ async function withCommandJournal(command, argv, execute) {
25
29
  if (!JOURNALED_COMMANDS.has(command)) return execute();
26
30
  const handle = tryBeginCommandJournal(command, argv);
27
31
  const previous = activeCommandJournal;
32
+ const parentId = process.env.MM_HARNESS_OPERATION_ID;
33
+ if (handle) process.env.MM_HARNESS_OPERATION_ID = handle.operation.value.id;
28
34
  activeCommandJournal = handle;
29
35
  try {
30
36
  const exitCode = await execute();
@@ -35,8 +41,16 @@ async function withCommandJournal(command, argv, execute) {
35
41
  throw error;
36
42
  } finally {
37
43
  activeCommandJournal = previous;
44
+ if (parentId) process.env.MM_HARNESS_OPERATION_ID = parentId;
45
+ else delete process.env.MM_HARNESS_OPERATION_ID;
38
46
  }
39
47
  }
48
+ function recordCommandStage(stage) {
49
+ activeCommandJournal?.operation.stage(stage);
50
+ }
51
+ function recordCommandOutput(chunk) {
52
+ activeCommandJournal?.operation.output(chunk);
53
+ }
40
54
  function recordCommandEvidence(...evidence) {
41
55
  const handle = activeCommandJournal;
42
56
  if (!handle || evidence.length === 0) return;
@@ -58,7 +72,12 @@ function commandJournalPath(target, runtimeDir) {
58
72
  return path.join(path.resolve(target), relative, COMMAND_JOURNAL_FILE);
59
73
  }
60
74
  function readCommandJournal(target, runtimeDir) {
61
- const file = commandJournalPath(target, runtimeDir);
75
+ const legacyFile = commandJournalPath(target, runtimeDir);
76
+ const operations = readOperations(path.join(path.dirname(legacyFile), "operations"));
77
+ const live = operations.filter((operation) => operation.status === "running" && processIdentity(operation.pid) === operation.processStartedAt);
78
+ const roots = operations.filter((operation) => !operation.parentId).sort((a, b) => (a.finishedAt ?? a.startedAt).localeCompare(b.finishedAt ?? b.startedAt));
79
+ const selected = live.find((operation) => !operation.parentId) ?? live.at(-1) ?? roots.at(-1) ?? operations.at(-1);
80
+ const file = selected ? path.join(path.dirname(legacyFile), "operations", `${selected.id}.journal.json`) : legacyFile;
62
81
  let descriptor;
63
82
  try {
64
83
  const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
@@ -113,12 +132,34 @@ function redactCommandArgs(argv) {
113
132
  }
114
133
  return redacted;
115
134
  }
135
+ function pruneCompletedJournals(directory, operations) {
136
+ const completed = operations.filter((operation) => operation.status !== "running");
137
+ for (const operation of completed.slice(0, -100)) {
138
+ for (const suffix of [".json", ".journal.json", ".log"]) {
139
+ fs.rmSync(path.join(directory, `${operation.id}${suffix}`), { force: true });
140
+ }
141
+ }
142
+ }
116
143
  function tryBeginCommandJournal(command, argv) {
117
144
  try {
118
145
  const target = invocationTarget(argv);
119
146
  if (!fs.existsSync(target)) return void 0;
120
147
  const runtimeDir = optionValue(argv, "--runtime-dir");
121
- const file = commandJournalPath(target, runtimeDir);
148
+ const legacyFile = commandJournalPath(target, runtimeDir);
149
+ const taskDir = latestTaskDir(path.join(target, "temp", "tasks"));
150
+ const signalFile = taskDir && path.join(taskDir, "SIGNAL.json");
151
+ const liveTask = signalFile && fs.existsSync(signalFile) && JSON.parse(fs.readFileSync(signalFile, "utf8")).status === "running";
152
+ const directory = path.join(path.dirname(legacyFile), "operations");
153
+ const operations = readOperations(directory, (error) => process.stderr.write(`mm-harness: ignoring invalid prior operation: ${error.message}
154
+ `));
155
+ pruneCompletedJournals(directory, operations);
156
+ const inherited = process.env.MM_HARNESS_OPERATION_ID;
157
+ const parent = inherited && operations.find((operation2) => operation2.id === inherited && operation2.target === target && operation2.status === "running" && processIdentity(operation2.pid) === operation2.processStartedAt);
158
+ const operation = new OperationRecord(directory, command, target, {
159
+ ...parent ? { parentId: parent.id } : {},
160
+ ...liveTask ? { mirrorDirectory: path.join(taskDir, "artifacts", "operations") } : {}
161
+ });
162
+ const file = path.join(directory, `${operation.value.id}.journal.json`);
122
163
  const now = (/* @__PURE__ */ new Date()).toISOString();
123
164
  const record = {
124
165
  schemaVersion: 1,
@@ -132,7 +173,7 @@ function tryBeginCommandJournal(command, argv) {
132
173
  finishedAt: null
133
174
  };
134
175
  writeAtomic(file, record);
135
- return { file, record };
176
+ return { file, record, operation };
136
177
  } catch (error) {
137
178
  process.stderr.write(
138
179
  `mm-harness: resumability journal unavailable: ${errorMessage(error)}
@@ -155,14 +196,17 @@ function tryFinishCommandJournal(handle, exitCode) {
155
196
  `mm-harness: resumability journal could not finalize: ${errorMessage(error)}
156
197
  `
157
198
  );
199
+ } finally {
200
+ handle.operation.finish(exitCode);
158
201
  }
159
202
  }
160
203
  function writeAtomic(file, record) {
161
- fs.mkdirSync(path.dirname(file), { recursive: true });
204
+ ensureRuntimeDirectory(path.dirname(file));
162
205
  const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
163
206
  let descriptor;
164
207
  try {
165
208
  descriptor = fs.openSync(temporary, "wx", 384);
209
+ fs.fchmodSync(descriptor, 384);
166
210
  fs.writeFileSync(descriptor, `${JSON.stringify(record, null, 2)}
167
211
  `);
168
212
  fs.fsyncSync(descriptor);
@@ -175,9 +219,8 @@ function writeAtomic(file, record) {
175
219
  }
176
220
  }
177
221
  function invocationTarget(argv) {
178
- return path.resolve(
179
- optionValue(argv, "--target") ?? process.cwd()
180
- );
222
+ const target = path.resolve(optionValue(argv, "--target") ?? process.cwd());
223
+ return fs.existsSync(target) ? fs.realpathSync(target) : target;
181
224
  }
182
225
  function evidencePaths(argv) {
183
226
  const values = /* @__PURE__ */ new Set();
@@ -257,6 +300,8 @@ export {
257
300
  isSensitiveKey,
258
301
  readCommandJournal,
259
302
  recordCommandEvidence,
303
+ recordCommandOutput,
304
+ recordCommandStage,
260
305
  redactCommandArgs,
261
306
  redactStructuredValue,
262
307
  withCommandJournal
@@ -65,6 +65,9 @@ async function handleCall(argv) {
65
65
  }
66
66
  const { action: shortName, args, rest } = parseCallArgs(argv);
67
67
  const { options } = parseArgs(rest, "call");
68
+ if (["domain", "source", "sort"].some((name) => optionString(options, name) !== void 0)) {
69
+ throw usageError("--domain, --source, and --sort require call --list.");
70
+ }
68
71
  const json = optionFlag(options, "json");
69
72
  if (!shortName) {
70
73
  let example = "mm-harness call <action>";
@@ -12,6 +12,7 @@ const MARK_SCRIPT = require2.resolve(
12
12
  const { resolveChecklistPaths } = require2(
13
13
  "@farmslot/agent-runtime/scripts/checklist-target.cjs"
14
14
  );
15
+ const { readSubtaskIndex, SubtaskRefusal } = require2("@farmslot/agent-runtime/scripts/subtask-unit.cjs");
15
16
  const USAGE = "mm-harness checklist <mark <task-dir> <step> | closeout <task-dir>> [options]";
16
17
  const CHECKLIST_VALUE_OPTIONS = /* @__PURE__ */ new Set([
17
18
  "--reason",
@@ -129,10 +130,10 @@ function checklistStepRequiresDiffPass(checklist, stepNumber) {
129
130
  const step = enumerateChecklistCheckboxes(checklist).find((item) => item.stepNumber === stepNumber);
130
131
  return step !== void 0 && /mm-harness check diff/u.test(step.rawLabel);
131
132
  }
132
- function checkDiffGateReady(taskDir, step) {
133
+ function checkDiffGateReady(taskDir, step, parentChecklist) {
133
134
  const stepNumber = numericMarkStep(step);
134
135
  if (stepNumber === null) return true;
135
- const { taskPath } = resolveChecklistPaths(taskDir);
136
+ const taskPath = parentChecklist ? path.join(taskDir, parentChecklist) : resolveChecklistPaths(taskDir).taskPath;
136
137
  let checklist = "";
137
138
  try {
138
139
  checklist = fs.readFileSync(taskPath, "utf8");
@@ -203,6 +204,16 @@ async function handleChecklist(argv) {
203
204
  return EXIT.usage;
204
205
  }
205
206
  if (preserveTerminalSignal(resolvedTaskDir, step)) return EXIT.ok;
207
+ if (step === "sub" && forwarded[2] === "complete") {
208
+ try {
209
+ const unit = readSubtaskIndex(resolvedTaskDir)?.units.find((unit2) => unit2.id === forwarded[1]);
210
+ if (unit && !checkDiffGateReady(resolvedTaskDir, String(unit.parent.stepNumber), unit.parent.checklist)) return EXIT.runtime;
211
+ } catch (error) {
212
+ if (!(error instanceof SubtaskRefusal)) throw error;
213
+ console.error(error.message);
214
+ return error.code;
215
+ }
216
+ }
206
217
  if (!checkDiffGateReady(resolvedTaskDir, step)) return EXIT.runtime;
207
218
  teachRecipeCompletion(resolvedTaskDir, step);
208
219
  const result = spawnSync(
@@ -559,13 +559,12 @@ async function fixturesFinalize(adapter, target, options, json) {
559
559
  }
560
560
  return exitCode;
561
561
  }
562
- function fixturesSync(adapter, target, json) {
562
+ async function fixturesSync(adapter, target, json) {
563
563
  const mmHarnessBin = path.join(runnerDir, "bin/mm-harness");
564
- const installResult = spawnScript(
564
+ const installResult = await spawnScriptStreaming(
565
565
  mmHarnessBin,
566
566
  ["install", "--adapter", adapter, "--target", target],
567
- target,
568
- json
567
+ target
569
568
  );
570
569
  if (installResult.status !== 0) {
571
570
  if (!json) {
@@ -578,7 +577,7 @@ function fixturesSync(adapter, target, json) {
578
577
  const syncArgs = ["--target", target];
579
578
  if (process.env.CDP_PORT) syncArgs.push("--cdp-port", process.env.CDP_PORT);
580
579
  if (process.env.RECIPE_SLOT_ID) syncArgs.push("--slot-id", process.env.RECIPE_SLOT_ID);
581
- const syncResult = spawnScript(syncFixtureSh, syncArgs, target, json);
580
+ const syncResult = await spawnScriptStreaming(syncFixtureSh, syncArgs, target);
582
581
  if (syncResult.status !== 0 || fixtureSummary(target, adapter).status !== "ready") {
583
582
  if (!json) console.error("\u2717 no usable wallet fixture source was found.\n Next: mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
584
583
  return EXIT.runtime;
@@ -320,6 +320,10 @@ async function handleLaunchLocked(argv, stream) {
320
320
  if (attempt.status === 0) {
321
321
  return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
322
322
  }
323
+ const readinessFailure = mobileReadinessFailure(attempt);
324
+ if (readinessFailure) {
325
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, readinessFailure);
326
+ }
323
327
  if (adapter === "extension" && attempt.output.includes("REMOTE_FLAG_NOT_PINNABLE:")) {
324
328
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
325
329
  code: "REMOTE_FLAG_NOT_PINNABLE",
@@ -503,6 +507,10 @@ async function handleLaunchLocked(argv, stream) {
503
507
  state.recovered.push(recoveryCode);
504
508
  return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
505
509
  }
510
+ const retryReadinessFailure = mobileReadinessFailure(attempt);
511
+ if (retryReadinessFailure) {
512
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, retryReadinessFailure);
513
+ }
506
514
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
507
515
  code: "SAME_RECOVERY_TWICE",
508
516
  message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed again after ${recoveryCode} recovery \u2014 refusing to loop.`,
@@ -511,6 +519,17 @@ async function handleLaunchLocked(argv, stream) {
511
519
  originalError: attempt.output.trim() || void 0
512
520
  });
513
521
  }
522
+ function mobileReadinessFailure(result) {
523
+ const block = result.readinessBlock;
524
+ if (!block) return void 0;
525
+ return {
526
+ code: block.reasonCode === "deps-not-ready" ? "MOBILE_DEPENDENCIES_NOT_READY" : "MOBILE_READINESS_BLOCKED",
527
+ message: block.message,
528
+ userAction: block.userAction,
529
+ recoverable: false,
530
+ exitCode: result.status
531
+ };
532
+ }
514
533
  function extensionQuickReattachFailed(output) {
515
534
  return /\[reattach\]|extension quick reattach|mm-harness launch --build/u.test(output);
516
535
  }
@@ -5,13 +5,14 @@ import { importRecipeProtocol } from "../paths.js";
5
5
  import { color } from "../cli-color.js";
6
6
  import { EXIT } from "./shared.js";
7
7
  import { optionFlag, optionString, optionStrings, resolveAdapter, shellQuoteArg } from "./parse-args.js";
8
- import { describeManifestActions, renderHumanActionCatalog } from "./manifest.js";
8
+ import { actionDomain, describeManifestActions, renderHumanActionCatalog } from "./manifest.js";
9
9
  import {
10
10
  describeRunnableRecipe,
11
11
  listRunnableRecipes,
12
12
  resolveMetaMaskLibrarySources
13
13
  } from "./run-engine.js";
14
14
  const finalSegment = (name) => name.split(".").pop() ?? name;
15
+ const recipeDomain = (name) => name.includes(".") ? name.split(".")[0].replaceAll("_", "-") : "general";
15
16
  function catalogDescription(description) {
16
17
  if (!description) return "";
17
18
  const firstSentence = description.match(/^.*?[.!?](?:\s|$)/u)?.[0]?.trim() ?? description;
@@ -20,23 +21,64 @@ function catalogDescription(description) {
20
21
  async function handleListExecutables(command, options) {
21
22
  const json = optionFlag(options, "json");
22
23
  const { adapter } = resolveAdapter(options);
24
+ const domainFilter = optionString(options, "domain")?.replaceAll("_", "-");
25
+ const sourceFilter = optionString(options, "source");
26
+ const sort = optionString(options, "sort") ?? (json ? "name" : "domain");
27
+ const groupKey = (entry, domain) => sort === "library" ? entry.source : sort === "domain" ? domain : "";
28
+ const select = (available2, domainOf) => available2.filter((entry) => (!domainFilter || domainOf(entry) === domainFilter) && (!sourceFilter || entry.source === sourceFilter)).sort((left, right) => groupKey(left, domainOf(left)).localeCompare(groupKey(right, domainOf(right))) || left.name.localeCompare(right.name));
29
+ const out = (style, text) => color(style, text, { stream: process.stdout });
30
+ const stylesFor = (domains) => {
31
+ const palette = ["label", "accent", "ok", "warn"];
32
+ return new Map([...new Set(domains)].sort().map((domain, index) => [domain, palette[index % palette.length]]));
33
+ };
23
34
  const librarySources = await resolveMetaMaskLibrarySources(optionStrings(options, "library"));
35
+ const libraryLines = () => {
36
+ const lines = ["Libraries loaded:"];
37
+ for (const source of librarySources ?? []) {
38
+ const name = source.name ?? path.basename(source.root);
39
+ const kind = source.provenance?.kind === "bundled" ? "bundled" : "linked";
40
+ lines.push(` ${out("bold", name)} ${kind} \xB7 ${source.root}`);
41
+ }
42
+ if (domainFilter || sourceFilter) {
43
+ lines.push(`Filter: ${[domainFilter && `domain=${domainFilter}`, sourceFilter && `source=${sourceFilter}`].filter(Boolean).join(" ")}`);
44
+ }
45
+ return lines;
46
+ };
24
47
  if (command === "run") {
25
- const recipes = await listRunnableRecipes(adapter, librarySources);
48
+ const available2 = await listRunnableRecipes(adapter, librarySources);
49
+ const recipes = select(available2, (recipe) => recipeDomain(recipe.name));
26
50
  if (json) {
27
51
  console.log(JSON.stringify({ schemaVersion: 1, command, action: "list", adapter, recipes }, null, 2));
28
52
  return EXIT.ok;
29
53
  }
30
- const out = (style, text) => color(style, text, { stream: process.stdout });
54
+ const domainStyles = stylesFor(available2.map((recipe) => recipeDomain(recipe.name)));
31
55
  console.log(out("bold", `runnable recipes (${adapter})`));
56
+ for (const line of libraryLines()) console.log(line);
32
57
  console.log(out("comment", "Inspect: mm-harness run <recipe> --describe"));
58
+ if (recipes.length === 0) {
59
+ console.log("No runnable recipes match.");
60
+ console.log(`Available domains: ${[...domainStyles.keys()].join(", ") || "none"}`);
61
+ console.log(`Available sources: ${[...new Set(available2.map((recipe) => recipe.source))].join(", ") || "none"}`);
62
+ }
63
+ const groups = /* @__PURE__ */ new Map();
33
64
  for (const recipe of recipes) {
34
- const shadowed = recipe.shadows.length > 0 ? ` shadows=${recipe.shadows.join(",")}` : "";
35
- const variant = recipe.variant ? ` variant=${recipe.variant}` : " variant=all";
36
- const parameters = recipe.parameters.length > 0 ? ` params=${recipe.parameters.map((parameter) => parameter.name).join(",")}` : "";
37
- const summary = catalogDescription(recipe.description);
38
- const description = summary ? ` \u2014 ${summary}` : "";
39
- console.log(` ${out("cmd", recipe.name)} ${out("dim", `[${recipe.source}]${variant}${shadowed}${parameters}`)}${description}`);
65
+ const group = groupKey(recipe, recipeDomain(recipe.name));
66
+ groups.set(group, [...groups.get(group) ?? [], recipe]);
67
+ }
68
+ for (const [group, entries] of groups) {
69
+ if (group) {
70
+ const label = sort === "library" ? `Library: ${group}` : group;
71
+ console.log(`
72
+ ${out(sort === "domain" ? domainStyles.get(group) : "bold", label)} ${out("dim", `(${entries.length})`)}`);
73
+ }
74
+ for (const recipe of entries) {
75
+ const style = domainStyles.get(recipeDomain(recipe.name));
76
+ const shadowed = recipe.shadows.length > 0 ? ` shadows=${recipe.shadows.join(",")}` : "";
77
+ const variant = recipe.variant ? ` variant=${recipe.variant}` : " variant=all";
78
+ const summary = catalogDescription(recipe.description);
79
+ console.log(` ${out(style, recipe.name)} ${out("dim", `library=${recipe.source}${variant}${shadowed}`)}`);
80
+ if (summary) console.log(` ${summary}`);
81
+ }
40
82
  }
41
83
  return EXIT.ok;
42
84
  }
@@ -47,9 +89,10 @@ async function handleListExecutables(command, options) {
47
89
  );
48
90
  const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
49
91
  const protocolNames = new Set(getRecipeActionManifestActionNames(manifest));
50
- const described = describeManifestActions(manifest, actionSources).filter((entry) => protocolNames.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name));
92
+ const available = describeManifestActions(manifest, actionSources).filter((entry) => protocolNames.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name));
93
+ const described = select(available, actionDomain);
51
94
  const shortCounts = /* @__PURE__ */ new Map();
52
- for (const entry of described) {
95
+ for (const entry of available) {
53
96
  const short = finalSegment(entry.name);
54
97
  shortCounts.set(short, (shortCounts.get(short) ?? 0) + 1);
55
98
  }
@@ -73,9 +116,17 @@ async function handleListExecutables(command, options) {
73
116
  console.log(JSON.stringify({ schemaVersion: 1, command, action: "list", adapter, actions }, null, 2));
74
117
  return EXIT.ok;
75
118
  }
119
+ const contextLines = libraryLines();
120
+ if (described.length === 0) {
121
+ contextLines.push(`Available domains: ${[...new Set(available.map(actionDomain))].sort().join(", ") || "none"}`);
122
+ contextLines.push(`Available sources: ${[...new Set(available.map((entry) => entry.source))].sort().join(", ") || "none"}`);
123
+ }
76
124
  console.log(renderHumanActionCatalog(described, {
77
125
  title: `invocable actions (${adapter})`,
78
126
  guidance: "Use: mm-harness call <name> \xB7 short names shown first where unambiguous",
127
+ sort,
128
+ domainStyles: stylesFor(available.map(actionDomain)),
129
+ contextLines,
79
130
  displayName: (entry) => {
80
131
  const short = shortByName.get(entry.name);
81
132
  return short ? `${short} (${entry.name})` : `${entry.name} (full name only \u2014 ambiguous short)`;
@@ -345,16 +345,21 @@ function summarizeDescribedAction(entry) {
345
345
  const { schema: _schema, examples: _examples, ...summary } = entry;
346
346
  return summary;
347
347
  }
348
+ function actionDomain(entry) {
349
+ return entry.category.replaceAll("_", "-");
350
+ }
348
351
  function renderHumanActionCatalog(actions, options) {
349
352
  const out = (style, text) => color(style, text, { stream: process.stdout });
350
353
  const groups = /* @__PURE__ */ new Map();
351
354
  for (const entry of actions) {
352
- const group = actionDomainGroup(entry);
355
+ const group = options.sort === "name" ? "" : options.sort === "library" ? `Library: ${entry.source}` : options.sort === "domain" ? actionDomain(entry) : actionDomainGroup(entry);
353
356
  groups.set(group, [...groups.get(group) ?? [], entry]);
354
357
  }
355
- const lines = [out("bold", options.title), out("comment", options.guidance)];
358
+ const lines = [out("bold", options.title), ...options.contextLines ?? [], out("comment", options.guidance)];
359
+ if (actions.length === 0) lines.push("No callable actions match.");
356
360
  for (const [group, entries] of groups) {
357
- lines.push("", `${out(group === "official" ? "label" : "accent", group)} ${out("dim", `(${entries.length})`)}`);
361
+ const groupStyle = options.sort === "domain" ? options.domainStyles?.get(group) ?? "accent" : group === "official" ? "label" : "accent";
362
+ if (group) lines.push("", `${out(groupStyle, group)} ${out("dim", `(${entries.length})`)}`);
358
363
  for (const entry of entries) {
359
364
  const fieldNames = humanFieldNames(entry);
360
365
  const fields = fieldNames.length ? ` ${out("dim", `fields=${fieldNames.join(",")}`)}` : "";
@@ -362,7 +367,8 @@ function renderHumanActionCatalog(actions, options) {
362
367
  const description = entry.description ? ` ${out("comment", `\u2014 ${entry.description}`)}` : "";
363
368
  const source = ` ${out("dim", `[${entry.source}]`)}`;
364
369
  const risk = entry.capabilities.length ? ` ${out("dim", `risk=${entry.capabilities.join(",")}`)}` : "";
365
- lines.push(` ${out("cmd", options.displayName?.(entry) ?? entry.name)}${source}${fields}${cases}${risk}${description}`);
370
+ const nameStyle = options.domainStyles?.get(actionDomain(entry)) ?? "cmd";
371
+ lines.push(` ${out(nameStyle, options.displayName?.(entry) ?? entry.name)}${source}${fields}${cases}${risk}${description}`);
366
372
  }
367
373
  }
368
374
  return lines.join("\n");
@@ -585,6 +591,7 @@ function levenshtein(left, right) {
585
591
  }
586
592
  export {
587
593
  ACTION_MATRIX_ADAPTERS,
594
+ actionDomain,
588
595
  actionExampleCommand,
589
596
  actionLibraryContextArgs,
590
597
  describeManifestActions,
@@ -1,4 +1,7 @@
1
+ import { acquireCheckoutLock, trackCheckoutChild } from "../checkout-lock.js";
2
+ import { recordCommandStage } from "../command-journal.js";
1
3
  import { spawn } from "node:child_process";
4
+ import { OperationOutputTail } from "@farmslot/recipe-harness/runtime/operation";
2
5
  import fs from "node:fs";
3
6
  import path from "node:path";
4
7
  import {
@@ -168,18 +171,19 @@ function spawnStep(executable, args, target) {
168
171
  // stream a recorded readiness step captures.
169
172
  env: { ...process.env, MM_HARNESS_NO_UPDATE_CHECK: "1" }
170
173
  });
174
+ if (child.pid) trackCheckoutChild(target, child.pid);
171
175
  let stdout = "";
172
- let stderr = "";
176
+ const stderr = new OperationOutputTail();
173
177
  child.stdout?.setEncoding("utf8");
174
178
  child.stderr?.setEncoding("utf8");
175
179
  child.stdout?.on("data", (chunk) => {
176
180
  stdout += chunk;
177
181
  });
178
182
  child.stderr?.on("data", (chunk) => {
179
- stderr += chunk;
183
+ stderr.append(chunk);
180
184
  });
181
- child.on("error", (error) => resolve({ error, status: null, stdout, stderr }));
182
- child.on("close", (status) => resolve({ status, stdout, stderr }));
185
+ child.on("error", (error) => resolve({ error, status: null, stdout, stderr: stderr.toString() }));
186
+ child.on("close", (status) => resolve({ status, stdout, stderr: stderr.toString() }));
183
187
  });
184
188
  }
185
189
  async function runStep(executable, target, artifactsDir, id, args, { json, onSpawn }) {
@@ -305,6 +309,22 @@ function resolvePlatform(requested, target) {
305
309
  );
306
310
  }
307
311
  async function handlePrepare(argv) {
312
+ const { options } = parseArgs(argv, "prepare");
313
+ const target = targetPath(options);
314
+ if (!fs.existsSync(target)) return handlePrepareLocked(argv);
315
+ const lock = acquireCheckoutLock(target, "prepare");
316
+ if ("message" in lock) {
317
+ if (optionFlag(options, "json")) console.log(JSON.stringify({ status: "fail", error: { code: "SANDBOX_BUSY", message: lock.message }, exitCode: EXIT.runtime }));
318
+ else console.error(`${lock.message} Next: inspect the owning operation with mm-harness status --watch`);
319
+ return EXIT.runtime;
320
+ }
321
+ try {
322
+ return await handlePrepareLocked(argv);
323
+ } finally {
324
+ lock.release();
325
+ }
326
+ }
327
+ async function handlePrepareLocked(argv) {
308
328
  const { options } = parseArgs(argv, "prepare");
309
329
  const json = optionFlag(options, "json");
310
330
  const target = targetPath(options);
@@ -372,6 +392,7 @@ async function handlePrepare(argv) {
372
392
  const step = await runStep(executable, target, artifactsDir, id, args, {
373
393
  json,
374
394
  onSpawn: (stepStartedAt) => {
395
+ recordCommandStage(id);
375
396
  activeStep = { id, startedAt: new Date(stepStartedAt).toISOString() };
376
397
  writeProgress();
377
398
  view?.start(id, stepStartedAt);
@@ -20,7 +20,8 @@ import {
20
20
  import {
21
21
  importRecipeHarness,
22
22
  importRecipeProtocol,
23
- runnerDir
23
+ runnerDir,
24
+ recipeRuntimePath
24
25
  } from "../paths.js";
25
26
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
26
27
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
@@ -277,7 +278,11 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
277
278
  recipeDocument: recipeDocument ?? recipe,
278
279
  ...absoluteRecipePath ? { recipePath: absoluteRecipePath } : {},
279
280
  ...librarySources ? { librarySources } : {},
280
- excludedProductRoots: [absoluteArtifactsDir],
281
+ excludedProductRoots: [
282
+ absoluteArtifactsDir,
283
+ recipeRuntimePath(projectRoot, "operations"),
284
+ recipeRuntimePath(projectRoot, "lock-members")
285
+ ],
281
286
  helperPaths: commandHelperPaths(recipeDocument ?? recipe, projectRoot)
282
287
  };
283
288
  const startProvenance = await captureExecutionProvenance(
@@ -416,6 +421,7 @@ function runtimeRecipeDocument(recipe, absoluteRecipePath) {
416
421
  function recipeProcessEnvironment() {
417
422
  const env = { ...process.env };
418
423
  delete env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
424
+ delete env.MM_HARNESS_OPERATION_ID;
419
425
  return env;
420
426
  }
421
427
  function executionLibrarySources(sources, invocationTrust) {
@@ -147,6 +147,9 @@ async function handleRun(parsed) {
147
147
  }
148
148
  }
149
149
  async function handleRunInner({ positional, options }, stream) {
150
+ if (!optionFlag(options, "list") && ["domain", "source", "sort"].some((name) => optionString(options, name) !== void 0)) {
151
+ throw usageError("--domain, --source, and --sort require run --list.");
152
+ }
150
153
  if (optionFlag(options, "describe") && (optionFlag(options, "list") || optionFlag(options, "plan"))) {
151
154
  const message = "--describe cannot be combined with --list or --plan.";
152
155
  const userAction = "choose one: mm-harness run --list, mm-harness run <recipe> --describe, or mm-harness run <recipe> --plan";
@@ -1,4 +1,5 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { checkoutBusyOut, spawnScriptStreaming } from "./shared.js";
2
+ import { acquireCheckoutLock } from "../checkout-lock.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import {
@@ -23,7 +24,18 @@ import {
23
24
  usageError
24
25
  } from "./parse-args.js";
25
26
  import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
26
- async function handleRuntimeLaunch({ options }) {
27
+ async function handleRuntimeLaunch(args) {
28
+ const target = targetPath(args.options);
29
+ if (optionFlag(args.options, "help") || !fs.existsSync(target)) return handleRuntimeLaunchLocked(args);
30
+ const lock = acquireCheckoutLock(target, "runtime-launch");
31
+ if ("message" in lock) return checkoutBusyOut(optionFlag(args.options, "json"), "runtime-launch", lock.message, lock.path);
32
+ try {
33
+ return await handleRuntimeLaunchLocked(args);
34
+ } finally {
35
+ lock.release();
36
+ }
37
+ }
38
+ async function handleRuntimeLaunchLocked({ options }) {
27
39
  if (optionFlag(options, "help")) {
28
40
  console.log(`mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
29
41
 
@@ -186,11 +198,11 @@ async function handleRuntimeLaunch({ options }) {
186
198
  cdpPort
187
199
  }));
188
200
  }
189
- const result = spawnSync(command[0], command.slice(1), {
190
- cwd: target,
191
- encoding: "utf8",
192
- maxBuffer: 5 * 1024 * 1024,
193
- stdio: jsonMode ? ["ignore", "pipe", "pipe"] : "inherit"
201
+ const result = await spawnScriptStreaming(command[0], command.slice(1), target, {
202
+ forward: jsonMode ? "stderr" : "original",
203
+ stdin: jsonMode ? "ignore" : "inherit",
204
+ capture: false,
205
+ stage: mode
194
206
  });
195
207
  const summaryPath = path.join(artifactsDir, "summary.json");
196
208
  const summary = readJsonIfExists(summaryPath);