@deeeed/metamask-harness 0.56.0 → 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,18 @@
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
+
5
17
  ## 0.56.0 - 2026-09-18
6
18
 
7
19
  ### Changed
@@ -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,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.56.0",
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",