@deeeed/metamask-harness 0.62.0 → 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,17 @@
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
+
5
16
  ## 0.62.0 - 2026-09-22
6
17
 
7
18
  ### Added
@@ -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 {
@@ -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
  };
@@ -20,7 +20,8 @@ import {
20
20
  renderMobileLiveBlock
21
21
  } from "./mobile-device-view.js";
22
22
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
23
- import { handleStatusTaskView } from "./status-watch.js";
23
+ import { handleStatusTaskView, renderAcceptanceLines } from "./status-watch.js";
24
+ import { collectTaskView, findTaskDir } from "../task-view.js";
24
25
  import { optionFlag, optionString, targetPath } from "./parse-args.js";
25
26
  async function handleStatus({ options }) {
26
27
  const target = targetPath(options);
@@ -106,6 +107,12 @@ async function handleStatus({ options }) {
106
107
  const out = (style, text) => color(style, text, { stream: process.stdout });
107
108
  console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
108
109
  console.log(renderCheckoutView(view));
110
+ if (!fast) {
111
+ const taskView = collectTaskView(target, findTaskDir(target), Date.now());
112
+ if (taskView.activity.ongoing && taskView.acceptance?.length) {
113
+ console.log(renderAcceptanceLines(taskView).join("\n"));
114
+ }
115
+ }
109
116
  if (adapter !== "mobile") renderFeatureFlagLine(staticFeatureFlags, out);
110
117
  if (deviceView) renderMobileDeviceList(deviceView, out);
111
118
  if (!fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
package/dist/devices.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
3
+ const IOS_DEVICE_DISCOVERY_TIMEOUT_MS = 3e4;
3
4
  function reportProbeFailure(errors, platform, error) {
4
5
  const code = error?.code;
5
6
  if (code === "ENOENT") return;
6
- const reason = code === "ETIMEDOUT" ? "timed out after 5 seconds" : "failed";
7
+ const timeoutSeconds = platform === "ios" ? IOS_DEVICE_DISCOVERY_TIMEOUT_MS / 1e3 : 5;
8
+ const reason = code === "ETIMEDOUT" ? `timed out after ${timeoutSeconds} seconds` : "failed";
7
9
  const probeCode = code ?? error?.status;
8
10
  errors.push({
9
11
  platform,
@@ -61,7 +63,7 @@ function listIosSimulators(scope, errors) {
61
63
  try {
62
64
  output = execFileSync("xcrun", ["simctl", "list", "devices", scope, "-j"], {
63
65
  encoding: "utf8",
64
- timeout: 5e3,
66
+ timeout: IOS_DEVICE_DISCOVERY_TIMEOUT_MS,
65
67
  stdio: ["ignore", "pipe", "ignore"]
66
68
  });
67
69
  } catch (error) {
@@ -192,8 +192,10 @@ Example:
192
192
  The task view reads task files only \u2014 no probe, no launch, no write \u2014 and shows
193
193
  the same facts whether a skill or a control plane started the run: slot and
194
194
  device isolation, template and flow, fixture and sandbox readiness, then the
195
- checklist with the time each row took and how long the current row has been
196
- running. --task without --watch prints it once. With --json, --watch emits one
195
+ checklist with the time each row took, how long the current row has been
196
+ running, and acceptance criteria with recorded verdicts. The home dashboard
197
+ also shows criteria for an ongoing task. --task without --watch prints it once.
198
+ With --json, --watch emits one
197
199
  object per change (newline-delimited) instead of redrawing.
198
200
 
199
201
  The checkout view (also under "view" in --json, and printed by doctor) is what
package/dist/paths.js CHANGED
@@ -5,6 +5,7 @@ const runnerDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
5
5
  const pathDefaults = readPathDefaults();
6
6
  const DEFAULT_RECIPE_RUNTIME_DIR = pathDefaults.recipeRuntimeDir;
7
7
  const DEFAULT_RECIPE_HARNESS_ROOT = pathDefaults.recipeHarnessRoot;
8
+ const PREPARE_PROGRESS_ARTIFACT = "prepare/progress.json";
8
9
  function recipeRuntimeDir() {
9
10
  return validateRelativeRecipePath("RECIPE_RUNTIME_DIR", process.env.RECIPE_RUNTIME_DIR || DEFAULT_RECIPE_RUNTIME_DIR);
10
11
  }
@@ -171,6 +172,7 @@ function isMissingPackageError(error, packageName) {
171
172
  export {
172
173
  DEFAULT_RECIPE_HARNESS_ROOT,
173
174
  DEFAULT_RECIPE_RUNTIME_DIR,
175
+ PREPARE_PROGRESS_ARTIFACT,
174
176
  assertAdapter,
175
177
  extensionIdPath,
176
178
  harnessExecutable,
package/dist/task-view.js CHANGED
@@ -2,6 +2,12 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { HANDOFF_INPUT, latestTaskDir } from "@farmslot/agent-runtime";
5
+ import {
6
+ ACCEPTANCE_STATUS_ARTIFACT,
7
+ acceptanceCriteriaView,
8
+ acceptanceCriterionId,
9
+ validateAcceptanceStatusLedger
10
+ } from "@farmslot/protocol/contracts/acceptance";
5
11
  import {
6
12
  checklistStepName,
7
13
  deriveChecklistStepDurations,
@@ -17,7 +23,7 @@ import {
17
23
  } from "@farmslot/protocol";
18
24
  import { readCommandJournal } from "./command-journal.js";
19
25
  import { resolveRuntimeContextPath } from "./harness.js";
20
- import { recipeRuntimeDir } from "./paths.js";
26
+ import { PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "./paths.js";
21
27
  import { parseLibraryPathEnv } from "./review/knowledge.js";
22
28
  const CHECKLIST_LABEL_LIMIT = 70;
23
29
  const TASK_SILENT_AFTER_MS = 10 * 60 * 1e3;
@@ -42,8 +48,22 @@ function collectTaskView(target, taskDir, now) {
42
48
  const checklist = readChecklistRows(path.join(taskDir, INTERACTIVE_CHECKLIST_MARKDOWN));
43
49
  const template = asObject(handoff?.executionTemplate);
44
50
  const task = asObject(handoff?.task);
51
+ const rawCriteria = task?.acceptanceCriteria;
52
+ if (rawCriteria !== void 0 && !Array.isArray(rawCriteria)) {
53
+ throw new Error(`${path.join(taskDir, HANDOFF_INPUT)}: task.acceptanceCriteria must be an array.`);
54
+ }
55
+ const criteria = rawCriteria?.map((text, index) => ({
56
+ id: acceptanceCriterionId(index),
57
+ text: String(text)
58
+ })).filter((criterion) => criterion.text.trim().length > 0) ?? [];
59
+ const rawLedger = readJsonObject(path.join(taskDir, ACCEPTANCE_STATUS_ARTIFACT));
60
+ if (rawLedger) {
61
+ const issues = validateAcceptanceStatusLedger(rawLedger);
62
+ if (issues.length > 0) throw new Error(`${ACCEPTANCE_STATUS_ARTIFACT}: ${issues.join("; ")}`);
63
+ }
45
64
  const startedAtIso = pickString(handoff, "startedAt");
46
65
  const startedAt = parseTime(startedAtIso);
66
+ const prepare = readPrepareProgress(taskDir, now, startedAt, parseTime(pickString(sandbox, "recordedAt")));
47
67
  const timing = applyTiming(checklist, signal, startedAtIso, now);
48
68
  const ticket = pickString(task, "ticket");
49
69
  const journal = readLastCommand(target, now);
@@ -56,7 +76,8 @@ function collectTaskView(target, taskDir, now) {
56
76
  startedAt,
57
77
  parseTime(pickString(signal, "timestamp")),
58
78
  ...readMarkEvents(signal).map((event) => Date.parse(event.checkedAt)),
59
- commandCounts ? commandAt : void 0
79
+ commandCounts ? commandAt : void 0,
80
+ prepare ? parseTime(prepare.updatedAt) : void 0
60
81
  ],
61
82
  branch: base.checkout.branch,
62
83
  ticket,
@@ -79,6 +100,7 @@ function collectTaskView(target, taskDir, now) {
79
100
  title: pickString(task, "title"),
80
101
  ticket
81
102
  }),
103
+ ...criteria.length > 0 || rawLedger ? { acceptance: acceptanceCriteriaView(criteria, rawLedger ? rawLedger : null) } : {},
82
104
  fixture: section({
83
105
  fixture: pickString(fixtures, "fixture"),
84
106
  canonicalFixture: pickString(fixtures, "canonicalFixture"),
@@ -86,6 +108,7 @@ function collectTaskView(target, taskDir, now) {
86
108
  walletReset: pickBoolean(fixtures, "walletReset")
87
109
  }),
88
110
  sandbox: section({ ready: pickBoolean(sandbox, "ready") }),
111
+ ...prepare ? { prepare } : {},
89
112
  checklist,
90
113
  subtasks: readSubtasks(taskDir),
91
114
  signal: section({
@@ -99,6 +122,26 @@ function collectTaskView(target, taskDir, now) {
99
122
  activity
100
123
  };
101
124
  }
125
+ function readPrepareProgress(taskDir, now, taskStartedAt, recordedAt) {
126
+ const progress = readJsonObject(path.join(taskDir, "artifacts", PREPARE_PROGRESS_ARTIFACT));
127
+ const active = asObject(progress?.active);
128
+ const step = pickString(active, "id");
129
+ const startedAt = pickString(active, "startedAt");
130
+ const stepStartedAt = parseTime(startedAt);
131
+ const runStartedAt = parseTime(pickString(progress, "startedAt"));
132
+ const updatedAtIso = pickString(progress, "updatedAt");
133
+ const updatedAt = parseTime(updatedAtIso);
134
+ const total = pickNumber(progress, "total");
135
+ if (!step || !["doctor", "status", "launch", "fixtures", "verify"].includes(step) || stepStartedAt === void 0 || runStartedAt === void 0 || updatedAt === void 0 || now - updatedAt > 3e4 || total === void 0 || total < 1 || taskStartedAt !== void 0 && runStartedAt < taskStartedAt || recordedAt !== void 0 && recordedAt >= runStartedAt) return void 0;
136
+ const steps = Array.isArray(progress?.steps) ? progress.steps.flatMap((value) => {
137
+ const item = asObject(value);
138
+ const id = pickString(item, "id");
139
+ const status = pickString(item, "status");
140
+ const durationMs = pickNumber(item, "durationMs");
141
+ return id && ["doctor", "status", "launch", "fixtures", "verify"].includes(id) && status && ["pass", "fail", "skipped"].includes(status) && durationMs !== void 0 ? [{ id, status, durationMs }] : [];
142
+ }) : [];
143
+ return { step, startedAt, updatedAt: updatedAtIso, elapsedMs: Math.max(0, now - stepStartedAt), completed: steps.length, total, steps };
144
+ }
102
145
  function readLastCommand(target, now) {
103
146
  const { record } = readCommandJournal(target);
104
147
  if (!record) return void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"