mobile-automator 0.25.0 → 0.27.1

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.
Files changed (46) hide show
  1. package/README.md +13 -2
  2. package/bin/mauto-session-daemon.js +85 -16
  3. package/package.json +3 -3
  4. package/src/cli.js +754 -65
  5. package/src/device/action-catalog.js +2 -2
  6. package/src/device/bridge.js +66 -9
  7. package/src/device/connection.js +23 -4
  8. package/src/device/crash-model.js +110 -0
  9. package/src/device/device-call.js +145 -0
  10. package/src/device/device-resolver.js +2 -1
  11. package/src/device/element-model.js +37 -15
  12. package/src/device/engine-hints.js +83 -0
  13. package/src/device/failure-probe.js +276 -0
  14. package/src/device/mobile-mcp-client.js +3 -1
  15. package/src/device/mobile-mcp-tools.js +64 -0
  16. package/src/device/resolve-connection.js +8 -10
  17. package/src/device/session-daemon.js +265 -51
  18. package/src/device/session-handle.js +53 -0
  19. package/src/device/session-log.js +5 -16
  20. package/src/device/swipe-geometry.js +39 -0
  21. package/src/device/tool-args.js +3 -1
  22. package/src/guide/content/execute.agnostic.md +48 -5
  23. package/src/guide/content/execute.aware.md +42 -1
  24. package/src/guide/content/execute.invariants.md +1 -0
  25. package/src/observe/accepts.js +108 -0
  26. package/src/observe/event.js +241 -0
  27. package/src/observe/failure-capture.js +166 -0
  28. package/src/observe/flush.js +177 -0
  29. package/src/observe/paths.js +125 -0
  30. package/src/observe/recorder.js +178 -0
  31. package/src/observe/settings.js +51 -0
  32. package/src/observe/sinks/file.js +101 -0
  33. package/src/observe/sinks/stderr.js +20 -0
  34. package/src/observe/spool.js +199 -0
  35. package/src/observe/telemetry.js +143 -0
  36. package/src/observe/trace.js +174 -0
  37. package/src/observe/transport.js +169 -0
  38. package/src/observe/vocab.js +96 -0
  39. package/src/output/envelope.js +6 -0
  40. package/src/result/capability-catalog.js +14 -0
  41. package/src/result/store.js +98 -1
  42. package/src/schemas/config_schema.json +11 -0
  43. package/src/schemas/result_schema.json +55 -0
  44. package/src/setup/scaffold.js +27 -0
  45. package/src/util/log-rotate.js +34 -0
  46. package/src/workspace/discover.js +69 -0
package/src/cli.js CHANGED
@@ -8,6 +8,7 @@ const { version: PKG_VERSION } = require('../package.json');
8
8
  const { ok, fail, render, exitCodeFor } = require('./output/envelope');
9
9
  const { DeviceBridge } = require('./device/bridge');
10
10
  const selectionStore = require('./device/selection');
11
+ const { engineHint } = require('./device/engine-hints');
11
12
  const { ScenarioValidator } = require('./scenario/validator');
12
13
  const { evaluate, MECHANICAL_TYPES } = require('./assertion/evaluator');
13
14
  const { ResultStore } = require('./result/store');
@@ -23,6 +24,19 @@ const guideEmitter = require('./guide/emitter');
23
24
  const { ADAPTERS } = require('./init/adapters');
24
25
  const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
25
26
  const connection = require('./device/connection');
27
+ const { record } = require('./observe/recorder');
28
+ const { resolveRunId } = require('./observe/settings');
29
+ const { runTracePath } = require('./observe/paths');
30
+ const { readTrace, deriveRun, pruneRunTraces } = require('./observe/trace');
31
+ const { readHandle, readSessionId } = require('./device/session-handle');
32
+ const { captureOnFailure } = require('./observe/failure-capture');
33
+ const { crashTimestampMs } = require('./device/crash-model');
34
+ const { probeCrashes } = require('./device/failure-probe');
35
+ const observeTelemetry = require('./observe/telemetry');
36
+ const observeSpool = require('./observe/spool');
37
+ const observeTransport = require('./observe/transport');
38
+ const { makeFlusher } = require('./observe/flush');
39
+ const { findWorkspaceRoot, noWorkspaceFailure } = require('./workspace/discover');
26
40
 
27
41
  // Commander throws (via exitOverride) for two very different reasons, and the
28
42
  // split below is the ONLY thing that tells them apart.
@@ -145,6 +159,16 @@ function toEnvelope(err) {
145
159
  };
146
160
  }
147
161
 
162
+ // The hint for any device-kind failure. Precedence: an explicit err.hint wins
163
+ // (our own code attached it knowing the exact cause — a DeviceResolutionError,
164
+ // the daemon-log pointer, a timeout); then engineHint, which recognises engine
165
+ // messages that are useless on their own (the 1.0.5 engine's missing iOS agent
166
+ // and changed Android device ids, #199); then the handler's generic fallback.
167
+ // Every device fail() below goes through here so no handler can miss it.
168
+ function deviceHint(err, fallback) {
169
+ return (err && err.hint) || engineHint(err && err.message) || fallback;
170
+ }
171
+
148
172
  // ---------------------------------------------------------------------------
149
173
  // Handlers — pure-ish: accept injected deps, return { envelope, exitKind }.
150
174
  // No process.exit / printing here so they are trivially unit-testable.
@@ -159,7 +183,7 @@ async function handleElements({ deviceBridge }) {
159
183
  envelope: fail(
160
184
  'device',
161
185
  err.message || String(err),
162
- err.hint || 'Ensure a device or simulator is connected and the app is running.'
186
+ deviceHint(err, 'Ensure a device or simulator is connected and the app is running.')
163
187
  ),
164
188
  exitKind: 'device',
165
189
  };
@@ -175,7 +199,7 @@ async function handleScreenshot({ deviceBridge }, destPath) {
175
199
  envelope: fail(
176
200
  'device',
177
201
  err.message || String(err),
178
- err.hint || 'Ensure a device or simulator is connected.'
202
+ deviceHint(err, 'Ensure a device or simulator is connected.')
179
203
  ),
180
204
  exitKind: 'device',
181
205
  };
@@ -234,7 +258,7 @@ function deviceFail(err) {
234
258
  envelope: fail(
235
259
  kind,
236
260
  err.message || String(err),
237
- err.hint || 'Ensure a device or simulator is connected and the app is running.'
261
+ deviceHint(err, 'Ensure a device or simulator is connected and the app is running.')
238
262
  ),
239
263
  exitKind: kind,
240
264
  };
@@ -266,7 +290,20 @@ function parseCoordinates(raw) {
266
290
  error: fail('invalid_input', `coordinates must be numbers, got "${raw}"`, 'Pass --at <x,y>, e.g. --at 100,250.'),
267
291
  };
268
292
  }
269
- return { x: Math.round(x), y: Math.round(y) };
293
+ // Check AFTER rounding: the device receives the rounded ints, so -0.4 (-> 0)
294
+ // is a valid on-screen point while -0.6 (-> -1) is not. `+ 0` normalizes -0
295
+ // to 0 so neither the envelope nor the engine sees a signed zero. Negatives
296
+ // must be caught here because mobile-mcp 1.0.5 declares coordinates .min(0)
297
+ // and would reject them device-side, misclassified as a device failure that
298
+ // triggers the crash probe (#199).
299
+ const rx = Math.round(x) + 0;
300
+ const ry = Math.round(y) + 0;
301
+ if (rx < 0 || ry < 0) {
302
+ return {
303
+ error: fail('invalid_input', `coordinates must be non-negative, got "${raw}"`, 'Coordinates must be >= 0 (screen pixels from the top-left), e.g. --at 100,250.'),
304
+ };
305
+ }
306
+ return { x: rx, y: ry };
270
307
  }
271
308
 
272
309
  async function handleTap({ deviceBridge }, raw) {
@@ -625,6 +662,75 @@ function handleResultAddAssertion({ resultStoreFactory, projectRoot }, opts) {
625
662
  return { envelope: ok({ run_id: runId, assertion: entry }, storeHint(store)), exitKind: 'ok' };
626
663
  }
627
664
 
665
+ // Record an observed app crash.
666
+ function handleResultAddCrash({ resultStoreFactory, projectRoot }, opts = {}) {
667
+ const store = resultStoreFactory({
668
+ runId: opts.runId,
669
+ scenarioId: opts.scenarioId,
670
+ projectRoot,
671
+ });
672
+ const entry = store.addCrash({
673
+ crash_id: opts.crashId,
674
+ process: opts.process,
675
+ timestamp: opts.crashTimestamp,
676
+ step_id: opts.stepId,
677
+ excerpt: opts.excerpt,
678
+ report_path: opts.reportPath,
679
+ });
680
+ return { envelope: ok({ crash: entry }, storeHint(store)), exitKind: 'ok' };
681
+ }
682
+
683
+ // Tolerance for calling a reported duration wrong.
684
+ //
685
+ // The measured value is a wall-clock span between two recorded events, so
686
+ // sub-second differences are rounding rather than disagreement, and 10% absorbs
687
+ // the honest gap between "the run" and "the part of the run mauto saw" —
688
+ // setup before the first verb, and finalize's own execution after the last.
689
+ //
690
+ // Symmetric on purpose. An UNDER-claim means the agent lost track of its own
691
+ // retries; an OVER-claim means it counted its planning as run time. Both are
692
+ // worth a human seeing, and one rule is two tests instead of four.
693
+ const DURATION_TOLERANCE_SECONDS = 1;
694
+ const DURATION_TOLERANCE_RATIO = 0.1;
695
+
696
+ // Turn the run's trace into the duration to record and the measurement block to
697
+ // record beside it. Total: an unreadable, absent or unmeasurable trace yields
698
+ // the reported value and a `measurements` block that says so, never an error.
699
+ function measureRun({ projectRoot, runId, reported }) {
700
+ const derived = deriveRun(readTrace(runTracePath(projectRoot, runId)));
701
+ const reportedSeconds = reported === undefined ? null : Number(reported);
702
+ const hasReported = reportedSeconds !== null && Number.isFinite(reportedSeconds);
703
+
704
+ const base = {
705
+ reported_duration_seconds: hasReported ? reportedSeconds : null,
706
+ trace_events: derived ? derived.trace_events : 0,
707
+ device_failures: derived ? derived.device_failures : 0,
708
+ trace_truncated: derived ? derived.trace_truncated : false,
709
+ failure_screenshots: derived ? derived.failure_screenshots : [],
710
+ };
711
+
712
+ // No trace, or a trace too short to span any time. Falling back to the flag
713
+ // is 0.24.0's behaviour, and `source` records that it IS a fallback rather
714
+ // than dressing a self-report as a measurement.
715
+ if (!derived || derived.duration_seconds === null) {
716
+ return {
717
+ durationSeconds: hasReported ? reportedSeconds : 0,
718
+ measurements: { source: hasReported ? 'reported' : 'none', duration_disagreement: false, ...base },
719
+ };
720
+ }
721
+
722
+ const measured = derived.duration_seconds;
723
+ const tolerance = Math.max(DURATION_TOLERANCE_SECONDS, measured * DURATION_TOLERANCE_RATIO);
724
+ return {
725
+ durationSeconds: measured,
726
+ measurements: {
727
+ source: 'trace',
728
+ duration_disagreement: hasReported && Math.abs(reportedSeconds - measured) > tolerance,
729
+ ...base,
730
+ },
731
+ };
732
+ }
733
+
628
734
  function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectRoot }, opts) {
629
735
  const { runId, scenarioId, status, duration } = opts;
630
736
  if (!runId) {
@@ -643,11 +749,20 @@ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectR
643
749
  if (opts.apiLevel !== undefined) metadata.api_level = opts.apiLevel;
644
750
  if (opts.environment !== undefined) metadata.environment = opts.environment;
645
751
 
752
+ // Measure BEFORE constructing the store: the trace is read-only here, and
753
+ // deriving first keeps the store's lock window as small as it already is.
754
+ const measured = measureRun({ projectRoot, runId, reported: duration });
755
+
646
756
  const store = resultStoreFactory({ runId, scenarioId, projectRoot });
647
757
  const result = store.finalize({
648
758
  status,
649
- durationSeconds: duration === undefined ? 0 : Number(duration),
759
+ // Derived, not trusted. `--duration` survives in
760
+ // measurements.reported_duration_seconds and, when it disagrees with the
761
+ // clock, in a typed state_context observation — the disagreement is a
762
+ // signal about the agent, so it is recorded rather than resolved.
763
+ durationSeconds: measured.durationSeconds,
650
764
  metadata,
765
+ measurements: measured.measurements,
651
766
  // Undefined when --summary is omitted; ResultStore.finalize's own
652
767
  // `summary || <generated default>` already treats that as "no override",
653
768
  // so no provided-keys-only guard is needed here (unlike metadata, whose
@@ -655,6 +770,12 @@ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectR
655
770
  summary: opts.summary,
656
771
  });
657
772
 
773
+ // Retention, at the only moment a run is known to be over and the only place
774
+ // off the hot path. Never this run's own trace — it is the evidence behind
775
+ // every number just written. Best-effort by construction; pruneRunTraces
776
+ // swallows everything and returns a count.
777
+ pruneRunTraces(projectRoot, { except: runId });
778
+
658
779
  // Auto-harvest into cross-session memory. This is best-effort: a memory
659
780
  // failure must never fail an otherwise-successful finalize, so its warnings
660
781
  // fold into the envelope hint instead of throwing.
@@ -691,7 +812,11 @@ function storeHint(store) {
691
812
  // on success; only their ERROR paths (unknown topic/name) fall back to the
692
813
  // `fail(...)` envelope + exit 3.
693
814
 
694
- function handleSetup({ projectRoot }, opts = {}) {
815
+ // `ancestorRoot` is the workspace discovery found ABOVE cwd, if any. setup
816
+ // still scaffolds in cwd — it is the one verb whose job is to create a
817
+ // workspace where the user stands — but a nested workspace silently shadows
818
+ // the ancestor for every later verb run below it, so say so.
819
+ function handleSetup({ projectRoot, ancestorRoot = null }, opts = {}) {
695
820
  const raw = opts.mode === undefined ? 'aware' : String(opts.mode);
696
821
  const mode = MODE_ALIASES[raw];
697
822
  if (!mode) {
@@ -701,8 +826,24 @@ function handleSetup({ projectRoot }, opts = {}) {
701
826
  };
702
827
  }
703
828
  const r = scaffold(projectRoot, { mode });
829
+ const hint = ancestorRoot
830
+ ? `An existing workspace was found in an ancestor directory (${ancestorRoot}). This new workspace in ${projectRoot} shadows it for every mauto verb run from here or below; remove it if you meant to use the ancestor's.`
831
+ : null;
704
832
  return {
705
- envelope: ok({ created: r.created, mode: r.mode, next: 'run `mauto guide setup`' }),
833
+ envelope: ok(
834
+ {
835
+ created: r.created,
836
+ mode: r.mode,
837
+ // A NOTICE, not a prompt. mauto verbs are invoked by an agent, so there
838
+ // is nobody at the keyboard to answer a question — and an agent answering
839
+ // a consent question on a human's behalf is worse than not asking. It
840
+ // rides in the envelope (structured, in-band) rather than as a stderr
841
+ // banner, so the agent can surface it to the human it belongs to.
842
+ telemetry: { enabled: false, notice: observeTelemetry.CONSENT_NOTICE },
843
+ next: 'run `mauto guide setup`',
844
+ },
845
+ hint
846
+ ),
706
847
  exitKind: 'ok',
707
848
  };
708
849
  }
@@ -732,6 +873,87 @@ function handleConfigSet({ projectRoot }, key, rawValue) {
732
873
  return { envelope: ok({ key, value }), exitKind: 'ok' };
733
874
  }
734
875
 
876
+ // --- Slice 5: telemetry consent surface -----------------------------------
877
+ //
878
+ // `status` is the informed half of informed consent, and it is COMPUTED: the
879
+ // field lists come from EVENT_FIELDS at runtime, so the disclosure a user reads
880
+ // before opting in cannot drift from what the uploader actually sends. Prose
881
+ // that restates a catalog is prose that will eventually be wrong.
882
+
883
+ function telemetryReport({ projectRoot, env }, extra = {}) {
884
+ const decision = observeTelemetry.decideForProject(projectRoot, env);
885
+ return {
886
+ enabled: decision.enabled,
887
+ reason: decision.reason,
888
+ endpoint: observeTransport.endpointUrl(env),
889
+ fields_sent: observeTelemetry.sentFields(),
890
+ fields_never_sent: observeTelemetry.neverSentFields(),
891
+ spool: observeSpool.stats({ projectRoot, env }),
892
+ notice: observeTelemetry.CONSENT_NOTICE,
893
+ enable_with: 'mauto telemetry enable',
894
+ ...extra,
895
+ };
896
+ }
897
+
898
+ function handleTelemetryStatus({ projectRoot, env = process.env }) {
899
+ return { envelope: ok(telemetryReport({ projectRoot, env })), exitKind: 'ok' };
900
+ }
901
+
902
+ // Writes the durable intent, then reports the RESOLVED state — which may still
903
+ // be off, because an env kill switch wins. Reporting `enabled: true` here when
904
+ // MAUTO_TELEMETRY=0 is set would be a lie, and the config is not the place to
905
+ // resolve a conflict the environment owns.
906
+ function handleTelemetryEnable({ projectRoot, env = process.env }) {
907
+ observeTelemetry.setEnabled(projectRoot, true);
908
+ const data = telemetryReport({ projectRoot, env });
909
+ const hint =
910
+ data.reason === 'kill_switch'
911
+ ? 'Telemetry is enabled in config.json but MAUTO_TELEMETRY=0 is set in this environment and overrides it.'
912
+ : data.reason === 'do_not_track'
913
+ ? 'Telemetry is enabled in config.json but DO_NOT_TRACK is set in this environment and overrides it.'
914
+ : data.reason === 'no_token'
915
+ ? 'Telemetry is enabled in config.json but this build ships no project token, so nothing will be sent.'
916
+ : undefined;
917
+ return { envelope: ok(data, hint), exitKind: 'ok' };
918
+ }
919
+
920
+ function handleTelemetryDisable({ projectRoot, env = process.env }) {
921
+ observeTelemetry.setEnabled(projectRoot, false);
922
+ return { envelope: ok(telemetryReport({ projectRoot, env })), exitKind: 'ok' };
923
+ }
924
+
925
+ // The explicit escape hatch. This is the ONE verb allowed a network round trip,
926
+ // because the user asked for one and is willing to wait for it — every other
927
+ // verb appends to the spool and exits. A machine that never runs a daemon
928
+ // drains here.
929
+ async function handleTelemetryFlush({ projectRoot, env = process.env, flusher }) {
930
+ const flush = flusher || makeFlusher({ projectRoot, env });
931
+ const r = await flush();
932
+ // exitKind stays 'ok': the envelope's own `ok` says the VERB executed — an
933
+ // undelivered spool is a non-error state by design (src/observe/spool.js).
934
+ // The flush's verdict — including permanently discarded events — is data:
935
+ // `ok` below, alongside the counts. Units: `kept` counts BATCHES (files);
936
+ // `sent`, `dropped` and `kept_events` count events.
937
+ const hint =
938
+ r.dropped > 0
939
+ ? `${r.dropped} event${r.dropped === 1 ? '' : 's'} permanently discarded — the endpoint rejected them (revoked token, wrong host, or a non-retryable status). They will not be retried.`
940
+ : r.kept > 0
941
+ ? `${r.kept} batch${r.kept === 1 ? '' : 'es'} (${r.kept_events} events) kept for retry — the next daemon drain or mauto telemetry flush will resend them.`
942
+ : undefined;
943
+ return {
944
+ envelope: ok({
945
+ skipped: r.skipped,
946
+ sent: r.sent,
947
+ dropped: r.dropped,
948
+ kept: r.kept,
949
+ kept_events: r.kept_events,
950
+ ok: r.ok,
951
+ spool: observeSpool.stats({ projectRoot, env }),
952
+ }, hint),
953
+ exitKind: 'ok',
954
+ };
955
+ }
956
+
735
957
  // RAW content on success; fail envelope only when the topic is unknown.
736
958
  function handleGuide({ projectRoot, emitter = guideEmitter, config = configManager }, topic) {
737
959
  const cfg = config.load(projectRoot);
@@ -1043,7 +1265,7 @@ async function handleDevices({ deviceBridge }) {
1043
1265
  envelope: fail(
1044
1266
  'device',
1045
1267
  err.message || String(err),
1046
- err.hint || 'Ensure a device or simulator is connected and reachable.'
1268
+ deviceHint(err, 'Ensure a device or simulator is connected and reachable.')
1047
1269
  ),
1048
1270
  exitKind: 'device',
1049
1271
  };
@@ -1067,7 +1289,7 @@ async function handleDevicesUse({ deviceBridge, store = selectionStore, projectR
1067
1289
  devices = await deviceBridge.listDevices();
1068
1290
  } catch (err) {
1069
1291
  return {
1070
- envelope: fail('device', err.message || String(err), err.hint || 'Ensure a device or simulator is connected and reachable.'),
1292
+ envelope: fail('device', err.message || String(err), deviceHint(err, 'Ensure a device or simulator is connected and reachable.')),
1071
1293
  exitKind: 'device',
1072
1294
  };
1073
1295
  }
@@ -1118,6 +1340,134 @@ async function handleDevicesClear({
1118
1340
  };
1119
1341
  }
1120
1342
 
1343
+ // --- Crash diagnostics (slice 4) -----------------------------------------
1344
+ //
1345
+ // A DIAGNOSTIC verb, like `devices` / `session` / `memory` — deliberately NOT
1346
+ // in src/device/action-catalog.js, which holds exactly the scenario-schema
1347
+ // actions and whose lint guard asserts parity in both directions.
1348
+
1349
+ const CRASH_REPORT_HEAD_LINES = 200;
1350
+ const CRASH_LIST_HINT =
1351
+ 'The device engine could not read crash reports. Run `mauto devices` to confirm the device is reachable; crash reporting needs the helper binary that ships with the device engine.';
1352
+
1353
+ // Default watermark: when a device session is live, its handle already records
1354
+ // started_at (src/device/session-daemon.js). Bind, do not copy — there is no new
1355
+ // timestamp artifact and no second source of truth for "when did this session
1356
+ // begin". No handle means no watermark, which means UNSCOPED, which the envelope
1357
+ // reports as `since: null` rather than pretending the list is recent.
1358
+ function sessionWatermark(projectRoot) {
1359
+ const handle = readHandle(projectRoot);
1360
+ const t = handle && handle.started_at;
1361
+ return typeof t === 'string' && Number.isFinite(Date.parse(t)) ? t : null;
1362
+ }
1363
+
1364
+ // Split a crash list around a watermark. Reports whose own time is unreadable
1365
+ // are neither included nor discarded — they are counted, so the caller can see
1366
+ // that the device returned something the tool could not place in time.
1367
+ function scopeCrashes(crashes, sinceMs) {
1368
+ if (sinceMs == null) return { crashes, unattributed: 0 };
1369
+ const inWindow = [];
1370
+ let unattributed = 0;
1371
+ for (const c of crashes) {
1372
+ const ms = crashTimestampMs(c);
1373
+ if (ms == null) unattributed += 1;
1374
+ else if (ms >= sinceMs) inWindow.push(c);
1375
+ }
1376
+ return { crashes: inWindow, unattributed };
1377
+ }
1378
+
1379
+ async function handleCrashList({ deviceBridge, projectRoot }, opts = {}) {
1380
+ const sinceRaw = opts.since !== undefined ? opts.since : sessionWatermark(projectRoot);
1381
+ let sinceMs = null;
1382
+ if (sinceRaw != null) {
1383
+ sinceMs = Date.parse(sinceRaw);
1384
+ if (!Number.isFinite(sinceMs)) {
1385
+ return {
1386
+ envelope: fail(
1387
+ 'invalid_input',
1388
+ `could not parse --since "${sinceRaw}" as a date`,
1389
+ 'Pass an ISO 8601 timestamp, e.g. --since 2026-09-05T10:00:00Z.'
1390
+ ),
1391
+ exitKind: 'invalid_input',
1392
+ };
1393
+ }
1394
+ }
1395
+
1396
+ let all;
1397
+ try {
1398
+ all = await deviceBridge.listCrashes();
1399
+ } catch (err) {
1400
+ // "Could not look" is NOT "found none". Reporting ok:true with an empty
1401
+ // list here would be this slice's own bug with the sign flipped.
1402
+ return {
1403
+ envelope: fail(err.kind || 'device', err.message || String(err), deviceHint(err, CRASH_LIST_HINT)),
1404
+ exitKind: err.kind || 'device',
1405
+ };
1406
+ }
1407
+
1408
+ const { crashes, unattributed } = scopeCrashes(all, sinceMs);
1409
+ record(
1410
+ {
1411
+ level: 'info',
1412
+ src: 'cli',
1413
+ event: 'crash.list',
1414
+ crash_count: Number.isFinite(crashes.length) ? crashes.length : undefined,
1415
+ },
1416
+ { projectRoot }
1417
+ );
1418
+ return {
1419
+ envelope: ok({
1420
+ crashes,
1421
+ count: crashes.length,
1422
+ since: sinceMs == null ? null : sinceRaw,
1423
+ unattributed,
1424
+ }),
1425
+ exitKind: 'ok',
1426
+ };
1427
+ }
1428
+
1429
+ async function handleCrashGet({ deviceBridge, fs: fsDep = fs }, id, opts = {}) {
1430
+ let report;
1431
+ try {
1432
+ report = await deviceBridge.getCrash(id);
1433
+ } catch (err) {
1434
+ return {
1435
+ envelope: fail(err.kind || 'device', err.message || String(err), deviceHint(err, CRASH_LIST_HINT)),
1436
+ exitKind: err.kind || 'device',
1437
+ };
1438
+ }
1439
+
1440
+ // --out writes the FULL report and returns only its path. This is how a
1441
+ // tens-of-kilobytes native tombstone reaches a result file without passing
1442
+ // through the agent's context window.
1443
+ if (opts.out) {
1444
+ try {
1445
+ fsDep.writeFileSync(opts.out, report);
1446
+ } catch (err) {
1447
+ return {
1448
+ envelope: fail(
1449
+ 'environment',
1450
+ `could not write the crash report to ${opts.out}: ${err.message}`,
1451
+ 'Check the directory exists and is writable.'
1452
+ ),
1453
+ exitKind: 'environment',
1454
+ };
1455
+ }
1456
+ return { envelope: ok({ crash_id: id, path: opts.out, truncated: false }), exitKind: 'ok' };
1457
+ }
1458
+
1459
+ const lines = String(report).split('\n');
1460
+ const truncated = !opts.full && lines.length > CRASH_REPORT_HEAD_LINES;
1461
+ return {
1462
+ envelope: ok({
1463
+ crash_id: id,
1464
+ report: truncated ? lines.slice(0, CRASH_REPORT_HEAD_LINES).join('\n') : String(report),
1465
+ truncated,
1466
+ }),
1467
+ exitKind: 'ok',
1468
+ };
1469
+ }
1470
+
1121
1471
  // ---------------------------------------------------------------------------
1122
1472
  // Program wiring
1123
1473
  // ---------------------------------------------------------------------------
@@ -1142,10 +1492,31 @@ function buildProgram(deps = {}) {
1142
1492
  resultStoreFactory = (args) => new ResultStore(args),
1143
1493
  // Factory for the cross-session memory store. Overridable in tests.
1144
1494
  memoryStoreFactory = (args) => new MemoryStore(args),
1145
- // Project root used to resolve mobile-automator/results/.
1146
- projectRoot = process.cwd(),
1495
+ // Where the creating verbs (setup, init) write: always the directory the
1496
+ // user stands in, never a discovered ancestor. Defaults to an injected
1497
+ // projectRoot so tests that inject only a root keep writing there.
1498
+ cwd = deps.projectRoot !== undefined ? deps.projectRoot : process.cwd(),
1499
+ // The upward-discovery result `{ root, searched }` (#188): run() resolves
1500
+ // it once and passes it in, tests inject it wholesale, and a bare
1501
+ // buildProgram() discovers from cwd exactly as run() does. ONE fabrication
1502
+ // rule, stated once: a caller that injects ONLY a projectRoot is trusted
1503
+ // to know where the workspace is ({ root: projectRoot }) — the legacy
1504
+ // handler-test seam, and deliberately gate-arming. run() passes cwd +
1505
+ // workspace and NEVER projectRoot, so production cannot arm this branch
1506
+ // and silently disable the requireWorkspace gate.
1507
+ workspace = deps.projectRoot !== undefined
1508
+ ? { root: deps.projectRoot, searched: [deps.projectRoot] }
1509
+ : findWorkspaceRoot(cwd),
1510
+ // Project root used to resolve mobile-automator/results/ — and, through the
1511
+ // emitters below, mobile-automator/.logs/. One root, one tree. The
1512
+ // discovered workspace when there is one; cwd otherwise, which is what the
1513
+ // tolerant verbs (guide, mcp, read-only device/session) have always used.
1514
+ projectRoot = workspace.root || cwd,
1515
+ // The exit paths. run() passes its own instance so the help/version and
1516
+ // fatal paths it owns share this program's resolved verb.
1517
+ emitters = makeEmitters({ projectRoot }),
1147
1518
  // Sink used to emit the rendered envelope + drive the exit code.
1148
- emit = defaultEmit,
1519
+ emit = emitters.emit,
1149
1520
  } = deps;
1150
1521
 
1151
1522
  const program = new Command();
@@ -1184,6 +1555,31 @@ function buildProgram(deps = {}) {
1184
1555
  });
1185
1556
  program.configureOutput({ writeErr: () => {} });
1186
1557
 
1558
+ // Name the verb for the observability record, from commander's own resolution
1559
+ // rather than from argv. preAction fires only after a SUCCESSFUL parse and
1560
+ // only for a command that exists, which is what makes `verb` provably a name
1561
+ // out of the vocabulary this CLI ships — the property event.js's `sends: true`
1562
+ // classification asserts. Hooks registered on the root are inherited by every
1563
+ // subcommand, so one registration covers all of them.
1564
+ //
1565
+ // `actionCommand` is the innermost command (`get` in `config get mode`), so
1566
+ // walk up to the child of the root: the record names the verb, not its
1567
+ // subcommand, keeping the vocabulary the size of the verb list.
1568
+ program.hook('preAction', (_thisCommand, actionCommand) => {
1569
+ // Read --run-id off the INNERMOST command (e.g. `add-step`) before walking
1570
+ // up to name the verb (`result`). The three result verbs already require
1571
+ // it, so they correlate with no new flag and no environment variable; an
1572
+ // explicit flag beats the ambient MAUTO_RUN_ID because it is scoped to this
1573
+ // invocation and the environment is not.
1574
+ const opts = actionCommand.opts();
1575
+ emitters.setRunId(
1576
+ (typeof opts.runId === 'string' && opts.runId ? opts.runId : null) || resolveRunId(process.env)
1577
+ );
1578
+ let command = actionCommand;
1579
+ while (command.parent && command.parent.parent) command = command.parent;
1580
+ emitters.setVerb(command.name());
1581
+ });
1582
+
1187
1583
  const humanFlag = () => Boolean(program.opts().human);
1188
1584
 
1189
1585
  // The single envelope boundary. Wrap every `.action(...)` callback so any
@@ -1203,6 +1599,20 @@ function buildProgram(deps = {}) {
1203
1599
  }
1204
1600
  };
1205
1601
 
1602
+ // Gate for verbs whose whole job is reading or writing the workspace. With
1603
+ // no workspace discovered, carrying on against cwd is what produced #188's
1604
+ // confident wrong answers — `ok:true` with a value silently absent, an
1605
+ // "empty" memory store, a result file written where nobody will look — and
1606
+ // `config set` would even create a stray workspace. Fail environment
1607
+ // (exit 5) naming where we looked, so the agent can recover.
1608
+ const requireWorkspace = (action) => (...args) => {
1609
+ if (!workspace.root) {
1610
+ emit(noWorkspaceFailure(workspace.searched), humanFlag());
1611
+ return undefined;
1612
+ }
1613
+ return action(...args);
1614
+ };
1615
+
1206
1616
  // Resolve which device a verb targets: explicit --device wins, else the
1207
1617
  // persisted selection, else null (mobile-mcp auto-selects a single device).
1208
1618
  // Keeping the no-selection fast path returning null preserves zero-config use.
@@ -1221,13 +1631,55 @@ function buildProgram(deps = {}) {
1221
1631
  let bridge;
1222
1632
  let close;
1223
1633
  try {
1224
- ({ bridge, close } = await deviceBridgeFactory({ device, projectRoot }));
1634
+ // autostart: the daemon is a per-workspace artifact — its .session/ tree
1635
+ // and logs live under mobile-automator/. With no discovered workspace
1636
+ // there is nowhere legitimate to put one, so never autostart: the verb
1637
+ // connects one-shot and creates nothing. `ok:true` plus a phantom
1638
+ // workspace is the exact #188 pathology.
1639
+ ({ bridge, close } = await deviceBridgeFactory({
1640
+ device,
1641
+ projectRoot,
1642
+ autostart: Boolean(workspace.root),
1643
+ }));
1644
+ // AFTER the connect, deliberately: the handle is written when the daemon
1645
+ // starts listening, so before this point it may legitimately not exist
1646
+ // yet. Best-effort — readSessionId never throws and returns null for an
1647
+ // absent or malformed handle, which is a normal state (a one-shot
1648
+ // fallback connection has no daemon at all).
1649
+ emitters.setSessionId(readSessionId(projectRoot));
1225
1650
  } catch (err) {
1226
1651
  emit(deviceFail(err), humanFlag());
1227
1652
  return;
1228
1653
  }
1229
1654
  try {
1230
1655
  const r = await fn(bridge);
1656
+ // The ONE window where a device failure can still be investigated: after
1657
+ // fn(bridge) has produced its verdict and before `finally` closes the
1658
+ // connection. The connect-failure catch above cannot do this — there is
1659
+ // no bridge there — which is why this is here and not wrapped around the
1660
+ // whole function.
1661
+ //
1662
+ // The return value of BOTH calls is deliberately unused. `r` reaches
1663
+ // emit() untouched (aside from probeCrashes's own in-place, additive
1664
+ // mutation of r.envelope) whether either helper succeeded, failed, or
1665
+ // never ran; neither may mask or replace the error the caller actually
1666
+ // asked about. Probe before screenshot: the probe has its own short,
1667
+ // gated deadline and must not sit behind the screenshot's device round
1668
+ // trip.
1669
+ await probeCrashes({
1670
+ bridge,
1671
+ envelope: r.envelope,
1672
+ verb: emitters.getVerb(),
1673
+ projectRoot,
1674
+ watermark: sessionWatermark(projectRoot),
1675
+ });
1676
+ await captureOnFailure({
1677
+ bridge,
1678
+ result: r,
1679
+ projectRoot,
1680
+ runId: emitters.getRunId(),
1681
+ verb: emitters.getVerb(),
1682
+ });
1231
1683
  emit(r, humanFlag());
1232
1684
  } finally {
1233
1685
  if (typeof close === 'function') await close();
@@ -1382,7 +1834,7 @@ function buildProgram(deps = {}) {
1382
1834
  []
1383
1835
  )
1384
1836
  .option('--capture <name=value>', 'captured variable; repeatable', collect, [])
1385
- .action(withEnvelope((opts) => {
1837
+ .action(withEnvelope(requireWorkspace((opts) => {
1386
1838
  const r = handleResultAddStep(
1387
1839
  { resultStoreFactory, projectRoot },
1388
1840
  {
@@ -1398,7 +1850,7 @@ function buildProgram(deps = {}) {
1398
1850
  }
1399
1851
  );
1400
1852
  emit(r, humanFlag());
1401
- }));
1853
+ })));
1402
1854
 
1403
1855
  result
1404
1856
  .command('add-assertion')
@@ -1412,7 +1864,7 @@ function buildProgram(deps = {}) {
1412
1864
  .option('--message <text>', 'human-readable justification for the verdict')
1413
1865
  .option('--expected <v>', 'expected value')
1414
1866
  .option('--actual <v>', 'observed value')
1415
- .action(withEnvelope((opts) => {
1867
+ .action(withEnvelope(requireWorkspace((opts) => {
1416
1868
  const r = handleResultAddAssertion(
1417
1869
  { resultStoreFactory, projectRoot },
1418
1870
  {
@@ -1428,7 +1880,35 @@ function buildProgram(deps = {}) {
1428
1880
  }
1429
1881
  );
1430
1882
  emit(r, humanFlag());
1431
- }));
1883
+ })));
1884
+
1885
+ result
1886
+ .command('add-crash')
1887
+ .description('Record an app crash observed during the run')
1888
+ .requiredOption('--run-id <id>', 'run identifier (run_YYYYMMDD_HHMMSS)')
1889
+ .option('--scenario-id <id>', 'scenario identifier')
1890
+ .requiredOption('--crash-id <id>', 'device-local report id (from `mauto crash list`)')
1891
+ .option('--step-id <id>', 'step in flight when the crash was observed')
1892
+ .option('--process <name>', 'package/bundle identifier of the process that died')
1893
+ .option('--crash-timestamp <iso>', "the device's own time for the report")
1894
+ .option('--excerpt <text>', 'head of the crash report (stored capped at 2000 chars)')
1895
+ .option('--report-path <path>', 'path to the full report written by `mauto crash get --out`')
1896
+ .action(withEnvelope(requireWorkspace((opts) => {
1897
+ const r = handleResultAddCrash(
1898
+ { resultStoreFactory, projectRoot },
1899
+ {
1900
+ runId: opts.runId,
1901
+ scenarioId: opts.scenarioId,
1902
+ crashId: opts.crashId,
1903
+ stepId: opts.stepId,
1904
+ process: opts.process,
1905
+ crashTimestamp: opts.crashTimestamp,
1906
+ excerpt: opts.excerpt,
1907
+ reportPath: opts.reportPath,
1908
+ }
1909
+ );
1910
+ emit(r, humanFlag());
1911
+ })));
1432
1912
 
1433
1913
  result
1434
1914
  .command('finalize')
@@ -1442,7 +1922,7 @@ function buildProgram(deps = {}) {
1442
1922
  .option('--api-level <v>', 'OS API level / version')
1443
1923
  .option('--environment <v>', "target environment (e.g. 'staging')")
1444
1924
  .option('--summary <text>', 'override the generated one-line result summary')
1445
- .action(withEnvelope((opts) => {
1925
+ .action(withEnvelope(requireWorkspace((opts) => {
1446
1926
  const r = handleResultFinalize(
1447
1927
  { resultStoreFactory, memoryStoreFactory, projectRoot },
1448
1928
  {
@@ -1458,7 +1938,7 @@ function buildProgram(deps = {}) {
1458
1938
  }
1459
1939
  );
1460
1940
  emit(r, humanFlag());
1461
- }));
1941
+ })));
1462
1942
 
1463
1943
  // --- Slice 3 verbs --------------------------------------------------------
1464
1944
 
@@ -1467,7 +1947,7 @@ function buildProgram(deps = {}) {
1467
1947
  // anything else is an envelope error and goes through the normal emit path.
1468
1948
  const emitMaybeRaw = (r) => {
1469
1949
  if (r.raw !== undefined && r.exitKind === 'ok') {
1470
- emitRaw(r.raw, r.exitKind);
1950
+ emitters.emitRaw(r.raw, r.exitKind);
1471
1951
  } else {
1472
1952
  emit(r, humanFlag());
1473
1953
  }
@@ -1478,7 +1958,10 @@ function buildProgram(deps = {}) {
1478
1958
  .description('Scaffold the workspace (mobile-automator/) and write a config')
1479
1959
  .option('--mode <mode>', 'aware (platform-aware, default) | agnostic (platform-agnostic)')
1480
1960
  .action(withEnvelope((opts) => {
1481
- const r = handleSetup({ projectRoot }, { mode: opts.mode });
1961
+ // cwd, not the discovered root: setup creates a workspace where the user
1962
+ // stands. A discovered root that is not cwd is an ancestor worth naming.
1963
+ const ancestorRoot = workspace.root && workspace.root !== cwd ? workspace.root : null;
1964
+ const r = handleSetup({ projectRoot: cwd, ancestorRoot }, { mode: opts.mode });
1482
1965
  emit(r, humanFlag());
1483
1966
  }));
1484
1967
 
@@ -1486,11 +1969,11 @@ function buildProgram(deps = {}) {
1486
1969
  config
1487
1970
  .command('get <key>')
1488
1971
  .description('Get a dotted-path config value')
1489
- .action(withEnvelope((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag())));
1972
+ .action(withEnvelope(requireWorkspace((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag()))));
1490
1973
  config
1491
1974
  .command('set <key> <value>')
1492
1975
  .description('Set a dotted-path config value (coerced to its declared type per `mauto schema config`)')
1493
- .action(withEnvelope((key, value) => emit(handleConfigSet({ projectRoot }, key, value), humanFlag())));
1976
+ .action(withEnvelope(requireWorkspace((key, value) => emit(handleConfigSet({ projectRoot }, key, value), humanFlag()))));
1494
1977
 
1495
1978
  program
1496
1979
  .command('guide <topic>')
@@ -1516,28 +1999,28 @@ function buildProgram(deps = {}) {
1516
1999
  .description('Print RAW memory markdown (run-history[, app-knowledge, preferences])')
1517
2000
  .option('--kind <k>', 'run-history | app-knowledge | preferences')
1518
2001
  .option('--scenario <id>', 'filter run-history to one scenario')
1519
- .action(withEnvelope((opts) =>
2002
+ .action(withEnvelope(requireWorkspace((opts) =>
1520
2003
  emitMaybeRaw(
1521
2004
  handleMemoryShow({ memoryStoreFactory, projectRoot }, { kind: opts.kind, scenario: opts.scenario })
1522
2005
  )
1523
- ));
2006
+ )));
1524
2007
 
1525
2008
  memory
1526
2009
  .command('add <text>')
1527
2010
  .description('Record an agent-authored memory entry (app-knowledge or preferences)')
1528
2011
  .requiredOption('--kind <k>', 'app-knowledge | preferences')
1529
- .action(withEnvelope((text, opts) =>
2012
+ .action(withEnvelope(requireWorkspace((text, opts) =>
1530
2013
  emit(handleMemoryAdd({ memoryStoreFactory, projectRoot }, { kind: opts.kind, text }), humanFlag())
1531
- ));
2014
+ )));
1532
2015
 
1533
2016
  memory
1534
2017
  .command('forget')
1535
2018
  .description('Remove agent-authored memory entries whose text contains a substring')
1536
2019
  .requiredOption('--kind <k>', 'app-knowledge | preferences')
1537
2020
  .requiredOption('--match <substr>', 'remove entries containing this substring')
1538
- .action(withEnvelope((opts) =>
2021
+ .action(withEnvelope(requireWorkspace((opts) =>
1539
2022
  emit(handleMemoryForget({ memoryStoreFactory, projectRoot }, { kind: opts.kind, match: opts.match }), humanFlag())
1540
- ));
2023
+ )));
1541
2024
 
1542
2025
  // --- Slice 7: vendor init + MCP prompts server ---------------------------
1543
2026
 
@@ -1546,7 +2029,9 @@ function buildProgram(deps = {}) {
1546
2029
  .description('Install native Agent Skills (+ slash commands/rules + MCP entry) for an agent')
1547
2030
  .requiredOption('--agent <name>', 'claude | cursor | gemini | copilot | agents | all')
1548
2031
  .action(withEnvelope((opts) => {
1549
- const r = handleInit({ projectRoot }, opts.agent);
2032
+ // cwd, as before discovery existed: init writes host files where the
2033
+ // user invoked it.
2034
+ const r = handleInit({ projectRoot: cwd }, opts.agent);
1550
2035
  emit(r, humanFlag());
1551
2036
  }));
1552
2037
 
@@ -1561,10 +2046,10 @@ function buildProgram(deps = {}) {
1561
2046
  .description('Start the device session daemon (subsequent verbs reuse its connection)')
1562
2047
  .option('--device <id>', 'pin the daemon to a target device id')
1563
2048
  .option('--idle <ms>', 'idle timeout in milliseconds before the daemon self-reaps')
1564
- .action(withEnvelope(async (opts) => {
2049
+ .action(withEnvelope(requireWorkspace(async (opts) => {
1565
2050
  const r = await handleSessionStart({ projectRoot }, { device: opts.device, idle: opts.idle });
1566
2051
  emit(r, humanFlag());
1567
- }));
2052
+ })));
1568
2053
 
1569
2054
  session
1570
2055
  .command('status')
@@ -1596,11 +2081,11 @@ function buildProgram(deps = {}) {
1596
2081
  devices
1597
2082
  .command('use <id>')
1598
2083
  .description('Persist a device selection so subsequent verbs reuse it (--device still overrides per-call)')
1599
- .action(withEnvelope((id) =>
2084
+ .action(withEnvelope(requireWorkspace((id) =>
1600
2085
  connectBridge(null, (bridge) =>
1601
2086
  handleDevicesUse({ deviceBridge: bridge, projectRoot }, id)
1602
2087
  )
1603
- ));
2088
+ )));
1604
2089
 
1605
2090
  devices
1606
2091
  .command('clear')
@@ -1610,6 +2095,69 @@ function buildProgram(deps = {}) {
1610
2095
  emit(r, humanFlag());
1611
2096
  }));
1612
2097
 
2098
+ const crash = program
2099
+ .command('crash')
2100
+ .description('Diagnostics: crash reports currently readable on the device');
2101
+
2102
+ crash
2103
+ .command('list')
2104
+ .description('List crash reports, scoped to the current device session by default')
2105
+ .option('--device <id>', 'target device id')
2106
+ .option('--since <iso>', 'only reports at or after this ISO timestamp (default: session start)')
2107
+ .action(withEnvelope((opts) =>
2108
+ connectBridge(resolveVerbDevice(opts.device), (bridge) =>
2109
+ handleCrashList(
2110
+ { deviceBridge: bridge, projectRoot },
2111
+ opts.since === undefined ? {} : { since: opts.since }
2112
+ )
2113
+ )
2114
+ ));
2115
+
2116
+ crash
2117
+ .command('get <id>')
2118
+ .description('Fetch one crash report (head by default)')
2119
+ .option('--device <id>', 'target device id')
2120
+ .option('--full', 'return the whole report instead of its head')
2121
+ .option('--out <path>', 'write the full report to a file and return its path')
2122
+ .action(withEnvelope((id, opts) =>
2123
+ connectBridge(resolveVerbDevice(opts.device), (bridge) =>
2124
+ handleCrashGet({ deviceBridge: bridge }, id, { full: opts.full, out: opts.out })
2125
+ )
2126
+ ));
2127
+
2128
+ // --- Slice 5: telemetry ---------------------------------------------------
2129
+
2130
+ const telemetryCmd = program
2131
+ .command('telemetry')
2132
+ .description('Inspect and control anonymous usage telemetry (off by default)');
2133
+
2134
+ // requireWorkspace, same as config get/set: telemetry status/enable/disable
2135
+ // all read or write mobile-automator/config.json, and flush reads/claims
2136
+ // mobile-automator/.logs/telemetry.spool, so all four have #188's exact
2137
+ // failure mode without the gate — status would report the SAME
2138
+ // not_configured/no_token answer for "no workspace here" as for "a real,
2139
+ // just-unconfigured workspace", and enable/disable would create a stray
2140
+ // mobile-automator/ wherever the caller happened to be standing.
2141
+ telemetryCmd
2142
+ .command('status')
2143
+ .description('Report whether telemetry is on, what would be sent, and what is queued')
2144
+ .action(withEnvelope(requireWorkspace(() => emit(handleTelemetryStatus({ projectRoot }), humanFlag()))));
2145
+
2146
+ telemetryCmd
2147
+ .command('enable')
2148
+ .description('Turn on anonymous usage telemetry')
2149
+ .action(withEnvelope(requireWorkspace(() => emit(handleTelemetryEnable({ projectRoot }), humanFlag()))));
2150
+
2151
+ telemetryCmd
2152
+ .command('disable')
2153
+ .description('Turn off anonymous usage telemetry')
2154
+ .action(withEnvelope(requireWorkspace(() => emit(handleTelemetryDisable({ projectRoot }), humanFlag()))));
2155
+
2156
+ telemetryCmd
2157
+ .command('flush')
2158
+ .description('Upload any spooled telemetry now instead of waiting for the daemon')
2159
+ .action(withEnvelope(requireWorkspace(async () => emit(await handleTelemetryFlush({ projectRoot }), humanFlag()))));
2160
+
1613
2161
  program
1614
2162
  .command('mcp')
1615
2163
  .description('Run the MCP prompts server (stdio) exposing the mauto workflows as prompts')
@@ -1623,18 +2171,6 @@ function buildProgram(deps = {}) {
1623
2171
  return program;
1624
2172
  }
1625
2173
 
1626
- function defaultEmit({ envelope, exitKind }, human) {
1627
- process.stdout.write(render(envelope, { human }) + '\n');
1628
- process.exit(exitCodeFor(exitKind));
1629
- }
1630
-
1631
- // Print raw content (markdown / JSON schema / text) verbatim — no envelope
1632
- // wrapping — then exit. Used by guide/schema/bootstrap success paths.
1633
- function emitRaw(content, exitKind) {
1634
- process.stdout.write(content.endsWith('\n') ? content : content + '\n');
1635
- process.exit(exitCodeFor(exitKind));
1636
- }
1637
-
1638
2174
  // Keep the stack of a genuinely-unexpected error on STDERR so a crash stays
1639
2175
  // debuggable. The stdout envelope (the agent's contract) is untouched, and the
1640
2176
  // expected/diagnosed classes (invalid_input, environment) carry an actionable
@@ -1645,19 +2181,146 @@ function diagnose(err, exitKind) {
1645
2181
  }
1646
2182
  }
1647
2183
 
1648
- // Last-resort emit for errors that escape BEFORE any action runs (so the
1649
- // in-program withEnvelope boundary can't see them) — e.g. the ScenarioValidator
1650
- // default-param compiling a corrupt bundled schema inside buildProgram, or a
1651
- // stray unhandled rejection. Always one JSON envelope on stdout + the mapped
1652
- // exit code, never a raw stack trace. `human` (opt-in --human) renders the fail
1653
- // envelope readably instead of as JSON.
1654
- function emitFatal(err, human = false) {
1655
- const { envelope, exitKind } = toEnvelope(err);
1656
- diagnose(err, exitKind);
1657
- process.stdout.write(render(envelope, { human }) + '\n');
1658
- process.exit(exitCodeFor(exitKind));
2184
+ // The exit paths, bound to ONE invocation's resolved dependencies. Per
2185
+ // invocation rather than module-scope because every input they need is: as
2186
+ // module-level functions, finish() could not see the projectRoot buildProgram()
2187
+ // was handed, so it reached around the program's own DI to process.cwd() and
2188
+ // wrote the record of a verb into a different tree than the verb ran against.
2189
+ function makeEmitters({ projectRoot = process.cwd() } = {}) {
2190
+ // The top-level command commander actually RESOLVED, or null when parsing
2191
+ // never got that far (a parse failure, or help/version). Set via setVerb()
2192
+ // from buildProgram()'s preAction hook.
2193
+ //
2194
+ // Deliberately not `process.argv[2]`: event.js marks `verb` as `sends: true`
2195
+ // on the grounds that it is "a fixed vocabulary we ship", and slice 5 ships
2196
+ // it to a third party on that justification. argv[2] is arbitrary user text —
2197
+ // `mauto --human config get mode` recorded `--human`.
2198
+ let resolvedVerb = null;
2199
+
2200
+ // The run this invocation belongs to, and the daemon lifetime it used.
2201
+ //
2202
+ // Both follow resolvedVerb's pattern for resolvedVerb's reason: finish() is
2203
+ // closed over this invocation's state, and every exit path reaches it
2204
+ // through commander's own call stack, so there is nowhere else to put them.
2205
+ //
2206
+ // resolvedRunId is NOT validated here. It becomes a filename in exactly one
2207
+ // place — runTracePath — which refuses anything unsafe and returns null, so
2208
+ // a second predicate here could only disagree with that one.
2209
+ let resolvedRunId = null;
2210
+ // Set ONLY by connectBridge, and only after a successful connect. That is
2211
+ // what makes `session_id` on a CLI event mean "this verb reached the device
2212
+ // through that daemon".
2213
+ let resolvedSessionId = null;
2214
+
2215
+ // THE process-ending path: every way this CLI terminates goes through here,
2216
+ // so "is this invocation observable" has exactly one answer. Only `mauto mcp`
2217
+ // is outside it — it serves until its client disconnects and then returns,
2218
+ // with no termination to record.
2219
+ //
2220
+ // One function rather than four copies of a ten-line record() literal. Two of
2221
+ // the four got written, and the classes left unrecorded were parse failures
2222
+ // (#146) — the crashes this instrumentation exists to see — and help/version.
2223
+ //
2224
+ // `text` is optional: commander writes help/version itself, so that path has
2225
+ // an invocation to record but nothing left to print.
2226
+ //
2227
+ // dur_ms uses process.uptime() (seconds since THIS process started, no state
2228
+ // needed). A Date.now() stamped at require time would measure from whenever
2229
+ // cli.js was loaded, which is not process start for anything that requires it
2230
+ // first (a test harness, bin/mauto.js's own guards).
2231
+ function finish({ text = '', exitKind, ok, errorKind, exitCode = exitCodeFor(exitKind) }) {
2232
+ // The flag-resolved id when a command parsed, the environment otherwise: a
2233
+ // parse failure reaches no preAction hook and so has no flag, but it is
2234
+ // still part of the run and is exactly the class of failure (#146) this
2235
+ // instrumentation exists to see.
2236
+ const runId = resolvedRunId || resolveRunId(process.env);
2237
+ // null for an id that cannot safely name a file. Every hostile
2238
+ // MAUTO_RUN_ID lands here as "no trace" — but NOT as "no run_id below":
2239
+ // the raw id is still recorded on the mauto.ndjson line, so a refused id
2240
+ // is provably distinguishable from one never exported at all (no field).
2241
+ const tracePath = runTracePath(projectRoot, runId);
2242
+
2243
+ // Record BEFORE the write: process.exit() below is immediate.
2244
+ const fields = {
2245
+ event: 'verb.end',
2246
+ // `info`, not `warn`-on-failure: the failure is carried by `ok` and
2247
+ // `error_kind`, and the LEVEL only decides routing. At `warn` every
2248
+ // failed verb would clear the stderr threshold and echo the envelope the
2249
+ // CLI just wrote. stderr stays a human's channel; the file sink
2250
+ // (threshold `info`) keeps the forensic record either way.
2251
+ level: 'info',
2252
+ src: 'cli',
2253
+ // Absent, not guessed, when no command resolved. makeEvent drops
2254
+ // undefined fields, so the line carries no `verb` rather than a lie.
2255
+ verb: resolvedVerb || undefined,
2256
+ // Recorded even when the id could not name a trace file: the
2257
+ // correlation still belongs in mauto.ndjson, where it is the only thing
2258
+ // tying this line to a run. sends:false in the event catalog.
2259
+ run_id: runId || undefined,
2260
+ session_id: resolvedSessionId || undefined,
2261
+ ok,
2262
+ error_kind: errorKind,
2263
+ exit_code: exitCode,
2264
+ dur_ms: Math.round(process.uptime() * 1000),
2265
+ };
2266
+ record(fields, { projectRoot, tracePath: tracePath || undefined });
2267
+ if (text) process.stdout.write(text.endsWith('\n') ? text : text + '\n');
2268
+ process.exit(exitCode);
2269
+ }
2270
+
2271
+ const fromEnvelope = (envelope, exitKind, human) => ({
2272
+ text: render(envelope, { human }),
2273
+ exitKind,
2274
+ ok: Boolean(envelope && envelope.ok),
2275
+ errorKind: envelope && envelope.error ? envelope.error.kind : undefined,
2276
+ });
2277
+
2278
+ return {
2279
+ finish,
2280
+ setVerb: (verb) => {
2281
+ resolvedVerb = verb;
2282
+ },
2283
+ setRunId: (runId) => {
2284
+ resolvedRunId = runId;
2285
+ },
2286
+ setSessionId: (sessionId) => {
2287
+ resolvedSessionId = sessionId;
2288
+ },
2289
+ // Read-only counterparts to the setters above, for the one other call site
2290
+ // that needs this invocation's resolved run id / verb: connectBridge's
2291
+ // screenshot-on-failure hook. Both values are already committed by the
2292
+ // preAction hook by the time any verb's fn(bridge) can have returned, so
2293
+ // there is nothing to resolve here — only to expose.
2294
+ getRunId: () => resolvedRunId,
2295
+ getVerb: () => resolvedVerb,
2296
+
2297
+ emit: ({ envelope, exitKind }, human) => finish(fromEnvelope(envelope, exitKind, human)),
2298
+
2299
+ // Raw content (markdown / JSON schema / text) verbatim, no envelope
2300
+ // wrapping. Used by the guide/schema/bootstrap success paths.
2301
+ emitRaw: (content, exitKind) => finish({ text: content, exitKind, ok: true }),
2302
+
2303
+ // Last-resort emit for errors that escape BEFORE any action runs, so the
2304
+ // in-program withEnvelope boundary can't see them — a throw inside
2305
+ // buildProgram, or a stray unhandled rejection. One JSON envelope on stdout
2306
+ // + the mapped exit code, never a raw stack trace.
2307
+ emitFatal: (err, human = false) => {
2308
+ const { envelope, exitKind } = toEnvelope(err);
2309
+ diagnose(err, exitKind);
2310
+ finish(fromEnvelope(envelope, exitKind, human));
2311
+ },
2312
+ };
1659
2313
  }
1660
2314
 
2315
+ // bin/mauto.js registers the exported emitFatal as a PROCESS-wide
2316
+ // unhandledRejection handler, which can fire before run() has built anything —
2317
+ // so it needs a module-level way to reach the invocation in flight. A pointer to
2318
+ // the per-invocation instance, not a second copy of its state: an unhandled
2319
+ // rejection is exactly the crash class this instrumentation exists to see, and
2320
+ // it should carry the verb and projectRoot the invocation actually resolved.
2321
+ let activeEmitters = null;
2322
+ const emitFatal = (err, human = false) => (activeEmitters || makeEmitters()).emitFatal(err, human);
2323
+
1661
2324
  async function run(argv) {
1662
2325
  // Belt-and-suspenders around the in-program withEnvelope boundary: a throw in
1663
2326
  // buildProgram() (e.g. the ScenarioValidator default-param compiling a corrupt
@@ -1666,21 +2329,40 @@ async function run(argv) {
1666
2329
  // `unhandledRejection` guard lives in bin/mauto.js (the process entry point),
1667
2330
  // not here, so calling run() in tests never installs a global exit-on-reject
1668
2331
  // listener that would leak across the suite.
2332
+ //
2333
+ // The workspace is resolved ONCE, here, and the same root reaches both the
2334
+ // emitters (the .logs/ file sink and run traces) and every handler — and,
2335
+ // through connection.js, the session daemon's MAUTO_SESSION_PROJECT_ROOT.
2336
+ // No workspace found falls back to cwd for the verbs that tolerate that;
2337
+ // buildProgram fails the ones that cannot (#188).
2338
+ const cwd = process.cwd();
2339
+ const workspace = findWorkspaceRoot(cwd);
2340
+ const projectRoot = workspace.root || cwd;
2341
+ const emitters = makeEmitters({ projectRoot });
2342
+ activeEmitters = emitters;
1669
2343
  try {
1670
- const program = buildProgram();
2344
+ // No projectRoot here, deliberately: buildProgram derives it as
2345
+ // `workspace.root || cwd`, and an injected projectRoot is the test seam
2346
+ // that FABRICATES a workspace — passing one would arm that branch and
2347
+ // silently disable the requireWorkspace gate.
2348
+ const program = buildProgram({ emitters, cwd, workspace });
1671
2349
  await program.parseAsync(argv);
1672
2350
  } catch (err) {
1673
2351
  // Help/version are NOT errors: commander already wrote the human-readable
1674
- // text to stdout. Leave them alone (no envelope) rather than wrapping them
1675
- // (#146). Same COMMANDER_NON_FAILURES set toEnvelope classifies against, so
1676
- // "which commander outcomes aren't failures" has exactly one answer.
2352
+ // text to stdout, so they get no envelope wrapped around them (#146). Same
2353
+ // COMMANDER_NON_FAILURES set toEnvelope classifies against, so "which
2354
+ // commander outcomes aren't failures" has exactly one answer.
2355
+ //
2356
+ // They still exit through finish(), with no text of their own to print.
2357
+ // They are real invocations, and slice 5 computes rates off this stream —
2358
+ // omitting a whole invocation class skews every one of those denominators.
2359
+ // `exitCode` comes from commander rather than a kind because commander
2360
+ // already computed one: `.help({error:true})` yields 1, not 0.
1677
2361
  if (err && err.name === 'CommanderError' && COMMANDER_NON_FAILURES.has(err.code)) {
1678
- // Honor commander's own exit code rather than assuming 0 — `.help()`
1679
- // invoked with `{error:true}` computes 1.
1680
- process.exit(err.exitCode || 0);
2362
+ emitters.finish({ exitCode: err.exitCode || 0, ok: true });
1681
2363
  return;
1682
2364
  }
1683
- emitFatal(err, argv.slice(2).includes('--human'));
2365
+ emitters.emitFatal(err, argv.slice(2).includes('--human'));
1684
2366
  }
1685
2367
  }
1686
2368
 
@@ -1707,6 +2389,7 @@ module.exports = {
1707
2389
  handleAssert,
1708
2390
  handleResultAddStep,
1709
2391
  handleResultAddAssertion,
2392
+ handleResultAddCrash,
1710
2393
  handleResultFinalize,
1711
2394
  handleSetup,
1712
2395
  handleConfigGet,
@@ -1724,4 +2407,10 @@ module.exports = {
1724
2407
  handleDevices,
1725
2408
  handleDevicesUse,
1726
2409
  handleDevicesClear,
2410
+ handleTelemetryStatus,
2411
+ handleTelemetryEnable,
2412
+ handleTelemetryDisable,
2413
+ handleTelemetryFlush,
2414
+ handleCrashList,
2415
+ handleCrashGet,
1727
2416
  };