fix(desktop): make storage-root-conflict e2e signal platform-independent

The regression test for the ESM startup deadlock treated 'CDP evaluate
never settles within 1s' as the proof that the repair dialog was open.
That holds only on macOS, where modal loops block CDP evaluation; on
Linux (CI) the modal keeps answering evaluation, so the test failed even
though the app parked correctly — and a deadlocked main process would
have been accepted as a pass.

Replace the heuristic with an explicit contract: boot.ts prints
'[storage-root] root-identity conflict; parking at repair dialog'
synchronously before the modal (printed only after ready, only when the
gate fired), and the test waits for that console event. The workspace
write-free assertion is unchanged. Deadlock and gate-removal both never
print the signal, so both still fail the test on every platform.
diff --git a/apps/desktop/e2e/storage-root-conflict.spec.ts b/apps/desktop/e2e/storage-root-conflict.spec.ts
index 1bbe022..c6c288c 100644
--- a/apps/desktop/e2e/storage-root-conflict.spec.ts
+++ b/apps/desktop/e2e/storage-root-conflict.spec.ts
@@ -10,32 +10,39 @@
 const DESKTOP_ROOT = process.cwd();
 
 /**
- * Prove the app parked at the modal repair dialog — the only success signal
- * this test accepts.
+ * Startup signal the main process prints synchronously right before showing
+ * the repair modal (see `confirmDesktopStorageRootRepair` in boot.ts). It is
+ * an explicit contract between the app and this test: it can only be printed
+ * after `ready` — the whole boot module runs inside the `whenReady` callback —
+ * and only when the root-identity gate fired, so seeing it proves both
+ * invariants at once.
  *
- * The dialog can only appear after ready: the whole boot module runs inside
- * the `whenReady` callback, so a modal dialog being up simultaneously proves
- * (a) ready was reached and (b) the root-identity gate fired and is holding
- * before any store/db write. On macOS the dialog's modal loop stops answering
- * CDP evaluation, so an evaluate that never settles within the deadline is the
- * observable form of "dialog is open". A deadlocked main process (the
- * regression this test guards) answers every evaluate with `isReady() ===
- * false` forever, and a future removal of the gate would make evaluate answer
- * `true` — both must fail, only the parked dialog may pass.
+ * CDP evaluation is deliberately not the success signal: macOS modal loops
+ * block CDP evaluation while Linux modal dialogs keep answering it, so no
+ * evaluate-based heuristic is portable. A deadlocked main process (the
+ * regression this test guards) never reaches the gate, so the signal never
+ * appears; a future removal of the gate skips the signal too.
  */
-async function appParkedAtRepairDialog(app: ElectronApplication): Promise<boolean> {
-  for (let attempt = 0; attempt < 40; attempt += 1) {
-    const outcome = await Promise.race([
-      app
-        .evaluate(({ app: electronApp }) => electronApp.isReady())
-        .then((ready) => (ready ? 'ready' : 'not-ready'))
-        .catch(() => 'evaluate-error'),
-      new Promise<string>((resolve) => setTimeout(() => resolve('modal-dialog'), 1_000)),
-    ]);
-    if (outcome === 'modal-dialog') return true;
-    await new Promise((resolve) => setTimeout(resolve, 250));
-  }
-  return false;
+const REPAIR_GATE_SIGNAL = '[storage-root] root-identity conflict; parking at repair dialog';
+
+async function appParkedAtRepairGate(app: ElectronApplication): Promise<boolean> {
+  return new Promise((resolve) => {
+    let settled = false;
+    const timeout = setTimeout(() => {
+      if (!settled) {
+        settled = true;
+        resolve(false);
+      }
+    }, 30_000);
+    app.on('console', (message) => {
+      if (settled) return;
+      if (message.text().includes(REPAIR_GATE_SIGNAL)) {
+        settled = true;
+        clearTimeout(timeout);
+        resolve(true);
+      }
+    });
+  });
 }
 
 /**
@@ -74,9 +81,9 @@
       env: buildFixtureEnv(userDataDir, homeDir, {}),
     });
 
-    // The app must park at the dialog — before this fix the main process
-    // deadlocked in module evaluation and no dialog ever appeared.
-    expect(await appParkedAtRepairDialog(app)).toBe(true);
+    // The gate signal is printed only after ready and only when the conflict
+    // fired; a deadlocked main process (the regression) never prints it.
+    expect(await appParkedAtRepairGate(app)).toBe(true);
 
     // While the dialog is unanswered, no store/db files may be created in
     // the workspace: the root-identity gate must precede all storage.
diff --git a/apps/desktop/src/main/boot.ts b/apps/desktop/src/main/boot.ts
index a4afbe1..33d1bcc 100644
--- a/apps/desktop/src/main/boot.ts
+++ b/apps/desktop/src/main/boot.ts
@@ -215,6 +215,10 @@
   if (!app.isReady()) {
     throw new Error('storage-root repair dialog requires app ready');
   }
+  // Explicit startup contract for the storage-root-conflict E2E: printed
+  // synchronously before the modal, so the test can observe the gate firing
+  // on any platform (macOS modal loops block CDP evaluation, Linux does not).
+  console.log('[storage-root] root-identity conflict; parking at repair dialog');
   const isChinese = resolveSystemUiLocale(app.getPreferredSystemLanguages()) === 'zh';
   const { response } = await dialog.showMessageBox({
     type: 'warning',