@deeeed/metamask-harness 0.47.1 → 0.47.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.47.3 - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - Select the requested Mobile Perps mode through the first-entry chooser, verify its detail root, and restore the requested market after mode selection changes the navigation stack.
10
+ - Preserve earlier `call` evidence by assigning each invocation a fresh default artifact directory; approval retries still use the explicit directory in the printed next command.
11
+
12
+ ### Changed
13
+
14
+ - Teach authors to inspect existing state and limit setup mutations to prerequisites required by their proof.
15
+
16
+ ## 0.47.2 - 2026-09-05
17
+
18
+ ### Changed
19
+
20
+ - Teach recipe and action discovery, bounded setup probes, source-based diagnosis, and honest blocked-proof reporting through version-matched CLI help.
21
+
22
+ ### Fixed
23
+
24
+ - Flush machine-readable command output before exit so large JSON documents remain complete when piped to another process.
25
+ - Keep `metamask.perps.set_market_favorite` in the current mode while waiting for a temporarily absent favorite control.
26
+ - Preserve the current Mobile Perps mode when `ui.navigate` omits `mode`, including opaque Android navigation.
27
+ - Prefer an exact visible Mobile Perps market row or recently-viewed tile before falling back to search.
28
+ - Preserve Mobile Perps teardown navigation when embedded and fallback bridges use different wording for a missing scrollable target.
29
+
5
30
  ## 0.47.1 - 2026-09-04
6
31
 
7
32
  ### Fixed
@@ -195,6 +195,7 @@ const NESTED_ROUTE_PARENTS = {
195
195
  PerpsPnlHeroCard: 'Perps', PerpsHIP3Debug: 'Perps',
196
196
  PerpsSelectModifyAction: 'Perps', PerpsSelectAdjustMarginAction: 'Perps',
197
197
  PerpsSelectOrderType: 'Perps', PerpsTradingView: 'Perps',
198
+ PerpsModeSelection: 'PerpsModals',
198
199
  // Predict
199
200
  PredictMarketList: 'Predict', PredictMarketDetails: 'Predict',
200
201
  PredictActivityDetail: 'Predict',
@@ -18,6 +18,10 @@ const BRIDGE_ERROR_CODES = {
18
18
  METRO_UNREACHABLE: 'METRO_UNREACHABLE',
19
19
  };
20
20
 
21
+ const BRIDGE_RESULT_ERROR_CODES = {
22
+ SCROLLABLE_NOT_FOUND: 'SCROLLABLE_NOT_FOUND',
23
+ };
24
+
21
25
  // Process exit code the bridge returns per failure code so a caller that only
22
26
  // sees the child's exit status (no stderr) can still recover the code. Kept
23
27
  // clear of exit 1 (unknown/uncoded) and 2 (usage).
@@ -78,6 +82,15 @@ function classifyBridgeErrorMessage(message) {
78
82
  return null;
79
83
  }
80
84
 
85
+ function classifyBridgeResultError(command, result) {
86
+ if (command !== 'scroll-view' || result?.ok !== false) return null;
87
+ return /^No scrollable(?: found)? near testID=/u.test(
88
+ String(result.error ?? ''),
89
+ )
90
+ ? BRIDGE_RESULT_ERROR_CODES.SCROLLABLE_NOT_FOUND
91
+ : null;
92
+ }
93
+
81
94
  // Attach a code to an error at its throw site (source classification).
82
95
  function coded(error, code) {
83
96
  if (error && typeof error === 'object') error.code = code;
@@ -99,10 +112,12 @@ function parseErrorMarker(text) {
99
112
 
100
113
  module.exports = {
101
114
  BRIDGE_ERROR_CODES,
115
+ BRIDGE_RESULT_ERROR_CODES,
102
116
  EXIT_CODE_BY_ERROR_CODE,
103
117
  ERROR_CODE_BY_EXIT_CODE,
104
118
  TEACHING_BY_ERROR_CODE,
105
119
  classifyBridgeErrorMessage,
120
+ classifyBridgeResultError,
106
121
  coded,
107
122
  formatErrorMarker,
108
123
  parseErrorMarker,
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { createHash } from "node:crypto";
2
+ import { randomUUID } from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { resolveActionManifest } from "../manifest.js";
5
5
  import { importRecipeProtocol } from "../paths.js";
@@ -212,7 +212,7 @@ async function handleCall(argv) {
212
212
  }
213
213
  return EXIT.validation;
214
214
  }
215
- const artifactsDir = optionString(options, "artifactsDir") ?? defaultCallArtifactsDir(target, resolvedAction, args);
215
+ const artifactsDir = optionString(options, "artifactsDir") ?? defaultCallArtifactsDir(target, resolvedAction);
216
216
  recordCommandEvidence(artifactsDir);
217
217
  const requestedRuntimeOptions = runtimeOptionsFromCli(options);
218
218
  const inheritedSource = process.env.FARMSLOT_RECIPE_SOURCE_TRUST || process.env.FARMSLOT_RECIPE_SOURCE_KIND || process.env.FARMSLOT_RECIPE_SOURCE_NAME || process.env.FARMSLOT_RECIPE_SOURCE_DIGEST;
@@ -493,10 +493,9 @@ function renderDefaultsUsed(defaults, stream) {
493
493
  const values = Object.entries(defaults).map(([name, value]) => `${out("cmd", name)}=${out("accent", JSON.stringify(value) ?? String(value))}`).join(", ");
494
494
  return `${out("label", "Defaults used:")} ${values}`;
495
495
  }
496
- function defaultCallArtifactsDir(target, action, args) {
496
+ function defaultCallArtifactsDir(target, action) {
497
497
  const actionStem = action.replace(/[^a-zA-Z0-9._-]/gu, "_");
498
- const digest = createHash("sha256").update(JSON.stringify({ action, args })).digest("hex").slice(0, 12);
499
- return path.join(target, "temp", "recipe", "calls", `${actionStem}-${digest}`);
498
+ return path.join(target, "temp", "recipe", "calls", `${actionStem}-${randomUUID()}`);
500
499
  }
501
500
  function readCallOutput(tracePath) {
502
501
  try {
@@ -830,7 +830,7 @@ for (const command of REAL) {
830
830
  if (command.name === "tutorial") {
831
831
  process.exit(handleTutorial(rawArgv.slice(1), { harnessVersion: pkgVersion }));
832
832
  }
833
- process.exit(await withCommandJournal(command.name, rawArgv, () => delegate(rawArgv)));
833
+ process.exitCode = await withCommandJournal(command.name, rawArgv, () => delegate(rawArgv));
834
834
  });
835
835
  }
836
836
  const HIDDEN = [
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
  import {
3
+ MOBILE_BRIDGE_RESULT_ERROR_CODES,
3
4
  bridgeCommand,
4
5
  evalAsync,
5
6
  isTargetTransition,
@@ -130,7 +131,7 @@ function withRemainingDeadline(input, requestedDeadline, attemptTimeoutMs) {
130
131
  };
131
132
  }
132
133
 
133
- async function isModePresent(input, mode, deadline) {
134
+ async function queryModeState(input, mode, deadline) {
134
135
  const rootTarget = JSON.stringify({
135
136
  testId: MODE_ROOT_TEST_IDS[mode],
136
137
  visibility: 'viewport',
@@ -139,6 +140,10 @@ async function isModePresent(input, mode, deadline) {
139
140
  testId: MODE_TEST_IDS[mode],
140
141
  visibility: 'tree',
141
142
  });
143
+ const choiceTarget = JSON.stringify({
144
+ testId: `perps-mode-selection-${mode}-option`,
145
+ visibility: 'viewport',
146
+ });
142
147
  const result = await evalAsync(
143
148
  withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
144
149
  `(function(){
@@ -148,21 +153,25 @@ async function isModePresent(input, mode, deadline) {
148
153
  }
149
154
  return Promise.all([
150
155
  api.queryUiTarget(${rootTarget}),
151
- api.queryUiTarget(${controlTarget})
156
+ api.queryUiTarget(${controlTarget}),
157
+ api.queryUiTarget(${choiceTarget})
152
158
  ]).then(function(values){
153
- return JSON.stringify({ root: values[0], control: values[1] });
159
+ return JSON.stringify({ root: values[0], control: values[1], choice: values[2] });
154
160
  });
155
161
  })()`,
156
162
  );
157
163
  if (result?.unsupported) {
158
164
  throw new Error('metamask.perps.ensure_mode requires UI target queries.');
159
165
  }
160
- return result?.root?.visible === true && result?.control?.present === true;
166
+ return {
167
+ present: result?.root?.visible === true && result?.control?.present === true,
168
+ choiceVisible: result?.choice?.visible === true,
169
+ };
161
170
  }
162
171
 
163
- async function readModePresence(input, mode, deadline) {
172
+ async function readModeState(input, mode, deadline) {
164
173
  try {
165
- return await isModePresent(input, mode, deadline);
174
+ return await queryModeState(input, mode, deadline);
166
175
  } catch (error) {
167
176
  if (!isTargetTransition(error)) throw error;
168
177
  return null;
@@ -180,14 +189,15 @@ export async function ensureMode(input) {
180
189
  Number(input.node?.timeout_ms ?? 30_000),
181
190
  );
182
191
  let switched = false;
192
+ let choiceSelected = false;
183
193
 
184
194
  while (Date.now() < deadline) {
185
- const requestedModePresent = await readModePresence(
195
+ const requestedState = await readModeState(
186
196
  input,
187
197
  requestedMode,
188
198
  deadline,
189
199
  );
190
- if (requestedModePresent) {
200
+ if (requestedState?.present && !requestedState.choiceVisible) {
191
201
  return {
192
202
  action: input.action,
193
203
  mode: requestedMode,
@@ -196,13 +206,23 @@ export async function ensureMode(input) {
196
206
  };
197
207
  }
198
208
  if (Date.now() >= deadline) break;
199
- if (requestedModePresent !== null && !switched) {
200
- const otherModePresent = await readModePresence(
209
+ if (requestedState?.choiceVisible && !choiceSelected) {
210
+ const pressed = await bridgeCommand(withRemainingDeadline(input, deadline), [
211
+ 'press-test-id',
212
+ `perps-mode-selection-${requestedMode}-option`,
213
+ ]);
214
+ if (pressed?.ok === false) {
215
+ throw new Error(String(pressed.error || 'Perps mode choice press failed.'));
216
+ }
217
+ choiceSelected = true;
218
+ switched = true;
219
+ } else if (requestedState !== null && !switched) {
220
+ const otherState = await readModeState(
201
221
  input,
202
222
  otherMode,
203
223
  deadline,
204
224
  );
205
- if (otherModePresent && Date.now() < deadline) {
225
+ if (otherState?.present && Date.now() < deadline) {
206
226
  // The visible control names the mode it will switch to, not the mode
207
227
  // currently selected.
208
228
  const pressed = await bridgeCommand(withRemainingDeadline(input, deadline), [
@@ -915,29 +935,50 @@ async function readVisiblePerpsTestIds(input, deadline) {
915
935
  return new Set(visible.items.map((item) => String(item.test_id ?? '')));
916
936
  }
917
937
 
918
- async function waitForPerpsUiTarget(input, testId, deadline) {
938
+ async function waitForPerpsUiTargets(input, testIds, deadline) {
919
939
  let lastTestIds = [];
920
940
  while (Date.now() < deadline) {
921
- const testIds = await readVisiblePerpsTestIds(input, deadline);
922
- if (testIds.has(testId)) return testIds;
923
- lastTestIds = [...testIds].filter(Boolean).slice(0, 20);
941
+ const visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
942
+ const matchedTestId = testIds.find((testId) =>
943
+ visibleTestIds.has(testId),
944
+ );
945
+ if (matchedTestId) return { matchedTestId, visibleTestIds };
946
+ lastTestIds = [...visibleTestIds].filter(Boolean).slice(0, 20);
924
947
  await sleepBeforeDeadline(deadline, 250);
925
948
  }
949
+ const targetDescription =
950
+ testIds.length === 1
951
+ ? `control ${testIds[0]}`
952
+ : `controls ${testIds.join(' or ')}`;
926
953
  throw new Error(
927
- `Perps control ${testId} did not become visible before timeout. Observed: ${lastTestIds.join(', ') || 'none'}.`,
954
+ `Perps ${targetDescription} did not become visible before timeout. Observed: ${lastTestIds.join(', ') || 'none'}.`,
928
955
  );
929
956
  }
930
957
 
958
+ async function waitForPerpsUiTarget(input, testId, deadline) {
959
+ const { visibleTestIds } = await waitForPerpsUiTargets(
960
+ input,
961
+ [testId],
962
+ deadline,
963
+ );
964
+ return visibleTestIds;
965
+ }
966
+
931
967
  async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
932
968
  const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
933
969
  const deadline = deadlineFromTimeout(input, timeoutMs);
934
970
  const params = { market: { symbol } };
935
971
  const rowTestId = `perps-market-row-item-${symbol}`;
972
+ const recentlyViewedTestId = `perps-recently-viewed-tile-${symbol}`;
973
+ const marketTestIds = [rowTestId, recentlyViewedTestId];
936
974
  const searchTestId = 'perps-market-list-search-bar';
937
975
  let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
976
+ let marketTestId = marketTestIds.find((testId) =>
977
+ visibleTestIds.has(testId),
978
+ );
938
979
  let usedSearch = false;
939
980
 
940
- if (!visibleTestIds.has(rowTestId)) {
981
+ if (!marketTestId) {
941
982
  if (!visibleTestIds.has(searchTestId)) {
942
983
  if (!visibleTestIds.has('perps-home-search-toggle')) {
943
984
  throw new Error(
@@ -959,17 +1000,19 @@ async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
959
1000
  searchTestId,
960
1001
  symbol,
961
1002
  ]);
962
- visibleTestIds = await waitForPerpsUiTarget(
1003
+ const visibleMarketTarget = await waitForPerpsUiTargets(
963
1004
  input,
964
- rowTestId,
1005
+ marketTestIds,
965
1006
  deadline,
966
1007
  );
1008
+ visibleTestIds = visibleMarketTarget.visibleTestIds;
1009
+ marketTestId = visibleMarketTarget.matchedTestId;
967
1010
  usedSearch = true;
968
1011
  }
969
1012
 
970
1013
  await bridgeCommand(withRemainingDeadline(input, deadline), [
971
1014
  'press-test-id',
972
- rowTestId,
1015
+ marketTestId,
973
1016
  ]);
974
1017
  const currentRoute = await waitForRoute(
975
1018
  withRemainingDeadline(input, deadline),
@@ -983,7 +1026,7 @@ async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
983
1026
  previousRoute,
984
1027
  currentRoute,
985
1028
  verifiedRoute: 'PerpsMarketDetails',
986
- visibleMarketSelection: { usedSearch },
1029
+ visibleMarketSelection: { usedSearch, testId: marketTestId },
987
1030
  };
988
1031
  }
989
1032
 
@@ -1033,7 +1076,12 @@ async function navigateFromMarketDetailsToHome(input, previousRoute) {
1033
1076
  backTestId = expectedBackTestId;
1034
1077
  break;
1035
1078
  } catch (error) {
1036
- if (!/No scrollable near testID=/u.test(String(error))) throw error;
1079
+ if (
1080
+ error?.code !==
1081
+ MOBILE_BRIDGE_RESULT_ERROR_CODES.SCROLLABLE_NOT_FOUND
1082
+ ) {
1083
+ throw error;
1084
+ }
1037
1085
  }
1038
1086
  }
1039
1087
  }
@@ -1079,7 +1127,7 @@ async function navigateFromMarketDetailsToHome(input, previousRoute) {
1079
1127
  };
1080
1128
  }
1081
1129
 
1082
- async function navigatePerps(input) {
1130
+ export async function navigatePerps(input) {
1083
1131
  const selected = String(
1084
1132
  input.node?.target ?? input.node?.destination ?? defaultNavigationTarget(input.node),
1085
1133
  ).toLowerCase();
@@ -1789,7 +1837,7 @@ export async function startState(input) {
1789
1837
  const navigation = await applyStateNavigation(stateInput(), config);
1790
1838
  const provider = await ensureProvider(stateInput(), config);
1791
1839
  const network = await ensureNetwork(stateInput(), config);
1792
- const resolvedNavigation = provider.changed || network.changed
1840
+ let resolvedNavigation = provider.changed || network.changed
1793
1841
  ? await applyStateNavigation(stateInput(), config)
1794
1842
  : navigation;
1795
1843
  const modeInput = stateInput();
@@ -1804,6 +1852,9 @@ export async function startState(input) {
1804
1852
  },
1805
1853
  })
1806
1854
  : { skipped: true };
1855
+ if (marketMode.alreadySelected === false) {
1856
+ resolvedNavigation = await applyStateNavigation(stateInput(), config);
1857
+ }
1807
1858
  const tutorial = await applyTutorialState(stateInput(), config);
1808
1859
  const readyToTrade = await assertReadyToTrade(stateInput(), config);
1809
1860
  const balance = await assertBalance(stateInput(), config);
@@ -51,17 +51,6 @@ runAdapter(async (input) => {
51
51
  throw new Error(`Visible Perps market ${JSON.stringify(subtitle)} does not match ${market}.`);
52
52
  }
53
53
  let control = favoriteControl(snapshot);
54
- if (!control && nativeNode(snapshot, 'perps-mode-toggle-lite')?.enabled !== false) {
55
- try {
56
- await session.execute('ui.press', {
57
- test_id: 'perps-mode-toggle-lite',
58
- timeout_ms: timeoutMs,
59
- settle: false,
60
- });
61
- } catch (error) {
62
- if (!/Selector did not match/iu.test(String(error?.message ?? error))) throw error;
63
- }
64
- }
65
54
  const controlDeadline = Date.now() + timeoutMs;
66
55
  let lastControls = [];
67
56
  while (Date.now() <= controlDeadline && !control) {
@@ -13,8 +13,10 @@ import { resolveMobileToolPath } from './tool-paths.mjs';
13
13
 
14
14
  const {
15
15
  BRIDGE_ERROR_CODES,
16
+ BRIDGE_RESULT_ERROR_CODES,
16
17
  ERROR_CODE_BY_EXIT_CODE,
17
18
  classifyBridgeErrorMessage,
19
+ classifyBridgeResultError,
18
20
  coded,
19
21
  parseErrorMarker,
20
22
  } = bridgeErrors;
@@ -24,6 +26,7 @@ const { resolvePort } = configModule;
24
26
  // Re-exported so the TS adapter classifies on the same code constants without a
25
27
  // second import path into the cjs bridge-runtime.
26
28
  export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
29
+ export const MOBILE_BRIDGE_RESULT_ERROR_CODES = BRIDGE_RESULT_ERROR_CODES;
27
30
 
28
31
  const execFileAsync = promisify(execFile);
29
32
  const ACTION_DEADLINE_FIELD = '_action_deadline_epoch_ms';
@@ -536,9 +539,11 @@ export async function bridgeCommand(input, args) {
536
539
  try {
537
540
  const parsed = JSON.parse(result.stdout);
538
541
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.ok === false) {
539
- throw new Error(
542
+ const error = new Error(
540
543
  `Mobile CDP bridge command reported failure for ${redactBridgeArgs(args).join(' ')}: ${redactBridgeOutput(JSON.stringify(parsed), sensitiveBridgeArgs(args))}`,
541
544
  );
545
+ const code = classifyBridgeResultError(command, parsed);
546
+ throw code ? coded(error, code) : error;
542
547
  }
543
548
  return parsed;
544
549
  } catch (error) {
@@ -73,7 +73,9 @@ export async function navigateOpaqueMobile(input, destination) {
73
73
  artifactsDir: input.context.artifactsDir,
74
74
  env,
75
75
  };
76
- const perpsMode = destination.params?.mode === 'pro' ? 'pro' : 'lite';
76
+ const perpsMode = ['lite', 'pro'].includes(destination.params?.mode)
77
+ ? destination.params.mode
78
+ : undefined;
77
79
  const press = (testId) => transport.execute('ui.press', {
78
80
  test_id: testId,
79
81
  timeout_ms: Number(input.node?.timeout_ms ?? 30_000),
@@ -542,11 +544,16 @@ async function navigatePerpsMarketList({ identifiers, press, wait, back, timeout
542
544
  throw new Error(`Opaque Mobile navigation reached ${observed}, but no visible market-list control was available.`);
543
545
  }
544
546
 
545
- async function waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode = 'lite') {
547
+ async function waitForPerpsDestination(identifiers, timeoutMs, press, perpsMode) {
546
548
  const deadline = Date.now() + timeoutMs;
547
549
  while (Date.now() <= deadline) {
548
550
  const ids = await identifiers();
549
551
  if (ids.has(IDS.modeLite) || ids.has(IDS.modePro)) {
552
+ if (!perpsMode) {
553
+ throw new Error(
554
+ 'Opaque Mobile navigation requires mode=lite or mode=pro when the product requests a Perps mode choice.',
555
+ );
556
+ }
550
557
  await press(perpsMode === 'pro' ? IDS.modePro : IDS.modeLite);
551
558
  continue;
552
559
  }
@@ -23,8 +23,8 @@ function text(value) {
23
23
  function pageRoute(node) {
24
24
  const page = text(node?.page);
25
25
  if (!page) return undefined;
26
- const mode = text(node?.mode)?.toLowerCase() ?? 'lite';
27
- if (!['lite', 'pro'].includes(mode)) {
26
+ const mode = text(node?.mode)?.toLowerCase();
27
+ if (mode && !['lite', 'pro'].includes(mode)) {
28
28
  throw new Error('mobile ui.navigate mode must be lite or pro.');
29
29
  }
30
30
  if (PAGE_ROUTES[page]) {
@@ -32,13 +32,22 @@ function pageRoute(node) {
32
32
  return {
33
33
  page,
34
34
  ...route,
35
- params: page.startsWith('perps') ? { ...route.params, mode } : route.params,
35
+ params: page.startsWith('perps') && mode
36
+ ? { ...route.params, mode }
37
+ : route.params,
36
38
  };
37
39
  }
38
40
  if (page === 'perps-market') {
39
41
  const market = text(node.market) ?? text(node.symbol) ?? text(node.params?.market?.symbol);
40
42
  if (!market) throw new Error('mobile ui.navigate page=perps-market requires market or symbol.');
41
- return { page, route: 'PerpsMarketDetails', params: { market: { symbol: market }, mode } };
43
+ return {
44
+ page,
45
+ route: 'PerpsMarketDetails',
46
+ params: {
47
+ market: { symbol: market },
48
+ ...(mode ? { mode } : {}),
49
+ },
50
+ };
42
51
  }
43
52
  throw new Error('mobile ui.navigate supported page intents: home, perps, perps-market-list, perps-market, swap.');
44
53
  }
@@ -412,8 +412,7 @@
412
412
  "enum": [
413
413
  "lite",
414
414
  "pro"
415
- ],
416
- "default": "lite"
415
+ ]
417
416
  },
418
417
  "url": {
419
418
  "type": "string"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.47.1",
3
+ "version": "0.47.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -26,7 +26,8 @@
26
26
  "instruction": "Search the installed recipe and action catalogs, inspect exact schemas, and select the closest recipe before writing anything new.",
27
27
  "details": [
28
28
  "A bundled recipe, including a Core recipe, is strong composable vocabulary, not authority for every future product version.",
29
- "Prefer an existing recipe, then existing actions, then read-only CDP or controller inspection. Add shared vocabulary only after repeated need is proven."
29
+ "Prefer an existing recipe, then existing actions, then read-only CDP or controller inspection. Add shared vocabulary only after repeated need is proven.",
30
+ "Before declaring a capability unsupported, inspect both catalogs and the closest recipe's composition. A missing dedicated action may already be composed from UI actions. Read the returned schema before filtering fields; do not treat guessed field names as empty capabilities."
30
31
  ],
31
32
  "commands": [
32
33
  "mm-harness actions --json",
@@ -51,6 +52,7 @@
51
52
  "instruction": "Use static call references for unchanged sub-journeys. Keep preparation, action, assertion, evidence, and guaranteed teardown visible as separate nodes with human-facing intent.",
52
53
  "details": [
53
54
  "Use idempotent ensure actions for required start state and independently assert their postconditions.",
55
+ "Inspect existing state before creating or cleaning fixtures. Establish only prerequisites required by the claim; a history or display check may already have usable records and need no trade or cleanup. Record the selected records and verify they still satisfy the proof boundary.",
54
56
  "Actions translate stable UI, CDP, or controller operations. Recipes compose them. Product code owns business rules."
55
57
  ],
56
58
  "commands": []
@@ -61,6 +63,7 @@
61
63
  "instruction": "Probe each unfamiliar action with call or a tiny action-to-end recipe. Use read-only CDP inspection only when declared actions cannot reveal the needed authoring detail, then consolidate the final proof from manifest-declared actions.",
62
64
  "details": [
63
65
  "Plan after every structural change and preserve the first failing trace.",
66
+ "A plan validates structure only. Probe a reused setup boundary against the current build with a short explicit timeout before a full journey; a bundled journey timeout is not a discovery budget.",
64
67
  "A CDP observation can help find the current route or selector; it is not final evidence unless the recipe records an allowed action and assertion for that signal."
65
68
  ],
66
69
  "commands": [
@@ -84,6 +87,7 @@
84
87
  "instruction": "Run the complete graph once into an explicit artifact directory, then apply the built-in quality bar before assigning coverage or a verdict.",
85
88
  "details": [
86
89
  "Coverage: every criterion needs an executable path and assertion; manual, untestable, and environment-dependent gaps stay explicit.",
90
+ "A blocked required runtime claim is incomplete proof. Passing static checks or closing a checklist cannot make that claim validated or fixed.",
87
91
  "Graph: no unconditional pass, generic intent, hidden start state, or opaque node that collapses preparation, action, assertion, evidence, and teardown.",
88
92
  "Evidence: inspect the recipe resolution, summary, trace, manifest, logs, and actual visual evidence. A filename or passing node is not visual proof.",
89
93
  "Flake risk: wait on observable state instead of sleeping, keep device and runtime identity explicit, and never overwrite a prior run's artifacts.",
@@ -101,6 +105,8 @@
101
105
  "instruction": "When a shared recipe fails, reproduce once and classify product regression, recipe drift, fixture/runtime drift, harness defect, or live dependency before changing anything.",
102
106
  "details": [
103
107
  "The graph and evidence contract can be deterministic while the product, provider, account, or network outcome is not. Honest external rejection remains a valid result, not a reason to seek a green retry.",
108
+ "Trace the first failure through the exact action source and test the smallest causal explanation before filing or fixing a harness issue. A selector name does not establish its cause. If a proposed fix outgrows the confirmed cause, preserve it separately and return to the minimal fix.",
109
+ "For blocked runtime proof, retain the target identity, build/app state, and first failing command and error. Reconcile later status snapshots with that evidence; they cannot establish that an earlier device was absent.",
104
110
  "For product-version drift, preserve the failed trace, copy the composed recipe into the task, keep working nodes, and repair the smallest owning node against the tested build.",
105
111
  "Prove the changed node in isolation, re-plan, then rerun the full task-local recipe and affected callers. Never weaken an assertion or change product code merely to turn stale proof green.",
106
112
  "Record the source recipe digest, tested product identity, repair, and fresh evidence. If the defect belongs to reusable harness code or bundled vocabulary, prepare a focused experimental-metamask-harness issue and open it when authorized.",