@deeeed/metamask-harness 0.61.1 → 0.63.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,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.63.0 - 2026-09-23
6
+
7
+ ### Added
8
+
9
+ - Show the task's acceptance criteria and recorded verdicts in `status`, `status --watch`, and `status --task`, using the Farmslot handoff and acceptance ledger.
10
+ - Show the active preparation step and elapsed time in `status --watch` and `status --task` even when the agent buffers preparation output.
11
+
12
+ ### Fixed
13
+
14
+ - Allow 30 seconds for iOS simulator discovery so a cold CoreSimulator startup does not fail at five seconds.
15
+
16
+ ## 0.62.0 - 2026-09-22
17
+
18
+ ### Added
19
+
20
+ - `MM_HARNESS_SIMULATOR_APP` can pin a standalone Simulator from another installed Xcode without changing build tools; an invalid preference fails instead of selecting a different viewer.
21
+
22
+ ### Fixed
23
+
24
+ - iOS launch opens the assigned simulator window through Device Hub on Xcode 27, including when Device Hub is already running. Older Xcode versions keep using Simulator. GUI launch errors stay visible instead of requiring a headless workaround. Explicit foreground opening no longer needs AppleScript permission, and fails the launch if it cannot open the UI.
25
+
26
+ ### Added
27
+
28
+ - `recipe-quality plan` maps acceptance criteria to existing adapter-supported actions and composable library recipes before authoring; `recipe-quality advise` reviews drafts and supplied evidence. Both offer opt-in semantic assessment through `--advisor <provider>` with configurable `--advisor-model`; TypeSafe is the first provider. Engineer settings and CLI/environment overrides share one resolver exposed by `recipe-quality advisor-status`, defaulting to disabled and planning only. They record provider/model provenance, write separate advisory results, and skip missing keys or report provider unavailability without changing normal execution or canonical quality.
29
+
5
30
  ## 0.61.1 - 2026-09-21
6
31
 
7
32
  ### Fixed
package/README.md CHANGED
@@ -237,6 +237,13 @@ mm-harness run --list
237
237
  Shared libraries hold durable actions and composable recipes. Task acceptance
238
238
  criteria remain task-local. See [Recipes](docs/RECIPES.md).
239
239
 
240
+ For optional model advice matching acceptance criteria to library flows and assessing
241
+ drafts or evidence, see [Optional recipe advisor](docs/RECIPES.md#optional-recipe-advisor).
242
+ Select a provider with `--advisor typesafe` or save `advisor.enabled` and
243
+ `advisor.provider` through `mm-harness config`. Inspect the effective policy with
244
+ `mm-harness recipe-quality advisor-status --json`; use `--advisor off` per task.
245
+ Ordinary recipe execution needs no advisor or API key.
246
+
240
247
  Exporting is not required: a library registered with `mm-harness config set
241
248
  libraries.<name> <path>`, or checked out beside the product checkout, reaches
242
249
  `run`, `actions`, and template discovery too. `mm-harness status` lists what this
@@ -14,7 +14,10 @@
14
14
  # --adb-serial <serial> Android device serial (default: ADB_SERIAL env)
15
15
  # --preflight-mode fast|auto|rebuild-native|clean (default: fast; env MOBILE_PREFLIGHT_MODE)
16
16
  # --restart terminate the selected app before opening it
17
- # --ui-only iOS only: ensure Simulator.app is visible; do not relaunch the app
17
+ # --ui-only iOS only: show the assigned simulator window without relaunching the app
18
+ # MM_HARNESS_SIMULATOR_APP absolute Simulator.app or DeviceHub.app path; GUI only
19
+ # MM_HARNESS_SHOW_SIMULATOR=0 opt out of automatic GUI opening
20
+ # MM_HARNESS_FOCUS_SIMULATOR=1 explicitly open/activate the GUI, even with SHOW_SIMULATOR=0
18
21
  # Outputs:
19
22
  # Progress on stderr; exit 0 pass; 1 open failed; 2 bad args.
20
23
  #
@@ -273,47 +276,71 @@ boot_simulator_if_needed() {
273
276
  xcrun simctl bootstatus "$udid" -b >/dev/null
274
277
  }
275
278
 
276
- simulator_ui_running() {
277
- pgrep -x Simulator >/dev/null 2>&1
279
+ resolve_simulator_ui_app() {
280
+ local preferred_app="${MM_HARNESS_SIMULATOR_APP:-}"
281
+ if [ -n "$preferred_app" ]; then
282
+ preferred_app="${preferred_app%/}"
283
+ case "$preferred_app" in
284
+ /*/Simulator.app|/*/DeviceHub.app)
285
+ if [ -d "$preferred_app" ]; then
286
+ printf '%s\n' "$preferred_app"
287
+ return 0
288
+ fi
289
+ ;;
290
+ esac
291
+ printf 'open-device: MM_HARNESS_SIMULATOR_APP must name an existing absolute Simulator.app or DeviceHub.app path.\nNext: ls -d %q\n' "$preferred_app" >&2
292
+ return 1
293
+ fi
294
+ local developer_dir="${DEVELOPER_DIR:-}"
295
+ if [ -z "$developer_dir" ]; then
296
+ developer_dir="$(xcode-select -p)" || return 1
297
+ fi
298
+ developer_dir="${developer_dir%/}"
299
+ case "$developer_dir" in *.app) developer_dir="$developer_dir/Contents/Developer" ;; esac
300
+ if [ -d "$developer_dir/../Applications/DeviceHub.app" ]; then
301
+ printf '%s\n' "$developer_dir/../Applications/DeviceHub.app"
302
+ elif [ -d "$developer_dir/Applications/Simulator.app" ]; then
303
+ printf '%s\n' "$developer_dir/Applications/Simulator.app"
304
+ else
305
+ printf 'open-device: no simulator UI found in the selected Xcode.\nNext: xcode-select -p\n' >&2
306
+ return 1
307
+ fi
278
308
  }
279
309
 
280
- open_simulator_in_background_if_needed() {
281
- local target="$1" udid=""
282
- [ "${MM_HARNESS_SHOW_SIMULATOR:-1}" != "0" ] || return 0
283
- simulator_ui_running && return 0
284
- printf 'Opening Simulator UI in the background\n' >&2
285
- if [ "$target" = "booted" ]; then
286
- open -g -a Simulator >/dev/null 2>&1 || {
287
- printf 'open-device: failed to open Simulator UI.\n' >&2
310
+ open_simulator_ui() {
311
+ local target="$1" app="$2" background="$3"
312
+ local open_args=(-a "$app")
313
+ [ "$background" != "true" ] || open_args=(-g "${open_args[@]}")
314
+ if [[ "$app" == */DeviceHub.app ]]; then
315
+ # Device Hub routes device windows through URLs, including when already open.
316
+ # Simulator's CurrentDeviceUDID launch argument does not select its window.
317
+ open "${open_args[@]}" "devices://device/open?id=$target" >&2 || {
318
+ printf 'open-device: failed to open Device Hub for %s.\nNext: open -a %q %q\n' "$target" "$app" "devices://device/open?id=$target" >&2
288
319
  return 1
289
320
  }
290
321
  else
291
- udid="$(sim_udid_for_target "$target")"
292
- if [ -n "$udid" ]; then
293
- open -g -a Simulator --args -CurrentDeviceUDID "$udid" >/dev/null 2>&1 || {
294
- printf 'open-device: failed to open Simulator UI for %s.\n' "$target" >&2
295
- return 1
296
- }
297
- else
298
- open -g -a Simulator >/dev/null 2>&1 || {
299
- printf 'open-device: failed to open Simulator UI.\n' >&2
300
- return 1
301
- }
302
- fi
322
+ open "${open_args[@]}" --args -CurrentDeviceUDID "$target" >&2 || {
323
+ printf 'open-device: failed to open Simulator for %s.\nNext: open -a %q --args -CurrentDeviceUDID %q\n' "$target" "$app" "$target" >&2
324
+ return 1
325
+ }
303
326
  fi
304
327
  }
305
328
 
306
- focus_simulator() {
307
- local target="$1" udid=""
308
- if [ "$target" != "booted" ]; then
309
- udid="$(sim_udid_for_target "$target")"
310
- fi
311
- if [ -n "$udid" ]; then
312
- open -a Simulator --args -CurrentDeviceUDID "$udid" >/dev/null 2>&1 || true
313
- else
314
- open -a Simulator >/dev/null 2>&1 || true
329
+ open_simulator_in_background_if_needed() {
330
+ local target="$1" app=""
331
+ [ "${MM_HARNESS_SHOW_SIMULATOR:-1}" != "0" ] || return 0
332
+ app="$(resolve_simulator_ui_app)" || return 1
333
+ if [[ "$app" == */Simulator.app ]] && [ -z "${MM_HARNESS_SIMULATOR_APP:-}" ] && pgrep -x Simulator >/dev/null 2>&1; then
334
+ return 0
315
335
  fi
316
- osascript -e 'tell application "Simulator" to activate' >/dev/null 2>&1 || true
336
+ printf 'Opening %s for %s in the background\n' "$(basename "$app" .app)" "$target" >&2
337
+ open_simulator_ui "$target" "$app" true
338
+ }
339
+
340
+ focus_simulator() {
341
+ local app=""
342
+ app="$(resolve_simulator_ui_app)" || return 1
343
+ open_simulator_ui "$1" "$app" false
317
344
  }
318
345
 
319
346
  # --- Android helpers ----------------------------------------------------------
@@ -507,9 +534,10 @@ if [ "$PLATFORM" = "ios" ]; then
507
534
  DEV_CLIENT_SCHEME="${IOS_DEV_CLIENT_SCHEME:-expo-metamask}"
508
535
 
509
536
  boot_simulator_if_needed "$SIM_TARGET" || exit 1
510
- open_simulator_in_background_if_needed "$SIM_TARGET" || exit 1
511
537
  if [ "${MM_HARNESS_FOCUS_SIMULATOR:-0}" = "1" ]; then
512
- focus_simulator "$SIM_TARGET"
538
+ focus_simulator "$SIM_TARGET" || exit 1
539
+ else
540
+ open_simulator_in_background_if_needed "$SIM_TARGET" || exit 1
513
541
  fi
514
542
  if $UI_ONLY; then
515
543
  exit 0
@@ -0,0 +1,40 @@
1
+ import { assessmentProvider } from "./assessment-providers.js";
2
+ import { optionString, usageError } from "./commands/parse-args.js";
3
+ import { configPath, parseAdvisorSettings, readConfig } from "./review/knowledge.js";
4
+ function resolveAdvisorPolicy(options, env = process.env, config) {
5
+ const saved = (config ?? readConfig(configPath(env))).advisor ?? {};
6
+ const cliAdvisor = optionString(options, "advisor");
7
+ const configuredAdvisor = cliAdvisor ?? env.MM_HARNESS_ADVISOR;
8
+ if (configuredAdvisor !== void 0 && !configuredAdvisor.trim()) throw usageError("Advisor override must name a provider or off. Next: unset MM_HARNESS_ADVISOR or select --advisor off.");
9
+ const enabled = configuredAdvisor !== void 0 ? configuredAdvisor !== "off" : saved.enabled === true;
10
+ const source = cliAdvisor !== void 0 ? "cli" : env.MM_HARNESS_ADVISOR !== void 0 ? "env" : saved.enabled !== void 0 ? "config" : "default";
11
+ const providerName = configuredAdvisor && configuredAdvisor !== "off" ? configuredAdvisor : saved.provider;
12
+ const cliModel = optionString(options, "advisorModel");
13
+ const overrideStages = optionString(options, "advisorStages") ?? env.MM_HARNESS_ADVISOR_STAGES;
14
+ const stages = enabled && overrideStages !== void 0 ? parseAdvisorSettings({ stages: overrideStages.split(",").map((stage) => stage.trim()) }).stages : saved.stages ?? ["plan"];
15
+ const sources = {
16
+ enabled: source,
17
+ provider: configuredAdvisor && configuredAdvisor !== "off" ? source : saved.provider ? "config" : "default",
18
+ model: cliModel !== void 0 ? "cli" : env.MM_HARNESS_ADVISOR_MODEL !== void 0 ? "env" : saved.model ? "config" : "default",
19
+ stages: enabled && options.advisorStages !== void 0 ? "cli" : enabled && env.MM_HARNESS_ADVISOR_STAGES !== void 0 ? "env" : saved.stages ? "config" : "default"
20
+ };
21
+ if (!enabled) {
22
+ if (cliModel !== void 0 && configuredAdvisor !== "off") throw usageError("A model alone does not enable advice. Next: select --advisor <provider> or enable advisor settings.");
23
+ return { policy: { enabled: false, status: "disabled", stages, sources }, selection: void 0 };
24
+ }
25
+ const provider = providerName ? assessmentProvider(providerName) : void 0;
26
+ if (!provider) throw usageError("Enabled advice requires a supported provider. Next: use --advisor typesafe or --advisor off.");
27
+ const savedModel = !configuredAdvisor || configuredAdvisor === saved.provider ? saved.model : void 0;
28
+ const model = cliModel ?? env.MM_HARNESS_ADVISOR_MODEL ?? savedModel ?? provider.defaultModel;
29
+ if (!model.trim()) throw usageError("Advisor model must be non-empty. Next: unset the model override to use the provider default.");
30
+ if (!cliModel && env.MM_HARNESS_ADVISOR_MODEL === void 0 && !savedModel) sources.model = "default";
31
+ const keyAvailable = Boolean(env[provider.apiKeyEnv]?.trim());
32
+ const selection = { provider, model: model.trim() };
33
+ return {
34
+ policy: { enabled: true, status: keyAvailable ? "ready" : "skipped", ...keyAvailable ? {} : { reason: "missing_key" }, provider: provider.id, model: model.trim(), stages, keyAvailable, sources },
35
+ selection
36
+ };
37
+ }
38
+ export {
39
+ resolveAdvisorPolicy
40
+ };
@@ -0,0 +1,12 @@
1
+ const providers = [{
2
+ id: "typesafe",
3
+ apiKeyEnv: "TYPESAFE_API_KEY",
4
+ defaultModel: "jev-1.13.0",
5
+ assess: async (request, options) => (await import("./typesafe-client.js")).requestTypeSafe(request, options)
6
+ }];
7
+ function assessmentProvider(name) {
8
+ return providers.find((provider) => provider.id === name);
9
+ }
10
+ export {
11
+ assessmentProvider
12
+ };
@@ -12,7 +12,7 @@ const SPEC = {
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
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"] },
15
- { name: "config", desc: "Per-engineer locations: libraries.<name>, references.<adapter>", args: ["list", "path", "get", "set", "unset"], flags: ["--json"] },
15
+ { name: "config", desc: "Per-engineer locations and optional advisor settings", 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
18
  { name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast", "--watch", "--task"] },
@@ -27,7 +27,7 @@ const SPEC = {
27
27
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
28
28
  { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
29
29
  { name: "execution-template", desc: "Discover and materialize shared agent checklists", args: ["list", "materialize", "lint", "new"], flags: ["--dir", "--domain-dir", "--project-worker", "--project-name", "--package-templates", "--package-id", "--flow", "--run-mode", "--platform", "--domain", "--id", "--provenance", "--include-shadowed", "--no-include-shadowed", "--title", "--force", "--json"] },
30
- { name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
30
+ { name: "recipe-quality", desc: "Build canonical quality or request optional recipe planning/advice", args: ["build", "plan", "advise", "advisor-status"], flags: ["--input", "--output", "--recipe", "--task", "--evidence", "--params", "--adapter", "--library", "--advisor", "--advisor-model", "--advisor-stages", "--json"] },
31
31
  { name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
32
32
  { name: "prepare", desc: "Run the readiness chain and write the readiness record", flags: ["--target", "--platform", "--mobile-platform", "--artifacts-dir", "--runtime-dir", "--json"] },
33
33
  { name: "task", desc: "Create a task directory from a shared execution template", args: ["init"], flags: ["--flow", "--run-mode", "--platform", "--template", "--title", "--task-text", "--task-file", "--ticket", "--source-ref", "--surface", "--project", "--repo", "--attempt-id", "--var", "--task-dir-label", "--mode-preamble", "--addendum-file", "--mark-command", "--dir", "--domain-dir", "--project-worker", "--project-name", "--package-templates", "--package-id", "--domain", "--json"] },
@@ -289,11 +289,19 @@ const PUBLIC_COMMAND_CONTRACTS = {
289
289
  minimumPositionals: 1
290
290
  },
291
291
  "recipe-quality": {
292
- options: options(HELP, JSON, {
292
+ options: options(HELP, JSON, ADAPTER, {
293
293
  "--input": value(),
294
- "--output": value()
294
+ "--output": value(),
295
+ "--recipe": value(),
296
+ "--task": value(),
297
+ "--evidence": value(),
298
+ "--params": value(),
299
+ "--library": value(),
300
+ "--advisor": value(),
301
+ "--advisor-model": value(),
302
+ "--advisor-stages": value()
295
303
  }),
296
- positionals: [{ label: "action", choices: ["build"] }]
304
+ positionals: [{ label: "action", choices: ["build", "plan", "advise", "advisor-status"] }]
297
305
  },
298
306
  provision: {
299
307
  options: options(HELP, JSON, TARGET, ADAPTER, DEVICE, RUNWAY, {
@@ -2,11 +2,12 @@ import path from "node:path";
2
2
  import {
3
3
  configPath,
4
4
  readConfig,
5
+ parseAdvisorSettings,
5
6
  REVIEW_ADAPTERS,
6
7
  writeConfig
7
8
  } from "../review/knowledge.js";
8
9
  import { optionFlag, parseArgs, usageError } from "./parse-args.js";
9
- const USAGE = "Usage: mm-harness config <list|path|get <key>|set <key> <path>|unset <key>> [--json]\n keys: libraries.<name>, references.<mobile|extension|core>";
10
+ const USAGE = "Usage: mm-harness config <list|path|get <key>|set <key> <value>|unset <key>> [--json]\n keys: libraries.<name>, references.<mobile|extension|core>, advisor.<enabled|provider|model|stages>";
10
11
  function parseKey(raw) {
11
12
  if (!raw) throw usageError(USAGE);
12
13
  const dot = raw.indexOf(".");
@@ -14,21 +15,23 @@ function parseKey(raw) {
14
15
  const name = dot === -1 ? "" : raw.slice(dot + 1);
15
16
  if (!name) throw usageError(`Config key must be libraries.<name> or references.<adapter> (got ${raw}).`);
16
17
  if (section === "libraries") return { section, name };
18
+ if (section === "advisor" && ["enabled", "provider", "model", "stages"].includes(name)) return { section, name };
17
19
  if (section === "references") {
18
20
  if (!REVIEW_ADAPTERS.includes(name)) {
19
21
  throw usageError(`references.<adapter> must be one of ${REVIEW_ADAPTERS.join(", ")} (got ${name}).`);
20
22
  }
21
23
  return { section, name };
22
24
  }
23
- throw usageError(`Config key must start with libraries. or references. (got ${raw}).`);
25
+ throw usageError(`Unknown config key ${raw}. Next: use libraries.<name>, references.<adapter> or advisor.<enabled|provider|model|stages>.`);
24
26
  }
25
27
  function getValue(config, key) {
26
- return key.section === "libraries" ? config.libraries?.[key.name] : config.references?.[key.name];
28
+ return config[key.section]?.[key.name];
27
29
  }
28
30
  function setValue(config, key, value) {
29
31
  const section = config[key.section] ?? {};
30
32
  if (value === void 0) delete section[key.name];
31
33
  else section[key.name] = value;
34
+ if (key.section === "advisor") parseAdvisorSettings(section);
32
35
  if (Object.keys(section).length === 0) delete config[key.section];
33
36
  else config[key.section] = section;
34
37
  }
@@ -56,7 +59,8 @@ async function handleConfig(argv) {
56
59
  }
57
60
  const lines = [
58
61
  ...Object.entries(config.libraries ?? {}).map(([name, root]) => `libraries.${name}=${root}`),
59
- ...Object.entries(config.references ?? {}).map(([name, root]) => `references.${name}=${root}`)
62
+ ...Object.entries(config.references ?? {}).map(([name, root]) => `references.${name}=${root}`),
63
+ ...Object.entries(config.advisor ?? {}).map(([name, value]) => `advisor.${name}=${Array.isArray(value) ? value.join(",") : value}`)
60
64
  ];
61
65
  process.stdout.write(lines.length > 0 ? `${lines.join("\n")}
62
66
  ` : `(empty) ${file}
@@ -71,17 +75,18 @@ async function handleConfig(argv) {
71
75
  return 0;
72
76
  }
73
77
  if (value === void 0) return 1;
74
- process.stdout.write(`${value}
78
+ process.stdout.write(`${Array.isArray(value) ? value.join(",") : value}
75
79
  `);
76
80
  return 0;
77
81
  }
78
82
  if (action === "set") {
79
83
  const key = parseKey(rawKey);
80
- if (!rawValue) throw usageError(`mm-harness config set ${rawKey} <path>`);
81
- setValue(config, key, path.resolve(rawValue));
84
+ if (!rawValue) throw usageError(`mm-harness config set ${rawKey} <value>`);
85
+ const value = key.section !== "advisor" ? path.resolve(rawValue) : key.name === "enabled" ? rawValue === "true" ? true : rawValue === "false" ? false : rawValue : key.name === "stages" ? rawValue.split(",").map((stage) => stage.trim()) : rawValue.trim();
86
+ setValue(config, key, value);
82
87
  writeConfig(file, config);
83
- process.stdout.write(json ? `${JSON.stringify({ command: "config", key: rawKey, value: path.resolve(rawValue), path: file })}
84
- ` : `${rawKey}=${path.resolve(rawValue)}
88
+ process.stdout.write(json ? `${JSON.stringify({ command: "config", key: rawKey, value, path: file })}
89
+ ` : `${rawKey}=${Array.isArray(value) ? value.join(",") : value}
85
90
  `);
86
91
  return 0;
87
92
  }
@@ -7,7 +7,7 @@ import {
7
7
  import { color, stripAnsi } from "../cli-color.js";
8
8
  import { runnerProvenance } from "../doctor.js";
9
9
  import { detectAdapter } from "../harness.js";
10
- import { harnessExecutable, manifestPath, recipeRuntimeDir } from "../paths.js";
10
+ import { harnessExecutable, manifestPath, PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "../paths.js";
11
11
  import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
12
12
  import { EXIT } from "./shared.js";
13
13
  import { formatDuration } from "./status-watch.js";
@@ -336,13 +336,32 @@ async function handlePrepare(argv) {
336
336
  const remoteFlagArgs = remoteFlag === void 0 ? [] : ["--remote-flag", remoteFlag];
337
337
  let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
338
338
  const recordPath = path.join(artifactsDir, READINESS_RECORD);
339
+ const progressPath = path.join(artifactsDir, PREPARE_PROGRESS_ARTIFACT);
339
340
  const chain = stepChain(platform);
340
341
  const view = json ? void 0 : createStepView(chain, stepHints(chain, lastRunDurations(recordPath)));
341
342
  view?.begin();
342
343
  const steps = [];
344
+ let activeStep;
345
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
346
+ const writeProgress = () => {
347
+ fs.mkdirSync(path.dirname(progressPath), { recursive: true });
348
+ const temporaryPath = `${progressPath}.${process.pid}.tmp`;
349
+ fs.writeFileSync(temporaryPath, `${JSON.stringify({
350
+ schemaVersion: 1,
351
+ startedAt,
352
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
353
+ total: chain.length,
354
+ steps: steps.filter(({ id }) => chain.includes(id)).map(({ id, status, durationMs }) => ({ id, status, durationMs })),
355
+ ...activeStep ? { active: activeStep } : {}
356
+ })}
357
+ `);
358
+ fs.renameSync(temporaryPath, progressPath);
359
+ };
343
360
  const failed = () => steps.some((step) => step.status === "fail");
344
361
  const addStep = (step) => {
345
362
  steps.push(step);
363
+ activeStep = void 0;
364
+ writeProgress();
346
365
  view?.finish(step);
347
366
  return step;
348
367
  };
@@ -352,10 +371,17 @@ async function handlePrepare(argv) {
352
371
  }
353
372
  const step = await runStep(executable, target, artifactsDir, id, args, {
354
373
  json,
355
- onSpawn: (startedAt) => view?.start(id, startedAt)
374
+ onSpawn: (stepStartedAt) => {
375
+ activeStep = { id, startedAt: new Date(stepStartedAt).toISOString() };
376
+ writeProgress();
377
+ view?.start(id, stepStartedAt);
378
+ }
356
379
  });
357
380
  return addStep(step);
358
381
  };
382
+ const heartbeat = setInterval(() => {
383
+ if (activeStep) writeProgress();
384
+ }, 1e4);
359
385
  try {
360
386
  await run("doctor", ["doctor", "--fix", "--json", "--adapter", platform, ...targetArgs]);
361
387
  await run("status", ["status", "--json", ...targetArgs]);
@@ -384,6 +410,7 @@ async function handlePrepare(argv) {
384
410
  }
385
411
  await run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
386
412
  } finally {
413
+ clearInterval(heartbeat);
387
414
  view?.end();
388
415
  }
389
416
  const provenance = runnerProvenance(manifestPath(platform));
@@ -404,6 +431,7 @@ async function handlePrepare(argv) {
404
431
  fs.mkdirSync(artifactsDir, { recursive: true });
405
432
  fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}
406
433
  `);
434
+ fs.rmSync(progressPath, { force: true });
407
435
  if (json) {
408
436
  console.log(JSON.stringify(record, null, 2));
409
437
  } else {
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { adviseRecipe } from "../recipe-assistance.js";
4
+ import { resolveAdvisorPolicy } from "../advisor-policy.js";
5
+ import { RECIPE_ADVICE_RUBRIC, RECIPE_PLANNING_RUBRIC } from "../recipe-assistance-rubric.js";
6
+ import { CliError, isRecord, optionFlag, optionString, usageError } from "./parse-args.js";
7
+ import { EXIT } from "./shared.js";
8
+ async function handleRecipeAdvice(options, mode = "review") {
9
+ try {
10
+ return await runRecipeAdvice(options, mode);
11
+ } catch (error) {
12
+ if (!(error instanceof CliError)) throw error;
13
+ const userAction = "mm-harness recipe-quality --help";
14
+ if (optionFlag(options, "json")) console.log(JSON.stringify({ schemaVersion: 1, command: "recipe-quality", action: mode === "status" ? "advisor-status" : mode === "plan" ? "plan" : "advise", status: "fail", exitCode: error.exitCode, error: { code: error.exitCode === EXIT.validation ? "RECIPE_ADVICE_INVALID" : "RECIPE_ADVICE_USAGE", message: error.message, userAction } }, null, 2));
15
+ else console.error(`\u2717 mm-harness recipe-quality: ${error.message}
16
+ Next: ${userAction}`);
17
+ return error.exitCode;
18
+ }
19
+ }
20
+ async function runRecipeAdvice(options, mode) {
21
+ const { policy, selection } = resolveAdvisorPolicy(options);
22
+ if (mode === "status") {
23
+ if (optionFlag(options, "json")) console.log(JSON.stringify({ schemaVersion: 1, command: "recipe-quality", action: "advisor-status", ...policy }, null, 2));
24
+ else console.log(`Recipe advisor: ${policy.status}${policy.enabled ? ` (${policy.provider}, ${policy.model}; stages: ${policy.stages.join(", ")})` : ""}.`);
25
+ return EXIT.ok;
26
+ }
27
+ const recipe = optionString(options, "recipe");
28
+ const task = optionString(options, "task");
29
+ const adapter = optionString(options, "adapter");
30
+ const output = optionString(options, "output");
31
+ const evidence = optionString(options, "evidence");
32
+ if (mode === "review" && !recipe || !task || !["mobile", "extension", "core"].includes(adapter ?? "")) {
33
+ throw usageError("Use recipe-quality plan --task <task.json>, or advise --recipe <recipe.json> --task <TASK.md>, with --adapter <adapter> [--advisor <provider>].");
34
+ }
35
+ if (mode === "plan" && (recipe || evidence || optionString(options, "params"))) throw usageError("Planning runs before a recipe exists. Next: use advise to review a draft or evidence.");
36
+ if (optionString(options, "input")) throw usageError("--input belongs to recipe-quality build. Next: use --recipe and --task for advise.");
37
+ let params;
38
+ if (optionString(options, "params")) {
39
+ try {
40
+ const parsed = JSON.parse(optionString(options, "params"));
41
+ if (!isRecord(parsed)) throw usageError("--params must be a JSON object.");
42
+ params = parsed;
43
+ } catch (error) {
44
+ if (error instanceof SyntaxError) throw usageError("--params must be a JSON object.");
45
+ throw error;
46
+ }
47
+ }
48
+ if (output) {
49
+ const resolved = path.resolve(output);
50
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) throw usageError("Advice output must be a file. Next: use artifacts/recipe-advice.json.");
51
+ const canonical = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
52
+ const inputs = [recipe, task, evidence].filter((file) => Boolean(file)).map((file) => fs.existsSync(file) ? fs.realpathSync(file) : path.resolve(file));
53
+ if ([resolved, canonical].some((file) => /^recipe(?:-quality(?:-input)?)?\.json$/iu.test(path.basename(file))) || inputs.includes(canonical) || inputs.includes(resolved)) {
54
+ throw usageError("Advice output must be separate from recipes, task inputs, and recipe-quality.json. Next: use artifacts/recipe-advice.json.");
55
+ }
56
+ }
57
+ const stage = mode === "plan" ? "plan" : evidence ? "evidence" : "draft";
58
+ const explicitCall = options.advisor !== void 0 && options.advisor !== "off" && options.advisorStages === void 0;
59
+ const stageDisabled = policy.enabled && !explicitCall && !policy.stages.includes(stage);
60
+ const artifact = stageDisabled ? { schemaVersion: 1, advisory: true, rubric: mode === "plan" ? RECIPE_PLANNING_RUBRIC : RECIPE_ADVICE_RUBRIC, status: "skipped", reason: "stage_disabled" } : await adviseRecipe({ recipe, task, mode, adapter, evidence, params, options }, selection);
61
+ if (output && artifact.status !== "disabled") {
62
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
63
+ fs.writeFileSync(path.resolve(output), `${JSON.stringify(artifact, null, 2)}
64
+ `);
65
+ }
66
+ if (optionFlag(options, "json")) console.log(JSON.stringify({ schemaVersion: 1, command: "recipe-quality", action: mode === "plan" ? "plan" : "advise", ...artifact, policy, ...output && artifact.status !== "disabled" ? { outputPath: output } : {} }, null, 2));
67
+ else {
68
+ console.log(`Recipe advice: ${artifact.status}${"reason" in artifact ? ` (${artifact.reason})` : ""}.`);
69
+ if (artifact.status === "completed" && "checks" in artifact) for (const check of artifact.checks) console.log(` ${check.id}: ${check.outcome}${check.suggestion ? `. ${check.suggestion}` : ""}`);
70
+ if (artifact.status === "completed" && "assessments" in artifact) for (const assessment of artifact.assessments) console.log(` ${assessment.id}: ${assessment.classification}${assessment.candidate ? ` (${assessment.candidate.ref})` : ""}`);
71
+ }
72
+ return EXIT.ok;
73
+ }
74
+ export {
75
+ handleRecipeAdvice
76
+ };
@@ -37,11 +37,18 @@ async function handleRecipeQuality(argv) {
37
37
  const { positional, options } = parseArgs(argv, "recipe-quality");
38
38
  const json = optionFlag(options, "json");
39
39
  const action = positional[0];
40
+ if (action === "advise" || action === "plan" || action === "advisor-status") {
41
+ const { handleRecipeAdvice } = await import("./recipe-advice.js");
42
+ return handleRecipeAdvice(options, action === "advisor-status" ? "status" : action === "plan" ? "plan" : "review");
43
+ }
44
+ if (["advisor", "advisorModel", "advisorStages", "recipe", "task", "evidence", "adapter", "library", "params"].some((key) => options[key] !== void 0)) {
45
+ return usageOut(json, "recipe-quality", "advice options require the advise subcommand", "mm-harness recipe-quality advise --recipe <recipe.json> --task <TASK.md> --adapter <adapter> --advisor <provider>");
46
+ }
40
47
  if (!action && (optionString(options, "input") || optionString(options, "output"))) {
41
48
  return buildArtifact(options, json);
42
49
  }
43
50
  if (action !== "build") {
44
- const message = action ? `unknown action '${action}'` : "missing action: use build, or pass --input and --output";
51
+ const message = action ? `unknown action '${action}'` : "missing action: use build, plan, or advise";
45
52
  return usageOut(json, "recipe-quality", message, SHORTHAND_USAGE);
46
53
  }
47
54
  return buildArtifact(options, json);
@@ -1470,6 +1470,7 @@ export {
1470
1470
  preflightRecipe,
1471
1471
  prepareHeal,
1472
1472
  prepareRuntimeIfNeeded,
1473
+ recipeComposition,
1473
1474
  resolveMetaMaskLibrarySources,
1474
1475
  resolveRunRecipeArg,
1475
1476
  runOneNode,
@@ -103,9 +103,13 @@ function renderTaskView(view, title, explicitTask = false) {
103
103
  const contextLine = joinParts([
104
104
  view.libraries.length > 0 && `libraries ${out("info", view.libraries.map(libraryLabel).join(" "))}`,
105
105
  showTask && view.fixture && `fixture ${out("info", path.basename(view.fixture.fixture ?? "unknown"))} ${statusWord(out, view.fixture.status)}${view.fixture.walletReset ? out("warn", " reset") : ""}`,
106
- showTask && view.sandbox?.ready !== void 0 && `sandbox ${view.sandbox.ready ? out("ok", "ready") : out("warn", "not ready")}`
106
+ showTask && (view.prepare ? `sandbox ${out("info", "preparing")}` : view.sandbox?.ready !== void 0 && `sandbox ${view.sandbox.ready ? out("ok", "ready") : out("warn", "not ready")}`)
107
107
  ]);
108
108
  if (contextLine) lines.push(contextLine);
109
+ if (showTask && view.acceptance?.length) lines.push("", ...renderAcceptanceLines(view));
110
+ if (showTask && view.prepare) {
111
+ lines.push(`prepare ${out("info", view.prepare.step)} \xB7 ${view.prepare.completed}/${view.prepare.total} steps \xB7 ${formatDuration(view.prepare.elapsedMs)} elapsed`);
112
+ }
109
113
  const last = view.lastCommand;
110
114
  if (showTask && last) {
111
115
  lines.push(joinParts([
@@ -148,6 +152,19 @@ function renderTaskView(view, title, explicitTask = false) {
148
152
  }
149
153
  return lines;
150
154
  }
155
+ function renderAcceptanceLines(view) {
156
+ if (!view.acceptance?.length) return [];
157
+ const out = (style, text) => color(style, text, { stream: process.stdout });
158
+ const proven = view.acceptance.filter((row) => row.status?.verdict === "proven").length;
159
+ return [
160
+ `acceptance criteria ${proven}/${view.acceptance.length} proven`,
161
+ ...view.acceptance.map((row) => {
162
+ const verdict = row.status?.verdict ?? "no verdict";
163
+ const style = verdict === "proven" ? "ok" : verdict === "weak" ? "warn" : verdict === "no verdict" ? "dim" : "err";
164
+ return ` ${row.id} [${out(style, verdict)}] ${row.text}`;
165
+ })
166
+ ];
167
+ }
151
168
  function idleReason(view) {
152
169
  if (!view.taskDir) return "no task directory under temp/tasks";
153
170
  const { reason, idleMs, branchCarriesTicket } = view.activity;
@@ -161,15 +178,33 @@ function idleReason(view) {
161
178
  }
162
179
  function changedLines(previous, next) {
163
180
  if (!previous || previous.taskDir !== next.taskDir) {
164
- return [joinParts([
165
- path.basename(next.checkout.target),
166
- next.taskDir ? path.basename(next.taskDir) : "no-task-dir",
167
- `status=${next.signal?.status ?? "unknown"}`,
168
- `step=${next.signal?.step ? stepLabel(next.signal.step) : "none"}`
169
- ], " ")];
181
+ return [
182
+ joinParts([
183
+ path.basename(next.checkout.target),
184
+ next.taskDir ? path.basename(next.taskDir) : "no-task-dir",
185
+ `status=${next.signal?.status ?? "unknown"}`,
186
+ `step=${next.signal?.step ? stepLabel(next.signal.step) : "none"}`
187
+ ], " "),
188
+ ...(next.acceptance ?? []).map((row) => `${row.id} ${row.status?.verdict ?? "no verdict"} ${row.text}`),
189
+ ...next.prepare ? [`prepare ${next.prepare.step} started (${next.prepare.completed}/${next.prepare.total} steps)`] : []
190
+ ];
170
191
  }
171
192
  const lines = [];
193
+ for (const step of next.prepare?.steps ?? []) {
194
+ if (!previous.prepare?.steps.some((prior) => prior.id === step.id && prior.status === step.status)) {
195
+ lines.push(`prepare ${step.id} ${step.status} (${formatDuration(step.durationMs)})`);
196
+ }
197
+ }
198
+ if (next.prepare && (previous.prepare?.step !== next.prepare.step || previous.prepare.startedAt !== next.prepare.startedAt)) {
199
+ lines.push(`prepare ${next.prepare.step} started (${next.prepare.completed}/${next.prepare.total} steps)`);
200
+ }
172
201
  const before = new Map(previous.checklist.map((row) => [row.number, row.checked]));
202
+ const beforeAcceptance = new Map(previous.acceptance?.map((row) => [row.id, row.status?.verdict]));
203
+ for (const row of next.acceptance ?? []) {
204
+ if (beforeAcceptance.get(row.id) !== row.status?.verdict || !beforeAcceptance.has(row.id)) {
205
+ lines.push(`${row.id} ${row.status?.verdict ?? "no verdict"} ${row.text}`);
206
+ }
207
+ }
173
208
  for (const row of next.checklist) {
174
209
  if (!row.checked || before.get(row.number) === true) continue;
175
210
  lines.push(`checked ${row.number} ${row.label}${row.durationMs !== void 0 ? ` (${formatDuration(row.durationMs)})` : ""}`);
@@ -254,6 +289,7 @@ export {
254
289
  changedLines,
255
290
  formatDuration,
256
291
  handleStatusTaskView,
292
+ renderAcceptanceLines,
257
293
  renderTaskView,
258
294
  runStatusWatch
259
295
  };