@deeeed/metamask-harness 0.21.0 → 0.23.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,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.23.0 - 2026-07-28
6
+
7
+ ### Added
8
+
9
+ - Core Perps actions support trigger orders, attached and partial TP/SL, and position TP/SL updates.
10
+
11
+ ### Fixed
12
+
13
+ - Core advanced-order mutations verify the submitted order fields, use executable trigger-limit defaults, honor bounded evidence polling, and classify mutation capabilities correctly.
14
+ - Core advanced-order camelCase aliases validate consistently with their documented snake_case forms.
15
+
16
+ ## 0.22.0 - 2026-07-26
17
+
18
+ ### Added
19
+
20
+ - Added `execution-template` as the thin public boundary for shared checklist discovery and materialization.
21
+
22
+ ### Fixed
23
+
24
+ - `ui.navigate page=home` recognizes both the current bottom navigation and the selected legacy Tokens tab.
25
+ - Extension readiness recovers deterministically from transient target-list failures.
26
+ - Team-library calls retain their source provenance.
27
+
5
28
  ## 0.21.0 - 2026-07-24
6
29
 
7
30
  ### Changed
@@ -36,9 +36,11 @@ async function openHome(port, extensionId) {
36
36
  try {
37
37
  let res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(8e3) });
38
38
  if (res.status === 404 || res.status === 405) res = await fetch(endpoint, { signal: AbortSignal.timeout(8e3) });
39
- return res.ok;
39
+ if (!res.ok) return null;
40
+ const value = await res.json();
41
+ return value && typeof value === "object" ? value : {};
40
42
  } catch {
41
- return false;
43
+ return null;
42
44
  }
43
45
  }
44
46
  function homePages(targets, extensionId) {
@@ -94,7 +96,7 @@ async function ensureExtensionReady(target, options) {
94
96
  continue;
95
97
  }
96
98
  action = "opened";
97
- opened = await openHome(cdpPort, extensionId) || opened;
99
+ opened = Boolean(await openHome(cdpPort, extensionId)) || opened;
98
100
  await sleep(1500);
99
101
  } else {
100
102
  action = "pruned";
@@ -149,20 +151,47 @@ async function ensureExtensionReady(target, options) {
149
151
  if (action === "pruned" && (health.status !== "PASS" || after !== 1)) {
150
152
  const listing = await jsonList(cdpPort);
151
153
  if (listing.ok) {
152
- action = "reopened";
153
- for (const h of homePages(listing.targets, extensionId)) {
154
- await closeTab(cdpPort, String(h.id));
155
- closed += 1;
156
- }
157
- await sleep(500);
158
- opened = await openHome(cdpPort, extensionId) || opened;
154
+ const existingHomeIds = new Set(
155
+ homePages(listing.targets, extensionId).map((home) => String(home.id))
156
+ );
157
+ const replacement = await openHome(cdpPort, extensionId);
158
+ opened = Boolean(replacement) || opened;
159
159
  await sleep(1500);
160
- const relisted = await jsonList(cdpPort);
161
- if (!relisted.ok) {
162
- return base({ extensionId, opened, action, homeTabs: { before, closed, after }, reasonCode: "cdp-unreachable" });
160
+ const replacementListing = await jsonList(cdpPort);
161
+ if (!replacementListing.ok) {
162
+ return base({
163
+ extensionId,
164
+ opened,
165
+ action,
166
+ homeTabs: { before, closed, after },
167
+ reasonCode: "cdp-unreachable"
168
+ });
169
+ }
170
+ const replacementHomes = homePages(replacementListing.targets, extensionId);
171
+ const replacementId = replacement?.id && replacementHomes.some((home) => home.id === replacement.id) ? String(replacement.id) : replacementHomes.find((home) => !existingHomeIds.has(String(home.id)))?.id;
172
+ if (replacementId) {
173
+ action = "reopened";
174
+ for (const home of replacementHomes) {
175
+ if (String(home.id) === replacementId) continue;
176
+ await closeTab(cdpPort, String(home.id));
177
+ closed += 1;
178
+ }
179
+ await sleep(500);
180
+ const relisted = await jsonList(cdpPort);
181
+ if (!relisted.ok) {
182
+ return base({
183
+ extensionId,
184
+ opened,
185
+ action,
186
+ homeTabs: { before, closed, after },
187
+ reasonCode: "cdp-unreachable"
188
+ });
189
+ }
190
+ after = homePages(relisted.targets, extensionId).length;
191
+ health = await checkHealth();
192
+ } else {
193
+ after = replacementHomes.length;
163
194
  }
164
- after = homePages(relisted.targets, extensionId).length;
165
- health = await checkHealth();
166
195
  }
167
196
  }
168
197
  let slotTitle;
@@ -18,6 +18,7 @@ const SPEC = {
18
18
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--categories", "--category", "--action", "--library"] },
19
19
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
20
20
  { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
21
+ { 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"] },
21
22
  { name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
22
23
  { name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
23
24
  { name: "prepare", desc: "Install harness (+ optional validate)", flags: ["--target", "--runtime-dir", "--json"] },
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { handleRecipeQuality } from "./commands/recipe-quality.js";
20
20
  import { handleStatus } from "./commands/status.js";
21
21
  import { handleCheck } from "./commands/check.js";
22
22
  import { handleChecklist } from "./commands/checklist.js";
23
+ import { handleExecutionTemplate } from "./commands/execution-template.js";
23
24
  import { handleLast } from "./commands/last.js";
24
25
  import { parseArgs, targetPath } from "./commands/parse-args.js";
25
26
  const COMMANDS = {
@@ -121,6 +122,7 @@ async function main(argv) {
121
122
  if (command === "recipe-quality") return handleRecipeQuality(argv.slice(1));
122
123
  if (command === "check") return handleCheck(argv.slice(1));
123
124
  if (command === "checklist") return handleChecklist(argv.slice(1));
125
+ if (command === "execution-template") return handleExecutionTemplate(argv.slice(1));
124
126
  if (command === "last") return handleLast(parseArgs(argv.slice(1), command));
125
127
  const handler = COMMANDS[command];
126
128
  if (!handler) throw new Error(`Unknown command: ${command}`);
@@ -88,6 +88,32 @@ const PUBLIC_COMMAND_CONTRACTS = {
88
88
  ],
89
89
  minimumPositionals: 2
90
90
  },
91
+ "execution-template": {
92
+ usage: "mm-harness execution-template <list|materialize|lint|new> [target] [options]",
93
+ options: options(HELP, JSON, {
94
+ "--dir": value(),
95
+ "--domain-dir": value(),
96
+ "--project-worker": value(),
97
+ "--project-name": value(),
98
+ "--package-templates": value(),
99
+ "--package-id": value(),
100
+ "--flow": value(),
101
+ "--run-mode": value(["autonomous", "interactive", "validation"]),
102
+ "--platform": value(),
103
+ "--domain": value(),
104
+ "--id": value(),
105
+ "--provenance": value(),
106
+ "--include-shadowed": bool(),
107
+ "--no-include-shadowed": bool(),
108
+ "--title": value(),
109
+ "--force": bool()
110
+ }),
111
+ positionals: [
112
+ { label: "action", choices: ["list", "materialize", "lint", "new"] }
113
+ ],
114
+ minimumPositionals: 1,
115
+ variadic: { label: "target", validate: (value2) => value2.length > 0 }
116
+ },
91
117
  actions: {
92
118
  options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, {
93
119
  "--action": value(),
@@ -360,6 +386,37 @@ function validatePublicInvocation(argv, examples = {}) {
360
386
  );
361
387
  }
362
388
  }
389
+ if (name === "execution-template") {
390
+ const action = positionals[0];
391
+ const validAction = ["list", "materialize", "lint", "new"].includes(action ?? "");
392
+ const targetCount = Math.max(0, positionals.length - 1);
393
+ if (validAction && action === "list" && targetCount > 0) {
394
+ return usageFailure(
395
+ "CLI_EXCESS_POSITIONAL",
396
+ name,
397
+ `execution-template list does not accept positional '${positionals[1]}'.`,
398
+ contract,
399
+ examples
400
+ );
401
+ }
402
+ if (validAction && (action === "materialize" || action === "lint" || action === "new") && targetCount === 0) {
403
+ return missingPositionalFailure(
404
+ name,
405
+ `execution-template ${action} requires <target>.`,
406
+ contract,
407
+ examples
408
+ );
409
+ }
410
+ if (validAction && targetCount > 1) {
411
+ return usageFailure(
412
+ "CLI_EXCESS_POSITIONAL",
413
+ name,
414
+ `unexpected positional '${positionals[2]}'; execution-template ${action} accepts one <target>.`,
415
+ contract,
416
+ examples
417
+ );
418
+ }
419
+ }
363
420
  if (contract.leadingPositionals && !contract.requiredUnless?.some((option) => seenOptions.has(option)) && tokens.slice(0, contract.leadingPositionals).some((argument) => !argument || argument.startsWith("-"))) {
364
421
  const missing = contract.positionals?.[0]?.label ?? "argument";
365
422
  return missingPositionalFailure(name, `${name} requires <${missing}> first.`, contract, examples);
@@ -0,0 +1,28 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ const require2 = createRequire(import.meta.url);
4
+ async function handleExecutionTemplate(argv) {
5
+ let entrypoint;
6
+ try {
7
+ entrypoint = require2.resolve(
8
+ "@farmslot/agent-runtime/scripts/execution-template-cli.mjs"
9
+ );
10
+ } catch (error) {
11
+ console.error(
12
+ `mm-harness execution-template: installed @farmslot/agent-runtime does not expose the catalog command: ${error instanceof Error ? error.message : String(error)}`
13
+ );
14
+ return 1;
15
+ }
16
+ const result = spawnSync(process.execPath, [entrypoint, ...argv], {
17
+ env: process.env,
18
+ stdio: "inherit"
19
+ });
20
+ if (result.error) {
21
+ console.error(`mm-harness execution-template: ${result.error.message}`);
22
+ return 1;
23
+ }
24
+ return result.status ?? 1;
25
+ }
26
+ export {
27
+ handleExecutionTemplate
28
+ };
@@ -21,6 +21,7 @@ import {
21
21
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
22
22
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
23
23
  import { validateMetaMaskActionInputs } from "../metamask-action-validation.js";
24
+ import { gitLibraryProvenance } from "../library-provenance.js";
24
25
  import {
25
26
  beginRunDiagnostics,
26
27
  finishRunDiagnostics
@@ -655,14 +656,22 @@ async function resolveMetaMaskLibrarySources(libraryEntry, recipePath) {
655
656
  if (!sources.some((source) => path.resolve(source.root) === canonicalRoot)) {
656
657
  sources.push({ name: "metamask", root: canonicalRoot });
657
658
  }
658
- return sources.map((source) => ({
659
- ...source,
660
- provenance: source.provenance ?? {
661
- kind: source.name === "metamask" ? "bundled" : "library",
662
- trust: source.name === "metamask" ? "trusted" : "unknown",
663
- name: source.name ?? path.basename(source.root)
664
- }
665
- }));
659
+ return Promise.all(
660
+ sources.map(async (source) => {
661
+ const isBundled = source.name === "metamask";
662
+ const detected = isBundled ? {} : await gitLibraryProvenance(source.root);
663
+ return {
664
+ ...source,
665
+ provenance: {
666
+ kind: isBundled ? "bundled" : "library",
667
+ trust: isBundled ? "trusted" : "unknown",
668
+ name: source.name ?? path.basename(source.root),
669
+ ...detected,
670
+ ...source.provenance
671
+ }
672
+ };
673
+ })
674
+ );
666
675
  }
667
676
  function synthesizeOneNodeRecipe(action, args) {
668
677
  return {
@@ -0,0 +1,33 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ async function gitLibraryProvenance(root) {
5
+ try {
6
+ const { stdout: tracked } = await execFileAsync("git", ["-C", root, "ls-files", "--", "."], {
7
+ encoding: "utf8",
8
+ timeout: 5e3
9
+ });
10
+ if (!tracked.trim()) return {};
11
+ const [{ stdout: revision }, { stdout: status }] = await Promise.all([
12
+ execFileAsync("git", ["-C", root, "rev-parse", "HEAD"], {
13
+ encoding: "utf8",
14
+ timeout: 5e3
15
+ }),
16
+ execFileAsync(
17
+ "git",
18
+ ["-C", root, "status", "--porcelain=v1", "--untracked-files=normal", "--", "."],
19
+ {
20
+ encoding: "utf8",
21
+ timeout: 5e3
22
+ }
23
+ )
24
+ ]);
25
+ const trimmedRevision = revision.trim();
26
+ return trimmedRevision ? { revision: trimmedRevision, dirty: status.trim().length > 0 } : {};
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+ export {
32
+ gitLibraryProvenance
33
+ };
@@ -77,6 +77,40 @@ Example:
77
77
  mm-harness checklist mark temp/tasks/recipe-cook/<task> complete --mark-last
78
78
  mm-harness checklist closeout temp/tasks/recipe-cook/<task> --share`
79
79
  },
80
+ {
81
+ name: "execution-template",
82
+ summary: "Discover, validate, and materialize shared agent checklists.",
83
+ example: "mm-harness execution-template list --package-templates <path> --json",
84
+ helpText: `mm-harness execution-template <list|materialize|lint|new> [options]
85
+
86
+ Shared Markdown checklist catalog used by direct and orchestrated workflows.
87
+ Sources use the flow-tree shape <root>/<flow>/<variant>.md.
88
+
89
+ list List compatible templates and portable source provenance.
90
+ materialize Select one template, copy its immutable snapshot, and optionally
91
+ write a provenance record.
92
+ lint Validate one template file or flow-tree directory.
93
+ new Create a minimal starter template.
94
+
95
+ Common source/filter options:
96
+ --dir <path> Custom flow-tree source (repeatable)
97
+ --domain-dir <domain=path> Domain-scoped team source (repeatable)
98
+ --package-templates <path> Canonical package flow-tree root
99
+ --package-id <id> Source label for the package root
100
+ --project-worker <path> Project template directory
101
+ --project-name <name> Source label for project templates
102
+ --flow <flow> Exact flow
103
+ --platform <platform> Exact platform
104
+ --run-mode <mode> autonomous|interactive|validation
105
+ --domain <domain> Exact domain
106
+ --id <id> Exact template id
107
+ --json Machine-readable output
108
+
109
+ Materialize:
110
+ mm-harness execution-template materialize <output> --flow fix-bug \\
111
+ --platform mobile --run-mode autonomous --id fix-bug/autonomous.mobile \\
112
+ --package-templates <path> --provenance <path> --json`
113
+ },
80
114
  {
81
115
  name: "actions",
82
116
  summary: "Discover typed single operations and their fields.",
@@ -552,7 +586,7 @@ const HELP_GROUPS = [
552
586
  {
553
587
  title: "DISCOVER",
554
588
  blurb: "discover atomic actions and reusable recipes (--json is the agent-primary form)",
555
- commands: ["actions", "call"]
589
+ commands: ["actions", "call", "execution-template"]
556
590
  },
557
591
  {
558
592
  title: "PROVE",
@@ -18,6 +18,7 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
18
18
  "metamask.perps.close_positions",
19
19
  "metamask.perps.close_orders",
20
20
  "metamask.perps.place_order",
21
+ "metamask.perps.update_position_tpsl",
21
22
  "metamask.perps.ensure_positions",
22
23
  "metamask.perps.ensure_orders",
23
24
  "metamask.perps.start_state",
@@ -27,6 +28,7 @@ const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
27
28
  "metamask.perps.close_positions",
28
29
  "metamask.perps.close_orders",
29
30
  "metamask.perps.place_order",
31
+ "metamask.perps.update_position_tpsl",
30
32
  "metamask.perps.ensure_positions",
31
33
  "metamask.perps.ensure_orders",
32
34
  "metamask.perps.start_state",
@@ -625,6 +625,13 @@ export function redactOrder(order) {
625
625
  size: order.size ?? order.sz ?? order.szi ?? null,
626
626
  price: order.price ?? order.limitPx ?? order.px ?? null,
627
627
  type: order.orderType ?? order.type ?? null,
628
+ // Trigger data, so evidence shows what a stop / take-profit placement
629
+ // actually round-tripped from the exchange.
630
+ triggerOrderType: order.triggerOrderType ?? null,
631
+ triggerPrice: order.triggerPrice ?? order.triggerPx ?? null,
632
+ detailedOrderType: order.detailedOrderType ?? null,
633
+ isTrigger: order.isTrigger ?? null,
634
+ reduceOnly: order.reduceOnly ?? null,
628
635
  };
629
636
  }
630
637
 
@@ -11,6 +11,13 @@ import {
11
11
  // over the controller's standalone getOpenOrders path (no signer / provider init
12
12
  // needed) — throws on mismatch so the recipe fails loudly. Mirrors
13
13
  // assert_positions.mjs.
14
+ //
15
+ // Optional expect_* fields additionally assert the TRIGGER DATA the exchange
16
+ // round-tripped for every matching order: expect_trigger_order_type (the
17
+ // normalized placement type, e.g. stop_market), expect_trigger_price,
18
+ // expect_execution (market | limit once triggered), expect_reduce_only, and
19
+ // expect_size (proves a partial TP/SL quantity). They use the controller's own
20
+ // field names (triggerOrderType, triggerPrice, orderType, reduceOnly, size).
14
21
 
15
22
  export function expectedOpen(input) {
16
23
  if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
@@ -20,6 +27,79 @@ export function expectedOpen(input) {
20
27
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
21
28
  }
22
29
 
30
+ /**
31
+ * Collect the optional trigger-data expectations from the node.
32
+ *
33
+ * @param input - Adapter input.
34
+ * @returns The expectations the node set, keyed by controller field name.
35
+ */
36
+ /**
37
+ * Whether the node narrowed matching to trigger orders only.
38
+ *
39
+ * @param input - Adapter input.
40
+ * @returns True when only trigger orders should be considered.
41
+ */
42
+ export function onlyTriggerOrders(input) {
43
+ const value = input.node?.only_trigger_orders ?? input.node?.onlyTriggerOrders;
44
+ return value === true || String(value).toLowerCase() === 'true';
45
+ }
46
+
47
+ export function expectedTriggerData(input) {
48
+ const node = input.node ?? {};
49
+ const expectations = {
50
+ triggerOrderType:
51
+ node.expect_trigger_order_type ?? node.expectTriggerOrderType,
52
+ triggerPrice: node.expect_trigger_price ?? node.expectTriggerPrice,
53
+ orderType: node.expect_execution ?? node.expectExecution,
54
+ reduceOnly: node.expect_reduce_only ?? node.expectReduceOnly,
55
+ size: node.expect_size ?? node.expectSize,
56
+ };
57
+ return Object.fromEntries(
58
+ Object.entries(expectations).filter(
59
+ ([, value]) => value !== undefined && value !== null,
60
+ ),
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Assert every matching order carries the expected trigger data.
66
+ *
67
+ * @param orders - Matching open orders.
68
+ * @param expectations - Expectations from `expectedTriggerData`.
69
+ */
70
+ export function assertTriggerData(orders, expectations) {
71
+ const fields = Object.keys(expectations);
72
+ if (fields.length === 0) return;
73
+
74
+ for (const order of orders) {
75
+ for (const field of fields) {
76
+ const expected = expectations[field];
77
+ const actual = order[field];
78
+ const expectedNumber = Number(expected);
79
+ const actualNumber = Number(actual);
80
+ const numeric =
81
+ typeof expected !== 'boolean' &&
82
+ expected !== '' &&
83
+ actual !== null &&
84
+ actual !== undefined &&
85
+ Number.isFinite(expectedNumber) &&
86
+ Number.isFinite(actualNumber);
87
+ const matches =
88
+ typeof expected === 'boolean'
89
+ ? Boolean(actual) === expected
90
+ : // Prices and sizes round-trip with exchange formatting
91
+ // ('44000' -> '44000.0'), so compare them numerically.
92
+ (numeric && expectedNumber === actualNumber) ||
93
+ String(actual) === String(expected);
94
+ if (!matches) {
95
+ throw new Error(
96
+ `Open Perps order ${order.orderId ?? '?'} (${order.symbol ?? '?'}) has ${field}=${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}.`,
97
+ );
98
+ }
99
+ }
100
+ }
101
+ }
102
+
23
103
  export async function assertOrders(input, expectOpen = expectedOpen(input)) {
24
104
  requireExplicitSelection(input);
25
105
  const { controller, accountAddress, network } = await getCoreController(input);
@@ -33,6 +113,11 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
33
113
  userAddress: accountAddress,
34
114
  });
35
115
  matching = selectedItems(input, orders);
116
+ if (onlyTriggerOrders(input)) {
117
+ // Narrow to the trigger orders on the market so expectations are not
118
+ // applied to an unrelated parent order resting alongside them.
119
+ matching = matching.filter((order) => order.isTrigger === true);
120
+ }
36
121
  if (expectOpen ? matching.length > 0 : matching.length === 0) break;
37
122
  if (Date.now() >= deadline) break;
38
123
  await new Promise((resolve) =>
@@ -44,6 +129,11 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
44
129
  if (expectOpen && !hasOrder) {
45
130
  throw new Error('Expected at least one matching open Perps order, but found none.');
46
131
  }
132
+
133
+ const triggerExpectations = expectedTriggerData(input);
134
+ if (expectOpen) {
135
+ assertTriggerData(matching, triggerExpectations);
136
+ }
47
137
  if (!expectOpen && hasOrder) {
48
138
  throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
49
139
  }
@@ -54,6 +144,8 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
54
144
  network,
55
145
  account: accountAddress,
56
146
  expectedOpen: expectOpen,
147
+ expectedTrigger:
148
+ Object.keys(triggerExpectations).length === 0 ? null : triggerExpectations,
57
149
  matchingCount: matching.length,
58
150
  orders: matching.map(redactOrder),
59
151
  proofPath: 'perps-controller-getOpenOrders',