@deeeed/metamask-harness 0.55.1 → 0.57.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,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.57.0 - 2026-09-18
6
+
7
+ ### Changed
8
+
9
+ - The task and checklist rules the harness shares with Farmslot now come from the Farmslot packages instead of private copies, against `@farmslot/protocol` 0.29.0 and `@farmslot/agent-runtime` 0.11.0 (`@farmslot/recipe-harness` 0.17.0, `@farmslot/expo-recipe` 0.10.0 follow the protocol pin). Which signal statuses are terminal: `isTerminalWorkerSignalStatus`, so `mark` now also protects a `failed` or `done` verdict from a later non-terminal mark, and `status --watch` no longer treats an unknown status as finished. Which box is step N: `enumerateChecklistCheckboxes`, so the `check diff` gate reads the row `mark N` ticks even when an upstream checklist sits under `## Rules` or inside `<details>`. Per-row durations: `deriveChecklistStepDurations`, the same numbers Command Center shows (a task without a recorded start now shows its first row as `0:00` rather than blank). Step names: `checklistStepName`. Which task a checkout is on: `latestTaskDir`, the task written to last by signal or by checklist. File names come from the protocol constants.
10
+ - `status --watch` reads the harness identity from the readiness record (`sandbox.json`) `prepare` writes on every surface; the `artifacts/harness-provenance.json` it looked for is the recipe-cook skill's locked-harness receipt and exists on skill runs only, so the `harness x.y.z` line was blank on every dispatched run.
11
+
12
+ ### Fixed
13
+
14
+ - `status --watch` no longer reports `no ongoing task detected` while an agent is busy on a long step. The view judged activity only by task-dir writes (`SIGNAL.json`, row marks) and called the task silent after ten minutes, so an investigation step that ran `mm-harness call` every few seconds without ticking a row read as abandoned. The command journal (`temp/recipe/runtime/last-command.json`) now counts as activity, and the view shows it: `last command call ui.navigate · pass · 0:12 ago`. Only the command name and a leading positional argument reach the view; option values never do, and the journal is read through the same hardened reader the harness uses (symlinks and malformed records mean no command line, never a dead watch). A journal older than the task's start belongs to an earlier task and does not count. Piped output logs `command call ui.navigate pass` when the journal moves. `status` itself is not journaled, so watching a checkout never counts as its own activity.
15
+ - `status --watch` renders a Farm checklist the way the Farmslot run view does. Steps, numbers and phases now come from `enumerateChecklistCheckboxes` in `@farmslot/protocol`, the same enumeration the gateway progress view and the `mark` helper use, instead of a private row pattern that rejected Farm's `- [x] **3. Resolve branch:**` rows and only knew `##` headings. A dispatched run therefore showed no rows at all; now it shows `Setup 5/5`, `Reproduce (BEFORE any code changes) 3/9`, … with the bold title as each step's name.
16
+
17
+ ## 0.56.0 - 2026-09-18
18
+
19
+ ### Changed
20
+
21
+ - `prepare` says what it is doing while it does it. It now opens with the whole chain — `doctor`, `status`, `launch`, `fixtures`, `verify`, or just `doctor`, `status`, `verify` on Core — one line each, so the wait is bounded before the first step runs. The running step counts up in place (`… launch: running 1:23`), each finished one keeps how long it took (`✓ launch: pass (0:47)`), and the slow steps carry a dim note saying what they are waiting on: `can take minutes on Mobile: Metro bundle and app start` for `launch`, `under a minute` for `fixtures`. Where the checkout has a previous `sandbox.json`, each step also shows what it cost last time (`last run 6:52`) — the only honest estimate the harness has. Before this, a Mobile prepare printed `✓ status: pass` and then nothing for the minutes the launch step ran, which reads as a hang. The clock on screen is the wall clock: one timestamp taken at the spawn feeds both the ticker and the `durationMs` in the record, and the repaint holds the event loop awake so a step that stays quiet for minutes — `verify` waiting on its bridge probe — cannot leave the count frozen behind the wall. Piped output is a plain log — the list, then each step as it lands, then the finished list. `--json` is untouched: stdout stays a single document.
22
+ - `sandbox.json` records `durationMs` on every readiness step (`0` for a skipped one), so a run that felt slow can be read back step by step instead of retimed by hand.
23
+
5
24
  ## 0.55.1 - 2026-09-18
6
25
 
7
26
  ### Fixed
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
+ import { HANDOFF_INPUT } from "@farmslot/agent-runtime";
6
+ import { enumerateChecklistCheckboxes, isTerminalWorkerSignalStatus } from "@farmslot/protocol";
5
7
  import { EXIT } from "./shared.js";
6
8
  const require2 = createRequire(import.meta.url);
7
9
  const MARK_SCRIPT = require2.resolve(
@@ -88,7 +90,7 @@ function runCloseout(taskDir, args, stdio) {
88
90
  }
89
91
  }
90
92
  function stageLearningPackage(taskDir, step, markArgs) {
91
- if (!["complete", "no-change"].includes(step ?? "") || markArgs.includes("--skip-learnings") || !fs.existsSync(path.join(taskDir, "inputs", "handoff.json"))) {
93
+ if (!["complete", "no-change"].includes(step ?? "") || markArgs.includes("--skip-learnings") || !fs.existsSync(path.join(taskDir, HANDOFF_INPUT))) {
92
94
  return;
93
95
  }
94
96
  try {
@@ -108,7 +110,7 @@ function terminalSignalStatus(taskDir) {
108
110
  } catch {
109
111
  return null;
110
112
  }
111
- return signal.status === "complete" || signal.status === "blocked" ? signal.status : null;
113
+ return isTerminalWorkerSignalStatus(signal.status) ? signal.status ?? null : null;
112
114
  }
113
115
  function preserveTerminalSignal(taskDir, step) {
114
116
  if (step !== "start" && numericMarkStep(step) === null) return false;
@@ -124,20 +126,8 @@ function numericMarkStep(step) {
124
126
  return Number.isInteger(stepNumber) && stepNumber >= 1 ? stepNumber : null;
125
127
  }
126
128
  function checklistStepRequiresDiffPass(checklist, stepNumber) {
127
- let inFence = false;
128
- let seen = 0;
129
- for (const line of checklist.split(/\n/u)) {
130
- if (/^\s*```/u.test(line)) {
131
- inFence = !inFence;
132
- continue;
133
- }
134
- if (inFence) continue;
135
- const match = line.match(/^\s*[-*]\s+\[(?: |x|X)\]\s+(.*)$/u);
136
- if (!match) continue;
137
- seen += 1;
138
- if (seen === stepNumber) return /mm-harness check diff/u.test(match[1] ?? "");
139
- }
140
- return false;
129
+ const step = enumerateChecklistCheckboxes(checklist).find((item) => item.stepNumber === stepNumber);
130
+ return step !== void 0 && /mm-harness check diff/u.test(step.rawLabel);
141
131
  }
142
132
  function checkDiffGateReady(taskDir, step) {
143
133
  const stepNumber = numericMarkStep(step);
@@ -1,14 +1,16 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import {
5
5
  READINESS_RECORD
6
6
  } from "@farmslot/protocol";
7
+ import { color, stripAnsi } from "../cli-color.js";
7
8
  import { runnerProvenance } from "../doctor.js";
8
9
  import { detectAdapter } from "../harness.js";
9
10
  import { harnessExecutable, manifestPath, recipeRuntimeDir } from "../paths.js";
10
11
  import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
11
12
  import { EXIT } from "./shared.js";
13
+ import { formatDuration } from "./status-watch.js";
12
14
  const USAGE = "mm-harness prepare [--target <checkout>] [--platform mobile|extension|core] [--mobile-platform ios|android] [--clear-metro] [--artifacts-dir <dir>] [--json]";
13
15
  const REPORT_DIR = "prepare";
14
16
  const SKIPPED_AFTER_FAILURE = "previous step failed";
@@ -26,31 +28,175 @@ function reportedError(stdout) {
26
28
  return typeof message === "string" && message ? message : void 0;
27
29
  }
28
30
  function skippedStep(id, command, reason) {
29
- return { id, command, status: "skipped", exitCode: 0, reason };
31
+ return { id, command, status: "skipped", exitCode: 0, reason, durationMs: 0 };
30
32
  }
31
- const STEP_MARK = { pass: "\u2713", fail: "\u2717", skipped: "\u2013" };
32
- function stepLine(step) {
33
- return `${STEP_MARK[step.status]} ${step.id}: ${step.status}${step.reason ? ` \u2014 ${step.reason}` : ""}`;
33
+ const STEP_MARK = {
34
+ pending: "\xB7",
35
+ running: "\u2026",
36
+ pass: "\u2713",
37
+ fail: "\u2717",
38
+ skipped: "\u2013"
39
+ };
40
+ const STEP_STYLE = {
41
+ pending: "dim",
42
+ running: "accent",
43
+ pass: "ok",
44
+ fail: "err",
45
+ skipped: "dim"
46
+ };
47
+ const STEP_HINT = {
48
+ launch: "can take minutes on Mobile: Metro bundle and app start",
49
+ fixtures: "under a minute"
50
+ };
51
+ function stepChain(platform) {
52
+ return platform === "core" ? ["doctor", "status", "verify"] : ["doctor", "status", "launch", "fixtures", "verify"];
34
53
  }
35
- function runStep(executable, target, artifactsDir, id, args, { json }) {
36
- const command = [executable, ...args].join(" ");
37
- const result = spawnSync(executable, [...args], {
38
- cwd: target,
39
- encoding: "utf8",
40
- // The update nudge is an interactive courtesy; it has no place in the
41
- // stream a recorded readiness step captures.
42
- env: { ...process.env, MM_HARNESS_NO_UPDATE_CHECK: "1" }
54
+ function lastRunDurations(recordPath) {
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(fs.readFileSync(recordPath, "utf8"));
58
+ } catch {
59
+ return {};
60
+ }
61
+ const steps = parsed?.steps;
62
+ if (!Array.isArray(steps)) return {};
63
+ const durations = {};
64
+ for (const step of steps) {
65
+ if (typeof step?.id === "string" && typeof step.durationMs === "number" && step.durationMs > 0) {
66
+ durations[step.id] = step.durationMs;
67
+ }
68
+ }
69
+ return durations;
70
+ }
71
+ function stepHints(chain, lastRun) {
72
+ const hints = {};
73
+ for (const id of chain) {
74
+ const previous = lastRun[id];
75
+ const parts = [];
76
+ if (STEP_HINT[id]) parts.push(STEP_HINT[id]);
77
+ if (previous !== void 0) parts.push(`last run ${formatDuration(previous)}`);
78
+ if (parts.length > 0) hints[id] = parts.join(", ");
79
+ }
80
+ return hints;
81
+ }
82
+ function stepRowLine(row, width) {
83
+ const out = (style, text) => color(style, text, { stream: process.stdout });
84
+ const running = row.state === "pending" || row.state === "running";
85
+ const elapsed = row.state === "running" && row.startedAt !== void 0 ? ` ${out("dim", formatDuration(Date.now() - row.startedAt))}` : "";
86
+ const note = running ? row.hint : row.durationMs ? formatDuration(row.durationMs) : void 0;
87
+ const mark = out(STEP_STYLE[row.state], STEP_MARK[row.state]);
88
+ return `${mark} ${out(running ? "bold" : "dim", row.id.padEnd(width))}: ${out(STEP_STYLE[row.state], row.state)}${elapsed}${row.reason ? ` \u2014 ${row.reason}` : ""}${note ? ` ${out("dim", `(${note})`)}` : ""}`;
89
+ }
90
+ function createStepView(chain, hints) {
91
+ const rows = new Map(
92
+ chain.map((id) => [id, { id, state: "pending", hint: hints[id] }])
93
+ );
94
+ const width = Math.max(...chain.map((id) => id.length));
95
+ const tty = Boolean(process.stdout.isTTY);
96
+ const block = () => {
97
+ const terminalColumns = process.stdout.columns;
98
+ const columns = tty && typeof terminalColumns === "number" && terminalColumns > 0 ? terminalColumns : Number.POSITIVE_INFINITY;
99
+ return [...rows.values()].map((row) => {
100
+ const line = stepRowLine(row, width);
101
+ return stripAnsi(line).length <= columns ? line : stripAnsi(line).slice(0, columns);
102
+ });
103
+ };
104
+ let painted = 0;
105
+ let lastFrame = "";
106
+ let ended = false;
107
+ let timer;
108
+ const repaint = () => {
109
+ const lines = block();
110
+ const frame = lines.join("\n");
111
+ if (frame === lastFrame) return;
112
+ lastFrame = frame;
113
+ const up = painted > 0 ? `\x1B[${painted}A` : "";
114
+ painted = lines.length;
115
+ process.stdout.write(`${up}${lines.map((line) => `\x1B[2K${line}`).join("\n")}
116
+ `);
117
+ };
118
+ const writeBlock = () => {
119
+ process.stdout.write(`${block().join("\n")}
120
+ `);
121
+ };
122
+ return {
123
+ begin() {
124
+ if (tty) repaint();
125
+ else writeBlock();
126
+ },
127
+ start(id, startedAt) {
128
+ const row = rows.get(id);
129
+ if (!row) return;
130
+ row.state = "running";
131
+ row.startedAt = startedAt;
132
+ if (!tty) return;
133
+ repaint();
134
+ timer = setInterval(repaint, 250);
135
+ },
136
+ finish(step) {
137
+ clearInterval(timer);
138
+ timer = void 0;
139
+ const row = rows.get(step.id);
140
+ if (!row) return;
141
+ row.state = step.status;
142
+ row.startedAt = void 0;
143
+ row.reason = step.reason;
144
+ row.durationMs = step.durationMs;
145
+ if (tty) repaint();
146
+ else process.stdout.write(`${stepRowLine(row, width)}
147
+ `);
148
+ },
149
+ // Idempotent: the caller ends the view where the final block belongs in the
150
+ // output, and a `finally` ends it again on the paths that never got there.
151
+ // The ticker holds the event loop awake, so this must run either way.
152
+ end() {
153
+ if (ended) return;
154
+ ended = true;
155
+ clearInterval(timer);
156
+ timer = void 0;
157
+ if (!tty) writeBlock();
158
+ }
159
+ };
160
+ }
161
+ function spawnStep(executable, args, target) {
162
+ return new Promise((resolve) => {
163
+ const child = spawn(executable, [...args], {
164
+ cwd: target,
165
+ stdio: ["ignore", "pipe", "pipe"],
166
+ // The update nudge is an interactive courtesy; it has no place in the
167
+ // stream a recorded readiness step captures.
168
+ env: { ...process.env, MM_HARNESS_NO_UPDATE_CHECK: "1" }
169
+ });
170
+ let stdout = "";
171
+ let stderr = "";
172
+ child.stdout?.setEncoding("utf8");
173
+ child.stderr?.setEncoding("utf8");
174
+ child.stdout?.on("data", (chunk) => {
175
+ stdout += chunk;
176
+ });
177
+ child.stderr?.on("data", (chunk) => {
178
+ stderr += chunk;
179
+ });
180
+ child.on("error", (error) => resolve({ error, status: null, stdout, stderr }));
181
+ child.on("close", (status) => resolve({ status, stdout, stderr }));
43
182
  });
183
+ }
184
+ async function runStep(executable, target, artifactsDir, id, args, { json, onSpawn }) {
185
+ const command = [executable, ...args].join(" ");
186
+ const startedAt = Date.now();
187
+ onSpawn?.(startedAt);
188
+ const result = await spawnStep(executable, args, target);
189
+ const durationMs = Date.now() - startedAt;
44
190
  if (result.error) {
45
- return { id, command, status: "fail", exitCode: 1, reason: result.error.message };
191
+ return { id, command, status: "fail", exitCode: 1, reason: result.error.message, durationMs };
46
192
  }
47
193
  const report = reportPathFor(id);
48
194
  const file = path.join(artifactsDir, report);
49
195
  fs.mkdirSync(path.dirname(file), { recursive: true });
50
- fs.writeFileSync(file, result.stdout ?? "");
196
+ fs.writeFileSync(file, result.stdout);
51
197
  const exitCode = result.status ?? 1;
52
198
  if (exitCode === 0) {
53
- return { id, command, status: "pass", exitCode, reportPath: report };
199
+ return { id, command, status: "pass", exitCode, reportPath: report, durationMs };
54
200
  }
55
201
  if (!json && result.stderr) process.stderr.write(result.stderr);
56
202
  return {
@@ -59,7 +205,8 @@ function runStep(executable, target, artifactsDir, id, args, { json }) {
59
205
  status: "fail",
60
206
  exitCode,
61
207
  reportPath: report,
62
- reason: reportedError(result.stdout) ?? `exited ${exitCode}`
208
+ reason: reportedError(result.stdout) ?? `exited ${exitCode}`,
209
+ durationMs
63
210
  };
64
211
  }
65
212
  function readDevices(artifactsDir) {
@@ -172,41 +319,56 @@ async function handlePrepare(argv) {
172
319
  Next: ${USAGE}`);
173
320
  }
174
321
  let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
322
+ const recordPath = path.join(artifactsDir, READINESS_RECORD);
323
+ const chain = stepChain(platform);
324
+ const view = json ? void 0 : createStepView(chain, stepHints(chain, lastRunDurations(recordPath)));
325
+ view?.begin();
175
326
  const steps = [];
176
327
  const failed = () => steps.some((step) => step.status === "fail");
177
328
  const addStep = (step) => {
178
329
  steps.push(step);
179
- if (!json) console.log(stepLine(step));
330
+ view?.finish(step);
180
331
  return step;
181
332
  };
182
- const run = (id, args) => addStep(
183
- failed() ? skippedStep(id, [executable, ...args].join(" "), SKIPPED_AFTER_FAILURE) : runStep(executable, target, artifactsDir, id, args, { json })
184
- );
185
- run("doctor", ["doctor", "--fix", "--json", "--adapter", platform, ...targetArgs]);
186
- run("status", ["status", "--json", ...targetArgs]);
187
- if (platform === "mobile" && !mobilePlatform && !failed()) {
188
- mobilePlatform = mobilePlatformFromConfiguredTarget(process.env, readRuntimeContextTarget(target)) ?? mobilePlatformFromDevices(readDevices(artifactsDir));
189
- if (!mobilePlatform) return ambiguousMobileTarget(json);
190
- }
191
- if (platform === "core") {
192
- const reason = "core is headless: no app surface to launch or seed";
193
- addStep(skippedStep("launch", `${executable} launch`, reason));
194
- addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
195
- } else if (platform === "mobile") {
196
- const device = mobilePlatform ? [mobilePlatform] : [];
197
- run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], "--verify", "--json", ...targetArgs]);
198
- run("fixtures", [
199
- "fixtures",
200
- "set",
201
- ...mobilePlatform ? ["--platform", mobilePlatform] : [],
202
- "--json",
203
- ...targetArgs
204
- ]);
205
- } else {
206
- run("launch", ["launch", "--verify", "--json", ...targetArgs]);
207
- run("fixtures", ["fixtures", "set", "--json", ...targetArgs]);
333
+ const run = async (id, args) => {
334
+ if (failed()) {
335
+ return addStep(skippedStep(id, [executable, ...args].join(" "), SKIPPED_AFTER_FAILURE));
336
+ }
337
+ const step = await runStep(executable, target, artifactsDir, id, args, {
338
+ json,
339
+ onSpawn: (startedAt) => view?.start(id, startedAt)
340
+ });
341
+ return addStep(step);
342
+ };
343
+ try {
344
+ await run("doctor", ["doctor", "--fix", "--json", "--adapter", platform, ...targetArgs]);
345
+ await run("status", ["status", "--json", ...targetArgs]);
346
+ if (platform === "mobile" && !mobilePlatform && !failed()) {
347
+ mobilePlatform = mobilePlatformFromConfiguredTarget(process.env, readRuntimeContextTarget(target)) ?? mobilePlatformFromDevices(readDevices(artifactsDir));
348
+ if (!mobilePlatform) return ambiguousMobileTarget(json);
349
+ }
350
+ if (platform === "core") {
351
+ const reason = "core is headless: no app surface to launch or seed";
352
+ addStep(skippedStep("launch", `${executable} launch`, reason));
353
+ addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
354
+ } else if (platform === "mobile") {
355
+ const device = mobilePlatform ? [mobilePlatform] : [];
356
+ await run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], "--verify", "--json", ...targetArgs]);
357
+ await run("fixtures", [
358
+ "fixtures",
359
+ "set",
360
+ ...mobilePlatform ? ["--platform", mobilePlatform] : [],
361
+ "--json",
362
+ ...targetArgs
363
+ ]);
364
+ } else {
365
+ await run("launch", ["launch", "--verify", "--json", ...targetArgs]);
366
+ await run("fixtures", ["fixtures", "set", "--json", ...targetArgs]);
367
+ }
368
+ await run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
369
+ } finally {
370
+ view?.end();
208
371
  }
209
- run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
210
372
  const provenance = runnerProvenance(manifestPath(platform));
211
373
  const record = {
212
374
  schemaVersion: 1,
@@ -222,7 +384,6 @@ async function handlePrepare(argv) {
222
384
  ready: steps.every((step) => step.status !== "fail"),
223
385
  recordedAt: (/* @__PURE__ */ new Date()).toISOString()
224
386
  };
225
- const recordPath = path.join(artifactsDir, READINESS_RECORD);
226
387
  fs.mkdirSync(artifactsDir, { recursive: true });
227
388
  fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}
228
389
  `);
@@ -1,11 +1,11 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
+ import { isTerminalWorkerSignalStatus } from "@farmslot/protocol";
3
4
  import { color } from "../cli-color.js";
4
5
  import {
5
6
  CHECKLIST_LABEL_LIMIT,
6
7
  collectTaskView,
7
8
  findTaskDir,
8
- isTerminalStatus,
9
9
  TASK_SILENT_AFTER_MS
10
10
  } from "../task-view.js";
11
11
  import { EXIT } from "./shared.js";
@@ -59,7 +59,7 @@ async function runStatusWatch(options, deps) {
59
59
  `);
60
60
  }
61
61
  previous = view;
62
- const terminal = isTerminalStatus(view.signal?.status);
62
+ const terminal = isTerminalWorkerSignalStatus(view.signal?.status);
63
63
  if (view.activity.ongoing) sawInProgress = true;
64
64
  if (!options.watch || terminal && (sawInProgress || options.taskDir !== void 0)) return EXIT.ok;
65
65
  await deps.wait(REFRESH_MS);
@@ -106,6 +106,14 @@ function renderTaskView(view, title, explicitTask = false) {
106
106
  showTask && 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
+ const last = view.lastCommand;
110
+ if (showTask && last) {
111
+ lines.push(joinParts([
112
+ `last command ${out("info", joinParts([last.command, last.subject], " "))}`,
113
+ last.verdict && statusWord(out, last.verdict),
114
+ last.ageMs !== void 0 && `${formatDuration(last.ageMs)} ago`
115
+ ]));
116
+ }
109
117
  lines.push("");
110
118
  if (!view.activity.ongoing) {
111
119
  lines.push(out("warn", "no ongoing task detected"));
@@ -169,6 +177,9 @@ function changedLines(previous, next) {
169
177
  if (previous.signal?.status !== next.signal?.status) {
170
178
  lines.push(`status ${next.signal?.status ?? "unknown"}`);
171
179
  }
180
+ if (next.lastCommand && previous.lastCommand?.at !== next.lastCommand.at) {
181
+ lines.push(`command ${joinParts([next.lastCommand.command, next.lastCommand.subject], " ")}${next.lastCommand.verdict ? ` ${next.lastCommand.verdict}` : ""}`);
182
+ }
172
183
  return lines;
173
184
  }
174
185
  function formatDuration(ms) {
package/dist/task-view.js CHANGED
@@ -1,34 +1,24 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { HANDOFF_INPUT, latestTaskDir } from "@farmslot/agent-runtime";
5
+ import {
6
+ checklistStepName,
7
+ deriveChecklistStepDurations,
8
+ enumerateChecklistCheckboxes,
9
+ INTERACTIVE_CHECKLIST_MARKDOWN,
10
+ isTerminalWorkerSignalStatus,
11
+ READINESS_RECORD,
12
+ WORKER_SIGNAL_FILE
13
+ } from "@farmslot/protocol";
14
+ import { readCommandJournal } from "./command-journal.js";
4
15
  import { resolveRuntimeContextPath } from "./harness.js";
16
+ import { recipeRuntimeDir } from "./paths.js";
5
17
  import { parseLibraryPathEnv } from "./review/knowledge.js";
6
- const TASK_SEARCH_DEPTH = 3;
7
18
  const CHECKLIST_LABEL_LIMIT = 70;
8
- const CHECKLIST_ROW = /^\s*-\s\[([ xX])\]\s*(\d+)\.\s*(.*)$/u;
9
- const CHECKLIST_PHASE = /^##\s+(.+?)\s*$/u;
10
19
  const TASK_SILENT_AFTER_MS = 10 * 60 * 1e3;
11
20
  function findTaskDir(target) {
12
- const root = path.join(target, "temp", "tasks");
13
- let bySignal;
14
- let byChecklist;
15
- const consider = (best, dir, file) => {
16
- const stat = fs.statSync(path.join(dir, file), { throwIfNoEntry: false });
17
- if (!stat?.isFile()) return best;
18
- return best && best.mtimeMs >= stat.mtimeMs ? best : { dir, mtimeMs: stat.mtimeMs };
19
- };
20
- const visit = (dir, depth) => {
21
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
22
- if (!entry.isDirectory()) continue;
23
- const child = path.join(dir, entry.name);
24
- bySignal = consider(bySignal, child, "SIGNAL.json");
25
- byChecklist = consider(byChecklist, child, "CHECKLIST.md");
26
- if (depth < TASK_SEARCH_DEPTH) visit(child, depth + 1);
27
- }
28
- };
29
- if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) return void 0;
30
- visit(root, 1);
31
- return (bySignal ?? byChecklist)?.dir;
21
+ return latestTaskDir(path.join(target, "temp", "tasks"));
32
22
  }
33
23
  function collectTaskView(target, taskDir, now) {
34
24
  const base = {
@@ -39,20 +29,29 @@ function collectTaskView(target, taskDir, now) {
39
29
  )
40
30
  };
41
31
  if (!taskDir) return { ...base, checklist: [], activity: { ongoing: false, reason: "no-task" } };
42
- const handoff = readJsonObject(path.join(taskDir, "inputs", "handoff.json"));
43
- const provenance = readJsonObject(path.join(taskDir, "artifacts", "harness-provenance.json"));
32
+ const handoff = readJsonObject(path.join(taskDir, HANDOFF_INPUT));
44
33
  const fixtures = readJsonObject(path.join(taskDir, "artifacts", "prepare", "fixtures.json"));
45
- const sandbox = readJsonObject(path.join(taskDir, "artifacts", "sandbox.json"));
46
- const signal = readJsonObject(path.join(taskDir, "SIGNAL.json"));
47
- const checklist = readChecklistRows(path.join(taskDir, "CHECKLIST.md"));
34
+ const sandbox = readJsonObject(path.join(taskDir, "artifacts", READINESS_RECORD)) ?? readJsonObject(path.join(target, recipeRuntimeDir(), READINESS_RECORD));
35
+ const signal = readJsonObject(path.join(taskDir, WORKER_SIGNAL_FILE));
36
+ const checklist = readChecklistRows(path.join(taskDir, INTERACTIVE_CHECKLIST_MARKDOWN));
48
37
  const template = asObject(handoff?.executionTemplate);
49
38
  const task = asObject(handoff?.task);
50
- const startedAt = parseTime(pickString(handoff, "startedAt"));
51
- const timing = applyTiming(checklist, signal, startedAt, now);
39
+ const startedAtIso = pickString(handoff, "startedAt");
40
+ const startedAt = parseTime(startedAtIso);
41
+ const timing = applyTiming(checklist, signal, startedAtIso, now);
52
42
  const ticket = pickString(task, "ticket");
43
+ const journal = readLastCommand(target, now);
44
+ const commandAt = parseTime(journal?.at);
45
+ const commandCounts = commandAt !== void 0 && (startedAt === void 0 || commandAt >= startedAt);
46
+ const lastCommand = commandCounts ? journal : void 0;
53
47
  const activity = taskActivity({
54
48
  status: pickString(signal, "status"),
55
- lastWrites: [startedAt, parseTime(pickString(signal, "timestamp")), ...readMarkTimes(signal).values()],
49
+ lastWrites: [
50
+ startedAt,
51
+ parseTime(pickString(signal, "timestamp")),
52
+ ...readMarkEvents(signal).map((event) => Date.parse(event.checkedAt)),
53
+ commandCounts ? commandAt : void 0
54
+ ],
56
55
  branch: base.checkout.branch,
57
56
  ticket,
58
57
  now
@@ -60,9 +59,10 @@ function collectTaskView(target, taskDir, now) {
60
59
  return {
61
60
  ...base,
62
61
  taskDir,
62
+ // The readiness record is the only place a harness records itself for a task.
63
63
  harness: section({
64
- version: pickString(asObject(provenance?.package), "version"),
65
- sourceKind: pickString(asObject(provenance?.source), "kind")
64
+ version: pickString(asObject(sandbox?.harness), "version"),
65
+ sourceKind: pickString(asObject(sandbox?.harness), "source")
66
66
  }),
67
67
  template: section({
68
68
  id: pickString(template, "id"),
@@ -87,10 +87,26 @@ function collectTaskView(target, taskDir, now) {
87
87
  timestamp: pickString(signal, "timestamp"),
88
88
  attemptId: pickString(signal, "attemptId")
89
89
  }),
90
+ ...lastCommand ? { lastCommand } : {},
90
91
  ...timing,
91
92
  activity
92
93
  };
93
94
  }
95
+ function readLastCommand(target, now) {
96
+ const { record } = readCommandJournal(target);
97
+ if (!record) return void 0;
98
+ const first = record.args[0];
99
+ const subject = first !== void 0 && !first.startsWith("-") ? first : void 0;
100
+ const at = record.finishedAt || record.startedAt;
101
+ const atMs = parseTime(at);
102
+ return {
103
+ command: record.command,
104
+ ...subject ? { subject } : {},
105
+ verdict: record.verdict,
106
+ ...at ? { at } : {},
107
+ ...atMs !== void 0 ? { ageMs: Math.max(0, now - atMs) } : {}
108
+ };
109
+ }
94
110
  function taskActivity(input) {
95
111
  const writes = input.lastWrites.filter((value) => value !== void 0);
96
112
  const idleMs = writes.length > 0 ? input.now - Math.max(...writes) : void 0;
@@ -100,71 +116,59 @@ function taskActivity(input) {
100
116
  ...branchCarriesTicket !== void 0 ? { branchCarriesTicket } : {}
101
117
  };
102
118
  if (input.status === void 0 && writes.length === 0) return { ongoing: false, reason: "no-task", ...extra };
103
- if (isTerminalStatus(input.status)) return { ongoing: false, reason: "finished", ...extra };
119
+ if (isTerminalWorkerSignalStatus(input.status)) return { ongoing: false, reason: "finished", ...extra };
104
120
  if (idleMs !== void 0 && idleMs > TASK_SILENT_AFTER_MS) return { ongoing: false, reason: "silent", ...extra };
105
121
  return { ongoing: true, ...extra };
106
122
  }
107
- function isTerminalStatus(status) {
108
- return status !== void 0 && status !== "running" && status !== "started";
109
- }
110
- function applyTiming(rows, signal, startedAt, now) {
111
- const marks = readMarkTimes(signal);
112
- let previousAt = startedAt;
113
- let lastAt;
114
- let firstAt;
123
+ function applyTiming(rows, signal, startedAtIso, now) {
124
+ const events = readMarkEvents(signal);
125
+ const durations = new Map(
126
+ deriveChecklistStepDurations({ schemaVersion: 1, events }, startedAtIso).map((step) => [
127
+ step.stepNumber,
128
+ step.durationMs
129
+ ])
130
+ );
131
+ const marks = new Map(events.map((event) => [event.stepNumber, Date.parse(event.checkedAt)]));
115
132
  for (const row of rows) {
116
- const checkedAt = marks.get(row.number);
117
- if (checkedAt === void 0) continue;
118
- if (previousAt !== void 0) row.durationMs = checkedAt - previousAt;
119
- if (firstAt === void 0) firstAt = checkedAt;
120
- previousAt = checkedAt;
121
- lastAt = checkedAt;
133
+ const durationMs = durations.get(row.number);
134
+ if (durationMs !== void 0) row.durationMs = durationMs;
122
135
  }
136
+ const instants = [...marks.values()];
137
+ const lastAt = instants.length > 0 ? Math.max(...instants) : void 0;
138
+ const firstAt = instants.length > 0 ? Math.min(...instants) : void 0;
123
139
  const current = rows.find((row) => !row.checked);
124
140
  if (current && lastAt !== void 0) current.runningMs = now - lastAt;
125
- const origin = startedAt ?? firstAt;
141
+ const origin = parseTime(startedAtIso) ?? firstAt;
126
142
  return {
127
143
  ...current ? { currentStep: current.number } : {},
128
144
  ...origin !== void 0 ? { elapsedMs: now - origin } : {}
129
145
  };
130
146
  }
131
- function readMarkTimes(signal) {
132
- const marks = /* @__PURE__ */ new Map();
147
+ function readMarkEvents(signal) {
133
148
  const events = asObject(signal?.checklistTiming)?.events;
134
- if (!Array.isArray(events)) return marks;
149
+ if (!Array.isArray(events)) return [];
150
+ const marks = [];
135
151
  for (const entry of events) {
136
152
  const event = asObject(entry);
137
153
  const stepNumber = pickNumber(event, "stepNumber");
138
- const checkedAt = parseTime(pickString(event, "checkedAt"));
139
- if (stepNumber === void 0 || checkedAt === void 0) continue;
140
- marks.set(stepNumber, checkedAt);
154
+ const checkedAt = pickString(event, "checkedAt");
155
+ if (stepNumber === void 0 || parseTime(checkedAt) === void 0) continue;
156
+ marks.push({ stepNumber, label: "", checkedAt });
141
157
  }
142
158
  return marks;
143
159
  }
144
160
  function readChecklistRows(file) {
145
161
  const text = readTextFile(file);
146
162
  if (text === void 0) return [];
147
- const rows = [];
148
- let phase;
149
- for (const line of text.split("\n")) {
150
- const heading = CHECKLIST_PHASE.exec(line);
151
- if (heading) {
152
- phase = heading[1];
153
- continue;
154
- }
155
- const match = CHECKLIST_ROW.exec(line);
156
- if (!match) continue;
157
- rows.push({
158
- ...phase ? { phase } : {},
159
- number: Number(match[2]),
160
- label: trimLabel(match[3]),
161
- checked: match[1].toLowerCase() === "x"
162
- });
163
- }
164
- return rows;
163
+ return enumerateChecklistCheckboxes(text).map((item) => ({
164
+ ...item.phase ? { phase: item.phase } : {},
165
+ number: item.stepNumber,
166
+ label: trimLabel(item.rawLabel),
167
+ checked: item.checked
168
+ }));
165
169
  }
166
170
  function trimLabel(text) {
167
- const plain = text.replace(/\*\*/gu, "").replace(/\s+/gu, " ").trim();
171
+ const plain = checklistStepName(text).replace(/\*\*/gu, "").replace(/^\d+\.\s*/u, "").replace(/\s+/gu, " ").trim();
168
172
  if (plain.length <= CHECKLIST_LABEL_LIMIT) return plain;
169
173
  return `${plain.slice(0, CHECKLIST_LABEL_LIMIT).trimEnd()}\u2026`;
170
174
  }
@@ -242,6 +246,5 @@ export {
242
246
  CHECKLIST_LABEL_LIMIT,
243
247
  TASK_SILENT_AFTER_MS,
244
248
  collectTaskView,
245
- findTaskDir,
246
- isTerminalStatus
249
+ findTaskDir
247
250
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.55.1",
3
+ "version": "0.57.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -20,11 +20,11 @@
20
20
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
21
21
  },
22
22
  "dependencies": {
23
- "@farmslot/agent-runtime": "0.10.0",
24
- "@farmslot/expo-recipe": "0.9.2",
23
+ "@farmslot/agent-runtime": "0.11.0",
24
+ "@farmslot/expo-recipe": "0.10.0",
25
25
  "@farmslot/handoff": "^0.3.1",
26
- "@farmslot/protocol": "0.28.0",
27
- "@farmslot/recipe-harness": "0.16.3",
26
+ "@farmslot/protocol": "0.29.0",
27
+ "@farmslot/recipe-harness": "0.17.0",
28
28
  "agent-device": "0.19.3",
29
29
  "commander": "^12.0.0",
30
30
  "es-module-lexer": "2.3.1",