mobile-automator 0.25.0 → 0.26.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.
Files changed (39) hide show
  1. package/README.md +11 -0
  2. package/bin/mauto-session-daemon.js +85 -16
  3. package/package.json +1 -1
  4. package/src/cli.js +724 -59
  5. package/src/device/bridge.js +30 -0
  6. package/src/device/connection.js +23 -4
  7. package/src/device/crash-model.js +110 -0
  8. package/src/device/device-call.js +145 -0
  9. package/src/device/failure-probe.js +276 -0
  10. package/src/device/mobile-mcp-tools.js +64 -0
  11. package/src/device/resolve-connection.js +8 -10
  12. package/src/device/session-daemon.js +265 -51
  13. package/src/device/session-handle.js +53 -0
  14. package/src/device/session-log.js +5 -16
  15. package/src/guide/content/execute.agnostic.md +48 -5
  16. package/src/guide/content/execute.aware.md +42 -1
  17. package/src/guide/content/execute.invariants.md +1 -0
  18. package/src/observe/accepts.js +108 -0
  19. package/src/observe/event.js +241 -0
  20. package/src/observe/failure-capture.js +166 -0
  21. package/src/observe/flush.js +177 -0
  22. package/src/observe/paths.js +125 -0
  23. package/src/observe/recorder.js +178 -0
  24. package/src/observe/settings.js +51 -0
  25. package/src/observe/sinks/file.js +101 -0
  26. package/src/observe/sinks/stderr.js +20 -0
  27. package/src/observe/spool.js +199 -0
  28. package/src/observe/telemetry.js +143 -0
  29. package/src/observe/trace.js +174 -0
  30. package/src/observe/transport.js +169 -0
  31. package/src/observe/vocab.js +96 -0
  32. package/src/output/envelope.js +6 -0
  33. package/src/result/capability-catalog.js +14 -0
  34. package/src/result/store.js +98 -1
  35. package/src/schemas/config_schema.json +11 -0
  36. package/src/schemas/result_schema.json +55 -0
  37. package/src/setup/scaffold.js +27 -0
  38. package/src/util/log-rotate.js +34 -0
  39. package/src/workspace/discover.js +69 -0
package/src/cli.js CHANGED
@@ -23,6 +23,19 @@ const guideEmitter = require('./guide/emitter');
23
23
  const { ADAPTERS } = require('./init/adapters');
24
24
  const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
25
25
  const connection = require('./device/connection');
26
+ const { record } = require('./observe/recorder');
27
+ const { resolveRunId } = require('./observe/settings');
28
+ const { runTracePath } = require('./observe/paths');
29
+ const { readTrace, deriveRun, pruneRunTraces } = require('./observe/trace');
30
+ const { readHandle, readSessionId } = require('./device/session-handle');
31
+ const { captureOnFailure } = require('./observe/failure-capture');
32
+ const { crashTimestampMs } = require('./device/crash-model');
33
+ const { probeCrashes } = require('./device/failure-probe');
34
+ const observeTelemetry = require('./observe/telemetry');
35
+ const observeSpool = require('./observe/spool');
36
+ const observeTransport = require('./observe/transport');
37
+ const { makeFlusher } = require('./observe/flush');
38
+ const { findWorkspaceRoot, noWorkspaceFailure } = require('./workspace/discover');
26
39
 
27
40
  // Commander throws (via exitOverride) for two very different reasons, and the
28
41
  // split below is the ONLY thing that tells them apart.
@@ -625,6 +638,75 @@ function handleResultAddAssertion({ resultStoreFactory, projectRoot }, opts) {
625
638
  return { envelope: ok({ run_id: runId, assertion: entry }, storeHint(store)), exitKind: 'ok' };
626
639
  }
627
640
 
641
+ // Record an observed app crash.
642
+ function handleResultAddCrash({ resultStoreFactory, projectRoot }, opts = {}) {
643
+ const store = resultStoreFactory({
644
+ runId: opts.runId,
645
+ scenarioId: opts.scenarioId,
646
+ projectRoot,
647
+ });
648
+ const entry = store.addCrash({
649
+ crash_id: opts.crashId,
650
+ process: opts.process,
651
+ timestamp: opts.crashTimestamp,
652
+ step_id: opts.stepId,
653
+ excerpt: opts.excerpt,
654
+ report_path: opts.reportPath,
655
+ });
656
+ return { envelope: ok({ crash: entry }, storeHint(store)), exitKind: 'ok' };
657
+ }
658
+
659
+ // Tolerance for calling a reported duration wrong.
660
+ //
661
+ // The measured value is a wall-clock span between two recorded events, so
662
+ // sub-second differences are rounding rather than disagreement, and 10% absorbs
663
+ // the honest gap between "the run" and "the part of the run mauto saw" —
664
+ // setup before the first verb, and finalize's own execution after the last.
665
+ //
666
+ // Symmetric on purpose. An UNDER-claim means the agent lost track of its own
667
+ // retries; an OVER-claim means it counted its planning as run time. Both are
668
+ // worth a human seeing, and one rule is two tests instead of four.
669
+ const DURATION_TOLERANCE_SECONDS = 1;
670
+ const DURATION_TOLERANCE_RATIO = 0.1;
671
+
672
+ // Turn the run's trace into the duration to record and the measurement block to
673
+ // record beside it. Total: an unreadable, absent or unmeasurable trace yields
674
+ // the reported value and a `measurements` block that says so, never an error.
675
+ function measureRun({ projectRoot, runId, reported }) {
676
+ const derived = deriveRun(readTrace(runTracePath(projectRoot, runId)));
677
+ const reportedSeconds = reported === undefined ? null : Number(reported);
678
+ const hasReported = reportedSeconds !== null && Number.isFinite(reportedSeconds);
679
+
680
+ const base = {
681
+ reported_duration_seconds: hasReported ? reportedSeconds : null,
682
+ trace_events: derived ? derived.trace_events : 0,
683
+ device_failures: derived ? derived.device_failures : 0,
684
+ trace_truncated: derived ? derived.trace_truncated : false,
685
+ failure_screenshots: derived ? derived.failure_screenshots : [],
686
+ };
687
+
688
+ // No trace, or a trace too short to span any time. Falling back to the flag
689
+ // is 0.24.0's behaviour, and `source` records that it IS a fallback rather
690
+ // than dressing a self-report as a measurement.
691
+ if (!derived || derived.duration_seconds === null) {
692
+ return {
693
+ durationSeconds: hasReported ? reportedSeconds : 0,
694
+ measurements: { source: hasReported ? 'reported' : 'none', duration_disagreement: false, ...base },
695
+ };
696
+ }
697
+
698
+ const measured = derived.duration_seconds;
699
+ const tolerance = Math.max(DURATION_TOLERANCE_SECONDS, measured * DURATION_TOLERANCE_RATIO);
700
+ return {
701
+ durationSeconds: measured,
702
+ measurements: {
703
+ source: 'trace',
704
+ duration_disagreement: hasReported && Math.abs(reportedSeconds - measured) > tolerance,
705
+ ...base,
706
+ },
707
+ };
708
+ }
709
+
628
710
  function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectRoot }, opts) {
629
711
  const { runId, scenarioId, status, duration } = opts;
630
712
  if (!runId) {
@@ -643,11 +725,20 @@ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectR
643
725
  if (opts.apiLevel !== undefined) metadata.api_level = opts.apiLevel;
644
726
  if (opts.environment !== undefined) metadata.environment = opts.environment;
645
727
 
728
+ // Measure BEFORE constructing the store: the trace is read-only here, and
729
+ // deriving first keeps the store's lock window as small as it already is.
730
+ const measured = measureRun({ projectRoot, runId, reported: duration });
731
+
646
732
  const store = resultStoreFactory({ runId, scenarioId, projectRoot });
647
733
  const result = store.finalize({
648
734
  status,
649
- durationSeconds: duration === undefined ? 0 : Number(duration),
735
+ // Derived, not trusted. `--duration` survives in
736
+ // measurements.reported_duration_seconds and, when it disagrees with the
737
+ // clock, in a typed state_context observation — the disagreement is a
738
+ // signal about the agent, so it is recorded rather than resolved.
739
+ durationSeconds: measured.durationSeconds,
650
740
  metadata,
741
+ measurements: measured.measurements,
651
742
  // Undefined when --summary is omitted; ResultStore.finalize's own
652
743
  // `summary || <generated default>` already treats that as "no override",
653
744
  // so no provided-keys-only guard is needed here (unlike metadata, whose
@@ -655,6 +746,12 @@ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectR
655
746
  summary: opts.summary,
656
747
  });
657
748
 
749
+ // Retention, at the only moment a run is known to be over and the only place
750
+ // off the hot path. Never this run's own trace — it is the evidence behind
751
+ // every number just written. Best-effort by construction; pruneRunTraces
752
+ // swallows everything and returns a count.
753
+ pruneRunTraces(projectRoot, { except: runId });
754
+
658
755
  // Auto-harvest into cross-session memory. This is best-effort: a memory
659
756
  // failure must never fail an otherwise-successful finalize, so its warnings
660
757
  // fold into the envelope hint instead of throwing.
@@ -691,7 +788,11 @@ function storeHint(store) {
691
788
  // on success; only their ERROR paths (unknown topic/name) fall back to the
692
789
  // `fail(...)` envelope + exit 3.
693
790
 
694
- function handleSetup({ projectRoot }, opts = {}) {
791
+ // `ancestorRoot` is the workspace discovery found ABOVE cwd, if any. setup
792
+ // still scaffolds in cwd — it is the one verb whose job is to create a
793
+ // workspace where the user stands — but a nested workspace silently shadows
794
+ // the ancestor for every later verb run below it, so say so.
795
+ function handleSetup({ projectRoot, ancestorRoot = null }, opts = {}) {
695
796
  const raw = opts.mode === undefined ? 'aware' : String(opts.mode);
696
797
  const mode = MODE_ALIASES[raw];
697
798
  if (!mode) {
@@ -701,8 +802,24 @@ function handleSetup({ projectRoot }, opts = {}) {
701
802
  };
702
803
  }
703
804
  const r = scaffold(projectRoot, { mode });
805
+ const hint = ancestorRoot
806
+ ? `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.`
807
+ : null;
704
808
  return {
705
- envelope: ok({ created: r.created, mode: r.mode, next: 'run `mauto guide setup`' }),
809
+ envelope: ok(
810
+ {
811
+ created: r.created,
812
+ mode: r.mode,
813
+ // A NOTICE, not a prompt. mauto verbs are invoked by an agent, so there
814
+ // is nobody at the keyboard to answer a question — and an agent answering
815
+ // a consent question on a human's behalf is worse than not asking. It
816
+ // rides in the envelope (structured, in-band) rather than as a stderr
817
+ // banner, so the agent can surface it to the human it belongs to.
818
+ telemetry: { enabled: false, notice: observeTelemetry.CONSENT_NOTICE },
819
+ next: 'run `mauto guide setup`',
820
+ },
821
+ hint
822
+ ),
706
823
  exitKind: 'ok',
707
824
  };
708
825
  }
@@ -732,6 +849,87 @@ function handleConfigSet({ projectRoot }, key, rawValue) {
732
849
  return { envelope: ok({ key, value }), exitKind: 'ok' };
733
850
  }
734
851
 
852
+ // --- Slice 5: telemetry consent surface -----------------------------------
853
+ //
854
+ // `status` is the informed half of informed consent, and it is COMPUTED: the
855
+ // field lists come from EVENT_FIELDS at runtime, so the disclosure a user reads
856
+ // before opting in cannot drift from what the uploader actually sends. Prose
857
+ // that restates a catalog is prose that will eventually be wrong.
858
+
859
+ function telemetryReport({ projectRoot, env }, extra = {}) {
860
+ const decision = observeTelemetry.decideForProject(projectRoot, env);
861
+ return {
862
+ enabled: decision.enabled,
863
+ reason: decision.reason,
864
+ endpoint: observeTransport.endpointUrl(env),
865
+ fields_sent: observeTelemetry.sentFields(),
866
+ fields_never_sent: observeTelemetry.neverSentFields(),
867
+ spool: observeSpool.stats({ projectRoot, env }),
868
+ notice: observeTelemetry.CONSENT_NOTICE,
869
+ enable_with: 'mauto telemetry enable',
870
+ ...extra,
871
+ };
872
+ }
873
+
874
+ function handleTelemetryStatus({ projectRoot, env = process.env }) {
875
+ return { envelope: ok(telemetryReport({ projectRoot, env })), exitKind: 'ok' };
876
+ }
877
+
878
+ // Writes the durable intent, then reports the RESOLVED state — which may still
879
+ // be off, because an env kill switch wins. Reporting `enabled: true` here when
880
+ // MAUTO_TELEMETRY=0 is set would be a lie, and the config is not the place to
881
+ // resolve a conflict the environment owns.
882
+ function handleTelemetryEnable({ projectRoot, env = process.env }) {
883
+ observeTelemetry.setEnabled(projectRoot, true);
884
+ const data = telemetryReport({ projectRoot, env });
885
+ const hint =
886
+ data.reason === 'kill_switch'
887
+ ? 'Telemetry is enabled in config.json but MAUTO_TELEMETRY=0 is set in this environment and overrides it.'
888
+ : data.reason === 'do_not_track'
889
+ ? 'Telemetry is enabled in config.json but DO_NOT_TRACK is set in this environment and overrides it.'
890
+ : data.reason === 'no_token'
891
+ ? 'Telemetry is enabled in config.json but this build ships no project token, so nothing will be sent.'
892
+ : undefined;
893
+ return { envelope: ok(data, hint), exitKind: 'ok' };
894
+ }
895
+
896
+ function handleTelemetryDisable({ projectRoot, env = process.env }) {
897
+ observeTelemetry.setEnabled(projectRoot, false);
898
+ return { envelope: ok(telemetryReport({ projectRoot, env })), exitKind: 'ok' };
899
+ }
900
+
901
+ // The explicit escape hatch. This is the ONE verb allowed a network round trip,
902
+ // because the user asked for one and is willing to wait for it — every other
903
+ // verb appends to the spool and exits. A machine that never runs a daemon
904
+ // drains here.
905
+ async function handleTelemetryFlush({ projectRoot, env = process.env, flusher }) {
906
+ const flush = flusher || makeFlusher({ projectRoot, env });
907
+ const r = await flush();
908
+ // exitKind stays 'ok': the envelope's own `ok` says the VERB executed — an
909
+ // undelivered spool is a non-error state by design (src/observe/spool.js).
910
+ // The flush's verdict — including permanently discarded events — is data:
911
+ // `ok` below, alongside the counts. Units: `kept` counts BATCHES (files);
912
+ // `sent`, `dropped` and `kept_events` count events.
913
+ const hint =
914
+ r.dropped > 0
915
+ ? `${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.`
916
+ : r.kept > 0
917
+ ? `${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.`
918
+ : undefined;
919
+ return {
920
+ envelope: ok({
921
+ skipped: r.skipped,
922
+ sent: r.sent,
923
+ dropped: r.dropped,
924
+ kept: r.kept,
925
+ kept_events: r.kept_events,
926
+ ok: r.ok,
927
+ spool: observeSpool.stats({ projectRoot, env }),
928
+ }, hint),
929
+ exitKind: 'ok',
930
+ };
931
+ }
932
+
735
933
  // RAW content on success; fail envelope only when the topic is unknown.
736
934
  function handleGuide({ projectRoot, emitter = guideEmitter, config = configManager }, topic) {
737
935
  const cfg = config.load(projectRoot);
@@ -1118,6 +1316,134 @@ async function handleDevicesClear({
1118
1316
  };
1119
1317
  }
1120
1318
 
1319
+ // --- Crash diagnostics (slice 4) -----------------------------------------
1320
+ //
1321
+ // A DIAGNOSTIC verb, like `devices` / `session` / `memory` — deliberately NOT
1322
+ // in src/device/action-catalog.js, which holds exactly the scenario-schema
1323
+ // actions and whose lint guard asserts parity in both directions.
1324
+
1325
+ const CRASH_REPORT_HEAD_LINES = 200;
1326
+ const CRASH_LIST_HINT =
1327
+ '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.';
1328
+
1329
+ // Default watermark: when a device session is live, its handle already records
1330
+ // started_at (src/device/session-daemon.js). Bind, do not copy — there is no new
1331
+ // timestamp artifact and no second source of truth for "when did this session
1332
+ // begin". No handle means no watermark, which means UNSCOPED, which the envelope
1333
+ // reports as `since: null` rather than pretending the list is recent.
1334
+ function sessionWatermark(projectRoot) {
1335
+ const handle = readHandle(projectRoot);
1336
+ const t = handle && handle.started_at;
1337
+ return typeof t === 'string' && Number.isFinite(Date.parse(t)) ? t : null;
1338
+ }
1339
+
1340
+ // Split a crash list around a watermark. Reports whose own time is unreadable
1341
+ // are neither included nor discarded — they are counted, so the caller can see
1342
+ // that the device returned something the tool could not place in time.
1343
+ function scopeCrashes(crashes, sinceMs) {
1344
+ if (sinceMs == null) return { crashes, unattributed: 0 };
1345
+ const inWindow = [];
1346
+ let unattributed = 0;
1347
+ for (const c of crashes) {
1348
+ const ms = crashTimestampMs(c);
1349
+ if (ms == null) unattributed += 1;
1350
+ else if (ms >= sinceMs) inWindow.push(c);
1351
+ }
1352
+ return { crashes: inWindow, unattributed };
1353
+ }
1354
+
1355
+ async function handleCrashList({ deviceBridge, projectRoot }, opts = {}) {
1356
+ const sinceRaw = opts.since !== undefined ? opts.since : sessionWatermark(projectRoot);
1357
+ let sinceMs = null;
1358
+ if (sinceRaw != null) {
1359
+ sinceMs = Date.parse(sinceRaw);
1360
+ if (!Number.isFinite(sinceMs)) {
1361
+ return {
1362
+ envelope: fail(
1363
+ 'invalid_input',
1364
+ `could not parse --since "${sinceRaw}" as a date`,
1365
+ 'Pass an ISO 8601 timestamp, e.g. --since 2026-09-05T10:00:00Z.'
1366
+ ),
1367
+ exitKind: 'invalid_input',
1368
+ };
1369
+ }
1370
+ }
1371
+
1372
+ let all;
1373
+ try {
1374
+ all = await deviceBridge.listCrashes();
1375
+ } catch (err) {
1376
+ // "Could not look" is NOT "found none". Reporting ok:true with an empty
1377
+ // list here would be this slice's own bug with the sign flipped.
1378
+ return {
1379
+ envelope: fail(err.kind || 'device', err.message || String(err), err.hint || CRASH_LIST_HINT),
1380
+ exitKind: err.kind || 'device',
1381
+ };
1382
+ }
1383
+
1384
+ const { crashes, unattributed } = scopeCrashes(all, sinceMs);
1385
+ record(
1386
+ {
1387
+ level: 'info',
1388
+ src: 'cli',
1389
+ event: 'crash.list',
1390
+ crash_count: Number.isFinite(crashes.length) ? crashes.length : undefined,
1391
+ },
1392
+ { projectRoot }
1393
+ );
1394
+ return {
1395
+ envelope: ok({
1396
+ crashes,
1397
+ count: crashes.length,
1398
+ since: sinceMs == null ? null : sinceRaw,
1399
+ unattributed,
1400
+ }),
1401
+ exitKind: 'ok',
1402
+ };
1403
+ }
1404
+
1405
+ async function handleCrashGet({ deviceBridge, fs: fsDep = fs }, id, opts = {}) {
1406
+ let report;
1407
+ try {
1408
+ report = await deviceBridge.getCrash(id);
1409
+ } catch (err) {
1410
+ return {
1411
+ envelope: fail(err.kind || 'device', err.message || String(err), err.hint || CRASH_LIST_HINT),
1412
+ exitKind: err.kind || 'device',
1413
+ };
1414
+ }
1415
+
1416
+ // --out writes the FULL report and returns only its path. This is how a
1417
+ // tens-of-kilobytes native tombstone reaches a result file without passing
1418
+ // through the agent's context window.
1419
+ if (opts.out) {
1420
+ try {
1421
+ fsDep.writeFileSync(opts.out, report);
1422
+ } catch (err) {
1423
+ return {
1424
+ envelope: fail(
1425
+ 'environment',
1426
+ `could not write the crash report to ${opts.out}: ${err.message}`,
1427
+ 'Check the directory exists and is writable.'
1428
+ ),
1429
+ exitKind: 'environment',
1430
+ };
1431
+ }
1432
+ return { envelope: ok({ crash_id: id, path: opts.out, truncated: false }), exitKind: 'ok' };
1433
+ }
1434
+
1435
+ const lines = String(report).split('\n');
1436
+ const truncated = !opts.full && lines.length > CRASH_REPORT_HEAD_LINES;
1437
+ return {
1438
+ envelope: ok({
1439
+ crash_id: id,
1440
+ report: truncated ? lines.slice(0, CRASH_REPORT_HEAD_LINES).join('\n') : String(report),
1441
+ truncated,
1442
+ }),
1443
+ exitKind: 'ok',
1444
+ };
1445
+ }
1446
+
1121
1447
  // ---------------------------------------------------------------------------
1122
1448
  // Program wiring
1123
1449
  // ---------------------------------------------------------------------------
@@ -1142,10 +1468,31 @@ function buildProgram(deps = {}) {
1142
1468
  resultStoreFactory = (args) => new ResultStore(args),
1143
1469
  // Factory for the cross-session memory store. Overridable in tests.
1144
1470
  memoryStoreFactory = (args) => new MemoryStore(args),
1145
- // Project root used to resolve mobile-automator/results/.
1146
- projectRoot = process.cwd(),
1471
+ // Where the creating verbs (setup, init) write: always the directory the
1472
+ // user stands in, never a discovered ancestor. Defaults to an injected
1473
+ // projectRoot so tests that inject only a root keep writing there.
1474
+ cwd = deps.projectRoot !== undefined ? deps.projectRoot : process.cwd(),
1475
+ // The upward-discovery result `{ root, searched }` (#188): run() resolves
1476
+ // it once and passes it in, tests inject it wholesale, and a bare
1477
+ // buildProgram() discovers from cwd exactly as run() does. ONE fabrication
1478
+ // rule, stated once: a caller that injects ONLY a projectRoot is trusted
1479
+ // to know where the workspace is ({ root: projectRoot }) — the legacy
1480
+ // handler-test seam, and deliberately gate-arming. run() passes cwd +
1481
+ // workspace and NEVER projectRoot, so production cannot arm this branch
1482
+ // and silently disable the requireWorkspace gate.
1483
+ workspace = deps.projectRoot !== undefined
1484
+ ? { root: deps.projectRoot, searched: [deps.projectRoot] }
1485
+ : findWorkspaceRoot(cwd),
1486
+ // Project root used to resolve mobile-automator/results/ — and, through the
1487
+ // emitters below, mobile-automator/.logs/. One root, one tree. The
1488
+ // discovered workspace when there is one; cwd otherwise, which is what the
1489
+ // tolerant verbs (guide, mcp, read-only device/session) have always used.
1490
+ projectRoot = workspace.root || cwd,
1491
+ // The exit paths. run() passes its own instance so the help/version and
1492
+ // fatal paths it owns share this program's resolved verb.
1493
+ emitters = makeEmitters({ projectRoot }),
1147
1494
  // Sink used to emit the rendered envelope + drive the exit code.
1148
- emit = defaultEmit,
1495
+ emit = emitters.emit,
1149
1496
  } = deps;
1150
1497
 
1151
1498
  const program = new Command();
@@ -1184,6 +1531,31 @@ function buildProgram(deps = {}) {
1184
1531
  });
1185
1532
  program.configureOutput({ writeErr: () => {} });
1186
1533
 
1534
+ // Name the verb for the observability record, from commander's own resolution
1535
+ // rather than from argv. preAction fires only after a SUCCESSFUL parse and
1536
+ // only for a command that exists, which is what makes `verb` provably a name
1537
+ // out of the vocabulary this CLI ships — the property event.js's `sends: true`
1538
+ // classification asserts. Hooks registered on the root are inherited by every
1539
+ // subcommand, so one registration covers all of them.
1540
+ //
1541
+ // `actionCommand` is the innermost command (`get` in `config get mode`), so
1542
+ // walk up to the child of the root: the record names the verb, not its
1543
+ // subcommand, keeping the vocabulary the size of the verb list.
1544
+ program.hook('preAction', (_thisCommand, actionCommand) => {
1545
+ // Read --run-id off the INNERMOST command (e.g. `add-step`) before walking
1546
+ // up to name the verb (`result`). The three result verbs already require
1547
+ // it, so they correlate with no new flag and no environment variable; an
1548
+ // explicit flag beats the ambient MAUTO_RUN_ID because it is scoped to this
1549
+ // invocation and the environment is not.
1550
+ const opts = actionCommand.opts();
1551
+ emitters.setRunId(
1552
+ (typeof opts.runId === 'string' && opts.runId ? opts.runId : null) || resolveRunId(process.env)
1553
+ );
1554
+ let command = actionCommand;
1555
+ while (command.parent && command.parent.parent) command = command.parent;
1556
+ emitters.setVerb(command.name());
1557
+ });
1558
+
1187
1559
  const humanFlag = () => Boolean(program.opts().human);
1188
1560
 
1189
1561
  // The single envelope boundary. Wrap every `.action(...)` callback so any
@@ -1203,6 +1575,20 @@ function buildProgram(deps = {}) {
1203
1575
  }
1204
1576
  };
1205
1577
 
1578
+ // Gate for verbs whose whole job is reading or writing the workspace. With
1579
+ // no workspace discovered, carrying on against cwd is what produced #188's
1580
+ // confident wrong answers — `ok:true` with a value silently absent, an
1581
+ // "empty" memory store, a result file written where nobody will look — and
1582
+ // `config set` would even create a stray workspace. Fail environment
1583
+ // (exit 5) naming where we looked, so the agent can recover.
1584
+ const requireWorkspace = (action) => (...args) => {
1585
+ if (!workspace.root) {
1586
+ emit(noWorkspaceFailure(workspace.searched), humanFlag());
1587
+ return undefined;
1588
+ }
1589
+ return action(...args);
1590
+ };
1591
+
1206
1592
  // Resolve which device a verb targets: explicit --device wins, else the
1207
1593
  // persisted selection, else null (mobile-mcp auto-selects a single device).
1208
1594
  // Keeping the no-selection fast path returning null preserves zero-config use.
@@ -1221,13 +1607,55 @@ function buildProgram(deps = {}) {
1221
1607
  let bridge;
1222
1608
  let close;
1223
1609
  try {
1224
- ({ bridge, close } = await deviceBridgeFactory({ device, projectRoot }));
1610
+ // autostart: the daemon is a per-workspace artifact — its .session/ tree
1611
+ // and logs live under mobile-automator/. With no discovered workspace
1612
+ // there is nowhere legitimate to put one, so never autostart: the verb
1613
+ // connects one-shot and creates nothing. `ok:true` plus a phantom
1614
+ // workspace is the exact #188 pathology.
1615
+ ({ bridge, close } = await deviceBridgeFactory({
1616
+ device,
1617
+ projectRoot,
1618
+ autostart: Boolean(workspace.root),
1619
+ }));
1620
+ // AFTER the connect, deliberately: the handle is written when the daemon
1621
+ // starts listening, so before this point it may legitimately not exist
1622
+ // yet. Best-effort — readSessionId never throws and returns null for an
1623
+ // absent or malformed handle, which is a normal state (a one-shot
1624
+ // fallback connection has no daemon at all).
1625
+ emitters.setSessionId(readSessionId(projectRoot));
1225
1626
  } catch (err) {
1226
1627
  emit(deviceFail(err), humanFlag());
1227
1628
  return;
1228
1629
  }
1229
1630
  try {
1230
1631
  const r = await fn(bridge);
1632
+ // The ONE window where a device failure can still be investigated: after
1633
+ // fn(bridge) has produced its verdict and before `finally` closes the
1634
+ // connection. The connect-failure catch above cannot do this — there is
1635
+ // no bridge there — which is why this is here and not wrapped around the
1636
+ // whole function.
1637
+ //
1638
+ // The return value of BOTH calls is deliberately unused. `r` reaches
1639
+ // emit() untouched (aside from probeCrashes's own in-place, additive
1640
+ // mutation of r.envelope) whether either helper succeeded, failed, or
1641
+ // never ran; neither may mask or replace the error the caller actually
1642
+ // asked about. Probe before screenshot: the probe has its own short,
1643
+ // gated deadline and must not sit behind the screenshot's device round
1644
+ // trip.
1645
+ await probeCrashes({
1646
+ bridge,
1647
+ envelope: r.envelope,
1648
+ verb: emitters.getVerb(),
1649
+ projectRoot,
1650
+ watermark: sessionWatermark(projectRoot),
1651
+ });
1652
+ await captureOnFailure({
1653
+ bridge,
1654
+ result: r,
1655
+ projectRoot,
1656
+ runId: emitters.getRunId(),
1657
+ verb: emitters.getVerb(),
1658
+ });
1231
1659
  emit(r, humanFlag());
1232
1660
  } finally {
1233
1661
  if (typeof close === 'function') await close();
@@ -1382,7 +1810,7 @@ function buildProgram(deps = {}) {
1382
1810
  []
1383
1811
  )
1384
1812
  .option('--capture <name=value>', 'captured variable; repeatable', collect, [])
1385
- .action(withEnvelope((opts) => {
1813
+ .action(withEnvelope(requireWorkspace((opts) => {
1386
1814
  const r = handleResultAddStep(
1387
1815
  { resultStoreFactory, projectRoot },
1388
1816
  {
@@ -1398,7 +1826,7 @@ function buildProgram(deps = {}) {
1398
1826
  }
1399
1827
  );
1400
1828
  emit(r, humanFlag());
1401
- }));
1829
+ })));
1402
1830
 
1403
1831
  result
1404
1832
  .command('add-assertion')
@@ -1412,7 +1840,7 @@ function buildProgram(deps = {}) {
1412
1840
  .option('--message <text>', 'human-readable justification for the verdict')
1413
1841
  .option('--expected <v>', 'expected value')
1414
1842
  .option('--actual <v>', 'observed value')
1415
- .action(withEnvelope((opts) => {
1843
+ .action(withEnvelope(requireWorkspace((opts) => {
1416
1844
  const r = handleResultAddAssertion(
1417
1845
  { resultStoreFactory, projectRoot },
1418
1846
  {
@@ -1428,7 +1856,35 @@ function buildProgram(deps = {}) {
1428
1856
  }
1429
1857
  );
1430
1858
  emit(r, humanFlag());
1431
- }));
1859
+ })));
1860
+
1861
+ result
1862
+ .command('add-crash')
1863
+ .description('Record an app crash observed during the run')
1864
+ .requiredOption('--run-id <id>', 'run identifier (run_YYYYMMDD_HHMMSS)')
1865
+ .option('--scenario-id <id>', 'scenario identifier')
1866
+ .requiredOption('--crash-id <id>', 'device-local report id (from `mauto crash list`)')
1867
+ .option('--step-id <id>', 'step in flight when the crash was observed')
1868
+ .option('--process <name>', 'package/bundle identifier of the process that died')
1869
+ .option('--crash-timestamp <iso>', "the device's own time for the report")
1870
+ .option('--excerpt <text>', 'head of the crash report (stored capped at 2000 chars)')
1871
+ .option('--report-path <path>', 'path to the full report written by `mauto crash get --out`')
1872
+ .action(withEnvelope(requireWorkspace((opts) => {
1873
+ const r = handleResultAddCrash(
1874
+ { resultStoreFactory, projectRoot },
1875
+ {
1876
+ runId: opts.runId,
1877
+ scenarioId: opts.scenarioId,
1878
+ crashId: opts.crashId,
1879
+ stepId: opts.stepId,
1880
+ process: opts.process,
1881
+ crashTimestamp: opts.crashTimestamp,
1882
+ excerpt: opts.excerpt,
1883
+ reportPath: opts.reportPath,
1884
+ }
1885
+ );
1886
+ emit(r, humanFlag());
1887
+ })));
1432
1888
 
1433
1889
  result
1434
1890
  .command('finalize')
@@ -1442,7 +1898,7 @@ function buildProgram(deps = {}) {
1442
1898
  .option('--api-level <v>', 'OS API level / version')
1443
1899
  .option('--environment <v>', "target environment (e.g. 'staging')")
1444
1900
  .option('--summary <text>', 'override the generated one-line result summary')
1445
- .action(withEnvelope((opts) => {
1901
+ .action(withEnvelope(requireWorkspace((opts) => {
1446
1902
  const r = handleResultFinalize(
1447
1903
  { resultStoreFactory, memoryStoreFactory, projectRoot },
1448
1904
  {
@@ -1458,7 +1914,7 @@ function buildProgram(deps = {}) {
1458
1914
  }
1459
1915
  );
1460
1916
  emit(r, humanFlag());
1461
- }));
1917
+ })));
1462
1918
 
1463
1919
  // --- Slice 3 verbs --------------------------------------------------------
1464
1920
 
@@ -1467,7 +1923,7 @@ function buildProgram(deps = {}) {
1467
1923
  // anything else is an envelope error and goes through the normal emit path.
1468
1924
  const emitMaybeRaw = (r) => {
1469
1925
  if (r.raw !== undefined && r.exitKind === 'ok') {
1470
- emitRaw(r.raw, r.exitKind);
1926
+ emitters.emitRaw(r.raw, r.exitKind);
1471
1927
  } else {
1472
1928
  emit(r, humanFlag());
1473
1929
  }
@@ -1478,7 +1934,10 @@ function buildProgram(deps = {}) {
1478
1934
  .description('Scaffold the workspace (mobile-automator/) and write a config')
1479
1935
  .option('--mode <mode>', 'aware (platform-aware, default) | agnostic (platform-agnostic)')
1480
1936
  .action(withEnvelope((opts) => {
1481
- const r = handleSetup({ projectRoot }, { mode: opts.mode });
1937
+ // cwd, not the discovered root: setup creates a workspace where the user
1938
+ // stands. A discovered root that is not cwd is an ancestor worth naming.
1939
+ const ancestorRoot = workspace.root && workspace.root !== cwd ? workspace.root : null;
1940
+ const r = handleSetup({ projectRoot: cwd, ancestorRoot }, { mode: opts.mode });
1482
1941
  emit(r, humanFlag());
1483
1942
  }));
1484
1943
 
@@ -1486,11 +1945,11 @@ function buildProgram(deps = {}) {
1486
1945
  config
1487
1946
  .command('get <key>')
1488
1947
  .description('Get a dotted-path config value')
1489
- .action(withEnvelope((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag())));
1948
+ .action(withEnvelope(requireWorkspace((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag()))));
1490
1949
  config
1491
1950
  .command('set <key> <value>')
1492
1951
  .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())));
1952
+ .action(withEnvelope(requireWorkspace((key, value) => emit(handleConfigSet({ projectRoot }, key, value), humanFlag()))));
1494
1953
 
1495
1954
  program
1496
1955
  .command('guide <topic>')
@@ -1516,28 +1975,28 @@ function buildProgram(deps = {}) {
1516
1975
  .description('Print RAW memory markdown (run-history[, app-knowledge, preferences])')
1517
1976
  .option('--kind <k>', 'run-history | app-knowledge | preferences')
1518
1977
  .option('--scenario <id>', 'filter run-history to one scenario')
1519
- .action(withEnvelope((opts) =>
1978
+ .action(withEnvelope(requireWorkspace((opts) =>
1520
1979
  emitMaybeRaw(
1521
1980
  handleMemoryShow({ memoryStoreFactory, projectRoot }, { kind: opts.kind, scenario: opts.scenario })
1522
1981
  )
1523
- ));
1982
+ )));
1524
1983
 
1525
1984
  memory
1526
1985
  .command('add <text>')
1527
1986
  .description('Record an agent-authored memory entry (app-knowledge or preferences)')
1528
1987
  .requiredOption('--kind <k>', 'app-knowledge | preferences')
1529
- .action(withEnvelope((text, opts) =>
1988
+ .action(withEnvelope(requireWorkspace((text, opts) =>
1530
1989
  emit(handleMemoryAdd({ memoryStoreFactory, projectRoot }, { kind: opts.kind, text }), humanFlag())
1531
- ));
1990
+ )));
1532
1991
 
1533
1992
  memory
1534
1993
  .command('forget')
1535
1994
  .description('Remove agent-authored memory entries whose text contains a substring')
1536
1995
  .requiredOption('--kind <k>', 'app-knowledge | preferences')
1537
1996
  .requiredOption('--match <substr>', 'remove entries containing this substring')
1538
- .action(withEnvelope((opts) =>
1997
+ .action(withEnvelope(requireWorkspace((opts) =>
1539
1998
  emit(handleMemoryForget({ memoryStoreFactory, projectRoot }, { kind: opts.kind, match: opts.match }), humanFlag())
1540
- ));
1999
+ )));
1541
2000
 
1542
2001
  // --- Slice 7: vendor init + MCP prompts server ---------------------------
1543
2002
 
@@ -1546,7 +2005,9 @@ function buildProgram(deps = {}) {
1546
2005
  .description('Install native Agent Skills (+ slash commands/rules + MCP entry) for an agent')
1547
2006
  .requiredOption('--agent <name>', 'claude | cursor | gemini | copilot | agents | all')
1548
2007
  .action(withEnvelope((opts) => {
1549
- const r = handleInit({ projectRoot }, opts.agent);
2008
+ // cwd, as before discovery existed: init writes host files where the
2009
+ // user invoked it.
2010
+ const r = handleInit({ projectRoot: cwd }, opts.agent);
1550
2011
  emit(r, humanFlag());
1551
2012
  }));
1552
2013
 
@@ -1561,10 +2022,10 @@ function buildProgram(deps = {}) {
1561
2022
  .description('Start the device session daemon (subsequent verbs reuse its connection)')
1562
2023
  .option('--device <id>', 'pin the daemon to a target device id')
1563
2024
  .option('--idle <ms>', 'idle timeout in milliseconds before the daemon self-reaps')
1564
- .action(withEnvelope(async (opts) => {
2025
+ .action(withEnvelope(requireWorkspace(async (opts) => {
1565
2026
  const r = await handleSessionStart({ projectRoot }, { device: opts.device, idle: opts.idle });
1566
2027
  emit(r, humanFlag());
1567
- }));
2028
+ })));
1568
2029
 
1569
2030
  session
1570
2031
  .command('status')
@@ -1596,11 +2057,11 @@ function buildProgram(deps = {}) {
1596
2057
  devices
1597
2058
  .command('use <id>')
1598
2059
  .description('Persist a device selection so subsequent verbs reuse it (--device still overrides per-call)')
1599
- .action(withEnvelope((id) =>
2060
+ .action(withEnvelope(requireWorkspace((id) =>
1600
2061
  connectBridge(null, (bridge) =>
1601
2062
  handleDevicesUse({ deviceBridge: bridge, projectRoot }, id)
1602
2063
  )
1603
- ));
2064
+ )));
1604
2065
 
1605
2066
  devices
1606
2067
  .command('clear')
@@ -1610,6 +2071,69 @@ function buildProgram(deps = {}) {
1610
2071
  emit(r, humanFlag());
1611
2072
  }));
1612
2073
 
2074
+ const crash = program
2075
+ .command('crash')
2076
+ .description('Diagnostics: crash reports currently readable on the device');
2077
+
2078
+ crash
2079
+ .command('list')
2080
+ .description('List crash reports, scoped to the current device session by default')
2081
+ .option('--device <id>', 'target device id')
2082
+ .option('--since <iso>', 'only reports at or after this ISO timestamp (default: session start)')
2083
+ .action(withEnvelope((opts) =>
2084
+ connectBridge(resolveVerbDevice(opts.device), (bridge) =>
2085
+ handleCrashList(
2086
+ { deviceBridge: bridge, projectRoot },
2087
+ opts.since === undefined ? {} : { since: opts.since }
2088
+ )
2089
+ )
2090
+ ));
2091
+
2092
+ crash
2093
+ .command('get <id>')
2094
+ .description('Fetch one crash report (head by default)')
2095
+ .option('--device <id>', 'target device id')
2096
+ .option('--full', 'return the whole report instead of its head')
2097
+ .option('--out <path>', 'write the full report to a file and return its path')
2098
+ .action(withEnvelope((id, opts) =>
2099
+ connectBridge(resolveVerbDevice(opts.device), (bridge) =>
2100
+ handleCrashGet({ deviceBridge: bridge }, id, { full: opts.full, out: opts.out })
2101
+ )
2102
+ ));
2103
+
2104
+ // --- Slice 5: telemetry ---------------------------------------------------
2105
+
2106
+ const telemetryCmd = program
2107
+ .command('telemetry')
2108
+ .description('Inspect and control anonymous usage telemetry (off by default)');
2109
+
2110
+ // requireWorkspace, same as config get/set: telemetry status/enable/disable
2111
+ // all read or write mobile-automator/config.json, and flush reads/claims
2112
+ // mobile-automator/.logs/telemetry.spool, so all four have #188's exact
2113
+ // failure mode without the gate — status would report the SAME
2114
+ // not_configured/no_token answer for "no workspace here" as for "a real,
2115
+ // just-unconfigured workspace", and enable/disable would create a stray
2116
+ // mobile-automator/ wherever the caller happened to be standing.
2117
+ telemetryCmd
2118
+ .command('status')
2119
+ .description('Report whether telemetry is on, what would be sent, and what is queued')
2120
+ .action(withEnvelope(requireWorkspace(() => emit(handleTelemetryStatus({ projectRoot }), humanFlag()))));
2121
+
2122
+ telemetryCmd
2123
+ .command('enable')
2124
+ .description('Turn on anonymous usage telemetry')
2125
+ .action(withEnvelope(requireWorkspace(() => emit(handleTelemetryEnable({ projectRoot }), humanFlag()))));
2126
+
2127
+ telemetryCmd
2128
+ .command('disable')
2129
+ .description('Turn off anonymous usage telemetry')
2130
+ .action(withEnvelope(requireWorkspace(() => emit(handleTelemetryDisable({ projectRoot }), humanFlag()))));
2131
+
2132
+ telemetryCmd
2133
+ .command('flush')
2134
+ .description('Upload any spooled telemetry now instead of waiting for the daemon')
2135
+ .action(withEnvelope(requireWorkspace(async () => emit(await handleTelemetryFlush({ projectRoot }), humanFlag()))));
2136
+
1613
2137
  program
1614
2138
  .command('mcp')
1615
2139
  .description('Run the MCP prompts server (stdio) exposing the mauto workflows as prompts')
@@ -1623,18 +2147,6 @@ function buildProgram(deps = {}) {
1623
2147
  return program;
1624
2148
  }
1625
2149
 
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
2150
  // Keep the stack of a genuinely-unexpected error on STDERR so a crash stays
1639
2151
  // debuggable. The stdout envelope (the agent's contract) is untouched, and the
1640
2152
  // expected/diagnosed classes (invalid_input, environment) carry an actionable
@@ -1645,19 +2157,146 @@ function diagnose(err, exitKind) {
1645
2157
  }
1646
2158
  }
1647
2159
 
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));
2160
+ // The exit paths, bound to ONE invocation's resolved dependencies. Per
2161
+ // invocation rather than module-scope because every input they need is: as
2162
+ // module-level functions, finish() could not see the projectRoot buildProgram()
2163
+ // was handed, so it reached around the program's own DI to process.cwd() and
2164
+ // wrote the record of a verb into a different tree than the verb ran against.
2165
+ function makeEmitters({ projectRoot = process.cwd() } = {}) {
2166
+ // The top-level command commander actually RESOLVED, or null when parsing
2167
+ // never got that far (a parse failure, or help/version). Set via setVerb()
2168
+ // from buildProgram()'s preAction hook.
2169
+ //
2170
+ // Deliberately not `process.argv[2]`: event.js marks `verb` as `sends: true`
2171
+ // on the grounds that it is "a fixed vocabulary we ship", and slice 5 ships
2172
+ // it to a third party on that justification. argv[2] is arbitrary user text —
2173
+ // `mauto --human config get mode` recorded `--human`.
2174
+ let resolvedVerb = null;
2175
+
2176
+ // The run this invocation belongs to, and the daemon lifetime it used.
2177
+ //
2178
+ // Both follow resolvedVerb's pattern for resolvedVerb's reason: finish() is
2179
+ // closed over this invocation's state, and every exit path reaches it
2180
+ // through commander's own call stack, so there is nowhere else to put them.
2181
+ //
2182
+ // resolvedRunId is NOT validated here. It becomes a filename in exactly one
2183
+ // place — runTracePath — which refuses anything unsafe and returns null, so
2184
+ // a second predicate here could only disagree with that one.
2185
+ let resolvedRunId = null;
2186
+ // Set ONLY by connectBridge, and only after a successful connect. That is
2187
+ // what makes `session_id` on a CLI event mean "this verb reached the device
2188
+ // through that daemon".
2189
+ let resolvedSessionId = null;
2190
+
2191
+ // THE process-ending path: every way this CLI terminates goes through here,
2192
+ // so "is this invocation observable" has exactly one answer. Only `mauto mcp`
2193
+ // is outside it — it serves until its client disconnects and then returns,
2194
+ // with no termination to record.
2195
+ //
2196
+ // One function rather than four copies of a ten-line record() literal. Two of
2197
+ // the four got written, and the classes left unrecorded were parse failures
2198
+ // (#146) — the crashes this instrumentation exists to see — and help/version.
2199
+ //
2200
+ // `text` is optional: commander writes help/version itself, so that path has
2201
+ // an invocation to record but nothing left to print.
2202
+ //
2203
+ // dur_ms uses process.uptime() (seconds since THIS process started, no state
2204
+ // needed). A Date.now() stamped at require time would measure from whenever
2205
+ // cli.js was loaded, which is not process start for anything that requires it
2206
+ // first (a test harness, bin/mauto.js's own guards).
2207
+ function finish({ text = '', exitKind, ok, errorKind, exitCode = exitCodeFor(exitKind) }) {
2208
+ // The flag-resolved id when a command parsed, the environment otherwise: a
2209
+ // parse failure reaches no preAction hook and so has no flag, but it is
2210
+ // still part of the run and is exactly the class of failure (#146) this
2211
+ // instrumentation exists to see.
2212
+ const runId = resolvedRunId || resolveRunId(process.env);
2213
+ // null for an id that cannot safely name a file. Every hostile
2214
+ // MAUTO_RUN_ID lands here as "no trace" — but NOT as "no run_id below":
2215
+ // the raw id is still recorded on the mauto.ndjson line, so a refused id
2216
+ // is provably distinguishable from one never exported at all (no field).
2217
+ const tracePath = runTracePath(projectRoot, runId);
2218
+
2219
+ // Record BEFORE the write: process.exit() below is immediate.
2220
+ const fields = {
2221
+ event: 'verb.end',
2222
+ // `info`, not `warn`-on-failure: the failure is carried by `ok` and
2223
+ // `error_kind`, and the LEVEL only decides routing. At `warn` every
2224
+ // failed verb would clear the stderr threshold and echo the envelope the
2225
+ // CLI just wrote. stderr stays a human's channel; the file sink
2226
+ // (threshold `info`) keeps the forensic record either way.
2227
+ level: 'info',
2228
+ src: 'cli',
2229
+ // Absent, not guessed, when no command resolved. makeEvent drops
2230
+ // undefined fields, so the line carries no `verb` rather than a lie.
2231
+ verb: resolvedVerb || undefined,
2232
+ // Recorded even when the id could not name a trace file: the
2233
+ // correlation still belongs in mauto.ndjson, where it is the only thing
2234
+ // tying this line to a run. sends:false in the event catalog.
2235
+ run_id: runId || undefined,
2236
+ session_id: resolvedSessionId || undefined,
2237
+ ok,
2238
+ error_kind: errorKind,
2239
+ exit_code: exitCode,
2240
+ dur_ms: Math.round(process.uptime() * 1000),
2241
+ };
2242
+ record(fields, { projectRoot, tracePath: tracePath || undefined });
2243
+ if (text) process.stdout.write(text.endsWith('\n') ? text : text + '\n');
2244
+ process.exit(exitCode);
2245
+ }
2246
+
2247
+ const fromEnvelope = (envelope, exitKind, human) => ({
2248
+ text: render(envelope, { human }),
2249
+ exitKind,
2250
+ ok: Boolean(envelope && envelope.ok),
2251
+ errorKind: envelope && envelope.error ? envelope.error.kind : undefined,
2252
+ });
2253
+
2254
+ return {
2255
+ finish,
2256
+ setVerb: (verb) => {
2257
+ resolvedVerb = verb;
2258
+ },
2259
+ setRunId: (runId) => {
2260
+ resolvedRunId = runId;
2261
+ },
2262
+ setSessionId: (sessionId) => {
2263
+ resolvedSessionId = sessionId;
2264
+ },
2265
+ // Read-only counterparts to the setters above, for the one other call site
2266
+ // that needs this invocation's resolved run id / verb: connectBridge's
2267
+ // screenshot-on-failure hook. Both values are already committed by the
2268
+ // preAction hook by the time any verb's fn(bridge) can have returned, so
2269
+ // there is nothing to resolve here — only to expose.
2270
+ getRunId: () => resolvedRunId,
2271
+ getVerb: () => resolvedVerb,
2272
+
2273
+ emit: ({ envelope, exitKind }, human) => finish(fromEnvelope(envelope, exitKind, human)),
2274
+
2275
+ // Raw content (markdown / JSON schema / text) verbatim, no envelope
2276
+ // wrapping. Used by the guide/schema/bootstrap success paths.
2277
+ emitRaw: (content, exitKind) => finish({ text: content, exitKind, ok: true }),
2278
+
2279
+ // Last-resort emit for errors that escape BEFORE any action runs, so the
2280
+ // in-program withEnvelope boundary can't see them — a throw inside
2281
+ // buildProgram, or a stray unhandled rejection. One JSON envelope on stdout
2282
+ // + the mapped exit code, never a raw stack trace.
2283
+ emitFatal: (err, human = false) => {
2284
+ const { envelope, exitKind } = toEnvelope(err);
2285
+ diagnose(err, exitKind);
2286
+ finish(fromEnvelope(envelope, exitKind, human));
2287
+ },
2288
+ };
1659
2289
  }
1660
2290
 
2291
+ // bin/mauto.js registers the exported emitFatal as a PROCESS-wide
2292
+ // unhandledRejection handler, which can fire before run() has built anything —
2293
+ // so it needs a module-level way to reach the invocation in flight. A pointer to
2294
+ // the per-invocation instance, not a second copy of its state: an unhandled
2295
+ // rejection is exactly the crash class this instrumentation exists to see, and
2296
+ // it should carry the verb and projectRoot the invocation actually resolved.
2297
+ let activeEmitters = null;
2298
+ const emitFatal = (err, human = false) => (activeEmitters || makeEmitters()).emitFatal(err, human);
2299
+
1661
2300
  async function run(argv) {
1662
2301
  // Belt-and-suspenders around the in-program withEnvelope boundary: a throw in
1663
2302
  // buildProgram() (e.g. the ScenarioValidator default-param compiling a corrupt
@@ -1666,21 +2305,40 @@ async function run(argv) {
1666
2305
  // `unhandledRejection` guard lives in bin/mauto.js (the process entry point),
1667
2306
  // not here, so calling run() in tests never installs a global exit-on-reject
1668
2307
  // listener that would leak across the suite.
2308
+ //
2309
+ // The workspace is resolved ONCE, here, and the same root reaches both the
2310
+ // emitters (the .logs/ file sink and run traces) and every handler — and,
2311
+ // through connection.js, the session daemon's MAUTO_SESSION_PROJECT_ROOT.
2312
+ // No workspace found falls back to cwd for the verbs that tolerate that;
2313
+ // buildProgram fails the ones that cannot (#188).
2314
+ const cwd = process.cwd();
2315
+ const workspace = findWorkspaceRoot(cwd);
2316
+ const projectRoot = workspace.root || cwd;
2317
+ const emitters = makeEmitters({ projectRoot });
2318
+ activeEmitters = emitters;
1669
2319
  try {
1670
- const program = buildProgram();
2320
+ // No projectRoot here, deliberately: buildProgram derives it as
2321
+ // `workspace.root || cwd`, and an injected projectRoot is the test seam
2322
+ // that FABRICATES a workspace — passing one would arm that branch and
2323
+ // silently disable the requireWorkspace gate.
2324
+ const program = buildProgram({ emitters, cwd, workspace });
1671
2325
  await program.parseAsync(argv);
1672
2326
  } catch (err) {
1673
2327
  // 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.
2328
+ // text to stdout, so they get no envelope wrapped around them (#146). Same
2329
+ // COMMANDER_NON_FAILURES set toEnvelope classifies against, so "which
2330
+ // commander outcomes aren't failures" has exactly one answer.
2331
+ //
2332
+ // They still exit through finish(), with no text of their own to print.
2333
+ // They are real invocations, and slice 5 computes rates off this stream —
2334
+ // omitting a whole invocation class skews every one of those denominators.
2335
+ // `exitCode` comes from commander rather than a kind because commander
2336
+ // already computed one: `.help({error:true})` yields 1, not 0.
1677
2337
  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);
2338
+ emitters.finish({ exitCode: err.exitCode || 0, ok: true });
1681
2339
  return;
1682
2340
  }
1683
- emitFatal(err, argv.slice(2).includes('--human'));
2341
+ emitters.emitFatal(err, argv.slice(2).includes('--human'));
1684
2342
  }
1685
2343
  }
1686
2344
 
@@ -1707,6 +2365,7 @@ module.exports = {
1707
2365
  handleAssert,
1708
2366
  handleResultAddStep,
1709
2367
  handleResultAddAssertion,
2368
+ handleResultAddCrash,
1710
2369
  handleResultFinalize,
1711
2370
  handleSetup,
1712
2371
  handleConfigGet,
@@ -1724,4 +2383,10 @@ module.exports = {
1724
2383
  handleDevices,
1725
2384
  handleDevicesUse,
1726
2385
  handleDevicesClear,
2386
+ handleTelemetryStatus,
2387
+ handleTelemetryEnable,
2388
+ handleTelemetryDisable,
2389
+ handleTelemetryFlush,
2390
+ handleCrashList,
2391
+ handleCrashGet,
1727
2392
  };